/* global React, Icon, Avatar, fmtAED */ const { useState, useEffect, useMemo } = React; const TEMP_COLOR = { cold: '#6b7280', warm: '#3b82f6', hot: '#f97316', engaged: '#22c55e' }; function _initialsP(name) { if (!name) return '?'; const parts = String(name).replace(/[._@-]/g, ' ').split(/\s+/).filter(Boolean); if (!parts.length) return name.slice(0, 2).toUpperCase(); if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } function _colorP(s) { const palette = ['#22c55e', '#3b82f6', '#a855f7', '#f97316', '#eab308', '#ec4899', '#06b6d4', '#84cc16']; let h = 0; for (let i = 0; i < (s || '').length; i++) h = ((h << 5) - h) + s.charCodeAt(i); return palette[Math.abs(h) % palette.length]; } function Pipeline({ goto }) { const [tab, setTab] = useState('outbound'); const [data, setData] = useState({ outbound: null, inbound: null }); const [draggedId, setDraggedId] = useState(null); const [dropStage, setDropStage] = useState(null); const [moving, setMoving] = useState(false); const load = (which) => { fetch(`/api/pipeline?pipeline=${which}`, { credentials: 'include' }) .then((r) => r.ok ? r.json() : Promise.reject(r.statusText)) .then((d) => setData((prev) => ({ ...prev, [which]: d }))) .catch(() => setData((prev) => ({ ...prev, [which]: { columns: [] } }))); }; useEffect(() => { load('outbound'); load('inbound'); }, []); const active = tab === 'combined' ? mergeCombined(data.outbound, data.inbound) : data[tab]; const onDrop = async (stageName) => { if (!draggedId || moving) return; const allItems = (active?.columns || []).flatMap((c) => c.items.map((i) => ({ ...i, stage: c.stage }))); const card = allItems.find((c) => c.contact_id === draggedId); setDraggedId(null); setDropStage(null); if (!card || card.stage === stageName) return; setMoving(true); try { await fetch(`/api/pipeline/contacts/${draggedId}/stage`, { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ stage: stageName }), }); load('outbound'); load('inbound'); } finally { setMoving(false); } }; const totalCount = active?.columns?.reduce((a, c) => a + c.count, 0) || 0; const totalValue = active?.columns?.reduce((a, c) => a + (c.value_aed || 0), 0) || 0; return (
Sales

Pipeline

{[ { id: 'outbound', label: 'Outbound LinkedIn', count: data.outbound?.columns?.reduce((a, c) => a + c.count, 0) ?? 0 }, { id: 'inbound', label: 'Inbound Ads', count: data.inbound?.columns?.reduce((a, c) => a + c.count, 0) ?? 0 }, { id: 'combined', label: 'Combined', count: (data.outbound?.columns?.reduce((a, c) => a + c.count, 0) ?? 0) + (data.inbound?.columns?.reduce((a, c) => a + c.count, 0) ?? 0) }, ].map((t) => (
setTab(t.id)}> {t.label}{t.count}
))}
{totalCount} contacts · AED {fmtAED(totalValue)}
{!active && (
Loading…
)} {active && (
{active.columns.map((col) => { const isDropTarget = dropStage === col.stage; return (
{ e.preventDefault(); setDropStage(col.stage); }} onDragLeave={() => setDropStage(null)} onDrop={() => onDrop(col.stage)} style={{ width: 240, flexShrink: 0, display: 'flex', flexDirection: 'column', background: isDropTarget ? 'rgba(34,197,94,0.05)' : 'transparent', border: '1px dashed', borderColor: isDropTarget ? 'var(--accent)' : 'transparent', }}>
{col.stage} {col.count}
{col.value_aed > 0 && (
AED {fmtAED(col.value_aed)}
)}
{col.items.map((c) => ( setDraggedId(c.contact_id)} dragging={draggedId === c.contact_id} goto={goto} /> ))} {col.items.length === 0 && (
Drop here
)}
); })}
)}
); } function mergeCombined(out, inb) { if (!out && !inb) return null; if (!out) return inb; if (!inb) return out; const colsByStage = {}; for (const c of [...out.columns, ...inb.columns]) { if (!colsByStage[c.stage]) colsByStage[c.stage] = { stage: c.stage, count: 0, value_aed: 0, items: [] }; colsByStage[c.stage].count += c.count; colsByStage[c.stage].value_aed += c.value_aed; colsByStage[c.stage].items.push(...c.items); } return { columns: Object.values(colsByStage) }; } function KanbanCard({ card, onDragStart, dragging, goto }) { const tempColor = TEMP_COLOR[card.temperature] || '#6b7280'; return (
goto('contact', card.contact_id)} style={{ background: 'var(--surface)', border: '1px solid var(--divider)', margin: '0 6px', padding: 10, cursor: 'grab', opacity: dragging ? 0.4 : 1, }}>
{card.name || '(unnamed)'}
{card.company || card.title || '—'}
{card.deal_value > 0 && (
AED {fmtAED(card.deal_value)}
)}
{card.source}
); } Object.assign(window, { Pipeline });