/* 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('painpoints', v)} placeholder="Chi phí cao, Chưa chuẩn SOP" />
set('dealValue', v)} placeholder="0" />
XẾP VÀO PIPELINE
set('grade', v)} options={[{ value: 'A', label: 'A' }, { value: 'B', label: 'B' }, { value: 'C', label: 'C' }, { value: 'D', label: 'D' }]} />
{st && st.slaText && st.slaText !== '—' && SLA stage {st.code}: {st.slaText}
}
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={<>>}>
{!reason.trim() &&
Phải nhập lý do thì mới được thả.
}
)}
);
}
Object.assign(window, { PipelineKanban });