Files
queue_analyzer/templates/index.html
T
2026-07-27 19:22:30 +03:30

872 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<title>Queue Analyzer</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Tahoma,Arial;background:#f0f2f5;direction:rtl}
/* Layout */
.app-layout{display:flex;min-height:100vh}
.sidebar{width:200px;min-width:200px;background:#1a237e;display:flex;flex-direction:column;padding:0;position:sticky;top:0;height:100vh;overflow-y:auto;z-index:100}
.sidebar-logo{padding:20px 16px 14px;border-bottom:1px solid rgba(255,255,255,.15);color:white;font-size:15px;font-weight:bold;line-height:1.4}
.sidebar-logo small{display:block;font-size:11px;opacity:.6;margin-top:2px}
.nav-item{display:flex;align-items:center;gap:10px;padding:12px 16px;color:rgba(255,255,255,.75);cursor:pointer;font-size:13px;border:none;background:none;width:100%;text-align:right;font-family:Tahoma;transition:background .2s;border-right:3px solid transparent}
.nav-item:hover{background:rgba(255,255,255,.1);color:white}
.nav-item.active{background:rgba(255,255,255,.15);color:white;border-right-color:#82b1ff;font-weight:bold}
.nav-icon{font-size:16px;flex-shrink:0}
.nav-sep{height:1px;background:rgba(255,255,255,.1);margin:6px 0}
.main-content{flex:1;padding:20px;overflow-x:hidden}
/* Cards */
.card{background:white;border-radius:10px;padding:20px;margin-bottom:18px;box-shadow:0 2px 8px rgba(0,0,0,.08)}
.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;flex-wrap:wrap;gap:8px}
.card-title{color:#1a237e;font-size:15px;font-weight:bold}
/* Collapse */
.collapse-header{display:flex;justify-content:space-between;align-items:center;cursor:pointer;user-select:none}
.collapse-arrow{font-size:14px;color:#1a237e;transition:transform .3s}
.collapse-arrow.open{transform:rotate(180deg)}
.collapse-body{overflow:hidden;max-height:0;transition:max-height .35s ease}
.collapse-body.open{max-height:400px}
/* Metrics */
.grid-4{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
.metric{background:#e8eaf6;border-radius:8px;padding:14px;text-align:center;position:relative;cursor:default}
.metric:hover{background:#dde0f5}
.metric h4{font-size:11px;color:#555;margin-bottom:6px;line-height:1.4;min-height:28px}
.metric .val{font-size:22px;font-weight:bold;color:#1a237e}
.metric .unit{font-size:11px;color:#888;margin-top:2px}
.green{color:#2e7d32!important}.red{color:#c62828!important}.orange{color:#e65100!important}
/* Tooltip */
[data-tip]{position:relative}
[data-tip]:hover::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 8px);right:50%;transform:translateX(50%);background:#263238;color:white;font-size:11px;padding:7px 10px;border-radius:6px;white-space:pre-line;z-index:999;min-width:180px;max-width:260px;text-align:right;line-height:1.6;pointer-events:none;box-shadow:0 4px 12px rgba(0,0,0,.3)}
[data-tip]:hover::before{content:'';position:absolute;bottom:calc(100% + 2px);right:50%;transform:translateX(50%);border:6px solid transparent;border-top-color:#263238;z-index:999;pointer-events:none}
/* Buttons */
select,input[type=file],input[type=text]{padding:8px 10px;border:1px solid #ccc;border-radius:6px;font-size:13px;font-family:Tahoma}
button{background:#1a237e;color:white;border:none;padding:9px 18px;border-radius:6px;cursor:pointer;font-size:13px;font-family:Tahoma}
button:hover{background:#283593}
.btn-green{background:#2e7d32}.btn-green:hover{background:#1b5e20}
.btn-export{background:#00695c;font-size:12px;padding:6px 12px}.btn-export:hover{background:#004d40}
.btn-help{background:#f57f17;font-size:12px;padding:6px 12px}.btn-help:hover{background:#e65100}
.btn-del{background:#c62828;color:white;border:none;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:11px}
/* Table */
table{width:100%;border-collapse:collapse;font-size:13px}
th{background:#e8eaf6;color:#1a237e;padding:10px 8px;text-align:center;border-bottom:2px solid #c5cae9;white-space:nowrap}
td{padding:9px 8px;text-align:center;border-bottom:1px solid #eee}
tr:hover td{background:#f5f5f5}
.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:bold}
.bg{background:#e8f5e9;color:#2e7d32}.br{background:#ffebee;color:#c62828}
.bo{background:#fff3e0;color:#e65100}.bb{background:#e3f2fd;color:#1565c0}
.bp{background:#f3e5f5;color:#6a1b9a}.bn{background:#eceff1;color:#37474f}
/* File items */
.file-item{display:inline-flex;align-items:center;background:#e8eaf6;border-radius:6px;padding:4px 10px;margin:3px;font-size:12px;gap:6px}
.filter-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
.info-box{background:#e3f2fd;border-right:4px solid #1565c0;padding:10px 14px;border-radius:6px;font-size:12px;color:#1565c0;margin-bottom:12px;line-height:1.6}
.sec-title{font-size:13px;color:#555;margin:18px 0 8px;border-right:3px solid #1a237e;padding-right:8px;font-weight:bold}
/* Pages */
.page{display:none}.page.active{display:block}
/* Heatmap */
.heatmap{display:grid;grid-template-columns:repeat(24,1fr);gap:3px;margin-top:8px}
.hm-cell{border-radius:4px;padding:4px 2px;text-align:center;font-size:10px;color:white;min-height:48px;display:flex;flex-direction:column;justify-content:center}
.hm-label{font-size:10px;color:#888;text-align:center;margin-top:3px}
/* Loading */
.loading-overlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.55);z-index:9999;justify-content:center;align-items:center}
.loading-overlay.show{display:flex}
.loading-box{background:white;border-radius:16px;padding:36px 48px;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.2);min-width:280px}
.loading-box h3{color:#1a237e;margin-bottom:8px;font-size:18px}
.loading-box p{color:#666;font-size:13px;margin-bottom:20px;line-height:1.7}
.spinner{width:52px;height:52px;border:5px solid #e8eaf6;border-top:5px solid #1a237e;border-radius:50%;animation:spin .9s linear infinite;margin:0 auto 16px}
@keyframes spin{to{transform:rotate(360deg)}}
.loading-steps{text-align:right;font-size:12px;color:#555;line-height:2;margin-top:8px}
.loading-steps span{display:block}
.loading-steps span::before{content:'○ '}
.loading-steps span.done::before{content:'✅ '}
.loading-steps span.active::before{content:'⏳ '}
.loading-steps span.done{color:#2e7d32}
.loading-steps span.active{color:#1a237e;font-weight:bold}
.cache-badge{display:inline-block;padding:3px 10px;border-radius:10px;font-size:11px}
.cache-hit{background:#e8f5e9;color:#2e7d32}.cache-miss{background:#e8eaf6;color:#1a237e}
/* Modal */
.modal-overlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:1000;justify-content:center;align-items:flex-start;padding-top:40px}
.modal-overlay.open{display:flex}
.modal{background:white;border-radius:12px;padding:28px;max-width:820px;width:95%;max-height:82vh;overflow-y:auto}
.modal h2{color:#1a237e;margin-bottom:16px;border-bottom:2px solid #e8eaf6;padding-bottom:10px}
.close-btn{float:left;background:#c62828;color:white;border:none;padding:6px 14px;border-radius:6px;cursor:pointer}
/* Pagination */
.pag-btn{padding:5px 12px;border-radius:5px;border:1px solid #c5cae9;cursor:pointer;font-family:Tahoma;font-size:12px;background:white;color:#333}
.pag-btn.active{background:#1a237e;color:white;border-color:#1a237e}
.pag-btn:disabled{opacity:.4;cursor:default}
/* header top */
.top-bar{display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;flex-wrap:wrap;gap:8px}
.top-bar h2{color:#1a237e;font-size:17px}
</style>
</head>
<body>
<!-- Loading -->
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-box">
<div class="spinner"></div>
<h3>در حال محاسبه...</h3>
<p>لطفاً صبر کنید</p>
<div class="loading-steps">
<span id="step1" class="active">خواندن داده‌ها از پایگاه داده</span>
<span id="step2">پردازش تماس‌ها</span>
<span id="step3">محاسبه KPI و آمار اپراتورها</span>
<span id="step4">آماده‌سازی نمودارها</span>
</div>
</div>
</div>
<!-- Modal راهنما -->
<div class="modal-overlay" id="helpModal" onclick="if(event.target.id==='helpModal')this.classList.remove('open')">
<div class="modal">
<button class="close-btn" onclick="document.getElementById('helpModal').classList.remove('open')">✕ بستن</button>
<h2>📖 راهنمای Queue Analyzer</h2>
<h3>رویدادهای queue_log</h3>
<table>
<tr><th>رویداد</th><th>معنی</th><th>data1</th><th>data2</th><th>data3</th></tr>
<tr><td>ENTERQUEUE</td><td>تماس وارد صف شد</td><td>url</td><td>CallerID</td><td>origposition</td></tr>
<tr><td>CONNECT</td><td>وصل به اپراتور</td><td>holdtime</td><td>channel</td><td>ringtime</td></tr>
<tr><td>ABANDON</td><td>مشتری قطع کرد</td><td>position</td><td>origposition</td><td>waittime ✅</td></tr>
<tr><td>EXITWITHTIMEOUT</td><td>timeout صف</td><td>position</td><td>origposition</td><td>waittime</td></tr>
<tr><td>COMPLETEAGENT</td><td>اپراتور قطع کرد</td><td>holdtime ✅</td><td>calltime ✅</td><td>origposition ✅</td></tr>
<tr><td>COMPLETECALLER</td><td>مشتری قطع کرد</td><td>holdtime ✅</td><td>calltime ✅</td><td>origposition ✅</td></tr>
<tr><td>RINGNOANSWER</td><td>در ringall طبیعیه</td><td></td><td></td><td></td></tr>
</table>
<h3 style="margin-top:16px">راهنمای رنگ‌ها</h3>
<table>
<tr><th>معیار</th><th style="color:#2e7d32">✅ خوب</th><th style="color:#e65100">⚠️ متوسط</th><th style="color:#c62828">❌ بد</th></tr>
<tr><td>نرخ پاسخ</td><td>بالای 90%</td><td>80-90%</td><td>زیر 80%</td></tr>
<tr><td>نرخ رهاشدن</td><td>زیر 5%</td><td>5-10%</td><td>بالای 10%</td></tr>
<tr><td>میانه انتظار</td><td>زیر 60s</td><td>60-120s</td><td>بالای 120s</td></tr>
<tr><td>Repeat Rate</td><td>زیر 10%</td><td>10-20%</td><td>بالای 20%</td></tr>
</table>
</div>
</div>
<!-- Layout -->
<div class="app-layout">
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar-logo">📊 Queue Analyzer<small>آنالیز صف تماس</small></div>
<button class="nav-item active" onclick="showPage('pg-upload')">
<span class="nav-icon">📁</span> آپلود فایل
</button>
<button class="nav-item" onclick="showPage('pg-filter')">
<span class="nav-icon">⚙️</span> فیلتر و محاسبه
</button>
<div class="nav-sep"></div>
<button class="nav-item" id="nav-kpi" onclick="showPage('pg-kpi')" disabled>
<span class="nav-icon">📈</span> KPI اصلی
</button>
<button class="nav-item" id="nav-timeout" onclick="showPage('pg-timeout')" disabled>
<span class="nav-icon">⏱️</span> Timeout
</button>
<button class="nav-item" id="nav-caller" onclick="showPage('pg-caller')" disabled>
<span class="nav-icon">🔁</span> تماس‌گیرندگان
</button>
<button class="nav-item" id="nav-hourly" onclick="showPage('pg-hourly')" disabled>
<span class="nav-icon"></span> تحلیل ساعتی
</button>
<button class="nav-item" id="nav-agents" onclick="showPage('pg-agents')" disabled>
<span class="nav-icon">👤</span> اپراتورها
</button>
<button class="nav-item" id="nav-details" onclick="showPage('pg-details');loadDetails(1)" disabled>
<span class="nav-icon">📋</span> جزئیات تماس‌ها
</button>
<button class="nav-item" id="nav-repeats" onclick="showPage('pg-repeats');loadRepeats(1)" disabled>
<span class="nav-icon">🔴</span> تماس‌های تکراری
</button>
<div class="nav-sep"></div>
<button class="nav-item" onclick="exportCSV()">
<span class="nav-icon">📥</span> خروجی CSV
</button>
<button class="nav-item" onclick="document.getElementById('helpModal').classList.add('open')">
<span class="nav-icon">📖</span> راهنما
</button>
</nav>
<!-- Main -->
<div class="main-content">
<!-- آپلود -->
<div class="page active" id="pg-upload">
<div class="top-bar"><h2>📁 آپلود فایل Queue Log</h2></div>
<div class="card">
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">
<input type="file" id="fileInput" accept=".log,.txt">
<button class="btn-green" onclick="uploadFile()">⬆️ آپلود</button>
<span id="uploadMsg" style="font-size:13px;"></span>
</div>
<div id="filesList" style="margin-top:14px;"></div>
</div>
</div>
<!-- فیلتر -->
<div class="page" id="pg-filter">
<div class="top-bar"><h2>⚙️ فیلتر و محاسبه</h2></div>
<div class="card">
<div class="filter-row">
<label>فایل:
<select id="fileFilter" onchange="saveFilters();loadQueues()">
<option value="">همه فایل‌ها</option>
</select>
</label>
<label>صف:
<select id="queue" onchange="saveFilters();loadAgents()">
<option value="">همه صف‌ها</option>
</select>
</label>
<label>اپراتور:
<select id="agent" onchange="saveFilters()">
<option value="">همه اپراتورها</option>
</select>
</label>
<button onclick="loadStats()">🔄 محاسبه آمار</button>
</div>
<div id="cacheStatus" style="margin-top:12px;font-size:12px;"></div>
</div>
</div>
<!-- KPI -->
<div class="page" id="pg-kpi">
<div class="top-bar">
<h2>📈 KPIهای اصلی</h2>
<span id="cacheBadge"></span>
</div>
<div class="card">
<div class="grid-4" id="queueMetrics"></div>
</div>
<div class="card">
<div class="sec-title"
data-tip="توزیع زمان انتظار تماس‌های پاسخ‌داده‌شده&#10;ایده‌آل: بیشتر تماس‌ها در بازه 0-40 ثانیه باشند">
📉 توزیع زمان انتظار
</div>
<canvas id="histChart" height="80"></canvas>
</div>
</div>
<!-- Timeout -->
<div class="page" id="pg-timeout">
<div class="top-bar"><h2>⏱️ تحلیل Timeout</h2></div>
<div class="card">
<div class="info-box">⏱️ تماس‌هایی که با EXITWITHTIMEOUT صف را ترک کردند — ظرفیت صف ممکن است کافی نباشد.</div>
<div class="grid-4" id="timeoutMetrics"></div>
</div>
<div class="card">
<div class="sec-title"
data-tip="توزیع ساعتی تماس‌های Timeout&#10;ساعت‌هایی با بیشترین Timeout نیاز به اپراتور بیشتر دارند">
توزیع ساعتی Timeout
</div>
<canvas id="timeoutChart" height="80"></canvas>
</div>
</div>
<!-- Caller -->
<div class="page" id="pg-caller">
<div class="top-bar"><h2>🔁 تماس‌گیرندگان</h2></div>
<div class="card">
<div class="info-box">🔁 Repeat Caller — مشتریانی که بیش از یک بار تماس گرفته‌اند (شماره‌ها نرمال‌سازی شده‌اند).</div>
<div class="grid-4" id="callerMetrics"></div>
</div>
<div class="card">
<div class="sec-title"
data-tip="جایگاه تماس در صف&#10;اگر رهاشده‌ها جایگاه بالاتری دارند، مشتریان در ابتدای صف تسلیم می‌شوند">
📍 جایگاه در صف
</div>
<div class="grid-4" id="posMetrics"></div>
</div>
</div>
<!-- ساعتی -->
<div class="page" id="pg-hourly">
<div class="top-bar"><h2>⏰ تحلیل ساعتی</h2></div>
<div class="card">
<p style="font-size:12px;color:#888;margin-bottom:8px;">تیره‌تر = شلوغ‌تر | عدد بالا = تعداد تماس | عدد پایین = نرخ رها%</p>
<div style="overflow-x:auto">
<div class="heatmap" id="heatmap"></div>
<div class="heatmap" style="margin-top:2px" id="heatmap-labels"></div>
</div>
</div>
<div class="card">
<div class="sec-title"
data-tip="روند ساعتی تعداد تماس‌ها&#10;سبز: پاسخ‌داده | قرمز: رهاشده | نارنجی خط‌چین: Timeout">
روند ساعتی تماس‌ها
</div>
<canvas id="hourlyChart" height="100"></canvas>
</div>
<div class="card">
<div class="sec-title">جزئیات ساعتی</div>
<div id="hourlyTable" style="overflow-x:auto"></div>
</div>
</div>
<!-- اپراتورها -->
<div class="page" id="pg-agents">
<div class="top-bar"><h2>👤 اپراتورها</h2></div>
<div class="card">
<div class="info-box">💡 در صف ringall، سهم از تیم به جای نرخ پاسخ نشان داده می‌شود.</div>
<div style="overflow-x:auto">
<table>
<thead>
<tr>
<th data-tip="نام اپراتور">اپراتور</th>
<th data-tip="تعداد تماس‌هایی که این اپراتور پاسخ داده">پاسخ ✅</th>
<th data-tip="درصد از کل تماس‌های پاسخ‌داده‌شده تیم&#10;معیار توزیع بار بین اپراتورها">سهم تیم 📊</th>
<th data-tip="میانه زمان انتظار مشتری تا پاسخ این اپراتور&#10;سبز: زیر 60s | زرد: 60-120s | قرمز: بالای 120s">میانه انتظار</th>
<th data-tip="میانگین طول مکالمه این اپراتور">میانگین مکالمه</th>
<th data-tip="میانه طول مکالمه — کمتر تحت تأثیر مکالمات طولانی">میانه مکالمه</th>
<th data-tip="Average Handle Time = زمان انتظار + زمان مکالمه&#10;معیار اصلی کارایی اپراتور">AHT ⭐</th>
<th data-tip="مقایسه AHT این اپراتور با میانگین تیم&#10;منفی = سریع‌تر از تیم | مثبت = کندتر از تیم">AHT vs تیم</th>
<th data-tip="درصد مشتریانی که با این اپراتور صحبت کرده‌اند و قبلاً هم زنگ زده بودند">Repeat %</th>
<th data-tip="میانگین جایگاه تماس در صف هنگام اتصال به این اپراتور">جایگاه صف</th>
<th data-tip="تعداد تماس‌هایی که اپراتور قطع کرد / مشتری قطع کرد">قطع اپراتور/مشتری</th>
</tr>
</thead>
<tbody id="agentTableBody"></tbody>
</table>
</div>
</div>
</div>
<!-- جزئیات تماس‌ها -->
<div class="page" id="pg-details">
<div class="top-bar"><h2>📋 جزئیات تماس‌ها</h2></div>
<div class="card">
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px;">
<input type="text" id="callerSearch" placeholder="🔍 جستجو شماره..." style="width:220px;" oninput="loadDetails(1)">
<label style="font-size:13px;">نمایش:
<select id="perPage" onchange="loadDetails(1)">
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
</label>
<span id="detailsCount" style="font-size:12px;color:#888;"></span>
<button class="btn-export" onclick="exportDetailCSV()">📥 خروجی این صفحه</button>
</div>
<div style="overflow-x:auto">
<table>
<thead>
<tr>
<th data-tip="تاریخ تماس (timezone ایران)">تاریخ</th>
<th data-tip="ساعت و دقیقه تماس (timezone ایران)">ساعت</th>
<th data-tip="شماره تماس‌گیرنده (نرمال‌سازی‌شده)">شماره</th>
<th data-tip="نام اپراتور پاسخ‌دهنده">اپراتور</th>
<th data-tip="نتیجه تماس: پاسخ / رها / Timeout">نتیجه</th>
<th data-tip="جایگاه این تماس در صف هنگام ورود">جایگاه صف</th>
<th data-tip="زمان انتظار تا پاسخ (ثانیه)">انتظار (s)</th>
<th data-tip="مدت مکالمه (ثانیه)">مکالمه (s)</th>
<th data-tip="AHT = انتظار + مکالمه (ثانیه)">AHT (s)</th>
</tr>
</thead>
<tbody id="detailsBody"></tbody>
</table>
</div>
<div id="detailsPagination" style="display:flex;gap:6px;justify-content:center;margin-top:16px;flex-wrap:wrap;"></div>
</div>
</div>
<!-- تماس‌های تکراری -->
<div class="page" id="pg-repeats">
<div class="top-bar"><h2>🔴 تماس‌های تکراری</h2></div>
<div class="card">
<div class="info-box">
مشتریانی که بیش از یک بار تماس گرفته‌اند — مرتب‌شده بر اساس تعداد تماس (بیشترین اول).
</div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px;">
<input type="text" id="repeatSearch" placeholder="🔍 جستجو شماره..." style="width:220px;" oninput="loadRepeats(1)">
<label style="font-size:13px;">نمایش:
<select id="repeatPerPage" onchange="loadRepeats(1)">
<option value="50">50</option>
<option value="100">100</option>
</select>
</label>
<span id="repeatsCount" style="font-size:12px;color:#888;"></span>
<button class="btn-export" onclick="exportRepeatCSV()">📥 خروجی CSV</button>
</div>
<div style="overflow-x:auto">
<table>
<thead>
<tr>
<th>#</th>
<th data-tip="شماره تماس‌گیرنده (نرمال‌سازی‌شده)">شماره</th>
<th data-tip="کل تعداد تماس‌های این شماره&#10;قرمز: 5+ | نارنجی: 3-4 | آبی: 2">کل تماس</th>
<th data-tip="تعداد تماس‌هایی که پاسخ داده شد">پاسخ</th>
<th data-tip="تعداد تماس‌هایی که رها شد (بدون short abandon)">رها</th>
<th data-tip="تاریخ اولین تماس این شماره">اولین تماس</th>
<th data-tip="تاریخ آخرین تماس این شماره">آخرین تماس</th>
<th data-tip="تعداد روزهای متمایزی که این شماره زنگ زده">روزها</th>
<th data-tip="اپراتورهایی که با این مشتری صحبت کرده‌اند">اپراتورها</th>
<th data-tip="میانگین زمان انتظار تماس‌های پاسخ‌داده‌شده این مشتری">میانگین انتظار (s)</th>
</tr>
</thead>
<tbody id="repeatsBody"></tbody>
</table>
</div>
<div id="repeatsPagination" style="display:flex;gap:6px;justify-content:center;margin-top:16px;flex-wrap:wrap;"></div>
</div>
</div>
</div><!-- /main-content -->
</div><!-- /app-layout -->
<script>
let histChart, hourlyChart, timeoutChart;
let loadingTimer = null;
let detailsData = [];
let repeatsData = [];
const fmt = n => Number(n).toLocaleString('en-US');
// ── Pages ──
function showPage(id) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById(id).classList.add('active');
const navId = 'nav-' + id.replace('pg-','');
const nav = document.getElementById(navId);
if (nav) nav.classList.add('active');
}
function enableNavItems() {
['kpi','timeout','caller','hourly','agents','details','repeats'].forEach(id => {
const el = document.getElementById('nav-' + id);
if (el) el.disabled = false;
});
}
// ── Loading ──
function showLoading() {
document.getElementById('loadingOverlay').classList.add('show');
const steps = ['step1','step2','step3','step4'];
let i = 0;
loadingTimer = setInterval(() => {
if (i < steps.length - 1) {
document.getElementById(steps[i]).className = 'done';
i++;
document.getElementById(steps[i]).className = 'active';
}
}, 1200);
}
function hideLoading() {
clearInterval(loadingTimer);
['step1','step2','step3','step4'].forEach(s => document.getElementById(s).className = 'done');
setTimeout(() => {
document.getElementById('loadingOverlay').classList.remove('show');
['step1','step2','step3','step4'].forEach((s,i) =>
document.getElementById(s).className = i===0 ? 'active' : '');
}, 400);
}
// ── Filters ──
function saveFilters() {
localStorage.setItem('qa_file', document.getElementById('fileFilter').value);
localStorage.setItem('qa_queue', document.getElementById('queue').value);
localStorage.setItem('qa_agent', document.getElementById('agent').value);
}
function restoreFilters() {
const ff = localStorage.getItem('qa_file');
const qf = localStorage.getItem('qa_queue');
if (ff) document.getElementById('fileFilter').value = ff;
if (qf) document.getElementById('queue').value = qf;
}
// ── Files ──
async function uploadFile() {
const file = document.getElementById('fileInput').files[0];
if (!file) return alert('لطفاً فایل انتخاب کنید');
const form = new FormData();
form.append('file', file);
const msg = document.getElementById('uploadMsg');
msg.textContent = '⏳ در حال پردازش...'; msg.style.color = '#666';
const data = await (await fetch('/queue/upload', {method:'POST', body:form})).json();
msg.textContent = data.message || data.error;
msg.style.color = data.warning ? '#e65100' : data.success ? '#2e7d32' : '#c62828';
loadFilesDropdown(); loadFiles(); loadQueues();
}
async function deleteFile(filename) {
if (!confirm(`فایل "${filename}" حذف شود؟`)) return;
const data = await (await fetch('/queue/delete_file', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({filename})
})).json();
alert(data.message || data.error);
loadFiles(); loadFilesDropdown(); loadQueues();
}
async function loadFiles() {
const files = await (await fetch('/queue/files')).json();
const div = document.getElementById('filesList');
if (!files.length) { div.innerHTML = '<p style="color:#888;font-size:12px;">هنوز فایلی آپلود نشده</p>'; return; }
div.innerHTML = files.map(f =>
`<div class="file-item">📄 <strong>${f.file_source}</strong>
<span style="color:#888;">(${fmt(f.count)} رکورد)</span>
<button class="btn-del" onclick="deleteFile('${f.file_source}')">حذف ✕</button></div>`
).join('');
}
async function loadFilesDropdown() {
const files = await (await fetch('/queue/files')).json();
const sel = document.getElementById('fileFilter');
const cur = sel.value;
sel.innerHTML = '<option value="">همه فایل‌ها</option>' +
files.map(f => `<option value="${f.file_source}">📄 ${f.file_source}</option>`).join('');
if (cur) sel.value = cur;
}
async function loadQueues() {
const queues = await (await fetch('/queue/queues')).json();
const sel = document.getElementById('queue');
const cur = sel.value;
sel.innerHTML = '<option value="">همه صف‌ها</option>' +
queues.map(q => `<option value="${q}">${q}</option>`).join('');
if (cur) sel.value = cur;
loadAgents();
}
async function loadAgents() {
const queue = document.getElementById('queue').value;
const agents = await (await fetch('/queue/agents?queue=' + queue)).json();
const sel = document.getElementById('agent');
const cur = localStorage.getItem('qa_agent') || '';
sel.innerHTML = '<option value="">همه اپراتورها</option>' +
agents.map(a => `<option value="${a}">${a}</option>`).join('');
if (cur) sel.value = cur;
}
function exportCSV() {
const queue = document.getElementById('queue').value;
const ff = document.getElementById('fileFilter').value;
window.location.href = `/queue/export_csv?queue=${queue}&file_filter=${ff}`;
}
// ── Stats ──
async function loadStats() {
const queue = document.getElementById('queue').value;
const agent = document.getElementById('agent').value;
const ff = document.getElementById('fileFilter').value;
saveFilters();
showLoading();
let d;
try {
const res = await fetch(`/queue/stats?queue=${queue}&agent=${agent}&file_filter=${ff}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
d = await res.json();
} catch(e) {
hideLoading(); alert('خطا در سرور: ' + e.message); return;
}
hideLoading();
if (d.error) { alert(d.error); return; }
enableNavItems();
const badge = document.getElementById('cacheBadge');
badge.className = d.from_cache ? 'cache-badge cache-hit' : 'cache-badge cache-miss';
badge.textContent = d.from_cache ? '⚡ از cache' : '🔄 محاسبه جدید';
const aClass = d.abandon_rate > 10 ? 'red' : d.abandon_rate > 5 ? 'orange' : 'green';
const ansClass= d.answer_rate < 80 ? 'red' : d.answer_rate < 90 ? 'orange' : 'green';
const mClass = d.median_wait >120 ? 'red' : d.median_wait > 60 ? 'orange' : 'green';
const toClass = d.timeout_rate > 5 ? 'red' : d.timeout_rate > 2 ? 'orange' : 'green';
document.getElementById('queueMetrics').innerHTML = `
<div class="metric" data-tip="تعداد کل callid یکتا در بازه انتخابی&#10;هر callid یک تماس مستقل است">
<h4>کل تماس‌ها</h4><div class="val">${fmt(d.total_calls)}</div><div class="unit">callid یکتا</div></div>
<div class="metric" data-tip="تماس‌هایی که به اپراتور وصل شدند&#10;خوب: بالای 90% | متوسط: 80-90% | بد: زیر 80%">
<h4>پاسخ‌داده‌شده</h4><div class="val ${ansClass}">${fmt(d.total_answered)} <small>(${d.answer_rate}%)</small></div></div>
<div class="metric" data-tip="تماس‌هایی که مشتری قبل از پاسخ قطع کرد&#10;short abandon (زیر 5 ثانیه) از محاسبه حذف شده&#10;خوب: زیر 5% | متوسط: 5-10% | بد: بالای 10%">
<h4>رهاشده واقعی</h4><div class="val ${aClass}">${fmt(d.total_abandoned)} <small>(${d.abandon_rate}%)</small></div></div>
<div class="metric" data-tip="تماس‌هایی که زمان صف تمام شد و از صف خارج شدند&#10;نشانه ظرفیت ناکافی اپراتور">
<h4>Timeout ⏱️</h4><div class="val ${toClass}">${fmt(d.total_timeout)} <small>(${d.timeout_rate}%)</small></div></div>
<div class="metric" data-tip="تماس‌هایی که مشتری در کمتر از 5 ثانیه قطع کرد&#10;احتمالاً تماس اشتباه یا تست — از همه محاسبات حذف می‌شود">
<h4>Short Abandon ️</h4><div class="val orange">${fmt(d.total_short_abandon)}</div><div class="unit">از محاسبات حذف شده</div></div>
<div class="metric" data-tip="میانه زمان انتظار تماس‌های پاسخ‌داده‌شده&#10;میانه بهتر از میانگین است چون تحت تأثیر مقادیر پرت نیست&#10;خوب: زیر 60s | متوسط: 60-120s | بد: بالای 120s">
<h4>میانه انتظار ⭐</h4><div class="val ${mClass}">${d.median_wait}s</div><div class="unit">${(d.median_wait/60).toFixed(1)} min</div></div>
<div class="metric" data-tip="Average Handle Time = زمان انتظار + زمان مکالمه&#10;معیار اصلی کارایی کل تیم">
<h4>AHT ⭐</h4><div class="val">${d.avg_aht}s</div><div class="unit">${(d.avg_aht/60).toFixed(1)} min | میانه: ${d.median_aht}s</div></div>
<div class="metric" data-tip="میانگین مدت مکالمه اپراتور با مشتری">
<h4>میانگین مکالمه</h4><div class="val">${d.avg_talk}s</div><div class="unit">میانه: ${d.median_talk}s</div></div>
<div class="metric" data-tip="میانگین مدت انتظار تماس‌هایی که رها شدند&#10;اگر کمتر از میانه انتظار پاسخ‌داده‌شده باشد، مشتریان صبورند">
<h4>میانگین انتظار رهاشده</h4><div class="val red">${d.avg_abandon_wait}s</div><div class="unit">میانه: ${d.median_abandon_wait}s</div></div>
`;
if (histChart) histChart.destroy();
histChart = new Chart(document.getElementById('histChart').getContext('2d'), {
type:'bar',
data:{ labels:['0-20s','20-40s','40-60s','60-120s','120s+'],
datasets:[{data:Object.values(d.histogram),
backgroundColor:['#2e7d32','#66bb6a','#ff9800','#ef5350','#b71c1c'],borderRadius:5}]},
options:{responsive:true,plugins:{legend:{display:false}},
scales:{y:{beginAtZero:true},x:{title:{display:true,text:'بازه انتظار'}}}}
});
document.getElementById('timeoutMetrics').innerHTML = `
<div class="metric" data-tip="کل تماس‌هایی که با timeout از صف خارج شدند">
<h4>کل Timeout</h4><div class="val ${toClass}">${fmt(d.total_timeout)}</div><div class="unit">${d.timeout_rate}% از کل</div></div>
<div class="metric" data-tip="میانگین مدت انتظار تماس‌های timeout&#10;یعنی مشتریان این مدت صبر کردند و بعد قطع شدند">
<h4>میانگین انتظار Timeout</h4><div class="val red">${d.avg_timeout_wait}s</div><div class="unit">میانه: ${d.median_timeout_wait}s</div></div>
`;
if (timeoutChart) timeoutChart.destroy();
timeoutChart = new Chart(document.getElementById('timeoutChart').getContext('2d'), {
type:'bar',
data:{labels:d.timeout_hourly.map(h=>`${String(h.hour).padStart(2,'0')}:00`),
datasets:[{label:'Timeout',data:d.timeout_hourly.map(h=>h.count),backgroundColor:'#ef5350',borderRadius:4}]},
options:{responsive:true,plugins:{legend:{display:false}},
scales:{y:{beginAtZero:true},x:{title:{display:true,text:'ساعت'}}}}
});
const rClass = d.repeat_rate > 20 ? 'red' : d.repeat_rate > 10 ? 'orange' : 'green';
document.getElementById('callerMetrics').innerHTML = `
<div class="metric" data-tip="تعداد شماره‌های منحصربه‌فرد که تماس گرفته‌اند&#10;شماره‌ها نرمال‌سازی شده‌اند (09 = 98 = 9)">
<h4>تماس‌گیرندگان یکتا</h4><div class="val">${fmt(d.unique_callers)}</div></div>
<div class="metric" data-tip="تعداد شماره‌هایی که بیش از یک بار تماس گرفته‌اند">
<h4>تماس‌گیرندگان تکراری</h4><div class="val ${rClass}">${fmt(d.repeat_callers)}</div></div>
<div class="metric" data-tip="درصد مشتریانی که بیش از یک بار زنگ زده‌اند&#10;خوب: زیر 10% | متوسط: 10-20% | بد: بالای 20%">
<h4>Repeat Caller Rate</h4><div class="val ${rClass}">${d.repeat_rate}%</div></div>
<div class="metric" data-tip="چه درصد از کل تماس‌ها مربوط به مشتریان تکراری است">
<h4>حجم تماس‌های تکراری</h4><div class="val ${rClass}">${d.repeat_calls_rate}%</div><div class="unit">از کل تماس‌ها</div></div>
`;
const posClass = (d.avg_pos_abandoned>0&&d.avg_pos_answered>0&&d.avg_pos_abandoned>d.avg_pos_answered)?'red':'green';
document.getElementById('posMetrics').innerHTML = `
<div class="metric" data-tip="میانگین جایگاه در صف هنگام پاسخ‌گیری&#10;عدد کمتر = اول صف">
<h4>جایگاه میانگین — پاسخ‌داده</h4><div class="val green">${d.avg_pos_answered}</div></div>
<div class="metric" data-tip="میانگین جایگاه در صف هنگام رهاشدن&#10;اگر از پاسخ‌داده‌شده بیشتر است یعنی مشتریان آخر صف بیشتر تسلیم می‌شوند">
<h4>جایگاه میانگین — رهاشده</h4><div class="val ${posClass}">${d.avg_pos_abandoned}</div></div>
`;
const maxC = Math.max(...d.hourly.map(h=>h.total), 1);
document.getElementById('heatmap').innerHTML = d.hourly.map(h => {
const iv = h.total/maxC;
const r=Math.round(26+iv*157), g=Math.round(35+iv*(-7)), b=Math.round(126+iv*(-90));
return `<div class="hm-cell" style="background:rgb(${r},${g},${b})"
title="ساعت ${h.hour}: ${h.total} تماس | رها: ${h.abandon_rate}%">
<div style="font-weight:bold">${h.total||''}</div>
<div style="font-size:9px">${h.total?h.abandon_rate+'%':''}</div>
</div>`;
}).join('');
document.getElementById('heatmap-labels').innerHTML = d.hourly.map(h =>
`<div class="hm-label">${String(h.hour).padStart(2,'0')}</div>`).join('');
if (hourlyChart) hourlyChart.destroy();
hourlyChart = new Chart(document.getElementById('hourlyChart').getContext('2d'), {
type:'line',
data:{
labels:d.hourly.map(h=>`${String(h.hour).padStart(2,'0')}:00`),
datasets:[
{label:'پاسخ‌داده', data:d.hourly.map(h=>h.answered), borderColor:'#2e7d32',backgroundColor:'rgba(46,125,50,.1)', fill:true, tension:.4},
{label:'رهاشده', data:d.hourly.map(h=>h.abandoned), borderColor:'#c62828',backgroundColor:'rgba(198,40,40,.1)', fill:true, tension:.4},
{label:'Timeout', data:d.hourly.map(h=>h.timeout), borderColor:'#e65100',backgroundColor:'rgba(230,81,0,.1)', fill:false,tension:.4,borderDash:[5,5]},
]
},
options:{responsive:true,plugins:{legend:{position:'top'}},
scales:{y:{beginAtZero:true},x:{title:{display:true,text:'ساعت'}}}}
});
document.getElementById('hourlyTable').innerHTML = `<table>
<thead><tr>
<th data-tip="ساعت روز">ساعت</th>
<th data-tip="کل تماس‌های این ساعت">کل</th>
<th data-tip="تماس‌های پاسخ‌داده‌شده">پاسخ</th>
<th data-tip="تماس‌های رهاشده (بدون short abandon)">رها</th>
<th data-tip="تماس‌های timeout">Timeout</th>
<th data-tip="درصد رهاشدن در این ساعت">نرخ رها</th>
<th data-tip="میانگین انتظار تماس‌های پاسخ‌داده این ساعت">میانگین انتظار</th>
<th data-tip="میانگین AHT این ساعت">AHT</th>
</tr></thead>
<tbody>${d.hourly.filter(h=>h.total>0).map(h => {
const ac=h.abandon_rate>10?'br':h.abandon_rate>5?'bo':'bg';
return `<tr>
<td><strong>${String(h.hour).padStart(2,'0')}:00</strong></td>
<td>${fmt(h.total)}</td>
<td><span class="badge bg">${h.answered}</span></td>
<td><span class="badge br">${h.abandoned}</span></td>
<td><span class="badge bo">${h.timeout}</span></td>
<td><span class="badge ${ac}">${h.abandon_rate}%</span></td>
<td>${h.avg_wait}s</td><td>${h.avg_aht}s</td>
</tr>`;
}).join('')}</tbody></table>`;
const tbody = document.getElementById('agentTableBody');
if (!d.agents.length) {
tbody.innerHTML='<tr><td colspan="11" style="color:#888;text-align:center;">داده‌ای یافت نشد</td></tr>';
} else {
tbody.innerHTML = d.agents.map((a,i) => {
const wC = a.median_wait>120?'br':a.median_wait>60?'bo':'bg';
const ahtD= a.aht_vs_team;
const ahtC= Math.abs(ahtD)<10?'bn':ahtD>0?'br':'bg';
const rC = a.repeat_rate>20?'br':a.repeat_rate>10?'bo':'bg';
const rank= i===0?'🥇':i===1?'🥈':i===2?'🥉':`${i+1}.`;
return `<tr>
<td><strong>${rank} ${a.agent}</strong></td>
<td><span class="badge bg">${fmt(a.answered)}</span></td>
<td><span class="badge bb">${a.share_of_team}%</span></td>
<td><span class="badge ${wC}">${a.median_wait}s</span></td>
<td>${a.avg_talk}s</td><td>${a.median_talk}s</td>
<td><strong>${a.avg_aht}s</strong> <small style="color:#888">(${(a.avg_aht/60).toFixed(1)}m)</small></td>
<td><span class="badge ${ahtC}">${ahtD>0?'+':''}${ahtD}%</span></td>
<td><span class="badge ${rC}">${a.repeat_rate}%</span></td>
<td>${a.avg_position}</td>
<td><span class="badge bb">${a.complete_agent}</span> / <span class="badge bp">${a.complete_caller}</span></td>
</tr>`;
}).join('');
}
showPage('pg-kpi');
}
// ── Call Details ──
async function loadDetails(page) {
const queue = document.getElementById('queue').value;
const agent = document.getElementById('agent').value;
const ff = document.getElementById('fileFilter').value;
const search = document.getElementById('callerSearch').value || '';
const pp = parseInt(document.getElementById('perPage').value) || 50;
const res = await fetch(`/queue/call_details?queue=${queue}&agent=${agent}&file_filter=${ff}&search=${search}&page=${page}&per_page=${pp}`);
const d = await res.json();
if (d.error) {
document.getElementById('detailsBody').innerHTML =
`<tr><td colspan="9" style="color:#888;text-align:center;">${d.error}</td></tr>`;
return;
}
detailsData = d.calls;
document.getElementById('detailsCount').textContent = `${d.total.toLocaleString('en-US')} تماس`;
const om = {
answered :'<span class="badge bg">پاسخ</span>',
abandoned:'<span class="badge br">رها</span>',
timeout :'<span class="badge bo">Timeout</span>',
other :'<span class="badge bn">سایر</span>',
};
document.getElementById('detailsBody').innerHTML = d.calls.map(c => `
<tr>
<td>${c.date}</td><td>${c.time}</td>
<td><strong style="font-family:monospace;letter-spacing:1px">${c.caller_id}</strong></td>
<td>${c.agent}</td>
<td>${om[c.outcome]||c.outcome}</td>
<td>${c.position}</td>
<td>${c.wait_time}</td><td>${c.talk_time}</td><td>${c.aht}</td>
</tr>`).join('');
renderPagination('detailsPagination', page, d.pages, loadDetails);
}
function exportDetailCSV() {
if (!detailsData.length) return;
const h = ['تاریخ','ساعت','شماره','اپراتور','نتیجه','جایگاه','انتظار','مکالمه','AHT'];
const rows = detailsData.map(c =>
[c.date,c.time,c.caller_id,c.agent,c.outcome,c.position,c.wait_time,c.talk_time,c.aht].join(','));
downloadCSV(h.join(',')+'\n'+rows.join('\n'), 'call_details.csv');
}
// ── Repeat Callers ──
async function loadRepeats(page) {
const queue = document.getElementById('queue').value;
const agent = document.getElementById('agent').value;
const ff = document.getElementById('fileFilter').value;
const search = document.getElementById('repeatSearch').value || '';
const pp = parseInt(document.getElementById('repeatPerPage').value) || 50;
const res = await fetch(`/queue/repeat_callers?queue=${queue}&agent=${agent}&file_filter=${ff}&search=${search}&page=${page}&per_page=${pp}`);
const d = await res.json();
repeatsData = d.callers || [];
document.getElementById('repeatsCount').textContent =
`${(d.total||0).toLocaleString('en-US')} تماس‌گیرنده تکراری`;
if (!repeatsData.length) {
document.getElementById('repeatsBody').innerHTML =
'<tr><td colspan="10" style="color:#888;text-align:center;">تماس تکراری یافت نشد</td></tr>';
document.getElementById('repeatsPagination').innerHTML = '';
return;
}
const start = (page - 1) * pp;
document.getElementById('repeatsBody').innerHTML = repeatsData.map((c,i) => {
const cClass = c.call_count >= 5 ? 'br' : c.call_count >= 3 ? 'bo' : 'bb';
return `<tr>
<td>${start+i+1}</td>
<td><strong style="font-family:monospace">${c.caller_id}</strong></td>
<td><span class="badge ${cClass}">${c.call_count}</span></td>
<td><span class="badge bg">${c.answered}</span></td>
<td><span class="badge br">${c.abandoned}</span></td>
<td>${c.first_call}</td><td>${c.last_call}</td>
<td>${c.days_span}</td>
<td style="font-size:12px;text-align:right">${c.agents}</td>
<td>${c.avg_wait}</td>
</tr>`;
}).join('');
renderPagination('repeatsPagination', page, d.pages, loadRepeats);
}
function exportRepeatCSV() {
if (!repeatsData.length) return;
const h = ['شماره','کل تماس','پاسخ','رها','اولین تماس','آخرین تماس','روزها','اپراتورها','میانگین انتظار'];
const rows = repeatsData.map(c =>
[c.caller_id,c.call_count,c.answered,c.abandoned,c.first_call,c.last_call,c.days_span,`"${c.agents}"`,c.avg_wait].join(','));
downloadCSV(h.join(',')+'\n'+rows.join('\n'), 'repeat_callers.csv');
}
// ── Helpers ──
function renderPagination(containerId, page, pages, fn) {
const pag = document.getElementById(containerId);
pag.innerHTML = '';
if (!pages || pages <= 1) return;
const mk = (label, p, active=false, disabled=false) => {
const btn = document.createElement('button');
btn.textContent = label;
btn.className = 'pag-btn' + (active?' active':'');
btn.disabled = disabled;
if (!disabled) btn.onclick = () => fn(p);
return btn;
};
pag.appendChild(mk('◀ قبلی', page-1, false, page<=1));
const s=Math.max(1,page-2), e=Math.min(pages,page+2);
for (let i=s;i<=e;i++) pag.appendChild(mk(i,i,i===page));
pag.appendChild(mk('بعدی ▶', page+1, false, page>=pages));
}
function downloadCSV(content, filename) {
const blob = new Blob(['\ufeff'+content], {type:'text/csv;charset=utf-8'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
}
// ── Init ──
(async () => {
await loadFilesDropdown();
restoreFilters();
await loadFiles();
await loadQueues();
showPage('pg-upload');
})();
</script>
</body>
</html>