Spaces:
Sleeping
Sleeping
Update frontend/dashboard.html
Browse files- frontend/dashboard.html +102 -44
frontend/dashboard.html
CHANGED
|
@@ -156,63 +156,124 @@
|
|
| 156 |
</main>
|
| 157 |
|
| 158 |
<script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
let rawData = [];
|
| 160 |
let currentPeriod = 'today';
|
| 161 |
let busChart, intChart, sentChart;
|
| 162 |
|
| 163 |
-
// --- HÀM
|
| 164 |
async function fetchRealData() {
|
|
|
|
|
|
|
|
|
|
| 165 |
try {
|
| 166 |
-
// Hiệu ứng
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
const result = await response.json();
|
| 171 |
|
| 172 |
if (result.status === 'success') {
|
| 173 |
-
// Map dữ liệu
|
| 174 |
rawData = result.data.map(item => ({
|
| 175 |
id: item.id || 0,
|
| 176 |
-
time: item.timestamp, //
|
| 177 |
-
dur: item.duration || 0,
|
| 178 |
-
// Xử lý cost: Nếu là dict thì lấy value, không thì lấy chính nó
|
| 179 |
cost: (typeof item.cost === 'object') ? item.cost.value : item.cost,
|
| 180 |
csat: (typeof item.cost === 'object') ? item.cost.csat : (item.csat || 4),
|
| 181 |
-
ai: item.ai_resolved,
|
| 182 |
intent: item.intent || 'general',
|
| 183 |
-
sent: item.sentiment || 'neutral',
|
| 184 |
-
upsell: item.upsell || false
|
| 185 |
}));
|
| 186 |
|
| 187 |
-
// Sắp xếp
|
| 188 |
rawData.sort((a, b) => new Date(b.time) - new Date(a.time));
|
| 189 |
|
| 190 |
loadDashboard(currentPeriod);
|
| 191 |
|
| 192 |
-
// Cập nhật trạng thái
|
| 193 |
-
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
}
|
| 196 |
} catch (error) {
|
| 197 |
-
console.error("
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
}
|
| 200 |
}
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
function formatAHT(seconds) {
|
| 203 |
if (!seconds || isNaN(seconds)) return "00:00";
|
| 204 |
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
| 205 |
const s = (seconds % 60).toString().padStart(2, '0');
|
| 206 |
return `${m}:${s}`;
|
| 207 |
}
|
| 208 |
-
|
| 209 |
function filterData(period) {
|
| 210 |
if (!rawData || rawData.length === 0) return [];
|
| 211 |
const now = new Date();
|
| 212 |
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
| 213 |
-
|
|
|
|
| 214 |
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
| 215 |
-
|
| 216 |
return rawData.filter(item => {
|
| 217 |
const itemTime = new Date(item.time);
|
| 218 |
if (period === 'today') return itemTime >= startOfDay;
|
|
@@ -225,7 +286,7 @@
|
|
| 225 |
function calculateMetrics(data) {
|
| 226 |
const total = data.length;
|
| 227 |
if (total === 0) return { totalCalls: 0, avgCost: 0, aht: 0, fcr: 0, avgCSAT: 0, intents: { network:0, cancel:0, competitor:0, low_data:0 }, sentiments: { pos:0, neu:0, neg:0 } };
|
| 228 |
-
|
| 229 |
const totalCost = data.reduce((sum, c) => sum + (Number(c.cost) || 0), 0);
|
| 230 |
const totalDur = data.reduce((sum, c) => sum + (Number(c.dur) || 0), 0);
|
| 231 |
const totalCSAT = data.reduce((sum, c) => sum + (Number(c.csat) || 0), 0);
|
|
@@ -233,16 +294,17 @@
|
|
| 233 |
|
| 234 |
const intents = { network: 0, cancel: 0, competitor: 0, low_data: 0 };
|
| 235 |
const sents = { pos: 0, neu: 0, neg: 0 };
|
| 236 |
-
|
| 237 |
data.forEach(c => {
|
| 238 |
const i = (c.intent || "").toLowerCase();
|
| 239 |
if(i.includes('network')) intents.network++;
|
| 240 |
else if(i.includes('low_data') || i.includes('data')) intents.low_data++;
|
| 241 |
else if(i.includes('cancel')) intents.cancel++;
|
| 242 |
-
else intents.competitor++;
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
|
|
|
| 246 |
else sents.neu++;
|
| 247 |
});
|
| 248 |
|
|
@@ -260,9 +322,7 @@
|
|
| 260 |
function renderCharts(metrics, period) {
|
| 261 |
Chart.defaults.font.family = 'Plus Jakarta Sans';
|
| 262 |
Chart.defaults.color = '#64748b';
|
| 263 |
-
|
| 264 |
-
// Chart Logic (Vẫn dùng dummy cho đường line chart vì chưa đủ data history)
|
| 265 |
-
// Nhưng Pie/Doughnut dùng data thật
|
| 266 |
const labels = period === 'today' ? ['08:00','10:00','12:00','14:00','16:00'] : ['Th 2','Th 3','Th 4','Th 5','Th 6','Th 7','CN'];
|
| 267 |
const retentionData = [65,68,62,70,75,72,78];
|
| 268 |
const upsellData = [10,12,8,15,14,13,16];
|
|
@@ -304,11 +364,10 @@
|
|
| 304 |
function updateFeed() {
|
| 305 |
const feedContainer = document.getElementById('activity-feed');
|
| 306 |
feedContainer.innerHTML = '';
|
| 307 |
-
// Lấy 10 cuộc gọi gần nhất
|
| 308 |
const recentItems = rawData.slice(0,10);
|
| 309 |
-
|
| 310 |
if(recentItems.length === 0) {
|
| 311 |
-
feedContainer.innerHTML = '<div class="text-center text-slate-400 py-10">Chưa có dữ liệu cuộc gọi</div>';
|
| 312 |
return;
|
| 313 |
}
|
| 314 |
|
|
@@ -318,16 +377,18 @@
|
|
| 318 |
if(item.upsell){
|
| 319 |
iconClass='bg-emerald-100 text-emerald-600'; icon='fa-arrow-trend-up'; title='Upsell Thành công!'; msg=`KH ${item.id} đã chốt đơn.`;
|
| 320 |
}
|
| 321 |
-
else if(item.intent
|
| 322 |
iconClass='bg-purple-100 text-purple-600'; icon='fa-database'; title='Phàn nàn Data'; msg=`KH ${item.id} báo dung lượng thấp.`;
|
| 323 |
}
|
| 324 |
-
else if(item.sent
|
| 325 |
iconClass='bg-rose-100 text-rose-600'; icon='fa-triangle-exclamation'; title='Cảnh báo Rủi ro'; msg=`KH ${item.id} có thái độ tiêu cực.`;
|
| 326 |
}
|
| 327 |
|
| 328 |
-
// Format thời gian
|
| 329 |
let dateObj = new Date(item.time);
|
| 330 |
-
const timeString = isNaN(dateObj) ? "
|
|
|
|
|
|
|
|
|
|
| 331 |
|
| 332 |
feedContainer.innerHTML+=`
|
| 333 |
<div class="flex gap-4 p-4 bg-slate-50 rounded-xl border border-slate-100 hover:bg-white hover:shadow-md transition-all">
|
|
@@ -343,7 +404,7 @@
|
|
| 343 |
<div class="mt-2 flex gap-3 text-[10px] font-semibold text-slate-400 uppercase tracking-wide">
|
| 344 |
<span><i class="fas fa-star text-amber-400 mr-1"></i> ${item.csat}/5</span>
|
| 345 |
<span><i class="fas fa-clock text-blue-400 mr-1"></i> ${item.dur}s</span>
|
| 346 |
-
<span><i class="fas fa-coins text-green-500 mr-1"></i> ${
|
| 347 |
</div>
|
| 348 |
</div>
|
| 349 |
</div>
|
|
@@ -361,25 +422,22 @@
|
|
| 361 |
function loadDashboard(period){
|
| 362 |
const filteredData = filterData(period);
|
| 363 |
const metrics = calculateMetrics(filteredData);
|
| 364 |
-
|
| 365 |
-
// Animate Numbers
|
| 366 |
document.getElementById('kpi-total-calls').innerText = metrics.totalCalls.toLocaleString();
|
| 367 |
document.getElementById('kpi-cost').innerText = metrics.avgCost.toLocaleString();
|
| 368 |
document.getElementById('kpi-aht').innerText = formatAHT(metrics.aht);
|
| 369 |
document.getElementById('kpi-fcr').innerText = metrics.fcr;
|
| 370 |
document.getElementById('fcr-bar').style.width = `${metrics.fcr}%`;
|
| 371 |
document.getElementById('kpi-csat').innerText = metrics.avgCSAT;
|
| 372 |
-
|
| 373 |
renderCharts(metrics, period);
|
| 374 |
updateFeed();
|
| 375 |
}
|
| 376 |
|
| 377 |
document.addEventListener('DOMContentLoaded', ()=>{
|
| 378 |
-
// Gọi lần đầu
|
| 379 |
fetchRealData();
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
setInterval(fetchRealData, 10000);
|
| 383 |
});
|
| 384 |
</script>
|
| 385 |
|
|
|
|
| 156 |
</main>
|
| 157 |
|
| 158 |
<script>
|
| 159 |
+
// --- CẤU HÌNH API THÔNG MINH ---
|
| 160 |
+
// Giúp Dashboard chạy được cả trên Localhost, File HTML rời, và Hugging Face
|
| 161 |
+
function getBaseUrl() {
|
| 162 |
+
const host = window.location.hostname;
|
| 163 |
+
|
| 164 |
+
// 1. Nếu chạy Localhost (IP 127.0.0.1 hoặc localhost)
|
| 165 |
+
if (host === 'localhost' || host === '127.0.0.1') {
|
| 166 |
+
// Mặc định HF Spaces chạy port 7860, Local thường là 8000
|
| 167 |
+
// Bạn có thể sửa thành 8000 nếu chạy local
|
| 168 |
+
return "http://localhost:7860";
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
// 2. Nếu chạy file HTML trực tiếp (file://)
|
| 172 |
+
if (window.location.protocol === 'file:') {
|
| 173 |
+
return "http://localhost:7860";
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// 3. Nếu chạy trên Hugging Face (Production)
|
| 177 |
+
// Trả về chuỗi rỗng để trình duyệt tự nối vào domain hiện tại
|
| 178 |
+
return "";
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
const API_BASE = getBaseUrl();
|
| 182 |
+
console.log("👉 API BASE URL:", API_BASE || "(Relative Path)");
|
| 183 |
+
|
| 184 |
let rawData = [];
|
| 185 |
let currentPeriod = 'today';
|
| 186 |
let busChart, intChart, sentChart;
|
| 187 |
|
| 188 |
+
// --- HÀM GỌI API ---
|
| 189 |
async function fetchRealData() {
|
| 190 |
+
const statusDot = document.getElementById('status-dot');
|
| 191 |
+
const lastUpdate = document.getElementById('last-update');
|
| 192 |
+
|
| 193 |
try {
|
| 194 |
+
// Hiệu ứng đang tải
|
| 195 |
+
statusDot.className = "w-2 h-2 bg-yellow-400 rounded-full animate-ping";
|
| 196 |
+
lastUpdate.innerText = "Syncing...";
|
| 197 |
+
|
| 198 |
+
// Gọi API với đường dẫn đã xử lý
|
| 199 |
+
// Thêm timestamp để tránh cache trình duyệt
|
| 200 |
+
const response = await fetch(`${API_BASE}/api/dashboard-stats?t=${new Date().getTime()}`);
|
| 201 |
|
| 202 |
+
// Kiểm tra nếu Server báo lỗi (VD: 500 Internal Server Error)
|
| 203 |
+
if (!response.ok) {
|
| 204 |
+
throw new Error(`Server Error: ${response.status}`);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
const result = await response.json();
|
| 208 |
|
| 209 |
if (result.status === 'success') {
|
| 210 |
+
// Map dữ liệu
|
| 211 |
rawData = result.data.map(item => ({
|
| 212 |
id: item.id || 0,
|
| 213 |
+
time: item.timestamp || item.time, // Handle cả 2 trường hợp tên biến
|
| 214 |
+
dur: item.duration || item.dur || 0,
|
|
|
|
| 215 |
cost: (typeof item.cost === 'object') ? item.cost.value : item.cost,
|
| 216 |
csat: (typeof item.cost === 'object') ? item.cost.csat : (item.csat || 4),
|
| 217 |
+
ai: item.ai_resolved || item.ai,
|
| 218 |
intent: item.intent || 'general',
|
| 219 |
+
sent: item.sentiment || item.sent || 'neutral',
|
| 220 |
+
upsell: item.upsell_success || item.upsell || false
|
| 221 |
}));
|
| 222 |
|
| 223 |
+
// Sắp xếp
|
| 224 |
rawData.sort((a, b) => new Date(b.time) - new Date(a.time));
|
| 225 |
|
| 226 |
loadDashboard(currentPeriod);
|
| 227 |
|
| 228 |
+
// Cập nhật trạng thái XANH (Thành công)
|
| 229 |
+
statusDot.className = "w-2 h-2 bg-green-400 rounded-full animate-pulse";
|
| 230 |
+
lastUpdate.innerText = new Date().toLocaleTimeString('vi-VN');
|
| 231 |
+
// Xóa thông báo lỗi nếu có
|
| 232 |
+
document.getElementById('connection-error-msg')?.remove();
|
| 233 |
+
} else {
|
| 234 |
+
throw new Error("API trả về dữ liệu lỗi");
|
| 235 |
}
|
| 236 |
} catch (error) {
|
| 237 |
+
console.error("❌ LỖI DASHBOARD:", error);
|
| 238 |
+
|
| 239 |
+
// Cập nhật trạng thái ĐỎ (Lỗi)
|
| 240 |
+
statusDot.className = "w-2 h-2 bg-red-600 rounded-full";
|
| 241 |
+
lastUpdate.innerText = "Mất kết nối";
|
| 242 |
+
|
| 243 |
+
// Hiển thị lỗi lên màn hình để dễ debug trên HF
|
| 244 |
+
showErrorOnScreen(error.message);
|
| 245 |
}
|
| 246 |
}
|
| 247 |
|
| 248 |
+
// Hàm hiển thị lỗi nhỏ trên giao diện (giúp debug nhanh)
|
| 249 |
+
function showErrorOnScreen(msg) {
|
| 250 |
+
if(!document.getElementById('connection-error-msg')) {
|
| 251 |
+
const div = document.createElement('div');
|
| 252 |
+
div.id = 'connection-error-msg';
|
| 253 |
+
div.className = "fixed bottom-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-2 rounded shadow-lg text-xs z-50";
|
| 254 |
+
document.body.appendChild(div);
|
| 255 |
+
div.innerText = "⚠️ Lỗi: " + msg;
|
| 256 |
+
} else {
|
| 257 |
+
document.getElementById('connection-error-msg').innerText = "⚠️ Lỗi: " + msg;
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
// --- CÁC HÀM HELPER & CHART (GIỮ NGUYÊN LOGIC CŨ) ---
|
| 262 |
function formatAHT(seconds) {
|
| 263 |
if (!seconds || isNaN(seconds)) return "00:00";
|
| 264 |
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
| 265 |
const s = (seconds % 60).toString().padStart(2, '0');
|
| 266 |
return `${m}:${s}`;
|
| 267 |
}
|
| 268 |
+
|
| 269 |
function filterData(period) {
|
| 270 |
if (!rawData || rawData.length === 0) return [];
|
| 271 |
const now = new Date();
|
| 272 |
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
| 273 |
+
// Fix logic tuần: Lấy 7 ngày gần nhất
|
| 274 |
+
const startOfWeek = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
| 275 |
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
| 276 |
+
|
| 277 |
return rawData.filter(item => {
|
| 278 |
const itemTime = new Date(item.time);
|
| 279 |
if (period === 'today') return itemTime >= startOfDay;
|
|
|
|
| 286 |
function calculateMetrics(data) {
|
| 287 |
const total = data.length;
|
| 288 |
if (total === 0) return { totalCalls: 0, avgCost: 0, aht: 0, fcr: 0, avgCSAT: 0, intents: { network:0, cancel:0, competitor:0, low_data:0 }, sentiments: { pos:0, neu:0, neg:0 } };
|
| 289 |
+
|
| 290 |
const totalCost = data.reduce((sum, c) => sum + (Number(c.cost) || 0), 0);
|
| 291 |
const totalDur = data.reduce((sum, c) => sum + (Number(c.dur) || 0), 0);
|
| 292 |
const totalCSAT = data.reduce((sum, c) => sum + (Number(c.csat) || 0), 0);
|
|
|
|
| 294 |
|
| 295 |
const intents = { network: 0, cancel: 0, competitor: 0, low_data: 0 };
|
| 296 |
const sents = { pos: 0, neu: 0, neg: 0 };
|
| 297 |
+
|
| 298 |
data.forEach(c => {
|
| 299 |
const i = (c.intent || "").toLowerCase();
|
| 300 |
if(i.includes('network')) intents.network++;
|
| 301 |
else if(i.includes('low_data') || i.includes('data')) intents.low_data++;
|
| 302 |
else if(i.includes('cancel')) intents.cancel++;
|
| 303 |
+
else intents.competitor++;
|
| 304 |
+
|
| 305 |
+
const s = (c.sent || "").toLowerCase();
|
| 306 |
+
if(s.includes('pos')) sents.pos++;
|
| 307 |
+
else if(s.includes('neg')) sents.neg++;
|
| 308 |
else sents.neu++;
|
| 309 |
});
|
| 310 |
|
|
|
|
| 322 |
function renderCharts(metrics, period) {
|
| 323 |
Chart.defaults.font.family = 'Plus Jakarta Sans';
|
| 324 |
Chart.defaults.color = '#64748b';
|
| 325 |
+
|
|
|
|
|
|
|
| 326 |
const labels = period === 'today' ? ['08:00','10:00','12:00','14:00','16:00'] : ['Th 2','Th 3','Th 4','Th 5','Th 6','Th 7','CN'];
|
| 327 |
const retentionData = [65,68,62,70,75,72,78];
|
| 328 |
const upsellData = [10,12,8,15,14,13,16];
|
|
|
|
| 364 |
function updateFeed() {
|
| 365 |
const feedContainer = document.getElementById('activity-feed');
|
| 366 |
feedContainer.innerHTML = '';
|
|
|
|
| 367 |
const recentItems = rawData.slice(0,10);
|
| 368 |
+
|
| 369 |
if(recentItems.length === 0) {
|
| 370 |
+
feedContainer.innerHTML = '<div class="text-center text-slate-400 py-10 text-sm">Chưa có dữ liệu cuộc gọi nào.</div>';
|
| 371 |
return;
|
| 372 |
}
|
| 373 |
|
|
|
|
| 377 |
if(item.upsell){
|
| 378 |
iconClass='bg-emerald-100 text-emerald-600'; icon='fa-arrow-trend-up'; title='Upsell Thành công!'; msg=`KH ${item.id} đã chốt đơn.`;
|
| 379 |
}
|
| 380 |
+
else if(item.intent.includes('low_data')){
|
| 381 |
iconClass='bg-purple-100 text-purple-600'; icon='fa-database'; title='Phàn nàn Data'; msg=`KH ${item.id} báo dung lượng thấp.`;
|
| 382 |
}
|
| 383 |
+
else if(item.sent.includes('neg')){
|
| 384 |
iconClass='bg-rose-100 text-rose-600'; icon='fa-triangle-exclamation'; title='Cảnh báo Rủi ro'; msg=`KH ${item.id} có thái độ tiêu cực.`;
|
| 385 |
}
|
| 386 |
|
|
|
|
| 387 |
let dateObj = new Date(item.time);
|
| 388 |
+
const timeString = isNaN(dateObj) ? "--:--" : dateObj.toLocaleTimeString('vi-VN',{hour:'2-digit',minute:'2-digit'});
|
| 389 |
+
|
| 390 |
+
// Format cost
|
| 391 |
+
const costDisplay = item.cost ? Number(item.cost).toLocaleString() : "0";
|
| 392 |
|
| 393 |
feedContainer.innerHTML+=`
|
| 394 |
<div class="flex gap-4 p-4 bg-slate-50 rounded-xl border border-slate-100 hover:bg-white hover:shadow-md transition-all">
|
|
|
|
| 404 |
<div class="mt-2 flex gap-3 text-[10px] font-semibold text-slate-400 uppercase tracking-wide">
|
| 405 |
<span><i class="fas fa-star text-amber-400 mr-1"></i> ${item.csat}/5</span>
|
| 406 |
<span><i class="fas fa-clock text-blue-400 mr-1"></i> ${item.dur}s</span>
|
| 407 |
+
<span><i class="fas fa-coins text-green-500 mr-1"></i> ${costDisplay}đ</span>
|
| 408 |
</div>
|
| 409 |
</div>
|
| 410 |
</div>
|
|
|
|
| 422 |
function loadDashboard(period){
|
| 423 |
const filteredData = filterData(period);
|
| 424 |
const metrics = calculateMetrics(filteredData);
|
| 425 |
+
|
|
|
|
| 426 |
document.getElementById('kpi-total-calls').innerText = metrics.totalCalls.toLocaleString();
|
| 427 |
document.getElementById('kpi-cost').innerText = metrics.avgCost.toLocaleString();
|
| 428 |
document.getElementById('kpi-aht').innerText = formatAHT(metrics.aht);
|
| 429 |
document.getElementById('kpi-fcr').innerText = metrics.fcr;
|
| 430 |
document.getElementById('fcr-bar').style.width = `${metrics.fcr}%`;
|
| 431 |
document.getElementById('kpi-csat').innerText = metrics.avgCSAT;
|
| 432 |
+
|
| 433 |
renderCharts(metrics, period);
|
| 434 |
updateFeed();
|
| 435 |
}
|
| 436 |
|
| 437 |
document.addEventListener('DOMContentLoaded', ()=>{
|
|
|
|
| 438 |
fetchRealData();
|
| 439 |
+
// Refresh mỗi 5s
|
| 440 |
+
setInterval(fetchRealData, 5000);
|
|
|
|
| 441 |
});
|
| 442 |
</script>
|
| 443 |
|