/* AAU CRM — Pipeline Kanban: 3 funnel rows (Lead / Deal / Won), branch & lane labels, drag-drop + MAKE THE MOVE drawer. */ function LeadCard({ lead, onDragStart, onOpen }) { const tier = priorityTier(lead); return (
{lead.company}
{lead.hot && }
{lead.name} · {lead.role}
{lead.signal &&
}
{lead.dealValue > 0 ? {AAU.fmtVNDm(lead.dealValue)} : }
); } function KanbanColumn({ stage, leads, onDrop, onDragStart, onOpen, onAdd, canEdit }) { const [over, setOver] = useState(false); const dropRef = useRef(null); const CAP = 10; const total = leads.reduce((s, l) => s + (l.dealValue || 0), 0); const overflow = Math.max(0, leads.length - CAP); // Cap the visible height to exactly CAP cards; the rest scroll. useEffect(() => { const el = dropRef.current; if (!el) return; const cards = el.querySelectorAll('.kb-card'); if (cards.length > CAP) { const last = cards[CAP - 1]; el.style.maxHeight = (last.offsetTop + last.offsetHeight + 8) + 'px'; } else { el.style.maxHeight = ''; } }, [leads.length, leads.map(l => l.id).join(',')]); return (
{ e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)} onDrop={() => { setOver(false); onDrop(stage.id); }}>
{stage.code} {stage.name} {leads.length}
{stage.branch && } {stage.lane && } {stage.slaText && stage.slaText !== '—' && {stage.slaText}} {total > 0 && {AAU.fmtVNDm(total)}}
{leads.map(l => onDragStart(l.id, stage.id)} onOpen={() => onOpen(l.id)} />)} {leads.length === 0 &&
}
{overflow > 0 &&
↑ cuộn để xem {overflow} khách còn lại
} {canEdit && (
)}
); } function FunnelRow({ index, title, hint, stages, leadsByStage, ...handlers }) { const total = stages.reduce((s, st) => s + (leadsByStage[st.id] || []).length, 0); const val = stages.reduce((sum, st) => sum + (leadsByStage[st.id] || []).reduce((a, l) => a + (l.dealValue || 0), 0), 0); return (
{index} {title} {hint} {total} lead{val > 0 ? ' · ' + AAU.fmtVNDm(val) : ''}
{stages.map(s => )}
); } function NewLeadDrawer({ defaultStage, viewer, onClose, onCreated }) { const leadStages = AAU.stages.filter(s => s.funnel === 'lead' || s.funnel === 'leadb').sort((a, b) => a.pos - b.pos); const courseOpts = [{ value: '', label: '— Chưa rõ —' }].concat(AAU.courses.map(c => ({ value: c.id, label: c.code + ' · ' + c.name }))); const sourceOpts = Object.keys(AAU.sourceMeta).map(k => ({ value: k, label: AAU.sourceMeta[k].label })); const canAssignSelf = viewer && (viewer.salesRole === 'member' || viewer.salesRole === 'leader'); const [f, setF] = useState({ company: '', name: '', role: 'Chủ', phone: '', email: '', region: 'TP.HCM', bizModel: '', chainSize: '', courseInterest: '', source: 'direct', stage: defaultStage || 's_raw', grade: 'C', dealValue: '', painpoints: '', nextAction: '', assignSelf: !!canAssignSelf, }); const set = (k, v) => setF(p => ({ ...p, [k]: v })); const num = v => Number(String(v).replace(/\D/g, '')) || 0; const valid = f.company.trim() && f.name.trim(); function save() { if (!valid) return; const id = LeadStore.add({ company: f.company.trim(), name: f.name.trim(), role: f.role.trim() || 'Chủ', phone: f.phone.trim(), email: f.email.trim(), region: f.region.trim(), bizModel: f.bizModel.trim(), industry: f.bizModel.trim() || 'F&B', chainSize: f.chainSize.trim(), courseInterest: f.courseInterest, source: f.source, stage: f.stage, grade: f.grade, dealValue: num(f.dealValue), painpoints: f.painpoints.split(',').map(s => s.trim()).filter(Boolean), nextAction: f.nextAction.trim(), assignedTo: f.assignSelf && canAssignSelf ? viewer.id : '', }); onCreated && onCreated(id); } const st = AAU.stageById(f.stage); return ( }>
Dùng khi khách gọi hotline / giới thiệu mà chưa có sẵn trên hệ thống. Tối thiểu cần Tên doanh nghiệp + Người liên hệ.
ĐỊNH DANH
set('company', v)} placeholder="VD: Sơn Seafood" autoFocus /> set('name', v)} placeholder="VD: Anh Sơn" /> set('role', v)} placeholder="Founder / Chủ / CEO" /> set('region', v)} /> set('phone', v)} placeholder="09xx xxx xxx" /> set('email', v)} />
KINH DOANH & NHU CẦU
set('bizModel', v)} placeholder="Hải sản / Cà phê…" /> set('chainSize', v)} placeholder="3 chi nhánh" /> set('source', v)} options={sourceOpts} /> set('painpoints', v)} placeholder="Chi phí cao, Chưa chuẩn SOP" /> set('dealValue', v)} placeholder="0" />
XẾP VÀO PIPELINE
set('nextAction', v)} placeholder="VD: Gọi lại qualify 4 điều kiện trong 24h" /> {canAssignSelf && ( )} ); } function PipelineKanban() { const allLeads = useLeads(); const viewer = useViewer(); const canEdit = SalesAccess.canEdit(viewer); const leads = SalesAccess.scoped(allLeads, viewer); const dragRef = useRef(null); const [pending, setPending] = useState(null); // {leadId, from, to} const [reason, setReason] = useState(''); const [trigger, setTrigger] = useState('manual'); const [open, setOpen] = useState(null); const [adding, setAdding] = useState(null); // stageId for new-lead drawer const byStage = {}; leads.forEach(l => { (byStage[l.stage] = byStage[l.stage] || []).push(l); }); Object.values(byStage).forEach(arr => arr.sort((a, b) => leadPriority(b) - leadPriority(a))); const onDragStart = (leadId, from) => { if (!canEdit) return; dragRef.current = { leadId, from }; }; const onDrop = (to) => { if (!canEdit) return; const d = dragRef.current; if (!d || d.from === to) return; setPending({ ...d, to }); setReason(''); setTrigger('manual'); }; const confirmMove = () => { if (!reason.trim()) return; const toStage = AAU.stageById(pending.to); LeadStore.move(pending.leadId, toStage.code, reason, trigger); setPending(null); }; const ord = (arr) => arr.slice().sort((a, b) => a.pos - b.pos); const leadStages = ord(AAU.stages.filter(s => s.funnel === 'lead')); const leadbStages = ord(AAU.stages.filter(s => s.funnel === 'leadb')); const dealStages = ord(AAU.stages.filter(s => s.funnel === 'deal')); const tnStages = ord(AAU.stages.filter(s => s.funnel === 'potential')); const wonStages = ord(AAU.stages.filter(s => s.funnel === 'won')); const alumniStages = ord(AAU.stages.filter(s => s.funnel === 'alumni')); const handlers = { onDrop, onDragStart, onOpen: (id) => setOpen(id), onAdd: (stageId) => setAdding(stageId), canEdit }; const current = open ? leads.find(l => l.id === open) : null; return (
{canEdit && }} />
{current && setOpen(null)} onMove={(id, to, r, t) => { LeadStore.move(id, to, r, t); setOpen(null); }} onToggleQualify={(id, k) => LeadStore.toggleQualify(id, k)} readOnly={!canEdit} />} {adding && setAdding(null)} onCreated={(id) => { setAdding(null); setOpen(id); }} />} {pending && ( setPending(null)} footer={<>}>