this is first one

This commit is contained in:
mohammadreza
2026-07-27 19:22:30 +03:30
commit ec22b78689
5866 changed files with 1511603 additions and 0 deletions
+871
View File
@@ -0,0 +1,871 @@
<!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>