/* AAU CRM — Unified Inbox (3 columns, interactive) */
const INBOX_LABELS = [
{ id: 'hot', label: 'Hot', color: '#ef4444' },
{ id: 'proposal', label: 'Chờ proposal', color: '#f59e0b' },
{ id: 'callback', label: 'Hẹn gọi lại', color: '#3b82f6' },
{ id: 'franchise', label: 'Nhượng quyền', color: '#8b5cf6' },
{ id: 'care', label: 'Cần CSKH', color: '#0a9e6e' },
{ id: 'spam', label: 'Rác / Spam', color: '#9ca3af' },
];
const labelById = (id) => INBOX_LABELS.find(l => l.id === id);
const SENTIMENT = { cv1: 'pos', cv2: 'neu', cv3: 'neg', cv4: 'pos', cv5: 'neu', cv6: 'neg', cv7: 'neu', cv8: 'pos' };
const SENT_META = { pos: { l: 'Thiện chí', c: '#0a9e6e' }, neu: { l: 'Trung lập', c: '#8a8a8a' }, neg: { l: 'Do dự', c: '#c4320a' } };
const DUPES = { L1: 'Khoa BBQ — hồ sơ cũ (T9/2025)' };
const ME_ID = 'u5'; // người đang trực inbox (demo)
// media tự host trên backend (/media/..). Same-origin LIVE → URL tương đối chạy
// thẳng; dev static (:5500) → ghép API.base. http/data URL giữ nguyên.
function mediaSrc(u) { if (!u) return u; if (/^(https?:|data:|blob:)/.test(u)) return u; const b = (window.API && window.API.base) || ''; return b + u; }
function fmtBytes(n) { n = Number(n) || 0; if (!n) return ''; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(0) + ' KB'; return (n / 1048576).toFixed(1) + ' MB'; }
// nhãn ngắn cho danh sách hội thoại khi tin cuối là media
function attShortLabel(a) { return ({ image: '🖼 Hình ảnh', video: '🎬 Video', audio: '🎤 Tin thoại', sticker: '😀 Sticker' })[a.type] || ('📎 ' + (a.name || 'Tệp')); }
// Attachment — 1 bong bóng media (ảnh/video/voice/tệp) cho cả tin nhận & gửi.
function Attachment({ a, dir }) {
const src = mediaSrc(a.url); const out = dir === 'out';
if (a.type === 'image') return (
);
if (a.type === 'video') return ;
if (a.type === 'audio') return ;
return (
{a.name || 'Tệp đính kèm'}
{fmtBytes(a.size) || 'Tệp'} · tải về
);
}
function ChannelAvatar({ ch, name, color, size = 40 }) {
const c = (AAU.channels && AAU.channels[ch]) || { color: '#8a8f98', short: (ch || '?').toUpperCase() };
return (
);
}
function LabelChip({ id, small, onRemove }) {
const l = labelById(id); if (!l) return null;
return (
{l.label}
{onRemove && { e.stopPropagation(); onRemove(); }}> }
);
}
function SentimentChip({ s }) {
const m = SENT_META[s] || SENT_META.neu;
return {m.l} ;
}
/* ── Call modal (click-to-call → log + disposition) ── */
function CallModal({ lead, onClose, onLog }) {
const [phase, setPhase] = useState('calling');
const [sec, setSec] = useState(0);
const [disp, setDisp] = useState('consulted');
const [note, setNote] = useState('');
useEffect(() => { if (phase !== 'calling') return; const t = setInterval(() => setSec(s => s + 1), 1000); return () => clearInterval(t); }, [phase]);
const mmss = `${String(Math.floor(sec / 60)).padStart(2, '0')}:${String(sec % 60).padStart(2, '0')}`;
const dispo = [
{ v: 'consulted', l: 'Đã tư vấn xong', tone: 'success' },
{ v: 'callback', l: 'Hẹn gọi lại', tone: 'info' },
{ v: 'noanswer', l: 'Không nghe máy', tone: 'warning' },
{ v: 'wrong', l: 'Sai số', tone: 'critical' },
{ v: 'refused', l: 'Từ chối', tone: 'critical' },
];
return (
setPhase('ended')} style={{ margin: '0 auto' }}>Kết thúc cuộc gọi
: Hủy onLog({ duration: mmss, disp, note })}>Lưu vào nhật ký
}>
{lead.name}
{lead.phone}
{phase === 'calling'
?
Đang kết nối · {mmss}
:
Đã kết thúc · {mmss} }
{phase === 'ended' && (
KẾT QUẢ CUỘC GỌI
{dispo.map(d => (
setDisp(d.v)}>
{d.l}
))}
Tự động ghi vào hội thoại + Timeline hồ sơ 360°. Chọn "Hẹn gọi lại" sẽ snooze hội thoại.
)}
);
}
function InboxEmptyState() {
return (
);
}
// Wrapper: chặn crash khi chưa có hội thoại nào (sau khi wipe / trước khi nối real data).
function UnifiedInbox() {
if (!AAU.conversations || Object.keys(AAU.conversations).length === 0) return ;
return ;
}
function UnifiedInboxBody() {
const convList = Object.values(AAU.conversations);
// Hội thoại thật từ CQA có thể CHƯA gắn lead (leadId rỗng) → trả lead mặc định an
// toàn để màn không crash. Khi lead được tạo/nối, lookup thật sẽ thay thế.
const leadOf = c => AAU.leadById(c.leadId) || {
id: '', name: c.name || 'Khách', company: '', role: '', phone: '', email: '',
bizModel: '', industry: '', courseInterest: '', source: c.channel || 'zalo', channel: c.channel || 'zalo',
stage: 's4', branch: '', grade: '', dealValue: 0, sla: 'ok', slaLeft: '', hot: false,
signal: { level: '', text: '' }, painpoints: [], region: '', assignedTo: '', qualify: {},
};
// người dùng hiện tại + phạm vi kênh được phân (channelScope). Rỗng/elevated = mọi kênh.
const meUser = (() => {
try { return (typeof AccessStore !== 'undefined' && AccessStore.get && AccessStore.get()) || (typeof AAU !== 'undefined' && AAU.currentUser) || {}; }
catch (e) { return {}; }
})();
const meRoles = [meUser.role, meUser.salesRole].concat(meUser.roles || []).filter(Boolean).join(' ').toLowerCase();
const elevated = /admin|manager|superadmin/.test(meRoles);
// danh sách kênh user được phép thấy (null = không giới hạn). Backend đã enforce thật;
// đây chỉ để dropdown lọc không hiện kênh ngoài quyền.
const allowedChannels = (elevated || !Array.isArray(meUser.channelScope) || meUser.channelScope.length === 0)
? null : meUser.channelScope;
const canSeeCh = (ch) => !allowedChannels || allowedChannels.includes(ch);
// default filter theo team: MKT → Social, Sales → Zalo (đổi được)
const [chFilter, setChFilter] = useState(() => {
if (/mkt|marketing/.test(meRoles) && canSeeCh('fb')) return 'g:social';
if (/sales/.test(meRoles) && canSeeCh('zalo')) return 'g:zalo';
return 'all';
});
const [acctFilter, setAcctFilter] = useState('all');
const [triage, setTriage] = useState('all');
const [q, setQ] = useState('');
const [selId, setSelId] = useState(() => {
const ni = window.NavIntent;
if (ni && ni.inboxConv && AAU.conversations[ni.inboxConv]) { window.NavIntent = null; return ni.inboxConv; }
const firstVisible = convList.find(c => canSeeCh(c.channel)) || convList[0];
return firstVisible.id;
});
const [store, setStore] = useState(() => JSON.parse(JSON.stringify(AAU.conversations)));
const [meta, setMeta] = useState(() => {
const m = {};
convList.forEach(c => {
const l = leadOf(c);
const labels = [];
if (l.hot) labels.push('hot');
if (l.courseInterest === 'c10') labels.push('franchise');
if (['s6', 's7'].includes(l.stage)) labels.push('proposal');
m[c.id] = { owner: l.assignedTo || '', labels, status: 'open', snoozeLabel: '', notes: [] };
});
if (m.cv1) m.cv1.notes = [{ id: 'n0', by: ME_ID, at: new Date(Date.now() - 36e5).toISOString(), text: '@Lê Vy khách quyết nhanh, ưu tiên gọi xác nhận lịch trước 5h chiều nay nhé.' }];
return m;
});
const [draft, setDraft] = useState('');
const [composerTab, setComposerTab] = useState('reply');
const [aiMode, setAiMode] = useState(store[selId].aiMode);
const [copilot, setCopilot] = useState('');
const [playbook, setPlaybook] = useState(true);
const [slash, setSlash] = useState(false);
const [panel, setPanel] = useState(true);
const [botOff, setBotOff] = useState({});
const [labelMenu, setLabelMenu] = useState(false);
const [assignMenu, setAssignMenu] = useState(false);
const [attachMenu, setAttachMenu] = useState(false);
const [callOpen, setCallOpen] = useState(false);
const bodyRef = useRef(null);
const fileRef = useRef(null);
const conv = store[selId];
const lead = leadOf(conv);
const cm = meta[selId];
const sales = AAU.users.filter(u => u.role === 'sales');
const playbookSteps = {
s7: { step: 'Bước: Chốt đàm phán', tip: 'Khách đã hỏi proposal & muốn chốt sớm → đưa ưu đãi early-bird có thời hạn, xác nhận lịch khai giảng.', asset: 'Brochure Nhượng quyền + Bảng giá Q2', reply: 'Dạ em xác nhận ưu đãi early-bird giảm 20% nếu anh đăng ký trước 15/06. Lớp Nhượng quyền F&B khai giảng 22/06, em giữ slot giúp anh nhé?' },
s6: { step: 'Bước: Theo dõi proposal', tip: 'Đã gửi proposal → hỏi phản hồi, xử lý phản đối về ngân sách.', asset: 'Case study + So sánh đối thủ', reply: 'Dạ chị xem proposal em gửi chưa ạ? Nếu cần em gửi thêm case study của chuỗi tương tự để chị tham khảo phần ROI nhé.' },
s5: { step: 'Bước: Tư vấn chuyên sâu', tip: 'Đào sâu painpoint, map đúng khóa, đặt lịch tư vấn 1-1.', asset: 'Pitch deck khóa quan tâm', reply: 'Dạ để tư vấn sát nhất, em xin phép đặt lịch gọi 15 phút trao đổi về tình hình quán mình hiện tại được không ạ?' },
s4: { step: 'Bước: Qualify SQL', tip: 'Xác nhận mô hình KD, ngân sách, nhu cầu → chấm điểm qualify.', asset: 'Brochure khóa + Bảng giá', reply: 'Dạ để em tư vấn đúng khóa, cho em hỏi hiện quán mình đang vận hành mấy điểm và anh/chị muốn cải thiện nhất điều gì ạ?' },
};
const pb = playbookSteps[lead.stage] || playbookSteps.s4;
const detectedMap = {
s7: { label: '"Đòi chốt nhanh / hỏi thanh toán"', cat: 'Tín hiệu mua', color: '#0e7c4a', win: 88 },
s6: { label: '"Im lặng > 3 ngày sau báo giá"', cat: 'Rủi ro mất deal', color: '#b42318', win: 33 },
s5: { label: '"So sánh AAU vs đối thủ X"', cat: 'So sánh đối thủ', color: '#6d28d9', win: 59 },
s4: { label: '"Hỏi lịch khai giảng"', cat: 'Câu hỏi lặp lại', color: '#1a4fa3', win: 72 },
};
const detected = detectedMap[lead.stage] || detectedMap.s4;
useEffect(() => { setAiMode(store[selId].aiMode); setComposerTab('reply'); }, [selId]);
useEffect(() => { setCopilot(aiMode === 'copilot' ? pb.reply : ''); }, [selId, aiMode]);
useEffect(() => { if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; }, [selId, conv.messages.length, cm.notes.length]);
function patchMeta(id, p) { setMeta(m => ({ ...m, [id]: { ...m[id], ...p } })); }
const triageTabs = [
{ v: 'all', l: 'Tất cả', f: c => meta[c.id].status !== 'done' },
{ v: 'unread', l: 'Chưa đọc', f: c => c.unread > 0 && meta[c.id].status !== 'done' },
{ v: 'mine', l: 'Của tôi', f: c => meta[c.id].owner === ME_ID && meta[c.id].status !== 'done' },
{ v: 'unassigned', l: 'Chưa gán', f: c => !meta[c.id].owner && meta[c.id].status !== 'done' },
{ v: 'overdue', l: 'Quá hạn SLA', f: c => leadOf(c).sla === 'bad' && meta[c.id].status !== 'done' },
{ v: 'snoozed', l: 'Snooze', f: c => meta[c.id].status === 'snoozed' },
{ v: 'done', l: 'Xong', f: c => meta[c.id].status === 'done' },
];
const triF = triageTabs.find(t => t.v === triage).f;
const acctOf = (c) => c.accountName || c.account || c.page || '';
// nhóm kênh: Social (FB/IG/TikTok) · Zalo (OA + cá nhân) — 1 Inbox, lọc theo nhóm
const CH_GROUPS = { 'g:social': ['fb', 'instagram', 'tiktok'], 'g:zalo': ['zalo', 'zalo_personal'] };
const chMatch = (c) => canSeeCh(c.channel)
&& (chFilter === 'all' || c.channel === chFilter || (CH_GROUPS[chFilter] && CH_GROUPS[chFilter].includes(c.channel)));
// tài khoản khả dụng theo kênh/nhóm đang lọc (phân biệt từng nguồn, vd 2 số Zalo cá nhân)
const acctOptions = [...new Set(convList.filter(chMatch).map(acctOf).filter(Boolean))];
const filtered = convList
.filter(triF)
.filter(c => chMatch(c)
&& (acctFilter === 'all' || acctOf(c) === acctFilter)
&& (!q || leadOf(c).name.toLowerCase().includes(q.toLowerCase())))
.sort((a, b) => new Date(b.lastAt) - new Date(a.lastAt));
// keyboard shortcuts: J/K navigate, E mark done
useEffect(() => {
function onKeyG(e) {
if (/input|textarea|select/i.test(e.target.tagName)) return;
const ids = filtered.map(c => c.id); const i = ids.indexOf(selId);
if (e.key === 'j' && i < ids.length - 1) { setSelId(ids[i + 1]); }
else if (e.key === 'k' && i > 0) { setSelId(ids[i - 1]); }
else if (e.key === 'e') { patchMeta(selId, { status: 'done' }); }
}
window.addEventListener('keydown', onKeyG);
return () => window.removeEventListener('keydown', onKeyG);
}, [filtered, selId]);
// A — auto-refresh: định kỳ kéo /conversations, gộp tin mới (theo id) vào store +
// cập nhật danh sách trái. Không clobber tin optimistic/note đang có.
useEffect(() => {
if (!(window.API && window.API.enabled && window.API.get)) return;
let alive = true;
const pull = () => window.API.get('/conversations').then(server => {
if (!alive || !server) return;
const list = Array.isArray(server) ? server : Object.values(server);
list.forEach(sc => { AAU.conversations[sc.id] = { ...(AAU.conversations[sc.id] || {}), ...sc }; });
// lead mới (auto-tạo từ chat) chưa có trong AAU.leads → refresh để panel + kanban điền
const haveLead = new Set((AAU.leads || []).map(l => l.id));
if (list.some(sc => sc.leadId && !haveLead.has(sc.leadId)) && window.API.refreshLeads) window.API.refreshLeads().catch(() => { });
setMeta(m => { let ch = false; const nm = { ...m }; list.forEach(sc => { if (!nm[sc.id]) { const l = leadOf(sc); nm[sc.id] = { owner: l.assignedTo || '', labels: [], status: 'open', snoozeLabel: '', notes: [] }; ch = true; } }); return ch ? nm : m; });
setStore(prev => {
const next = { ...prev }; let ch = false;
list.forEach(sc => {
const loc = prev[sc.id];
if (!loc) { next[sc.id] = sc; ch = true; return; }
const have = new Set((loc.messages || []).map(m => m.id));
const add = (sc.messages || []).filter(m => !have.has(m.id));
if (add.length || sc.unread !== loc.unread || sc.lastAt !== loc.lastAt || sc.leadId !== loc.leadId) {
next[sc.id] = { ...loc, leadId: sc.leadId || loc.leadId, linked: !!(sc.leadId || loc.leadId), name: sc.name || loc.name, messages: [...(loc.messages || []), ...add].sort((a, b) => new Date(a.at) - new Date(b.at)), lastAt: sc.lastAt || loc.lastAt, unread: sc.unread };
ch = true;
}
});
return ch ? next : prev;
});
}).catch(() => { });
const iv = setInterval(pull, 30000);
return () => { alive = false; clearInterval(iv); };
}, []);
// Tạo Lead từ chat hiện tại → gắn vào pipeline + điền panel.
function createLeadFromChat() {
if (!(window.API && window.API.enabled)) { window.alert('Cần chế độ LIVE để tạo lead.'); return; }
const cid = selId;
window.API.createLeadFromConv(cid).then(lead => {
if (!lead || !lead.id) return;
if (Array.isArray(AAU.leads) && !AAU.leads.some(l => l.id === lead.id)) AAU.leads.unshift(lead);
if (typeof LeadStore !== 'undefined' && LeadStore.reload) LeadStore.reload();
AAU.conversations[cid] = { ...(AAU.conversations[cid] || {}), leadId: lead.id, linked: true };
setStore(s => { const c = s[cid]; if (!c) return s; return { ...s, [cid]: { ...c, leadId: lead.id, linked: true } }; });
}).catch(e => window.alert('Tạo lead lỗi: ' + (e && e.message || e)));
}
const lastOutId = [...conv.messages].reverse().find(m => m.dir === 'out')?.id;
// B — gửi ra khách: optimistic 'sending' → patch theo kết quả thật (delivered/failed).
function send(text) {
const t = (text ?? draft).trim(); if (!t) return;
const mid = 'x' + Date.now();
const liveSend = !!(window.API && window.API.enabled);
setStore(s => { const n = { ...s }; n[selId] = { ...n[selId], messages: [...n[selId].messages, { id: mid, dir: 'out', at: new Date().toISOString(), by: ME_ID, text: t, ai: aiMode === 'autopilot', status: liveSend ? 'sending' : 'sent' }], unread: 0 }; return n; });
setDraft(''); setSlash(false); setCopilot('');
if (liveSend) {
const cid = selId;
window.API.sendMessage(cid, t, ME_ID)
.then(r => setStore(s => { const c = s[cid]; if (!c) return s; return { ...s, [cid]: { ...c, messages: c.messages.map(m => m.id === mid ? { ...m, id: (r && r.id) || mid, status: (r && r.delivered) ? 'delivered' : 'sent', note: r && r.note } : m) } }; }))
.catch(e => { setStore(s => { const c = s[cid]; if (!c) return s; return { ...s, [cid]: { ...c, messages: c.messages.map(m => m.id === mid ? { ...m, status: 'failed' } : m) } }; }); window.alert('Gửi tới khách thất bại: ' + (e && e.message || e)); });
}
}
function attach(name) {
setStore(s => { const n = { ...s }; n[selId] = { ...n[selId], messages: [...n[selId].messages, { id: 'x' + Date.now(), dir: 'out', at: new Date().toISOString(), by: ME_ID, attachment: name, status: 'sent' }] }; return n; });
setAttachMenu(false);
}
// gửi tệp THẬT từ máy ra khách: upload → /send kèm attachments. Optimistic bằng
// blob URL cục bộ, đổi sang URL tự host khi gửi xong (để bền sau refresh).
const attType = (mime) => mime && mime.startsWith('image/') ? 'image' : mime && mime.startsWith('video/') ? 'video' : mime && mime.startsWith('audio/') ? 'audio' : 'file';
function sendFiles(files) {
const list = Array.from(files || []); if (!list.length) return;
setAttachMenu(false);
const liveSend = !!(window.API && window.API.enabled && window.API.uploadFile);
const cid = selId;
list.forEach(f => {
const mid = 'x' + Date.now() + Math.floor(Math.random() * 1e4);
const localUrl = URL.createObjectURL(f);
const optimistic = { type: attType(f.type), url: localUrl, name: f.name, size: f.size, mime: f.type };
setStore(s => { const c = s[cid]; if (!c) return s; return { ...s, [cid]: { ...c, messages: [...c.messages, { id: mid, dir: 'out', at: new Date().toISOString(), by: ME_ID, attachments: [optimistic], status: liveSend ? 'sending' : 'sent' }], unread: 0 } }; });
if (!liveSend) return;
window.API.uploadFile(f)
.then(att => window.API.sendMessage(cid, '', ME_ID, [att]).then(() => att))
.then(att => setStore(s => { const c = s[cid]; if (!c) return s; return { ...s, [cid]: { ...c, messages: c.messages.map(m => m.id === mid ? { ...m, attachments: [att], status: 'delivered' } : m) } }; }))
.catch(e => { setStore(s => { const c = s[cid]; if (!c) return s; return { ...s, [cid]: { ...c, messages: c.messages.map(m => m.id === mid ? { ...m, status: 'failed' } : m) } }; }); window.alert('Gửi tệp thất bại: ' + (e && e.message || e)); });
});
}
function saveNote() {
const t = draft.trim(); if (!t) return;
patchMeta(selId, { notes: [...cm.notes, { id: 'n' + Date.now(), by: ME_ID, at: new Date().toISOString(), text: t }] });
setDraft('');
}
function onKey(e) { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); composerTab === 'note' ? saveNote() : send(); } }
function onDraft(v) { setDraft(v); setSlash(composerTab === 'reply' && v.startsWith('/')); }
function logCall({ duration, disp, note }) {
const dispMap = { consulted: 'Đã tư vấn xong', callback: 'Hẹn gọi lại', noanswer: 'Không nghe máy', wrong: 'Sai số', refused: 'Từ chối' };
setStore(s => { const n = { ...s }; n[selId] = { ...n[selId], messages: [...n[selId].messages, { id: 'c' + Date.now(), type: 'call', dir: 'out', at: new Date().toISOString(), by: ME_ID, duration, disp: dispMap[disp], note }] }; return n; });
if (window.API && window.API.enabled) { const lid = (store[selId] || {}).leadId; if (lid) window.API.logCall({ leadId: lid, duration, agent: ME_ID, verdict: disp }).catch(() => {}); }
if (disp === 'callback') patchMeta(selId, { status: 'snoozed', snoozeLabel: 'Hẹn gọi lại 3h', labels: [...new Set([...cm.labels, 'callback'])] });
setCallOpen(false);
}
// merged thread (messages + internal notes), sorted
const thread = [
...conv.messages.map(m => ({ ...m, _t: m.type === 'call' ? 'call' : 'msg' })),
...cm.notes.map(n => ({ ...n, _t: 'note', at: n.at })),
].sort((a, b) => new Date(a.at) - new Date(b.at));
const assets = ['Brochure Nhượng quyền F&B', 'Bảng giá Q2/2026', 'Case study chuỗi 6 chi nhánh', 'Lịch khai giảng tháng 6'];
const aiOpts = [{ value: 'off', label: 'Tắt' }, { value: 'copilot', label: 'Copilot' }, { value: 'autopilot', label: 'Autopilot' }];
return (
{/* LEFT */}
Hộp thư {convList.filter(c => canSeeCh(c.channel)).reduce((s, c) => s + c.unread, 0)} chưa đọc
{ setChFilter(v); setAcctFilter('all'); }} options={(() => {
const social = [['fb', '— FB'], ['instagram', '— IG'], ['tiktok', '— TikTok']].filter(([c]) => canSeeCh(c));
const zalo = [['zalo', '— Zalo OA'], ['zalo_personal', '— Zalo cá nhân']].filter(([c]) => canSeeCh(c));
const opts = [{ value: 'all', label: 'Mọi kênh' }];
if (social.length > 1) opts.push({ value: 'g:social', label: '📱 Social (tất cả)' });
social.forEach(([c, l]) => opts.push({ value: c, label: l }));
if (zalo.length > 1) opts.push({ value: 'g:zalo', label: '💬 Zalo (tất cả)' });
zalo.forEach(([c, l]) => opts.push({ value: c, label: l }));
return opts;
})()} style={{ width: 150 }} />
{acctOptions.length > 1 && (
({ value: a, label: a }))]} style={{ width: '100%' }} />
)}
{triageTabs.map(t => { const n = convList.filter(c => canSeeCh(c.channel)).filter(t.f).length; return (
setTriage(t.v)} className="row aic g4" style={{ flexShrink: 0, fontSize: 11.5, fontWeight: 600, padding: '4px 9px', borderRadius: 7, cursor: 'pointer', border: '1px solid ' + (triage === t.v ? '#1a4fa3' : 'var(--p-border)'), background: triage === t.v ? '#1a4fa3' : '#fff', color: triage === t.v ? '#fff' : 'var(--p-text)' }}>
{t.l}{n}
); })}
{filtered.length === 0 &&
Không có hội thoại nào.
}
{filtered.map(c => { const l = leadOf(c); const m2 = meta[c.id]; const last = c.messages[c.messages.length - 1]; const sel = c.id === selId; return (
setSelId(c.id)} style={{ display: 'flex', gap: 10, padding: '11px 14px', cursor: 'pointer', background: sel ? 'var(--p-bg-surface-selected)' : 'transparent', borderBottom: '1px solid var(--p-border-secondary)', borderLeft: sel ? '3px solid #1a4fa3' : '3px solid transparent', opacity: m2.status === 'done' ? 0.6 : 1 }}>
{l.name} {l.hot && }{relTime(c.lastAt)}
{!last ? '—' : last.type === 'call' ? '📞 Cuộc gọi' : (last.dir === 'out' ? 'Bạn: ' : '') + ((last.attachments && last.attachments.length) ? attShortLabel(last.attachments[0]) + (last.text ? ' · ' + last.text : '') : last.attachment ? '📎 ' + last.attachment : (last.text || ''))}
{m2.status === 'snoozed' && {m2.snoozeLabel || 'Snooze'} }
{m2.status === 'done' && Xong }
{m2.labels.slice(0, 2).map(id => )}
{acctOf(c) && {acctOf(c)} }
{c.unread > 0 &&
{c.unread} }
); })}
{/* MIDDLE */}
{lead.name} {lead.hot && }
{lead.company ? lead.company + ' · ' : ''}{(AAU.channels[conv.channel] || {}).label || conv.channel}{(conv.accountName || conv.account) ? ' · ' + (conv.accountName || conv.account) : ''}
Cửa sổ {conv.windowLeft}
setCallOpen(true)} />
navigate('/leads/' + lead.id)} />
setPanel(!panel)} />
{/* Toolbar: owner · labels · snooze/done */}
{ setAssignMenu(!assignMenu); setLabelMenu(false); }} style={{ cursor: 'pointer', border: '1px solid var(--p-border)', borderRadius: 7, padding: '3px 8px 3px 4px', background: '#fff' }}>
{cm.owner ? <> u.id === cm.owner)?.name} color={AAU.users.find(u => u.id === cm.owner)?.color} size={20} />{AAU.users.find(u => u.id === cm.owner)?.name} > : Chưa gán }
{assignMenu && (
GÁN PHỤ TRÁCH
{sales.map(u => (
{ patchMeta(selId, { owner: u.id }); setAssignMenu(false); }} style={{ padding: '8px 12px', cursor: 'pointer', background: cm.owner === u.id ? 'var(--p-bg-surface-selected)' : '' }} onMouseEnter={e => { if (cm.owner !== u.id) e.currentTarget.style.background = 'var(--p-bg-surface-hover)'; }} onMouseLeave={e => { if (cm.owner !== u.id) e.currentTarget.style.background = ''; }}>
{u.name} {cm.owner === u.id &&
}
))}
)}
{cm.labels.map(id =>
patchMeta(selId, { labels: cm.labels.filter(x => x !== id) })} />)}
{ setLabelMenu(!labelMenu); setAssignMenu(false); }} style={{ width: 26, height: 26 }}>
{labelMenu && (
{INBOX_LABELS.map(l => { const on = cm.labels.includes(l.id); return (
patchMeta(selId, { labels: on ? cm.labels.filter(x => x !== l.id) : [...cm.labels, l.id] })} style={{ padding: '7px 8px', cursor: 'pointer', borderRadius: 7 }} onMouseEnter={e => e.currentTarget.style.background = 'var(--p-bg-surface-hover)'} onMouseLeave={e => e.currentTarget.style.background = ''}>
{}} />
); })}
)}
{cm.status === 'snoozed'
? patchMeta(selId, { status: 'open', snoozeLabel: '' })}>Bỏ snooze
: patchMeta(selId, { status: 'snoozed', snoozeLabel: 'Snooze 3h' })}>Snooze }
{cm.status === 'done'
? patchMeta(selId, { status: 'open' })}>Mở lại
: patchMeta(selId, { status: 'done' })}>Đánh dấu xong }
{['fb', 'instagram', 'zalo'].includes(conv.channel) && (
botOff[selId] ? (
Đã tắt bot tự động {(AAU.channels[conv.channel] || {}).label || conv.channel} — hội thoại do CRM điều khiển, sales trả lời trực tiếp.
setBotOff(s => ({ ...s, [selId]: false }))}>Bật lại bot
) : (
Bot tự động {(AAU.channels[conv.channel] || {}).label || conv.channel} đang trả lời. Tắt để chuyển hội thoại về hệ thống chat & sales tiếp quản.
{ setBotOff(s => ({ ...s, [selId]: true })); if (window.API && window.API.enabled) window.API.setAIMode(selId, 'off').catch(() => {}); }}>Tắt bot & nhận hội thoại
)
)}
{thread.map(m => {
if (m._t === 'note') return (
NỘI BỘ · {AAU.users.find(u => u.id === m.by)?.name} {relTime(m.at)} trước
{m.text.split(/(@[^\s]+(?:\s[A-ZÀ-Ỹ][^\s]*)?)/).map((p, i) => p.startsWith('@') ? {p} : p)}
);
if (m._t === 'call') return (
Cuộc gọi đi · {m.duration || '—'} · {m.disp}{m.note ?
— {m.note} : ''}
{relTime(m.at)} trước · đã ghi vào hồ sơ 360°
);
return (
{(m.attachments && m.attachments.length) ? (
{m.attachments.map((a, i) =>
)}
{m.text ?
{m.text}
: null}
) : m.image ? (
{m.text ?
{m.text}
: null}
) : m.attachment ? (
{m.attachment}
PDF · đã gửi
) : (
{m.text}
)}
{m.ai && AI }
{relTime(m.at)} trước
{m.dir === 'out' && m.status === 'sending' && · Đang gửi… }
{m.dir === 'out' && m.status === 'failed' && · ⚠ Gửi lỗi }
{m.dir === 'out' && m.status === 'delivered' && · ✓ Đã gửi tới khách }
{m.dir === 'out' && m.id === lastOutId && !['sending', 'failed', 'delivered'].includes(m.status) && · {String(m.id).startsWith('x') || String(m.id).startsWith('c') ? 'Đã gửi' : (conv.linked ? '✓✓ Đã xem' : 'Đã gửi')} }
);
})}
{/* Playbook */}
setPlaybook(!playbook)}>
Playbook Assistant
{pb.step}
{playbook && (
AI PHÁT HIỆN PATTERN · {detected.cat}
{detected.label} · win-rate {detected.win}%
navigate('/enablement/intelligence')}>Xem
{pb.tip}
onDraft((draft ? draft + ' ' : '') + pb.reply)}>Chèn script
attach(pb.asset.split(' + ')[0])}> {pb.asset}
navigate('/enablement/assets')}>Mở Asset Library
)}
{/* Composer */}
{/* Copilot draft */}
{composerTab === 'reply' && aiMode === 'copilot' && copilot && (
AI GỢI Ý TRẢ LỜI Copilot — bạn duyệt trước khi gửi
{copilot}
send(copilot)}>Gửi ngay
{ setDraft(copilot); setCopilot(''); }}>Dùng & sửa
setCopilot('')}>Bỏ
)}
{slash && (
QUICK REPLY · gõ để lọc
{AAU.replyTemplates.filter(t => t.key.includes(draft.slice(1).toLowerCase()) || t.label.toLowerCase().includes(draft.slice(1).toLowerCase())).map(t => (
send(t.text)} style={{ padding: '9px 12px', cursor: 'pointer', borderTop: '1px solid var(--p-border-secondary)' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--p-bg-surface-hover)'} onMouseLeave={e => e.currentTarget.style.background = ''}>
/{t.key} · {t.label}
{t.text}
))}
)}
{attachMenu && (
fileRef.current && fileRef.current.click()} style={{ padding: '9px 12px', cursor: 'pointer', background: 'var(--p-bg-surface-hover)' }}>
Tải tệp từ máy… (ảnh/video/file)
HOẶC TÀI LIỆU MẪU
{assets.map(a => (
attach(a)} style={{ padding: '9px 12px', cursor: 'pointer', borderTop: '1px solid var(--p-border-secondary)' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--p-bg-surface-hover)'} onMouseLeave={e => e.currentTarget.style.background = ''}>
{a}
))}
)}
{composerTab === 'reply' && <>
AI:
{ setAiMode(v); setStore(s => ({ ...s, [selId]: { ...s[selId], aiMode: v } })); if (window.API && window.API.enabled) window.API.setAIMode(selId, v).catch(() => {}); }} options={aiOpts} />
{aiMode === 'autopilot' && AI tự trả lời }
>}
{composerTab === 'note' && Chỉ nội bộ thấy · gõ @ để nhắc đồng đội }
{ sendFiles(e.target.files); e.target.value = ''; }} />
{composerTab === 'reply' && setAttachMenu(!attachMenu)} />}
Phím tắt: J/K chuyển hội thoại · E đánh dấu xong
{/* RIGHT — Customer 360 */}
{panel &&
}
{callOpen &&
setCallOpen(false)} onLog={logCall} />}
);
}
function Section360({ title, children, action }) {
const [open, setOpen] = useState(true);
return (
setOpen(!open)}>
{title}
{action}
{open &&
{children}
}
);
}
function Row360({ k, v, auto }) {
return (
{k}{auto && auto }
{v}
);
}
/* Inline-editable key/value row */
function EditRow({ k, value, onChange, editing, type = 'text', suffix }) {
if (!editing) return ;
return (
{k}
onChange(e.target.value)} style={{ height: 28, fontSize: 12.5, textAlign: 'right', padding: '2px 8px' }} />
);
}
const LEARNER_ROLES = ['Chủ / Founder', 'CEO / Tổng GĐ', 'Quản lý vận hành', 'Quản lý cửa hàng', 'Bếp trưởng', 'Phụ trách Marketing', 'Phụ trách Nhân sự', 'Nhân viên cử đi học'];
/* Multi-value contact list (phone / email) */
function ContactList({ items, onChange, editing, type = 'text', icon }) {
const [val, setVal] = useState('');
function add() { const t = val.trim(); if (!t) return; onChange([...items, t]); setVal(''); }
return (
{items.map((it, i) => (
{i === 0 && chính }{it}
{editing && items.length > 1 && onChange(items.filter((_, j) => j !== i))}> }
))}
{editing && (
setVal(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') add(); }} style={{ height: 28, fontSize: 12.5 }} />
)}
);
}
/* Multi-select scrollable course picker */
function CourseMultiSelect({ value, onChange }) {
const [open, setOpen] = useState(false);
const courses = AAU.courses.filter(c => c.status === 'active');
function toggle(id) { onChange(value.includes(id) ? value.filter(x => x !== id) : [...value, id]); }
return (
{value.length === 0 &&
Chưa chọn khóa }
{value.map(id => { const c = AAU.courseById(id); if (!c) return null; return (
{c.name} toggle(id)}>
); })}
setOpen(!open)} style={{ width: '100%', justifyContent: 'space-between' }}>
Thêm khóa quan tâm
{open && (
{AAU.courseGroups.map(g => {
const list = courses.filter(c => c.group === g.id); if (!list.length) return null;
return (
{g.label.toUpperCase()}
{list.map(c => { const on = value.includes(c.id); return (
toggle(c.id)} className="row aic g8" style={{ padding: '7px 12px', cursor: 'pointer', background: on ? 'var(--p-bg-surface-selected)' : '' }} onMouseEnter={e => { if (!on) e.currentTarget.style.background = 'var(--p-bg-surface-hover)'; }} onMouseLeave={e => { if (!on) e.currentTarget.style.background = ''; }}>
toggle(c.id)} />
{c.name}
{c.code} · {AAU.fmtVNDm(c.price)}
); })}
);
})}
)}
);
}
function Customer360Panel({ lead, conv, onCreateLead }) {
const [editId, setEditId] = useState(false);
const [editBiz, setEditBiz] = useState(false);
const [editNeed, setEditNeed] = useState(false);
const [phones, setPhones] = useState([lead.phone || '']);
const [emails, setEmails] = useState([lead.email || '']);
const [biz, setBiz] = useState({ bizModel: lead.bizModel, industry: lead.industry, chainSize: lead.chainSize, revenue: AAU.fmtVNDm(lead.revenue) });
const [pains, setPains] = useState(Array.isArray(lead.painpoints) ? lead.painpoints : []);
const [newPain, setNewPain] = useState('');
const [courses, setCourses] = useState(lead.courseInterest ? [lead.courseInterest] : []);
const [learner, setLearner] = useState('');
const [applied, setApplied] = useState({});
const [dupWarn, setDupWarn] = useState(null); // lead trùng SĐT (thật, từ backend)
const [mergeOpen, setMergeOpen] = useState(false);
const [mergeQ, setMergeQ] = useState('');
const live = !!(window.API && window.API.enabled);
const aiSug = [{ id: 'budget', f: 'Ngân sách', v: '~20tr/khóa' }, { id: 'role', f: 'Vai trò', v: 'Người quyết định' }];
function addPain() { const t = newPain.trim(); if (!t) return; setPains([...pains, t]); setNewPain(''); }
// kiểm tra trùng SĐT ngay khi mở panel (nếu lead đã có SĐT)
useEffect(() => {
if (!live || !lead.id || !window.API.checkPhone || !lead.phone) { setDupWarn(null); return; }
let alive = true;
window.API.checkPhone(lead.phone, lead.id).then(r => { if (alive) setDupWarn(r && r.duplicate); }).catch(() => { });
return () => { alive = false; };
}, [lead.id, lead.phone]);
// LƯU định danh (SĐT/email) THẬT khi bấm ✓ — kèm cảnh báo trùng SĐT
const saveIdentity = async () => {
setEditId(false);
if (!live || !lead.id || !window.API.updateLead) return;
try {
const resp = await window.API.updateLead(lead.id, { phone: (phones[0] || '').trim(), email: (emails[0] || '').trim() });
if (resp && resp.duplicateWarning) setDupWarn(resp.duplicateWarning);
if (window.API.refreshLeads) await window.API.refreshLeads();
} catch (e) { window.alert('Lưu SĐT lỗi: ' + ((e && e.message) || '')); }
};
const doMerge = async (targetId) => {
if (!targetId || !window.API.mergeLeads) return;
try {
const r = await window.API.mergeLeads(lead.id, targetId);
if (window.API.refreshLeads) await window.API.refreshLeads();
setDupWarn(null); setMergeOpen(false);
window.dispatchEvent(new Event('aau:conversations-changed'));
if (r && r.keepId) navigate('/leads/' + r.keepId);
} catch (e) { window.alert('Gộp lỗi: ' + ((e && e.message) || '')); }
};
const dismissDup = async () => { if (dupWarn && window.API.markNotDuplicate) { try { await window.API.markNotDuplicate(lead.id, dupWarn.id); } catch (e) { } } setDupWarn(null); };
const mergeCandidates = (AAU.leads || []).filter(l => l.id !== lead.id && mergeQ.trim() && ((l.name || '').toLowerCase().includes(mergeQ.toLowerCase()) || (l.phone || '').includes(mergeQ))).slice(0, 8);
return (
{lead.name}
{lead.role} · {lead.company}
{lead.id
? <> Đã link CRM navigate('/leads/' + lead.id)}>Xem dữ liệu 360° >
: Tạo Lead từ chat }
{dupWarn && (
Nghi trùng — cùng SĐT
Trùng SĐT với lead {dupWarn.name} {dupWarn.stage ? ' (' + (AAU.stageById(dupWarn.stage)?.name || dupWarn.stage) + ')' : ''}. Cùng 1 người?
doMerge(dupWarn.id)}>Gộp làm 1 Không phải
)}
{mergeOpen && (
setMergeOpen(false)}
footer={ setMergeOpen(false)}>Đóng }>
Tìm lead (theo tên/SĐT) để gộp khách này vào — giữ lead tiến xa hơn, gom tên thành bí danh.
{mergeQ.trim() && mergeCandidates.length === 0 &&
Không thấy lead khớp.
}
{mergeCandidates.map(l => (
doMerge(l.id)}>
{l.name}
{l.phone || 'chưa SĐT'} · {(AAU.channels[l.channel] || {}).label || l.channel}
Gộp
))}
)}
{lead.id && { e.stopPropagation(); setMergeOpen(true); }} />} { e.stopPropagation(); editId ? saveIdentity() : setEditId(true); }} />}>
SĐT {!editId && auto }
Email
{lead.fb}} auto />
{ e.stopPropagation(); setEditBiz(!editBiz); }} />}>
setBiz({ ...biz, bizModel: v })} />
setBiz({ ...biz, industry: v })} />
setBiz({ ...biz, chainSize: v })} />
setBiz({ ...biz, revenue: v })} />
{editBiz && Lưu vào hồ sơ 360° của khách.
}
{ e.stopPropagation(); setEditNeed(!editNeed); }} />}>
Painpoints
{pains.map((p, i) => (
{p}{editNeed && setPains(pains.filter((_, j) => j !== i))}> }
))}
{editNeed && (
setNewPain(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addPain(); }} style={{ height: 30, fontSize: 12.5 }} />
)}
Khóa quan tâm (chọn 1 hoặc nhiều)
{editNeed
?
: {courses.length ? courses.map(id => {AAU.courseById(id)?.name} ) : — }
}
Người học
{editNeed
? ({ value: r, label: r }))]} style={{ width: '100%' }} />
: {learner || '—'} }
{aiSug.map(s => (
{s.f}: {s.v}
{applied[s.id]
?
Đã áp dụng
:
{ setApplied({ ...applied, [s.id]: true }); if (s.id === 'role') setLearner('Chủ / Founder'); }} />}
))}
} />
Có : 'Không'} />
} />
);
}
Object.assign(window, { UnifiedInbox, Customer360Panel, ChannelAvatar, CallModal });