/* AAU CRM — Pipeline Table · Sales Activity (Working Process) · Pipeline Config (Playbook) */ function PipelineTable() { const leads = useLeads(); const viewer = useViewer(); const canEdit = SalesAccess.canEdit(viewer); const isLeader = viewer.buScope === 'all'; const [adv, setAdv] = useState(false); const [fStage, setFStage] = useState('all'); const [fFunnel, setFFunnel] = useState('all'); const [fSource, setFSource] = useState('all'); const [fGrade, setFGrade] = useState('all'); const [fBranch, setFBranch] = useState('all'); const [fGroup, setFGroup] = useState('all'); const [fAssignee, setFAssignee] = useState('all'); const [fHot, setFHot] = useState(false); const [assign, setAssign] = useState(null); let rows = SalesAccess.scoped(leads, viewer); if (fFunnel !== 'all') rows = rows.filter(l => (AAU.stageById(l.stage) || {}).funnel === fFunnel); if (fStage !== 'all') rows = rows.filter(l => l.stage === fStage); if (fSource !== 'all') rows = rows.filter(l => l.source === fSource); if (fGrade !== 'all') rows = rows.filter(l => l.grade === fGrade); if (fBranch !== 'all') rows = rows.filter(l => l.branch === fBranch); if (fGroup !== 'all') rows = rows.filter(l => l.courseInterest === fGroup); if (fAssignee !== 'all') rows = rows.filter(l => fAssignee === 'none' ? !l.assignedTo : l.assignedTo === fAssignee); if (fHot) rows = rows.filter(l => l.hot); const cols = [ { key: 'company', label: 'Khách hàng', render: l =>
{l.company} {l.hot && 🔥}
{l.name} · {l.role}
, csv: l => l.company }, { key: 'stage', label: 'Stage', render: l => , csv: l => AAU.stageById(l.stage)?.code + ' ' + AAU.stageById(l.stage)?.name }, { key: 'branch', label: 'Nhánh', render: l => l.branch ? : , csv: l => l.branch || '' }, { key: 'signal', label: 'Hot signal', render: l => l.signal ? : , csv: l => l.signal ? l.signal.level : '' }, { key: 'grade', label: 'Grade', render: l => }, { key: 'source', label: 'Nguồn', render: l => {AAU.sourceMeta[l.source]?.label}, csv: l => AAU.sourceMeta[l.source]?.label }, { key: 'industry', label: 'Ngành F&B' }, { key: 'bu', label: 'Khóa quan tâm', render: l => { const c = AAU.courseById(l.courseInterest); return {c ? c.code : '—'}; }, csv: l => (AAU.courseById(l.courseInterest) || {}).name || '' }, { key: 'dealValue', label: 'Deal value', num: true, render: l => l.dealValue ? AAU.fmtVND(l.dealValue) : '—' }, { key: 'sla', label: 'SLA', render: l => }, { key: 'assignedTo', label: 'Phụ trách', render: l => { const u = AAU.users.find(x => x.id === l.assignedTo); return (
{u ? {u.name} : Chưa gán} {canEdit && { e.stopPropagation(); setAssign(l); }}>{u ? 'Đổi' : 'Phân công'}}
); }, csv: l => AAU.users.find(u => u.id === l.assignedTo)?.name || '' }, ]; const bulk = [ { icon: 'user', label: 'Gán sales', onClick: () => {} }, { icon: 'kanban', label: 'Chuyển stage', onClick: () => {} }, { icon: 'tag', label: 'Thêm tag', onClick: () => {} }, ]; const toolbar = ( <> fFunnel === 'all' || s.funnel === fFunnel).map(s => ({ value: s.id, label: s.code + ' ' + s.name }))]} style={{ width: 180 }} /> ); return (
} />
{adv && (
({ value: u.id, label: u.name })), { value: 'none', label: 'Chưa gán' }]} style={{ width: 150 }} /> ({ value: s.id, label: s.name }))} style={{ width: 160 }} /> {}} options={[{ value: 'd', label: 'Ngày' }, { value: 'w', label: 'Tuần' }, { value: 'm', label: 'Tháng' }]} />
{items.map(it => (
{it.l}
{it.v}
/ {it.t}
= it.t ? '#0e7c4a' : '#303030'} />
{it.sub &&
{it.sub}
}
))}
{rows.map(r => setSale(r.id)}>)}
Nhân viênCuộc gọiPhiên chatLead mớiDeal wonDoanh thu
u.id === r.id)?.color} />{r.name}
{r.calls}{r.chats}{r.leads}{r.won}{AAU.fmtVND(r.rev)}
); } function DailyFlowView() { const flow = PLAYBOOK.dailyFlowBU; const Block = ({ b, active }) => (
{b.time}{b.dur}
{b.title}
    {b.items.map((it, i) =>
  • {it}
  • )}
); return ( <>
Nguyên tắc: luôn từ hot nhất → nguội. Sáng là khung giờ năng lượng cao — ưu tiên việc khó (close, proposal). Dòng dò ngược: 3.5 → 3.6 → 3.7 → 3.1 → 3.2–3.4 → 3.0 → 2.3 → 2.1/2.2.
{flow.am.map((b, i) => )}
{flow.pm.map((b, i) => )}
); } function WeeklyView() { return (
Sales Leader chạy nhịp tuần với team: 60% coaching · 30% own deals · 10% admin. Imlặng là kẻ thù — escalate sớm.
{PLAYBOOK.weekly.map((w, i) => (
{w.title}{w.owner}
{w.day}
    {w.items.map((it, j) =>
  • {it}
  • )}
))}
); } function ReportView() { return (
{PLAYBOOK.dailyReport.map((r, i) => (
{i + 1}. {r.k}{r.v}
))}
{[['Daily Report', 'Daily 18:00', 'Sales BU → Leader'], ['Weekly Plan', 'Thứ 2 9:00', 'Leader → Team'], ['Weekly Report', 'Thứ 6 18:00', 'Leader → CEO'], ['QC Weekly', 'Thứ 6 17:00', 'QC → Leader/L&D'], ['Pipeline Dashboard', 'Real-time', 'BA → Cả team'], ['Monthly Revenue', 'Ngày 3', 'Finance → CEO/BOD']].map((r, i) => ( ))}
Báo cáoCadenceOwner → Audience
{r[0]}{r[1]}{r[2]}
); } /* ───────────────── Pipeline Config & Playbook reference ───────────────── */ function PipelineConfig() { const [tab, setTab] = useState('access'); return (
navigate('/leads/kanban')}>Xem Kanban} />
{tab === 'access' && } {tab === 'stages' && } {tab === 'transitions' && } {tab === 'signals' && } {tab === 'move' && } {tab === 'priority' && }
); } function ConfigAccess() { const leads = useLeads(); const SCOPE_KEY = 'aau.salesScope.v1'; const team = AAU.users.filter(u => u.salesRole && u.salesRole !== 'admin'); const members = team.filter(u => u.salesRole === 'member'); const [edit, setEdit] = useState(false); const [, bump] = useState(0); const [scopes, setScopes] = useState(() => { const o = {}; team.forEach(u => o[u.id] = u.buScope === 'all' ? 'all' : [...(u.buScope || [])]); return o; }); const persist = (next) => { setScopes(next); team.forEach(u => { if (next[u.id] !== undefined) u.buScope = next[u.id]; }); try { localStorage.setItem(SCOPE_KEY, JSON.stringify(next)); } catch (e) {} bump(x => x + 1); }; const addCourse = (uid, cid) => { const cur = scopes[uid]; if (cur === 'all' || !cid || cur.includes(cid)) return; persist({ ...scopes, [uid]: [...cur, cid] }); }; const removeCourse = (uid, cid) => { const cur = scopes[uid]; if (cur === 'all') return; persist({ ...scopes, [uid]: cur.filter(x => x !== cid) }); }; const visCount = (u) => SalesAccess.scoped(leads, { ...u, buScope: scopes[u.id] }).length; const allCourses = AAU.courses; const courseChip = (cid, uid) => { const c = AAU.courseById(cid); if (!c) return null; return ( {c.code}{edit && } ); }; return ( <>
Phân quyền lead theo từng khóa: mỗi Sales BU được gán danh sách khóa cụ thể và chỉ thấy lead của các khóa đó. Khóa có thể thêm/bớt tùy hiệu quả kinh doanh. Sales Leader/Manager thấy toàn bộ. Dùng "Xem với" trên thanh trên cùng để thử từng vai trò.
setEdit(!edit)}>{edit ? 'Xong' : 'Chỉnh sửa khóa'}}> {team.map(u => { const m = SalesAccess.accessMeta[u.access] || {}; const sc = scopes[u.id]; const avail = allCourses.filter(c => sc !== 'all' && !sc.includes(c.id)); return ( ); })}
Thành viênVai tròKhóa phụ tráchCấp quyềnLead thấy
{u.name}
{u.title}
{u.salesRole === 'leader' ? Leader : u.salesRole === 'member' ? Member : {u.salesRole}} {sc === 'all' ? Toàn bộ khóa : (
{sc.length ? sc.map(cid => courseChip(cid, u.id)) : — chưa gán khóa —} {edit && avail.length > 0 &&
{m.label} {visCount(u)}
{allCourses.filter(c => c.status === 'active').map(c => { const owners = members.filter(u => scopes[u.id] !== 'all' && (scopes[u.id] || []).includes(c.id)); return ( ); })}
Khóa họcSales BU phụ trách
{c.code}
{c.name}
{owners.length ?
{owners.map(o => {o.name})}
: Chưa ai phụ trách}
{[['view', 'Xem pipeline, hồ sơ lead trong phạm vi. KHÔNG move/sửa/assign.'], ['edit', 'Xem + MAKE THE MOVE + sửa qualify + tự nhận lead trong khóa phụ trách.'], ['manage', 'Toàn quyền: assign/đổi người phụ trách, cấu hình, xem mọi khóa.']].map(([k, d]) => { const m = SalesAccess.accessMeta[k]; return (
{m.label} {d}
); })}
); } function ConfigStages() { const leads = useLeads(); const STAGE_KEY = 'aau.stages.v1'; const leadCount = {}; leads.forEach(l => leadCount[l.stage] = (leadCount[l.stage] || 0) + 1); const [edit, setEdit] = useState(false); const [, bump] = useState(0); const [draft, setDraft] = useState({}); // { catId: { code, name } } const loadDiff = () => { try { return JSON.parse(localStorage.getItem(STAGE_KEY) || '{}'); } catch (e) { return {}; } }; const saveDiff = (d) => { try { localStorage.setItem(STAGE_KEY, JSON.stringify(d)); } catch (e) {} }; const addStage = (cat) => { const d = draft[cat.id] || {}; const name = (d.name || '').trim(); if (!name) return; const sameFunnel = AAU.stages.filter(s => s.funnel === cat.funnel); const maxPos = sameFunnel.reduce((m, s) => Math.max(m, s.pos), 0); const stage = { id: 'sx_' + Math.random().toString(36).slice(2, 8), code: (d.code || '·').trim(), name, category: cat.id, funnel: cat.funnel, branch: null, lane: null, pos: maxPos + 1, slaHours: 0, slaText: (d.sla || '—').trim() || '—', role: 'sales', color: '#64748b', flags: [] }; AAU.stages.push(stage); const diff = loadDiff(); diff.added = [...(diff.added || []), stage]; saveDiff(diff); setDraft(p => ({ ...p, [cat.id]: {} })); bump(x => x + 1); }; const removeStage = (s) => { if (leadCount[s.id] > 0) return; const i = AAU.stages.findIndex(x => x.id === s.id); if (i >= 0) AAU.stages.splice(i, 1); const diff = loadDiff(); diff.added = (diff.added || []).filter(x => x.id !== s.id); if (!String(s.id).startsWith('sx_')) diff.removed = [...(diff.removed || []), s.id]; saveDiff(diff); bump(x => x + 1); }; const resetStages = () => { localStorage.removeItem(STAGE_KEY); location.reload(); }; return ( <>
Bật Chỉnh sửa để thêm/bớt stage cho từng nhóm funnel. Stage còn lead sẽ không cho xóa (phải chuyển lead đi trước). Thay đổi áp dụng cho cả Kanban & bảng pipeline. {edit && }
{AAU.stageCategories.map(cat => { const ss = AAU.stages.filter(s => s.category === cat.id).sort((a, b) => a.pos - b.pos); if (!ss.length && !edit) return null; const d = draft[cat.id] || {}; return ( {edit && }{ss.map(s => { const g = pbGuide(s.code); const locked = leadCount[s.id] > 0; return ( {edit && } ); })}
CodeTên stageNhánh / LaneSLAPICScriptLeadCờ
{s.code} {s.name} {s.branch ? : ''} {s.lane ? : (!s.branch ? : '')} {s.slaText} {g ? g.pic : s.role} {g && g.type ? : '—'} {leadCount[s.id] || 0} {s.flags.length ? s.flags.map(f => {f}) : '—'}
{edit && (
setDraft(p => ({ ...p, [cat.id]: { ...d, code: e.target.value } }))} /> setDraft(p => ({ ...p, [cat.id]: { ...d, name: e.target.value } }))} /> setDraft(p => ({ ...p, [cat.id]: { ...d, sla: e.target.value } }))} />
)}
); })} ); } function ConfigTransitions() { const rows = PLAYBOOK.transitions; return ( {rows.map((r, i) => { const f = AAU.stageByCode(r.from), t = AAU.stageByCode(r.to); return ( ); })}
CurrentTriggerNext Stage
{f && } {r.trigger} {t && }
); } function ConfigSignals() { const map = { HIGH: 'critical', MED: 'warning', LOW: 'neutral' }; return ( <>
Rule: detect signal → MOVE NGAY lên Hot lane (3.5), không chờ timer. Sai quy trình nhất là giữ khách hot ở nurture lane.
{PLAYBOOK.hotSignals.map((s, i) => ( ))}
SignalLevelExample phrase / behavior
{s.name}{s.level}{s.example}
); } function ConfigMove() { return ( <>
Triết lý: Stage không phải trạng thái chờ — là điểm can thiệp có kịch bản cụ thể để push khách sang stage mới. Nếu không MAKE THE MOVE được, stage đó vô nghĩa.
{PLAYBOOK.makeTheMove.map(p => (
{p.n}
{p.title}
{p.en}
    {p.items.map((it, i) =>
  • {it}
  • )}
))}
{PLAYBOOK.principles.map(p => (
{p.n}
{p.t}
{p.d}
))}
); } function ConfigPriority() { const Col = ({ title, hint, list }) => (
{list.map((r, i) => { const s = AAU.stageByCode(r.code); return (
{String(i + 1).padStart(2, '0')} {s && } {r.note}
); })}
); return ( <>
Data Priority Flow: thứ tự dò khi Sales cần kiếm số — luôn từ nóng xuống nguội để tối đa số Won/ngày. Đây cũng là logic xếp hạng của màn "Hôm nay".
); } Object.assign(window, { PipelineTable, SalesActivity, PipelineConfig });