/* AAU CRM — Sales shared: MAKE THE MOVE drawer, qualify gate, priority helpers.
Reused by Work Queue, Kanban, and 360 profile. */
// ---- session lead store (moves persist across Sales screens) ----
const LeadStore = (function () {
let data = AAU.leads.map(l => ({ ...l, qualify: { ...l.qualify }, history: [] }));
const subs = new Set();
const emit = () => subs.forEach(f => f());
return {
all: () => data,
byId: (id) => data.find(l => l.id === id),
add: (lead) => {
const maxN = data.reduce((m, l) => Math.max(m, parseInt(String(l.id).replace(/\D/g, ''), 10) || 0), 0);
const id = 'L' + (maxN + 1);
const today = new Date().toISOString().slice(0, 10);
const full = {
id, name: '', company: '', role: '', phone: '', fb: '', email: '',
bizModel: '', industry: 'F&B', chainSize: '', revenue: 0, region: '',
painpoints: [], courseInterest: '', source: 'direct', channel: 'zalo',
stage: 's_raw', branch: null, grade: 'C', dealValue: 0,
sla: 'ok', slaLeft: '24h', hot: false, signal: null, daysInStage: 0,
nextAction: '', objection: null,
qualify: { bizModel: false, painpoint: false, courseMap: false, budget: false },
assignedTo: '', createdAt: today,
...lead,
};
const st = AAU.stageById(full.stage);
if (st) full.branch = st.branch;
full.history = [{ from: '—', to: st ? st.code : '1.0', reason: 'Tạo mới (nhập tay)', trigger: 'manual', at: 'vừa xong' }];
data = [full, ...data];
emit();
return id;
},
move: (id, toCode, reason, trigger) => {
const s = AAU.stageByCode(toCode); if (!s) return;
data = data.map(l => {
if (l.id !== id) return l;
const from = AAU.stageById(l.stage);
return { ...l, stage: s.id, daysInStage: 0, history: [{ from: from ? from.code : '?', to: s.code, reason, trigger, at: 'vừa xong' }, ...(l.history || [])] };
});
emit();
},
toggleQualify: (id, k) => { data = data.map(l => l.id === id ? { ...l, qualify: { ...l.qualify, [k]: !l.qualify[k] } } : l); emit(); },
note: (id, text) => { if (!text || !text.trim()) return; data = data.map(l => l.id === id ? { ...l, history: [{ note: true, reason: text.trim(), at: 'vừa xong' }, ...(l.history || [])] } : l); emit(); },
update: (id, patch) => { data = data.map(l => l.id === id ? { ...l, ...patch } : l); emit(); },
assign: (id, userId) => { data = data.map(l => l.id === id ? { ...l, assignedTo: userId } : l); emit(); },
sub: (f) => { subs.add(f); return () => subs.delete(f); },
// re-seed from window.AAU.leads (used after backend hydration in live mode)
reload: () => { data = AAU.leads.map(l => ({ ...l, qualify: { ...l.qualify }, history: l.history || [] })); emit(); },
};
})();
function useLeads() {
const [, bump] = useState(0);
useEffect(() => LeadStore.sub(() => bump(x => x + 1)), []);
return LeadStore.all();
}
// ---- Sales access control (viewer scope + permissions) ----
const AccessStore = (function () {
let viewerId = localStorage.getItem('sales_viewer') || AAU.currentUser.id;
if (!AAU.users.find(u => u.id === viewerId)) viewerId = AAU.currentUser.id;
const subs = new Set();
// Resolve to a REAL user, never undefined: viewerId may point at a user that
// was deleted (or that only existed in the mock seed before live hydrate
// replaced AAU.users). Falling back to currentUser → first user keeps every
// screen that reads viewer.id from crashing.
const resolve = () => AAU.users.find(u => u.id === viewerId)
|| AAU.users.find(u => u.id === (AAU.currentUser && AAU.currentUser.id))
|| AAU.users[0];
return {
get: resolve,
set: (id) => { viewerId = id; localStorage.setItem('sales_viewer', id); subs.forEach(f => f()); },
sub: (f) => { subs.add(f); return () => subs.delete(f); },
};
})();
function useViewer() {
const [, bump] = useState(0);
useEffect(() => AccessStore.sub(() => bump(x => x + 1)), []);
return AccessStore.get();
}
const SalesAccess = {
buOf: (lead) => (AAU.courseById(lead.courseInterest) || {}).group,
canSee: (lead, v) => {
if (!v) return true;
if (v.buScope === 'all') return true;
return (v.buScope || []).includes(lead.courseInterest) || lead.assignedTo === v.id;
},
scoped: (leads, v) => leads.filter(l => SalesAccess.canSee(l, v)),
canEdit: (v) => v && (v.access === 'edit' || v.access === 'manage'),
canManage: (v) => v && v.access === 'manage',
// members phụ trách đúng KHÓA của lead (+ leader/manager)
eligibleAssignees: (lead) => {
return AAU.users.filter(u => u.salesRole === 'member' && (u.buScope === 'all' || (u.buScope || []).includes(lead.courseInterest)));
},
members: () => AAU.users.filter(u => u.salesRole === 'member'),
accessMeta: { view: { label: 'Chỉ xem', tone: 'neutral', icon: 'eye' }, edit: { label: 'Sửa & move', tone: 'info', icon: 'edit' }, manage: { label: 'Toàn quyền', tone: 'success', icon: 'shield' } },
};
// Áp lại phân quyền khóa đã lưu (per-course scope) cho toàn app
(function applySavedScope() {
try {
const saved = JSON.parse(localStorage.getItem('aau.salesScope.v1') || '{}');
AAU.users.forEach(u => { if (saved[u.id] !== undefined) u.buScope = saved[u.id]; });
} catch (e) { /* ignore */ }
})();
// Áp lại stage đã chỉnh (thêm/xóa) cho toàn app — Kanban, bảng, config đều dùng chung
(function applySavedStages() {
try {
const sv = JSON.parse(localStorage.getItem('aau.stages.v1') || '{}');
(sv.added || []).forEach(s => { if (!AAU.stages.find(x => x.id === s.id)) AAU.stages.push(s); });
(sv.removed || []).forEach(id => { const i = AAU.stages.findIndex(x => x.id === id); if (i >= 0) AAU.stages.splice(i, 1); });
} catch (e) { /* ignore */ }
})();
function AccessNote({ viewer }) {
const m = SalesAccess.accessMeta[viewer.access] || {};
const scopeTxt = viewer.buScope === 'all' ? 'tất cả khóa' : (viewer.buScope || []).map(id => (AAU.courseById(id) || {}).code).filter(Boolean).join(' · ');
return (
{viewer.name} · {viewer.title}
Phạm vi: {scopeTxt}
{m.label}
);
}
function AssignDrawer({ lead, onClose }) {
const cur = AAU.users.find(u => u.id === lead.assignedTo);
const g = SalesAccess.buOf(lead);
const eligible = SalesAccess.eligibleAssignees(lead);
const course = AAU.courseById(lead.courseInterest);
const [pick, setPick] = useState(lead.assignedTo || '');
return (
Hủy { LeadStore.assign(lead.id, pick); onClose(); }}>Lưu phân công >}>
{lead.company}
{lead.name} · {lead.bizModel}
Khóa quan tâm (quyết định ai phụ trách)
{course ? course.code + ' · ' + course.name : '—'}
Chỉ Sales BU được gán khóa này (hoặc Leader) mới được phân công.
Phân cho Sales BU
{eligible.map(u => (
setPick(u.id)}>
{pick === u.id &&
}
))}
{eligible.length === 0 &&
Chưa có Sales BU nào được gán khóa này.
}
{cur &&
Hiện tại: {cur.name}
}
);
}
// ---- priority scoring (Data Priority Flow) ----
const DATA_PRIORITY_RANK = {
// deal hot first, then qualify-fresh, then nurture, then lead-side hot
'3.5': 100, '3.6': 92, '3.7': 86, '1.6': 84, '3.1': 80, '1.4': 78,
'3.8': 60, '3.2': 58, '2.3': 56, '3.3': 50, '2.1': 48, '3.4': 44,
'1.2': 40, '3.0': 38, '2.2': 34, '1.5': 30, '1.0': 20, '1.3': 0,
'4.0': 70, '4.1': 40, '4.2': 40, '4.3': 24, '4.6': 96, '5.0': 52, '5.1': 30, '5.2': 22, '5.3': 12,
};
function leadPriority(lead) {
const st = AAU.stageById(lead.stage); if (!st) return 0;
let score = DATA_PRIORITY_RANK[st.code] || 10;
if (lead.hot) score += 14;
if (lead.signal) score += { HIGH: 12, MED: 6, LOW: 1 }[lead.signal.level] || 0;
if (lead.sla === 'bad') score += 16; else if (lead.sla === 'warn') score += 8;
if (lead.dealValue) score += Math.min(8, lead.dealValue / 30000000);
return Math.round(score);
}
function priorityTier(lead) {
const st = AAU.stageById(lead.stage);
if (lead.sla === 'bad') return { key: 'now', label: 'Quá hạn — xử lý NGAY', color: '#c4320a' };
if (st && st.flags.includes('hotlane')) return { key: 'now', label: 'Hot lane — close mode', color: '#ea580c' };
if (lead.signal && lead.signal.level === 'HIGH') return { key: 'now', label: 'Hot signal HIGH', color: '#ea580c' };
if (st && ['1.6', '3.1', '1.4'].includes(st.code)) return { key: 'next', label: 'Critical window', color: '#9a6700' };
if (lead.sla === 'warn') return { key: 'next', label: 'Sắp tới hạn SLA', color: '#9a6700' };
return { key: 'later', label: 'Nurture / theo dõi', color: '#5a6472' };
}
function QualifyGate({ lead, editable, onToggle }) {
const conds = (window.PLAYBOOK && PLAYBOOK.qualifyConditions) || [];
const score = conds.filter(c => lead.qualify[c.key]).length;
return (
4 ĐIỀU KIỆN QUALIFY
= 2 ? 'warning' : 'neutral')}>{score}/4
{conds.map(c => {
const on = !!lead.qualify[c.key];
return (
onToggle(c.key) : undefined} style={{ cursor: editable ? 'pointer' : 'default' }}>
{c.label}
);
})}
);
}
function MakeTheMoveDrawer({ lead, onClose, onMove, onToggleQualify, readOnly }) {
const st = AAU.stageById(lead.stage);
const g = pbGuide(st.code) || {};
const meta = (window.PLAYBOOK && PLAYBOOK.scriptTypeMeta[g.type]) || {};
const [pending, setPending] = useState(null); // {toCode, trigger}
const [reason, setReason] = useState('');
const isLead = st.funnel === 'lead' || st.funnel === 'leadb';
const qScore = Object.values(lead.qualify).filter(Boolean).length;
const course = AAU.courseById(lead.courseInterest);
const assignee = AAU.users.find(u => u.id === lead.assignedTo);
const chLabel = (AAU.channels[lead.channel] || {}).label || lead.channel;
const srcLabel = (AAU.sourceMeta[lead.source] || {}).label || lead.source;
const [qedit, setQedit] = useState(false);
const [draft, setDraft] = useState({});
const [noteText, setNoteText] = useState('');
const beginEdit = () => { setDraft({ phone: lead.phone || '', email: lead.email || '', dealValue: lead.dealValue || 0, grade: lead.grade || 'C', nextAction: lead.nextAction || '', objection: lead.objection || '' }); setQedit(true); };
const saveEdit = () => { LeadStore.update(lead.id, { phone: draft.phone, email: draft.email, dealValue: Number(draft.dealValue) || 0, grade: draft.grade, nextAction: draft.nextAction, objection: draft.objection || null }); setQedit(false); };
const addNote = () => { LeadStore.note(lead.id, noteText); setNoteText(''); };
const startMove = (opt) => { setPending({ toCode: opt.code, trigger: opt.trigger }); setReason(opt.trigger ? '' : ''); };
const confirm = () => {
if (!reason.trim()) return;
onMove(lead.id, pending.toCode, reason.trim(), pending.trigger);
setPending(null);
};
return (
{lead.name} · MAKE THE MOVE } onClose={onClose} width={560}
footer={<>Đóng navigate('/leads/' + lead.id)}>Mở hồ sơ 360° >}>
{/* identity + stage */}
{lead.company} {lead.hot && }
{lead.role} · {lead.bizModel} · {lead.chainSize} · {lead.region}
{/* ── 360 snapshot + sửa nhanh ── */}
THÔNG TIN 360° NHANH
{!readOnly && (qedit
? setQedit(false)}>Hủy Lưu
: Sửa nhanh )}
SĐT
{qedit ?
setDraft({ ...draft, phone: e.target.value })} /> :
{lead.phone || '—'}
}
Email
{qedit ?
setDraft({ ...draft, email: e.target.value })} /> :
{lead.email || '—'}
}
Ngành · Quy mô
{lead.industry} · {lead.chainSize}
Doanh thu DN
{AAU.fmtVNDm(lead.revenue)}
Khóa quan tâm
{course ? course.code + ' · ' + course.name : '—'}
Giá trị deal
{qedit ?
setDraft({ ...draft, dealValue: e.target.value })} /> :
{lead.dealValue ? AAU.fmtVNDm(lead.dealValue) : '—'}
}
Grade
{qedit ?
setDraft({ ...draft, grade: v })} options={['A', 'B', 'C', 'D'].map(x => ({ value: x, label: x }))} /> : {lead.grade}
}
Phụ trách
{assignee ? assignee.name : 'Chưa gán'}
Tạo · trong stage
{AAU.fmtDate(lead.createdAt)} · {lead.daysInStage}d
Next action
{qedit ?
setDraft({ ...draft, nextAction: e.target.value })} /> :
{lead.nextAction || '—'}
}
Objection
{qedit ?
setDraft({ ...draft, objection: e.target.value })} /> :
{lead.objection || '—'}
}
{lead.painpoints && lead.painpoints.length > 0 &&
Painpoints
{lead.painpoints.map((p, i) => {p} )}
}
{/* ── hoạt động gần đây + ghi chú nhanh ── */}
HOẠT ĐỘNG GẦN ĐÂY
{!readOnly &&
setNoteText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addNote(); }} />Ghi
}
{(lead.history || []).slice(0, 4).map((h, i) => (
{h.note ? h.reason : {h.from} → {h.to} · {h.reason} }
{h.at}
))}
{(!lead.history || lead.history.length === 0) &&
Chưa có hoạt động — thêm ghi chú hoặc chuyển stage để bắt đầu timeline.
}
{/* signal */}
{lead.signal && (
HOT SIGNAL · {lead.signal.level}
{lead.signal.text}
{st.code !== '3.5' && !st.flags.includes('won') && !readOnly && (
startMove({ code: '3.5', trigger: 'Hot signal detected' })}>Đẩy lên Hot lane 3.5
)}
)}
{/* stage guide */}
{st.code} · {g.title || st.name} {g.lane && }
{g.def &&
{g.def}
}
Mục tiêu stage
{g.objective}
{g.pic && PIC: {g.pic} }
{(g.timer || st.slaText) && {g.timer || st.slaText} }
{g.kpi && {g.kpi} }
{/* script panel */}
{(g.openings && g.openings.length > 0 || g.send && g.send.length > 0) && (
KỊCH BẢN — {meta.label} {meta.desc}
{g.openings && g.openings.length > 0 && (
{g.openings.map((o, i) =>
{o}
)}
)}
{g.send && g.send.length > 0 && (
Tài liệu / hành động gửi
{g.send.map((s, i) => {s} )}
)}
{g.cadence &&
Cadence: {g.cadence}
}
)}
{/* qualify gate for lead funnel */}
{isLead && st.code !== '1.0' && st.code !== '1.3' && (
onToggleQualify && onToggleQualify(lead.id, k)} />
{qScore < 4 && Chưa đủ 4 điều kiện — không nên move sang 3.1 Qualify.
}
)}
{/* next stage / make the move */}
MAKE THE MOVE — chuyển stage
{readOnly &&
Vai trò chỉ-xem: bạn không có quyền chuyển stage lead này.
}
{!readOnly && !pending && (
{(g.next || []).map((opt, i) => {
const ts = AAU.stageByCode(opt.code);
return (
startMove(opt)}>
{ts && }
{opt.code} {ts ? ts.name : ''}
{opt.trigger}
);
})}
)}
{pending && (
Trigger: {pending.trigger || 'Thủ công'}
setPending(null)}>Hủy
Xác nhận chuyển
)}
);
}
Object.assign(window, { leadPriority, priorityTier, QualifyGate, MakeTheMoveDrawer, DATA_PRIORITY_RANK, LeadStore, useLeads, AccessStore, useViewer, SalesAccess, AccessNote, AssignDrawer });
// ───────────── Báo cáo Sales: chất lượng data + mức độ tương tác ─────────────
function RepBar({ label, n, total, color, suffix }) {
const p = total ? Math.round((n / total) * 100) : 0;
return (
{label} {AAU.fmtNum(n)}{suffix || ''} · {p}%
);
}
function SalesReport() {
const live = !!(window.API && window.API.enabled);
const [r, setR] = useState(null);
const [sig, setSig] = useState(null);
const [err, setErr] = useState(null);
const [tick, setTick] = useState(0);
useEffect(() => {
if (!live || !window.API.salesReport) return;
let alive = true; setErr(null);
window.API.salesReport().then(d => { if (alive) setR(d); }).catch(e => { if (alive) setErr((e && e.message) || 'lỗi'); });
if (window.API.buyingSignals) window.API.buyingSignals().then(d => { if (alive) setSig(d); }).catch(() => { });
return () => { alive = false; };
}, [live, tick]);
const openChat = cid => { window.NavIntent = { inboxConv: cid }; navigate('/inbox'); };
const head = setTick(t => t + 1)}>Làm mới : null} />;
if (!live) return {head}
;
if (!r) return {head}
{err ? 'Lỗi tải báo cáo: ' + err : 'Đang tải báo cáo…'}
;
const q = r.dataQuality, i = r.interaction, e = r.effectiveness, total = r.totalLeads;
const maxStage = Math.max(1, ...r.byStage.map(s => s.count));
const sugg = [
'Thời gian phản hồi đầu (SLA): khách nhắn → bao lâu sales trả lời.',
'Tuổi lead trong stage: lead "kẹt" ở Raw Lead bao lâu (cần đẩy).',
'Tỉ lệ qualify & chuyển stage theo nguồn/kênh (kênh nào ra lead chất).',
'Khóa quan tâm suy từ chat → map khóa để phân bổ & RBAC đúng.',
'Hiệu suất theo nhân sự: tải + tỉ lệ chốt từng sales.',
'Lead "nóng" (hỏi giá/lịch khai giảng) chưa ai xử → ưu tiên gọi.',
'Hội thoại nguội (>N ngày không tương tác) → nhắc/nurture.',
];
return (
{head}
{sig && sig.count > 0 && (
Khách SĐT Khóa Tín hiệu Giao
{sig.leads.slice(0, 12).map(l => (
{l.name}
{l.phone || — }
{l.courseInterest ? (AAU.courseById(l.courseInterest)?.name || l.courseInterest) : chưa rõ }
{l.signal}
{(AAU.users.find(u => u.id === l.assignedTo) || {}).name || l.assignedTo || '—'}
openChat(l.conversationId)}>Chat navigate('/leads/' + l.id)}>360°
))}
)}
Tin nhắn: khách {AAU.fmtNum(i.inMsgs)} · sales {AAU.fmtNum(i.salesMsgs)} · bot/AI {AAU.fmtNum(i.aiMsgs)} .
{(r.byLifecycle || []).length > 0 && (() => {
const lcColor = { 'Đang nóng': '#0a9e6e', 'Nguội dần': '#f59e0b', 'Có nguy cơ': '#ea580c', 'Ngủ đông': '#6b7280', 'Đã rời': '#c4320a', 'Chưa rõ': '#9aa0a6' };
const maxLc = Math.max(1, ...r.byLifecycle.map(s => s.count));
return (
{r.byLifecycle.map(s => (
{s.label}
{AAU.fmtNum(s.count)}
{Math.round(s.count / total * 100)}%
))}
{(e.followUp || 0) > 0 && (
{AAU.fmtNum(e.followUp)} lead đang Nguội dần / Có nguy cơ — follow-up ngay để không rớt về Ngủ đông/Đã rời.
)}
);
})()}
{r.byStage.filter(s => s.count > 0).map(s => (
{s.name} {AAU.fmtNum(s.count)}{s.value ? ' · ' + AAU.fmtVNDm(s.value) : ''}
))}
{!r.byStage.some(s => s.count > 0) && Chưa có lead.
}
{r.byChannel.map(s => )}
{(r.byCourse || []).length ? (r.byCourse || []).slice(0, 8).map(s => ) : Chưa map được khóa.
}
{AAU.fmtNum(e.phoneUncontacted)} lead có SĐT nhưng chưa sales nào liên hệ — ưu tiên gọi để không bỏ phí.
{sugg.map((s, k) =>
{s}
)}
);
}
window.SalesReport = SalesReport;