/* 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 ( }>
{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)}>
{u.name}
{u.title}
{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={<>}>
{/* 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 ? : )}
SĐT
{qedit ? setDraft({ ...draft, phone: e.target.value })} /> :
{lead.phone || '—'}
}
Email
{qedit ? setDraft({ ...draft, email: e.target.value })} /> :
{lead.email || '—'}
}
Kênh
{chLabel}
Nguồn
{srcLabel}
Ngành · Quy mô
{lead.industry} · {lead.chainSize}
Khu vực
{lead.region}
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, 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(); }} />
}
{(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 && ( )}
)} {/* stage guide */}
{st.code} · {g.title || st.name}{g.lane && }
{g.def &&
{g.def}
}
Mục tiêu stage
{g.objective}
Trạng thái KH
{g.state}
{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 && (