/* AAU CRM — Call Log + QC, Inbox Daily Report, Data Sync Explorer */
/* ── Date range + period comparison ── */
const REPORT_TODAY = new Date('2026-06-02T00:00:00');
const CMP_PROFILE = {
none: null,
prev: { label: 'kỳ trước liền kề', shift: 'adjacent', f: 0.9, respond: 89, frt: 4.7, qc: 7.6, callQc: 6.6, pass: 44 },
lastmonth: { label: 'cùng kỳ tháng trước', shift: 30, f: 0.8, respond: 86, frt: 5.3, qc: 7.3, callQc: 6.4, pass: 41 },
};
function fmtDM(d) { return ('0' + d.getDate()).slice(-2) + '/' + ('0' + (d.getMonth() + 1)).slice(-2); }
function rangeDays(range, custom) {
if (range === 'custom') {
const p = s => { const [d, m, y] = s.split('/').map(Number); return new Date(y, m - 1, d); };
try { return Math.max(1, Math.round((p(custom.to) - p(custom.from)) / 864e5) + 1); } catch (e) { return 14; }
}
return { '1d': 1, '7d': 7, '30d': 30 }[range] || 1;
}
function spanForDays(days, endOffset = 0) {
const end = new Date(REPORT_TODAY); end.setDate(end.getDate() - endOffset);
const start = new Date(end); start.setDate(start.getDate() - (days - 1));
return { label: fmtDM(start) + ' – ' + fmtDM(end) };
}
function compareSpan(days, cmpKey) {
const p = CMP_PROFILE[cmpKey]; if (!p) return null;
return p.shift === 'adjacent' ? spanForDays(days, days) : spanForDays(days, p.shift);
}
function deltaPct(cur, cmp) { return cmp ? Math.round(((cur - cmp) / cmp) * 100) : 0; }
function DateRangeBar({ range, setRange, custom, setCustom, cmp, setCmp }) {
const days = rangeDays(range, custom);
const cur = range === 'custom' ? { label: custom.from + ' – ' + custom.to } : spanForDays(days);
const cs = compareSpan(days, cmp);
return (
{range === 'custom' && (
setCustom({ ...custom, from: v })} style={{ width: 132 }} />
→
setCustom({ ...custom, to: v })} style={{ width: 132 }} />
)}
{cur.label} · {days} ngày
So sánh với:
{cs && ({cs.label})}
);
}
function InboxHeatmap({ grid: realGrid }) {
const days = ['T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'CN'];
const bands = ['8–10h', '10–12h', '12–14h', '14–16h', '16–18h', '18–20h'];
// [day][band]
const grid = realGrid || [
[14, 28, 16, 30, 38, 22],
[18, 32, 18, 34, 40, 26],
[16, 30, 14, 36, 44, 30],
[20, 34, 20, 38, 46, 28],
[22, 36, 18, 32, 42, 34],
[28, 40, 24, 30, 36, 44],
[24, 30, 20, 22, 28, 38],
];
const max = Math.max(1, ...grid.flat());
return (
{bands.map(b =>
{b}
)}
{days.map((d, di) => (
{d}
{grid[di].map((v, bi) => { const a = 0.12 + 0.88 * (v / max); return (
0.55 ? '#fff' : '#573b8a', fontSize: 11, fontWeight: 600, cursor: 'default' }}>{v}
); })}
))}
Ít
{[0.18, 0.4, 0.6, 0.8, 1].map(a => )}
Nhiều
Khung 16–18h T4–T6 là giờ vàng — nên xếp đủ trực.
);
}
const DISPO = { consulted: { l: 'Đã tư vấn', tone: 'success', c: '#0a9e6e' }, callback: { l: 'Hẹn gọi lại', tone: 'info', c: '#3b82f6' }, noanswer: { l: 'Không nghe máy', tone: 'warning', c: '#d97706' }, refused: { l: 'Từ chối', tone: 'critical', c: '#ef4444' }, wrong: { l: 'Sai số', tone: 'neutral', c: '#9ca3af' } };
const CALLTYPE = { new: 'Tư vấn mới', followup: 'Follow-up', care: 'CSKH' };
const CALL_DATA = [
{ id: 'c1', leadId: 'L1', dir: 'out', dur: '8:42', agent: 'u5', mins: 35, qcA: 8.2, qcH: 8.5, status: 'reviewed', disp: 'consulted', type: 'followup', talk: { a: 58, c: 42, int: 1, sil: 6 }, kw: ['nhượng quyền', 'học phí'], viol: [] },
{ id: 'c2', leadId: 'L2', dir: 'out', dur: '12:10', agent: 'u4', mins: 80, qcA: 9.0, qcH: 9.1, status: 'reviewed', disp: 'consulted', type: 'followup', talk: { a: 62, c: 38, int: 0, sil: 4 }, kw: ['proposal', 'vận hành'], viol: [] },
{ id: 'c3', leadId: 'L3', dir: 'in', dur: '3:24', agent: 'u4', mins: 120, qcA: 5.8, qcH: 5.5, status: 'disputed', disp: 'noanswer', type: 'new', talk: { a: 70, c: 30, int: 3, sil: 18 }, kw: ['marketing'], viol: ['Báo giá khi chưa qualify', 'Không chốt lịch hẹn'] },
{ id: 'c4', leadId: 'L6', dir: 'out', dur: '1:05', agent: 'u4', mins: 200, qcA: 4.2, qcH: null, status: 'auto', disp: 'noanswer', type: 'followup', talk: { a: 88, c: 12, int: 0, sil: 40 }, kw: [], viol: ['Không lắng nghe nhu cầu'] },
{ id: 'c5', leadId: 'L4', dir: 'out', dur: '6:30', agent: 'u9', mins: 240, qcA: 6.6, qcH: null, status: 'auto', disp: 'callback', type: 'new', talk: { a: 55, c: 45, int: 2, sil: 8 }, kw: ['AI', 'online'], viol: ['Không chốt lịch hẹn'] },
{ id: 'c6', leadId: 'L5', dir: 'out', dur: '9:12', agent: 'u5', mins: 300, qcA: 7.4, qcH: 7.6, status: 'reviewed', disp: 'consulted', type: 'new', talk: { a: 60, c: 40, int: 1, sil: 5 }, kw: ['nhượng quyền', 'trà sữa'], viol: [] },
{ id: 'c7', leadId: 'L8', dir: 'out', dur: '4:48', agent: 'u4', mins: 360, qcA: 8.1, qcH: null, status: 'auto', disp: 'consulted', type: 'new', talk: { a: 57, c: 43, int: 0, sil: 7 }, kw: ['chi phí', 'hải sản'], viol: [] },
{ id: 'c8', leadId: 'L7', dir: 'in', dur: '2:10', agent: 'u9', mins: 420, qcA: 5.0, qcH: 4.8, status: 'reviewed', disp: 'refused', type: 'new', talk: { a: 64, c: 36, int: 4, sil: 12 }, kw: ['giảm giá'], viol: ['Báo giá khi chưa qualify', 'Thái độ chưa chuẩn'] },
{ id: 'c9', leadId: 'L9', dir: 'out', dur: '0:48', agent: 'u9', mins: 480, qcA: 3.6, qcH: null, status: 'auto', disp: 'wrong', type: 'new', talk: { a: 90, c: 10, int: 0, sil: 30 }, kw: [], viol: ['Sai số'] },
{ id: 'c10', leadId: 'L10', dir: 'out', dur: '7:20', agent: 'u4', mins: 540, qcA: 8.8, qcH: 9.0, status: 'reviewed', disp: 'consulted', type: 'care', talk: { a: 61, c: 39, int: 0, sil: 3 }, kw: ['nâng cao', 'gia hạn'], viol: [] },
{ id: 'c11', leadId: 'L2', dir: 'out', dur: '5:55', agent: 'u5', mins: 600, qcA: 7.0, qcH: null, status: 'auto', disp: 'callback', type: 'followup', talk: { a: 59, c: 41, int: 1, sil: 9 }, kw: ['proposal'], viol: ['Không chốt lịch hẹn'] },
{ id: 'c12', leadId: 'L3', dir: 'out', dur: '3:02', agent: 'u5', mins: 660, qcA: 6.2, qcH: null, status: 'auto', disp: 'consulted', type: 'followup', talk: { a: 66, c: 34, int: 2, sil: 11 }, kw: ['đối thủ'], viol: ['Xử lý phản đối yếu'] },
{ id: 'c13', leadId: 'L1', dir: 'in', dur: '10:05', agent: 'u5', mins: 720, qcA: 8.4, qcH: 8.6, status: 'reviewed', disp: 'consulted', type: 'followup', talk: { a: 54, c: 46, int: 0, sil: 5 }, kw: ['chốt', 'early-bird'], viol: [] },
{ id: 'c14', leadId: 'L5', dir: 'out', dur: '2:40', agent: 'u9', mins: 800, qcA: 5.4, qcH: null, status: 'auto', disp: 'noanswer', type: 'new', talk: { a: 80, c: 20, int: 1, sil: 22 }, kw: [], viol: [] },
{ id: 'c15', leadId: 'L4', dir: 'out', dur: '6:14', agent: 'u4', mins: 880, qcA: 7.8, qcH: null, status: 'auto', disp: 'callback', type: 'followup', talk: { a: 58, c: 42, int: 0, sil: 6 }, kw: ['lịch khai giảng'], viol: [] },
{ id: 'c16', leadId: 'L8', dir: 'out', dur: '4:30', agent: 'u5', mins: 960, qcA: 6.9, qcH: null, status: 'auto', disp: 'consulted', type: 'new', talk: { a: 63, c: 37, int: 2, sil: 8 }, kw: ['chi phí'], viol: ['Không chốt lịch hẹn'] },
];
function callScore(c) { return c.qcH != null ? c.qcH : c.qcA; }
function callVerdict(c) { return callScore(c) >= 7 ? 'pass' : 'fail'; }
const SAMPLE_TRANSCRIPT = [
{ t: '00:03', who: 'agent', s: 'pos', text: 'Dạ em chào anh, em gọi từ AAU Academy về khóa anh đang quan tâm ạ.' },
{ t: '00:11', who: 'cust', s: 'neu', text: 'Ừ, mà anh đang hơi bận, học phí nhiêu vậy em?' },
{ t: '00:18', who: 'agent', s: 'neg', text: 'Dạ khóa học phí 18.9 triệu anh ạ.', viol: 'Báo giá khi chưa qualify' },
{ t: '00:27', who: 'cust', s: 'neg', text: 'Đắt vậy, bên đối thủ rẻ hơn mà.' },
{ t: '00:35', who: 'agent', s: 'neu', text: 'Dạ để em gửi anh so sánh giá trị và case study chuỗi tương tự nhé.' },
{ t: '00:48', who: 'cust', s: 'pos', text: 'Ừ em gửi đi, mà chốt lịch khai giảng giúp anh luôn.' },
];
function hl(text, kws) {
if (!kws || !kws.length) return text;
let nodes = [text];
kws.forEach((k, ki) => {
const next = [];
nodes.forEach(node => {
if (typeof node !== 'string') { next.push(node); return; }
const low = node.toLowerCase(), kl = k.toLowerCase();
let idx = 0, pos;
while ((pos = low.indexOf(kl, idx)) !== -1) {
if (pos > idx) next.push(node.slice(idx, pos));
next.push({node.slice(pos, pos + k.length)});
idx = pos + k.length;
}
if (idx < node.length) next.push(node.slice(idx));
});
nodes = next;
});
return nodes;
}
function TalkBar({ talk }) {
const over = talk.a > 65;
return (
Agent {talk.a}%Khách {talk.c}%
Ngắt lời: {talk.int}Khoảng lặng dài: {talk.sil}s{over && Agent nói quá nhiều}
);
}
function CoachItem({ who, at, text, ack }) {
return (
{who}{at}{ack ? Agent đã đọc : Chờ xác nhận}
{text}
);
}
// Chuẩn hoá 1 cuộc gọi từ backend (AAU.calls: {id,leadId,dir,duration,agent,at,qc,verdict})
// về shape mà UI QC cần — các field QC sâu (talk/viol/kw) để mặc định rỗng cho tới khi
// engine QC chấm (CQA). KHÔNG còn dùng CALL_DATA hardcode.
function normCall(c) {
const dispMap = { pass: 'consulted', fail: 'refused', callback: 'callback', 'no-answer': 'noanswer', noanswer: 'noanswer' };
return {
id: c.id, leadId: c.leadId, dir: c.dir || 'out',
dur: c.duration || c.dur || '—', agent: c.agent, mins: c.mins || 0,
qcA: c.qc != null ? c.qc : (c.qcA != null ? c.qcA : null),
qcH: c.qcH != null ? c.qcH : null, status: c.status || 'auto',
disp: c.disp || dispMap[c.verdict] || 'consulted', type: c.type || 'new',
talk: c.talk || { a: 0, c: 0, int: 0, sil: 0 }, kw: c.kw || [], viol: c.viol || [], at: c.at,
};
}
function CallLogEmpty() {
return (
);
}
// Wrapper: rỗng → EmptyState (tránh chia 0 / crash); có data → body đọc AAU.calls thật.
function CallLogQC() {
if (!AAU.calls || AAU.calls.length === 0) return ;
return ;
}
function CallLogQCBody() {
const [open, setOpen] = useState(null);
const [playing, setPlaying] = useState(false);
const [cbSet, setCbSet] = useState(false);
const [range, setRange] = useState('1d');
const [custom, setCustom] = useState({ from: '19/05/2026', to: '02/06/2026' });
const [cmp, setCmp] = useState('prev');
const [fAgent, setFAgent] = useState('all');
const [fVerdict, setFVerdict] = useState('all');
const [fDisp, setFDisp] = useState('all');
const [fType, setFType] = useState('all');
const [tab, setTab] = useState('qc');
const [coach, setCoach] = useState('');
const [coachSent, setCoachSent] = useState(false);
const [cbDone, setCbDone] = useState({});
useEffect(() => { setCbSet(false); setPlaying(false); setTab('qc'); setCoach(''); setCoachSent(false); }, [open]);
const ruleset = AAU.qcRulesets[1];
const sales = AAU.users.filter(u => u.role === 'sales');
const enrich = c => { const u = AAU.users.find(x => x.id === c.agent); return { ...c, lead: AAU.leadById(c.leadId) || { name: c.leadId || 'Khách', company: '' }, agentName: u?.name, agentColor: u?.color, score: callScore(c), verdict: callVerdict(c), at: c.at || new Date(Date.now() - (c.mins || 0) * 60000).toISOString() }; };
const all = AAU.calls.map(normCall).map(enrich);
const rows = all.filter(c => (fAgent === 'all' || c.agent === fAgent) && (fVerdict === 'all' || c.verdict === fVerdict) && (fDisp === 'all' || c.disp === fDisp) && (fType === 'all' || c.type === fType));
const days = rangeDays(range, custom);
const prof = CMP_PROFILE[cmp];
const cmpOn = !!prof;
const perDay = 16;
const calls = perDay * days;
const totalMin = 96 * days;
const durLabel = totalMin >= 60 ? Math.floor(totalMin / 60) + ' giờ ' + (totalMin % 60) + ' phút' : totalMin + ' phút';
const passRate = Math.round(all.filter(c => c.verdict === 'pass').length / all.length * 100);
const qcAvg = (all.reduce((s, c) => s + c.score, 0) / all.length).toFixed(1).replace('.', ',');
const dCalls = cmpOn ? deltaPct(calls, perDay * days * prof.f) : null;
const dQc = cmpOn ? deltaPct(6.9, prof.callQc) : null;
const dPass = cmpOn ? passRate - prof.pass : null;
const periodLabel = range === 'custom' ? custom.from + ' – ' + custom.to : spanForDays(days).label;
const cmpLabel = cmpOn ? compareSpan(days, cmp).label : '';
const byAgent = sales.map(u => { const cs = rows.filter(c => c.agent === u.id); const n = cs.length; const avg = n ? cs.reduce((s, c) => s + c.score, 0) / n : 0; return { u, n, avg, pass: n ? Math.round(cs.filter(c => c.verdict === 'pass').length / n * 100) : 0 }; }).sort((a, b) => b.avg - a.avg);
const violCounts = {};
rows.forEach(c => c.viol.forEach(v => { violCounts[v] = (violCounts[v] || 0) + 1; }));
const topViol = Object.entries(violCounts).map(([l, v]) => ({ l, v })).sort((a, b) => b.v - a.v).slice(0, 5);
const violMax = Math.max(1, ...topViol.map(t => t.v));
const dispAgg = Object.keys(DISPO).map(k => ({ l: DISPO[k].l, v: rows.filter(c => c.disp === k).length, color: DISPO[k].c })).filter(d => d.v > 0);
const queue = all.filter(c => ['callback', 'noanswer'].includes(c.disp));
const trendDays = Math.min(Math.max(days, 7), 30);
const trend = Array.from({ length: trendDays }, (_, i) => { const d = new Date(REPORT_TODAY); d.setDate(d.getDate() - (trendDays - 1 - i)); return { l: fmtDM(d), v: 12 + ((i * 7 + 5) % 9) }; });
const qcTrend = Array.from({ length: trendDays }, (_, i) => { const d = new Date(REPORT_TODAY); d.setDate(d.getDate() - (trendDays - 1 - i)); return { l: fmtDM(d), v: +(6.2 + ((i * 3 + 2) % 16) / 10).toFixed(1) }; });
const cols = [
{ key: 'lead', label: 'Khách hàng', render: c => { e.stopPropagation(); navigate('/leads/' + c.leadId); }}>{c.lead.name}
{c.lead.company}
, csv: c => c.lead.name },
{ key: 'type', label: 'Loại', render: c => {CALLTYPE[c.type]} },
{ key: 'dir', label: 'Hướng', render: c => {c.dir === 'out' ? 'Gọi đi' : 'Gọi đến'} },
{ key: 'disp', label: 'Kết quả', render: c => {DISPO[c.disp].l} },
{ key: 'dur', label: 'Thời lượng', num: true },
{ key: 'agentName', label: 'Agent' },
{ key: 'at', label: 'Thời gian', render: c => relTime(c.at) + ' trước', csv: c => c.mins + 'm' },
{ key: 'score', label: 'QC', num: true, render: c => = 7 ? '#0e7c4a' : '#c4320a' }}>{c.score}{c.qcH != null ? QC : AI} },
{ key: 'verdict', label: 'Verdict', render: c => {c.verdict.toUpperCase()} },
{ key: 'review', label: 'Review', render: c => c.status === 'reviewed' ? Đã duyệt : c.status === 'disputed' ? Khiếu nại : AI tự động },
{ key: 'act', label: '', sortable: false, render: c => },
];
const toolbar = (
);
return (
>} />
{cmpOn && (
Đang so sánh {periodLabel} với {cmpLabel} ({prof.label}).
)}
{queue.length > 0 && (
{queue.map(c => cbDone[c.id] ? (
Đã xử lý — {c.lead.name}
) : (
navigate('/leads/' + c.leadId)}>{c.lead.name} · {c.lead.company}
{DISPO[c.disp].l} · {relTime(c.at)} trước · phụ trách {c.agentName}
Hạn: hôm nay
))}
)}
v.toFixed(1)} />
| Agent | Cuộc | QC TB | PASS |
{byAgent.map((a, i) => (
| {i + 1}{a.u.name} |
{a.n} |
= 7 ? '#0e7c4a' : '#c4320a' }} className="fw7 tnum">{a.n ? a.avg.toFixed(1) : '—'} |
{a.n ? a.pass + '%' : '—'} |
))}
{topViol.length === 0 ? Không có vi phạm trong bộ lọc hiện tại.
: (
)}
{dispAgg.length === 0 ? Không có dữ liệu.
: (
{dispAgg.map(d =>
{d.l}{d.v}
)}
)}
Nhật ký cuộc gọi
c.id} searchKeys={['agentName']} toolbar={toolbar} onRowClick={c => setOpen(c)} exportName="calls" pageSize={8} />
{open && (
setOpen(null)}>
navigate('/leads/' + open.leadId)}>{open.lead.name}
{open.lead.company} · {open.agentName} · {CALLTYPE[open.type]}
{open.verdict.toUpperCase()}
AI chấm{open.qcA}
QC người{open.qcH != null ? open.qcH : '—'}
Review{open.status === 'reviewed' ? 'Đã duyệt' : open.status === 'disputed' ? 'Khiếu nại' : 'AI tự động'}
{tab === 'qc' && (
PHÂN TÍCH HỘI THOẠI
CHẤM ĐIỂM THEO TIÊU CHÍ
{ruleset.criteria.map((cr, i) => { const sc = [8, 6, 7, 4][i] ?? 6; return (
{cr.name} ({cr.weight}%){sc}/10
); })}
{open.viol.length > 0 && (
VI PHẠM PHÁT HIỆN
{open.viol.map((v, i) =>
{v}
)}
)}
KẾT QUẢ & FOLLOW-UP
Kết quả cuộc gọi{DISPO[open.disp].l}
)}
{tab === 'transcript' && (
Click một dòng để tua tới đoạn ghi âm tương ứng. Từ khóa quan trọng được tô sáng.
{SAMPLE_TRANSCRIPT.map((l, i) => { const sc = { pos: '#0a9e6e', neu: '#8a8a8a', neg: '#c4320a' }[l.s]; return (
setPlaying(true)} className="row g10" style={{ cursor: 'pointer' }}>
{l.t}
{l.who === 'agent' ? 'Agent' : 'Khách'}
{hl(l.text, open.kw)}
{l.viol &&
Vi phạm: {l.viol}}
); })}
{open.kw.length > 0 &&
{open.kw.map(k => {k})}
}
)}
{tab === 'coaching' && (
GỬI NHẬN XÉT CHO {(open.agentName || '').toUpperCase()}
LỊCH SỬ COACHING
{coachSent && }
)}
)}
);
}
function InboxReportEmpty() {
return (
);
}
// Wrapper: chưa có hội thoại thật → EmptyState (không hiện báo cáo giả).
function InboxDailyReport() {
if (!AAU.conversations || Object.keys(AAU.conversations).length === 0) return ;
return ;
}
function InboxDailyReportBody() {
const live = !!(window.API && window.API.enabled);
const [range, setRange] = useState('7d');
const [custom, setCustom] = useState({ from: '19/05/2026', to: '02/06/2026' });
const [cmp, setCmp] = useState('none');
const [rep, setRep] = useState(null);
const [loading, setLoading] = useState(false);
const isoD = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
const range2dates = () => {
if (range === 'custom') { const p = s => { const [d, m, y] = s.split('/').map(Number); return new Date(y, m - 1, d); }; try { return { from: isoD(p(custom.from)), to: isoD(p(custom.to)) }; } catch (e) { return {}; } }
const dd = rangeDays(range, custom); const end = new Date(); const start = new Date(); start.setDate(end.getDate() - (dd - 1));
return { from: isoD(start), to: isoD(end) };
};
useEffect(() => {
if (!live || !window.API.inboxReport) { setRep(null); return; }
const { from, to } = range2dates(); let alive = true; setLoading(true);
window.API.inboxReport({ from, to }).then(r => { if (alive) setRep(r); }).catch(() => { if (alive) setRep(null); }).finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [live, range, custom]);
const days = rangeDays(range, custom);
const fmtN = n => Math.round(n || 0).toLocaleString('vi-VN');
const fmtFrt = m => !m ? '—' : (m < 60 ? (Math.round(m * 10) / 10).toString().replace('.', ',') + ' ph' : (Math.round(m / 6) / 10).toString().replace('.', ',') + ' h');
const chMeta = ch => (AAU.channels && AAU.channels[ch]) || { label: ch, color: '#8a8f98' };
const userName = id => (AAU.users || []).find(u => u.id === id)?.name || id;
const userColor = id => (AAU.users || []).find(u => u.id === id)?.color || '#5b6cff';
const _rd = range2dates();
const _dm = iso => { try { const [y, m, d] = iso.split('-'); return d + '/' + m; } catch (e) { return iso; } };
const periodLabel = range === 'custom' ? custom.from + ' – ' + custom.to : (_rd.from && _rd.to ? _dm(_rd.from) + ' – ' + _dm(_rd.to) : spanForDays(days).label);
// ===== số liệu THẬT khi LIVE (rep), else MOCK demo =====
const liveData = live && rep;
const k = liveData ? rep.kpi : { data: 320 * days, replied: Math.round(320 * days * 0.92), replyRate: 92, avgFrtMin: 4.2, unanswered: 0 };
const srcRows = liveData
? (rep.bySource || []).map(s => ({ page: s.account || chMeta(s.channel).label, ch: s.channel, sync: true, data: s.data, replied: s.replied, frtMin: s.frtMin, rate: Math.round(s.replyRate) }))
: [{ page: 'Fanpage F&B Academy', ch: 'fb', sync: true, data: 96 * days, replied: 92 * days, frtMin: 3.1 }, { page: 'Zalo OA Học viện', ch: 'zalo', sync: true, data: 86 * days, replied: 82 * days, frtMin: 2.8 }].map(s => ({ ...s, rate: Math.round(s.replied / s.data * 100) }));
const chanRows = liveData
? (rep.byChannel || []).map(c => ({ l: chMeta(c.channel).label, v: c.count, color: chMeta(c.channel).color }))
: [{ l: 'FB', v: 142 * days, color: '#0084ff' }, { l: 'Zalo', v: 86 * days, color: '#0068ff' }];
const totalChan = chanRows.reduce((a, b) => a + b.v, 0);
const hourly = liveData
? (rep.byHour || []).filter(h => h.hour >= 6 && h.hour <= 22).map(h => ({ l: h.hour + 'h', v: h.count }))
: ['8h', '9h', '10h', '11h', '12h', '13h', '14h', '15h', '16h', '17h', '18h', '19h'].map((l, i) => ({ l, v: [12, 28, 34, 30, 18, 14, 26, 38, 42, 36, 22, 16][i] * days }));
// heatmap: fold [7][24] → [7][6] band 8-20
const heatGrid = liveData && rep.heat ? rep.heat.map(row => [0, 1, 2, 3, 4, 5].map(b => (row[8 + b * 2] || 0) + (row[9 + b * 2] || 0))) : null;
const lb = liveData
? (rep.leaderboard || []).map(r => ({ id: r.sales, name: userName(r.sales), color: userColor(r.sales), convos: r.convos, respond: Math.round(r.replyRate), frtMin: r.frtMin, closes: r.won }))
: [{ name: 'Phạm Tuấn', color: '#0a9e6e', convos: 64 * days, respond: 95, frtMin: 2.6, closes: 3 * days }];
const fn = liveData ? rep.funnel : { data: 320 * days, leads: 198 * days, twoWay: 142 * days, won: 6 * days };
const funnelSteps = [
{ l: 'Data về', v: fn.data, color: '#8a8a8a' },
{ l: 'Thành Lead', v: fn.leads, color: '#3b82f6' },
{ l: 'Chat 2 chiều', v: fn.twoWay, color: '#0a9e6e' },
{ l: 'Won', v: fn.won, color: '#7c3aed' },
];
const unanswered = liveData ? (rep.unansweredList || []) : [];
return (
{ const { from, to } = range2dates(); if (live) window.API.inboxReport({ from, to }).then(setRep); }} disabled={loading}>{loading ? 'Đang tải…' : 'Tải lại'}} />
{!live && Đang ở MOCK — mở app với ?api=… để xem số liệu thật từ hội thoại.
}
{unanswered.length > 0 && (
| Khách | Nguồn | Tin cuối | Chờ | |
{unanswered.slice(0, 15).map((w, i) => (
navigate('/inbox')}>
| {w.name || '(khách)'} |
{chMeta(w.channel).label}{w.account ? ' · ' + w.account : ''} |
{w.lastText || '—'} |
60 ? '#c4320a' : w.waitMin > 15 ? '#b06f00' : '#0e7c4a' }}>{fmtFrt(w.waitMin)} |
|
))}
)}
navigate('/system/channels')}>Quản lý kết nối}>
{srcRows.length === 0 ? Chưa có hội thoại trong kỳ này.
: (
| Nguồn / Tài khoản |
Kênh |
Data về |
Đã phản hồi |
Tỉ lệ respond |
First response |
{srcRows.map((s, i) => { const rate = s.rate; return (
| {s.page} |
|
{fmtN(s.data)} |
{fmtN(s.replied)} |
= 90 ? '#0e7c4a' : rate >= 80 ? '#b06f00' : '#c4320a' }}>{rate}%
|
{fmtFrt(s.frtMin)} |
); })}
)}
{chanRows.map(d =>
{d.l}{fmtN(d.v)}
)}
{lb.length === 0 ? Chưa có dữ liệu sales trong kỳ.
: (
| Sales |
Hội thoại |
Tỉ lệ respond |
First response |
Won |
{lb.map((r, i) => (
| {i + 1}{r.name} |
{fmtN(r.convos)} |
= 90 ? '#0e7c4a' : '#b06f00' }} className="fw7 tnum">{r.respond}% |
{fmtFrt(r.frtMin)} |
{r.closes} |
))}
)}
);
}
function DataExplorer() {
const [view, setView] = useState('all');
const rows = AAU.leads;
const cols = [
{ key: 'name', label: 'Tên', render: l => {l.name} },
{ key: 'company', label: 'Công ty' },
{ key: 'phone', label: 'SĐT' },
{ key: 'email', label: 'Email' },
{ key: 'source', label: 'Nguồn', render: l => AAU.sourceMeta[l.source]?.label, csv: l => AAU.sourceMeta[l.source]?.label },
{ key: 'channel', label: 'Kênh', render: l => AAU.channels[l.channel]?.label },
{ key: 'stage', label: 'Stage', render: l => AAU.stageById(l.stage)?.name, csv: l => AAU.stageById(l.stage)?.name },
{ key: 'industry', label: 'Ngành' },
{ key: 'region', label: 'Khu vực' },
{ key: 'revenue', label: 'Doanh thu/th', num: true, render: l => AAU.fmtVNDm(l.revenue) },
{ key: 'grade', label: 'Grade' },
{ key: 'createdAt', label: 'Ngày tạo', render: l => AAU.fmtDate(l.createdAt) },
];
const health = [
{ icon: 'phone', t: 'Thiếu SĐT', v: 0, tone: 'success' },
{ icon: 'users', t: 'Trùng lặp nghi ngờ', v: 2, tone: 'warning' },
{ icon: 'tag', t: 'Chưa gán nguồn', v: 0, tone: 'success' },
{ icon: 'user', t: 'Chưa gán phụ trách', v: 3, tone: 'critical' },
];
return (
Lưu view hiện tại} />
{health.map((h, i) => (
))}
l.id} searchKeys={['name', 'company', 'phone', 'email']} selectable bulkActions={[{ icon: 'tag', label: 'Gán nguồn', onClick: () => {} }, { icon: 'user', label: 'Gán sales', onClick: () => {} }]} onRowClick={l => navigate('/leads/' + l.id)} exportName="data-explorer" pageSize={9} />
);
}
Object.assign(window, { CallLogQC, InboxDailyReport, DataExplorer });