PINE-AI-Amdocs / frontend /dashboard.html
dammmmmmmmm's picture
Update frontend/dashboard.html
4751f7c verified
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PINE AI - VNPT Smart Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
:root { --vnpt-blue: #00a1e4; --vnpt-dark: #0072bc; --bg-body: #f8fafc; }
body { font-family: 'Plus Jakarta Sans', sans-serif; background-color: var(--bg-body); color: #0f172a; }
.sticky-header { position: sticky; top: 0; z-index: 50; background: linear-gradient(135deg, var(--vnpt-blue) 0%, var(--vnpt-dark) 100%); backdrop-filter: blur(12px); box-shadow: 0 4px 20px rgba(0,0,0,0.15); }
.mascot-container { width:60px;height:60px;background:rgba(255,255,255,0.2);border-radius:50%;display:flex;align-items:center;justify-content:center;border:2px solid rgba(255,255,255,0.5);transition:all 0.3s ease; }
.mascot-container:hover { transform: scale(1.1) rotate(5deg); }
.mascot-container img { width:75%; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2)); }
.glass-card { background:#ffffff; border:1px solid #e2e8f0; box-shadow:0 4px 6px -1px rgba(0,0,0,0.05),0 2px 4px -1px rgba(0,0,0,0.03); border-radius:1rem; transition:all 0.3s ease; height:100%; }
.glass-card:hover { transform:translateY(-2px); box-shadow:0 10px 15px -3px rgba(0,0,0,0.05),0 4px 6px -2px rgba(0,0,0,0.025); }
.tab-btn { padding:0.5rem 1.5rem; border-radius:0.75rem; font-size:0.875rem; font-weight:700; color: rgba(255,255,255,0.8); transition:all 0.2s; }
.tab-btn:hover { color:#fff; background: rgba(255,255,255,0.1); }
.tab-active { background:#ffffff !important; color: var(--vnpt-dark) !important; box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
::-webkit-scrollbar { width:6px; }
::-webkit-scrollbar-track { background:#f1f5f9; }
::-webkit-scrollbar-thumb { background:#cbd5e1; border-radius:10px; }
::-webkit-scrollbar-thumb:hover { background:#94a3b8; }
</style>
</head>
<body class="antialiased">
<header class="sticky-header px-6 py-3">
<div class="max-w-[1600px] mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
<div class="flex items-center gap-4">
<div class="mascot-container">
<img src="https://github.com/chydua/PINE/blob/main/Screenshot_2025-12-18_at_19.19.29-removebg-preview.png?raw=true" alt="PINE">
</div>
<div>
<h1 class="text-2xl font-extrabold text-white tracking-tight">PINE AI <span class="opacity-70 font-normal text-lg">| Dashboard</span></h1>
<p class="text-xs text-white/80 font-medium uppercase tracking-wider flex items-center gap-2">
<span id="status-dot" class="w-2 h-2 bg-red-400 rounded-full"></span>
Last updated: <span id="last-update">Waiting...</span>
</p>
</div>
</div>
<div class="flex bg-white/20 p-1 rounded-2xl backdrop-blur-md">
<button onclick="switchPeriod('today', event)" class="tab-btn tab-active filter-btn">HÔM NAY</button>
<button onclick="switchPeriod('week', event)" class="tab-btn filter-btn">TUẦN NÀY</button>
<button onclick="switchPeriod('month', event)" class="tab-btn filter-btn">THÁNG NÀY</button>
</div>
</div>
</header>
<main class="max-w-[1600px] mx-auto px-6 py-8 space-y-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
<div class="glass-card p-6 border-l-4 border-sky-500">
<p class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">Tổng cuộc gọi</p>
<h2 class="text-3xl font-black text-slate-800" id="kpi-total-calls">0</h2>
<div class="mt-2 text-xs text-green-600 font-bold flex items-center gap-1">
<i class="fas fa-signal"></i> Real-time Data
</div>
</div>
<div class="glass-card p-6 border-l-4 border-emerald-500">
<p class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">AI Tự xử lý (FCR)</p>
<h2 class="text-3xl font-black text-slate-800 flex items-baseline">
<span id="kpi-fcr">0</span><span class="text-lg text-slate-400 font-bold ml-1">%</span>
</h2>
<div class="w-full bg-slate-100 h-1.5 rounded-full mt-3 overflow-hidden">
<div id="fcr-bar" class="bg-emerald-500 h-full" style="width: 0%"></div>
</div>
</div>
<div class="glass-card p-6 border-l-4 border-amber-500">
<p class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">Chi phí / Cuộc</p>
<h2 class="text-3xl font-black text-slate-800 flex items-baseline">
<span id="kpi-cost">0</span><span class="text-sm text-slate-400 font-bold ml-1">VNĐ</span>
</h2>
</div>
<div class="glass-card p-6 border-l-4 border-blue-500">
<p class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">Thời gian xử lý (AHT)</p>
<h2 class="text-3xl font-black text-slate-800" id="kpi-aht">00:00</h2>
</div>
<div class="glass-card p-6 border-l-4 border-indigo-500">
<p class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">Hài lòng (CSAT)</p>
<h2 class="text-3xl font-black text-slate-800 flex items-baseline">
<span id="kpi-csat">0.0</span><span class="text-lg text-slate-400 font-bold ml-1">/ 5</span>
</h2>
<div class="mt-2 text-amber-400 text-sm flex gap-1">
<i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star-half-alt"></i>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div class="lg:col-span-8 glass-card p-6">
<h3 class="text-lg font-bold text-slate-800 mb-6 flex items-center gap-3">
<span class="w-8 h-8 rounded-lg bg-blue-100 text-blue-600 flex items-center justify-center"><i class="fas fa-chart-line"></i></span>
Hiệu quả Kinh doanh
</h3>
<div class="h-[320px] w-full">
<canvas id="businessChart"></canvas>
</div>
</div>
<div class="lg:col-span-4 glass-card p-6">
<h3 class="text-lg font-bold text-slate-800 mb-6 flex items-center gap-3">
<span class="w-8 h-8 rounded-lg bg-red-100 text-red-600 flex items-center justify-center"><i class="fas fa-bullseye"></i></span>
Tỉ lệ Dự định (Intent)
</h3>
<div class="h-[250px] relative mb-4">
<canvas id="intentChart"></canvas>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="bg-slate-50 p-2 rounded-lg text-xs font-bold text-slate-600 border border-slate-100 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-[#00a1e4]"></span> Mạng yếu
</div>
<div class="bg-slate-50 p-2 rounded-lg text-xs font-bold text-slate-600 border border-slate-100 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-[#ef4444]"></span> Hủy gói
</div>
<div class="bg-slate-50 p-2 rounded-lg text-xs font-bold text-slate-600 border border-slate-100 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-[#9b59b6]"></span> Ít Data
</div>
<div class="bg-slate-50 p-2 rounded-lg text-xs font-bold text-slate-600 border border-slate-100 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-[#eab308]"></span> Đối thủ
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div class="lg:col-span-8 glass-card p-6">
<h3 class="text-lg font-bold text-slate-800 mb-6 flex items-center gap-3">
<span class="w-8 h-8 rounded-lg bg-emerald-100 text-emerald-600 flex items-center justify-center"><i class="fas fa-satellite-dish"></i></span>
Nhật ký Hoạt động (Live Feed)
</h3>
<div id="activity-feed" class="space-y-3 h-[300px] overflow-y-auto pr-2 custom-scrollbar"></div>
</div>
<div class="lg:col-span-4 glass-card p-6 flex flex-col">
<h3 class="text-lg font-bold text-slate-800 mb-6 flex items-center gap-3">
<span class="w-8 h-8 rounded-lg bg-amber-100 text-amber-600 flex items-center justify-center"><i class="fas fa-face-smile"></i></span>
Cảm xúc Khách hàng
</h3>
<div class="h-[250px] w-full my-auto">
<canvas id="sentimentChart"></canvas>
</div>
</div>
</div>
<footer class="text-center pt-8 pb-4 text-slate-400 text-xs font-bold uppercase tracking-[0.2em]">
&copy; 2025 PINE AI - GIẢI PHÁP CSKH THÔNG MINH
</footer>
</main>
<script>
// --- CẤU HÌNH API THÔNG MINH ---
// Giúp Dashboard chạy được cả trên Localhost, File HTML rời, và Hugging Face
function getBaseUrl() {
const host = window.location.hostname;
// 1. Nếu chạy Localhost (IP 127.0.0.1 hoặc localhost)
if (host === 'localhost' || host === '127.0.0.1') {
// Mặc định HF Spaces chạy port 7860, Local thường là 8000
// Bạn có thể sửa thành 8000 nếu chạy local
return "http://localhost:7860";
}
// 2. Nếu chạy file HTML trực tiếp (file://)
if (window.location.protocol === 'file:') {
return "http://localhost:7860";
}
// 3. Nếu chạy trên Hugging Face (Production)
// Trả về chuỗi rỗng để trình duyệt tự nối vào domain hiện tại
return "";
}
const API_BASE = getBaseUrl();
console.log("👉 API BASE URL:", API_BASE || "(Relative Path)");
let rawData = [];
let currentPeriod = 'today';
let busChart, intChart, sentChart;
// --- HÀM GỌI API ---
async function fetchRealData() {
const statusDot = document.getElementById('status-dot');
const lastUpdate = document.getElementById('last-update');
try {
// Hiệu ứng đang tải
statusDot.className = "w-2 h-2 bg-yellow-400 rounded-full animate-ping";
lastUpdate.innerText = "Syncing...";
// Gọi API với đường dẫn đã xử lý
// Thêm timestamp để tránh cache trình duyệt
const response = await fetch(`${API_BASE}/api/dashboard-stats?t=${new Date().getTime()}`);
// Kiểm tra nếu Server báo lỗi (VD: 500 Internal Server Error)
if (!response.ok) {
throw new Error(`Server Error: ${response.status}`);
}
const result = await response.json();
if (result.status === 'success') {
// Map dữ liệu
rawData = result.data.map(item => ({
id: item.id || 0,
time: item.timestamp || item.time, // Handle cả 2 trường hợp tên biến
dur: item.duration || item.dur || 0,
cost: (typeof item.cost === 'object') ? item.cost.value : item.cost,
csat: (typeof item.cost === 'object') ? item.cost.csat : (item.csat || 4),
ai: item.ai_resolved || item.ai,
intent: item.intent || 'general',
sent: item.sentiment || item.sent || 'neutral',
upsell: item.upsell_success || item.upsell || false
}));
// Sắp xếp
rawData.sort((a, b) => new Date(b.time) - new Date(a.time));
loadDashboard(currentPeriod);
// Cập nhật trạng thái XANH (Thành công)
statusDot.className = "w-2 h-2 bg-green-400 rounded-full animate-pulse";
lastUpdate.innerText = new Date().toLocaleTimeString('vi-VN');
// Xóa thông báo lỗi nếu có
document.getElementById('connection-error-msg')?.remove();
} else {
throw new Error("API trả về dữ liệu lỗi");
}
} catch (error) {
console.error("❌ LỖI DASHBOARD:", error);
// Cập nhật trạng thái ĐỎ (Lỗi)
statusDot.className = "w-2 h-2 bg-red-600 rounded-full";
lastUpdate.innerText = "Mất kết nối";
// Hiển thị lỗi lên màn hình để dễ debug trên HF
showErrorOnScreen(error.message);
}
}
// Hàm hiển thị lỗi nhỏ trên giao diện (giúp debug nhanh)
function showErrorOnScreen(msg) {
if(!document.getElementById('connection-error-msg')) {
const div = document.createElement('div');
div.id = 'connection-error-msg';
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";
document.body.appendChild(div);
div.innerText = "⚠️ Lỗi: " + msg;
} else {
document.getElementById('connection-error-msg').innerText = "⚠️ Lỗi: " + msg;
}
}
// --- CÁC HÀM HELPER & CHART ---
function formatAHT(seconds) {
if (!seconds || isNaN(seconds)) return "00:00";
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
const s = (seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
}
function filterData(period) {
if (!rawData || rawData.length === 0) return [];
const now = new Date();
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
// Fix logic tuần: Lấy 7 ngày gần nhất
const startOfWeek = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
return rawData.filter(item => {
const itemTime = new Date(item.time);
if (period === 'today') return itemTime >= startOfDay;
if (period === 'week') return itemTime >= startOfWeek;
if (period === 'month') return itemTime >= startOfMonth;
return true;
});
}
function calculateMetrics(data) {
const total = data.length;
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 } };
const totalCost = data.reduce((sum, c) => sum + (Number(c.cost) || 0), 0);
const totalDur = data.reduce((sum, c) => sum + (Number(c.dur) || 0), 0);
const totalCSAT = data.reduce((sum, c) => sum + (Number(c.csat) || 0), 0);
const fcrCount = data.filter(c => c.ai).length;
const intents = { network: 0, cancel: 0, competitor: 0, low_data: 0 };
const sents = { pos: 0, neu: 0, neg: 0 };
data.forEach(c => {
const i = (c.intent || "").toLowerCase();
if(i.includes('network')) intents.network++;
else if(i.includes('low_data') || i.includes('data')) intents.low_data++;
else if(i.includes('cancel')) intents.cancel++;
else intents.competitor++;
const s = (c.sent || "").toLowerCase();
if(s.includes('pos')) sents.pos++;
else if(s.includes('neg')) sents.neg++;
else sents.neu++;
});
return {
totalCalls: total,
avgCost: Math.round(totalCost / total),
aht: Math.round(totalDur / total),
fcr: ((fcrCount / total) * 100).toFixed(1),
avgCSAT: (totalCSAT / total).toFixed(1),
intents: intents,
sentiments: sents
};
}
function renderCharts(metrics, period) {
Chart.defaults.font.family = 'Plus Jakarta Sans';
Chart.defaults.color = '#64748b';
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'];
const retentionData = [65,68,62,70,75,72,78];
const upsellData = [10,12,8,15,14,13,16];
// --- 1. BIỂU ĐỒ KINH DOANH (Line Chart) ---
const busCtx = document.getElementById('businessChart').getContext('2d');
if (busChart) {
// Nếu đã có, chỉ cập nhật data
busChart.data.labels = labels;
busChart.data.datasets[0].data = retentionData.slice(0,labels.length);
busChart.data.datasets[1].data = upsellData.slice(0,labels.length);
busChart.update('none'); // 'none' mode: Cập nhật ngay lập tức, không chạy animation
} else {
// Nếu chưa có thì tạo mới
busChart = new Chart(busCtx,{
type:'line',
data:{ labels, datasets:[
{label:'Retention (%)', data: retentionData.slice(0,labels.length), borderColor:'#10b981', backgroundColor:'rgba(16,185,129,0.1)', tension:0.4, fill:true, borderWidth:3},
{label:'Upsell (%)', data: upsellData.slice(0,labels.length), borderColor:'#0ea5e9', backgroundColor:'rgba(14,165,233,0.1)', tension:0.4, fill:true, borderWidth:3}
]},
options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{position:'top'} }, scales:{ y:{beginAtZero:true, grid:{color:'#f1f5f9'}}, x:{grid:{display:false}}} }
});
}
// --- 2. BIỂU ĐỒ INTENT (Doughnut Chart) ---
const intCtx = document.getElementById('intentChart').getContext('2d');
if (intChart) {
// Cập nhật data
intChart.data.datasets[0].data = [metrics.intents.network, metrics.intents.cancel, metrics.intents.competitor, metrics.intents.low_data];
intChart.update('none');
} else {
// Tạo mới
intChart = new Chart(intCtx,{
type:'doughnut',
data:{
labels:['Mạng yếu','Huỷ gói','Đối thủ','Ít Data'],
datasets:[{ data:[metrics.intents.network, metrics.intents.cancel, metrics.intents.competitor, metrics.intents.low_data], backgroundColor:['#0ea5e9','#ef4444','#eab308','#a855f7'], borderWidth:0 }]
},
options:{ responsive:true, maintainAspectRatio:false, cutout:'75%', plugins:{ legend:{display:false} } }
});
}
// --- 3. BIỂU ĐỒ CẢM XÚC (Pie Chart) ---
const sentCtx = document.getElementById('sentimentChart').getContext('2d');
if (sentChart) {
// Cập nhật data
sentChart.data.datasets[0].data = [metrics.sentiments.pos, metrics.sentiments.neu, metrics.sentiments.neg];
sentChart.update('none');
} else {
// Tạo mới
sentChart = new Chart(sentCtx,{
type:'pie',
data:{
labels:['Tích cực','Trung lập','Tiêu cực'],
datasets:[{ data:[metrics.sentiments.pos, metrics.sentiments.neu, metrics.sentiments.neg], backgroundColor:['#10b981','#cbd5e1','#f43f5e'], borderWidth:0 }]
},
options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{position:'bottom'} } }
});
}
}
function updateFeed() {
const feedContainer = document.getElementById('activity-feed');
feedContainer.innerHTML = '';
const recentItems = rawData.slice(0,10);
if(recentItems.length === 0) {
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>';
return;
}
recentItems.forEach(item=>{
let iconClass='bg-sky-100 text-sky-600', icon='fa-phone', title=`Cuộc gọi ID: ${item.id}`, msg=`Kết thúc. Intent: <b class="text-slate-700">${item.intent}</b>`;
if(item.upsell){
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.`;
}
else if(item.intent.includes('low_data')){
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.`;
}
else if(item.sent.includes('neg')){
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.`;
}
let dateObj = new Date(item.time);
const timeString = isNaN(dateObj) ? "--:--" : dateObj.toLocaleTimeString('vi-VN',{hour:'2-digit',minute:'2-digit'});
// Format cost
const costDisplay = item.cost ? Number(item.cost).toLocaleString() : "0";
feedContainer.innerHTML+=`
<div class="flex gap-4 p-4 bg-slate-50 rounded-xl border border-slate-100 hover:bg-white hover:shadow-md transition-all">
<div class="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${iconClass}">
<i class="fas ${icon}"></i>
</div>
<div class="flex-1">
<div class="flex justify-between items-start">
<h4 class="text-sm font-bold text-slate-800">${title}</h4>
<span class="text-[10px] font-bold text-slate-400 bg-white px-2 py-1 rounded-full border">${timeString}</span>
</div>
<p class="text-xs text-slate-600 mt-1 leading-relaxed">${msg}</p>
<div class="mt-2 flex gap-3 text-[10px] font-semibold text-slate-400 uppercase tracking-wide">
<span><i class="fas fa-star text-amber-400 mr-1"></i> ${item.csat}/5</span>
<span><i class="fas fa-clock text-blue-400 mr-1"></i> ${item.dur}s</span>
<span><i class="fas fa-coins text-green-500 mr-1"></i> ${costDisplay}đ</span>
</div>
</div>
</div>
`;
});
}
function switchPeriod(period, e){
currentPeriod=period;
document.querySelectorAll('.tab-btn').forEach(btn=>btn.classList.remove('tab-active'));
if(e?.target) e.target.classList.add('tab-active');
loadDashboard(period);
}
function loadDashboard(period){
const filteredData = filterData(period);
const metrics = calculateMetrics(filteredData);
document.getElementById('kpi-total-calls').innerText = metrics.totalCalls.toLocaleString();
document.getElementById('kpi-cost').innerText = metrics.avgCost.toLocaleString();
document.getElementById('kpi-aht').innerText = formatAHT(metrics.aht);
document.getElementById('kpi-fcr').innerText = metrics.fcr;
document.getElementById('fcr-bar').style.width = `${metrics.fcr}%`;
document.getElementById('kpi-csat').innerText = metrics.avgCSAT;
renderCharts(metrics, period);
updateFeed();
}
document.addEventListener('DOMContentLoaded', ()=>{
fetchRealData();
// Refresh mỗi 5s
setInterval(fetchRealData, 5000);
});
</script>
</body>
</html>