/* global React,
IconChart, IconSearch, IconCalendar, IconInbox, IconUsers, IconLogout, IconSidebar, IconPlug,
PipelineView, AnalyticsView, CallsView, IntegracionesView, Drawer, LoginScreen, UsersView, Avatar */
// REVOLVR CRM — shell. Auth + roles + switch de pipeline por closer + vistas + drawer.
const { useState, useEffect, useRef } = React;
// Supabase usa snake_case; el CRM usa camelCase. Convertidor de filas.
function dbLeadToCRM(row) {
return {
id: row.id,
name: row.name,
email: row.email,
phone: row.phone || "",
location: row.location || "",
timezone: row.timezone || "",
eventType: row.event_type || "",
duration: row.duration || 30,
scheduledAt: row.scheduled_at ? new Date(row.scheduled_at) : new Date(),
closerId: row.closer_id || "",
stage: row.stage || "agendada",
status: row.status || "agendada",
meetingLink: row.meeting_link || "",
form: row.form_data || {},
utm: row.utm || { source: "", medium: "", campaign: "" },
value: Number(row.value) || 0,
notes: row.notes || [],
history: row.history || [],
source: row.source || "manual",
createdAt: row.created_at ? new Date(row.created_at) : new Date(),
};
}
function CRMApp({ theme = "atrio" }) {
const D = window.REVOLVR_DATA;
const [currentUser, setCurrentUser] = useState(() => {
try {
const saved = localStorage.getItem("crm_session");
if (!saved) return null;
const u = JSON.parse(saved);
// Verificar que el usuario sigue existiendo en REVOLVR_DATA
const valid = window.REVOLVR_DATA.USERS.find((x) => x.id === u.id && x.active);
return valid ? u : null;
} catch { return null; }
});
const handleLogin = (u) => {
localStorage.setItem("crm_session", JSON.stringify(u));
setCurrentUser(u);
};
const handleLogout = () => {
localStorage.removeItem("crm_session");
setCurrentUser(null);
};
if (!currentUser) {
return (
);
}
return ;
}
function CRMShell({ theme, currentUser, onLogout }) {
const D = window.REVOLVR_DATA;
const isAdmin = currentUser.role === "admin";
const [tab, setTab] = useState("pipeline");
const [stages, setStages] = useState(() => D.STAGES.map((s) => ({ ...s })));
const [calls, setCalls] = useState([]); // Supabase carga los leads — data.js solo es fallback
const [loadingLeads, setLoadingLeads] = useState(false);
const [users, setUsers] = useState([]);
const [closers, setClosers] = useState([]);
const [openId, setOpenId] = useState(null);
const [filters, setFilters] = useState({ source: null, q: "", dateRange: null });
const [activeCloser, setActiveCloser] = useState(isAdmin ? "all" : currentUser.closerId);
const [adSpend, setAdSpend] = useState(() => ({ ...D.AD_SPEND }));
const [justMovedId, setJustMovedId] = useState(null);
const justMovedTimer = useRef(null);
const [toasts, setToasts] = useState([]);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [userMenu, setUserMenu] = useState(false);
const userMenuRef = useRef(null);
useEffect(() => {
if (!userMenu) return;
const onDown = (e) => { if (userMenuRef.current && !userMenuRef.current.contains(e.target)) setUserMenu(false); };
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [userMenu]);
// Carga leads y usuarios reales desde Supabase al montar
useEffect(() => {
if (!window.SUPABASE) return;
setLoadingLeads(true);
// Cargar leads
window.SUPABASE.from("leads").select("*").order("scheduled_at", { ascending: false })
.then(({ data, error }) => {
if (!error && data && data.length > 0) setCalls(data.map(dbLeadToCRM));
setLoadingLeads(false);
});
// Cargar usuarios
window.SUPABASE.from("crm_users").select("*").order("created_at")
.then(({ data, error }) => {
if (!error && data && data.length > 0) {
setUsers(data.map((u) => ({ id: u.id, name: u.name, email: u.email, password: u.password, role: u.role, closerId: u.closer_id, active: u.active, lastLogin: u.last_login || "" })));
}
});
// Cargar closers
window.SUPABASE.from("closers").select("*").order("order_index")
.then(({ data, error }) => {
if (!error && data) {
setClosers(data.map((c) => ({ id: c.id, name: c.name, initials: c.initials, color: c.color })));
}
});
}, []);
const viewCloser = isAdmin ? activeCloser : currentUser.closerId;
const globalScope = isAdmin && viewCloser === "all";
const visibleCalls = calls.filter((c) => viewCloser === "all" ? true : c.closerId === viewCloser);
const pushToast = (text, kind = "ok") => {
const id = Math.random().toString(36).slice(2);
setToasts((t) => [...t, { id, text, kind }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 4000);
};
const handleOpen = (id) => setOpenId(id);
const handleMove = (id, newStage, beforeId) => {
setCalls((cs) => {
const moving = cs.find((c) => c.id === id);
if (!moving) return cs;
const sameStage = moving.stage === newStage;
const fromLabel = stages.find((s) => s.id === moving.stage)?.label;
const toLabel = stages.find((s) => s.id === newStage)?.label;
const statusMap = { asistio: "asistio", noshow: "noshow", confirmada: "confirmada", agendada: "agendada" };
const status = sameStage ? moving.status : (statusMap[newStage] || moving.status);
const updated = sameStage ? { ...moving } : {
...moving, stage: newStage, status,
history: [{ ts: "ahora", kind: "stage", e: "Etapa cambiada", det: `${fromLabel} → ${toLabel}` }, ...moving.history],
};
const rest = cs.filter((c) => c.id !== id);
let idx;
if (beforeId) {
idx = rest.findIndex((c) => c.id === beforeId);
if (idx < 0) idx = rest.length;
} else {
// sin destino explícito: colócalo después de la última tarjeta de esa etapa
let last = -1;
rest.forEach((c, i) => { if (c.stage === newStage) last = i; });
idx = last + 1;
}
rest.splice(idx, 0, updated);
return rest;
});
setJustMovedId(id);
if (justMovedTimer.current) clearTimeout(justMovedTimer.current);
justMovedTimer.current = setTimeout(() => setJustMovedId(null), 600);
if (window.SUPABASE) {
const statusMap = { asistio: "asistio", noshow: "noshow", confirmada: "confirmada", agendada: "agendada", propuesta: "asistio", ganada: "asistio", perdida: "asistio" };
window.SUPABASE.from("leads")
.update({ stage: newStage, status: statusMap[newStage] || newStage })
.eq("id", id)
.then(({ error }) => { if (error) console.error("Error guardando etapa:", error); });
}
};
const handleChangeStage = handleMove;
const handleChangeCloser = (id, closerId) => {
setCalls((cs) => cs.map((c) => {
if (c.id !== id) return c;
const to = closers.find((x) => x.id === closerId)?.name;
return { ...c, closerId, history: [{ ts: "ahora", kind: "note", e: "Llamada reasignada", det: `a ${to}` }, ...c.history] };
}));
pushToast("Llamada reasignada");
};
const handleAddNote = (id, body) => {
const newNote = { ts: "ahora", who: currentUser.name, body };
const newHistEntry = { ts: "ahora", kind: "note", e: "Nota agregada", det: `por ${currentUser.name}` };
setCalls((cs) => cs.map((c) => c.id !== id ? c : {
...c,
notes: [newNote, ...c.notes],
history: [newHistEntry, ...c.history],
}));
if (window.SUPABASE) {
const lead = calls.find((c) => c.id === id);
const updatedNotes = lead ? [newNote, ...lead.notes] : [newNote];
const updatedHistory = lead ? [newHistEntry, ...lead.history] : [newHistEntry];
window.SUPABASE.from("leads")
.update({ notes: updatedNotes, history: updatedHistory })
.eq("id", id)
.then(({ error }) => { if (error) console.error("Error guardando nota:", error); });
}
};
const handleEditNote = (id, noteIndex, newBody) => {
setCalls((cs) => cs.map((c) => {
if (c.id !== id) return c;
const updatedNotes = c.notes.map((n, i) => i === noteIndex ? { ...n, body: newBody, ts: "editada" } : n);
if (window.SUPABASE) {
window.SUPABASE.from("leads").update({ notes: updatedNotes }).eq("id", id)
.then(({ error }) => { if (error) console.error("Error editando nota:", error); });
}
return { ...c, notes: updatedNotes };
}));
};
const handleDeleteNote = (id, noteIndex) => {
setCalls((cs) => cs.map((c) => {
if (c.id !== id) return c;
const updatedNotes = c.notes.filter((_, i) => i !== noteIndex);
if (window.SUPABASE) {
window.SUPABASE.from("leads").update({ notes: updatedNotes }).eq("id", id)
.then(({ error }) => { if (error) console.error("Error borrando nota:", error); });
}
return { ...c, notes: updatedNotes };
}));
pushToast("Nota eliminada");
};
const handleUpdateSale = (id, sale) => {
setCalls((cs) => cs.map((c) => c.id !== id ? c : {
...c, sale,
history: [{ ts: "ahora", kind: "stage", e: "Venta registrada", det: `${sale.programa} · $${(sale.total || 0).toLocaleString("en-US")} · ${sale.tipo === "total" ? "pago total" : "abono"}` }, ...c.history],
}));
pushToast("Venta actualizada");
};
// Stage CRUD
const handleAddStage = (label, color) => setStages((sx) => [...sx, { id: "s_" + Date.now(), label, color }]);
const handleRenameStage = (id, label) => setStages((sx) => sx.map((s) => s.id === id ? { ...s, label } : s));
const handleRecolorStage = (id, color) => setStages((sx) => sx.map((s) => s.id === id ? { ...s, color } : s));
const handleMoveStage = (id, dir) => setStages((sx) => {
const i = sx.findIndex((s) => s.id === id); const n = i + dir;
if (i < 0 || n < 0 || n >= sx.length) return sx;
const out = sx.slice(); [out[i], out[n]] = [out[n], out[i]]; return out;
});
const handleReorderStages = (fromId, toId) => setStages((sx) => {
const f = sx.findIndex((s) => s.id === fromId), t = sx.findIndex((s) => s.id === toId);
if (f < 0 || t < 0 || f === t) return sx;
const out = sx.slice(); const [m] = out.splice(f, 1); out.splice(t, 0, m); return out;
});
const handleDeleteStage = (id, moveTo) => {
if (moveTo) setCalls((cs) => cs.map((c) => c.stage === id ? { ...c, stage: moveTo } : c));
setStages((sx) => sx.filter((s) => s.id !== id));
};
const handleExport = (rows) => {
const headers = ["nombre", "email", "telefono", "ubicacion", "evento", "fecha", "closer", "etapa", "estado", "fuente", "campaña", "facturacion"];
const data = rows.map((c) => [
c.name, c.email, c.phone, c.location, c.eventType,
c.scheduledAt instanceof Date ? c.scheduledAt.toLocaleString("es-ES") : "",
closers.find((x) => x.id === c.closerId)?.name || "", c.stage, c.status,
c.utm.source, c.utm.campaign, c.form.facturacion,
]);
const esc = (v) => `"${String(v == null ? "" : v).replace(/"/g, '""')}"`;
const csv = [headers, ...data].map((r) => r.map(esc).join(",")).join("\n");
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a"); a.href = url;
a.download = `llamadas_${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
pushToast(`${rows.length} llamadas exportadas`);
};
const handleAddUser = (u) => {
const newId = "u_" + Date.now();
let closerId = u.closerId;
if (u.role === "closer" && u.closerId === "new") {
closerId = "c_" + Date.now();
const newCloser = { id: closerId, name: u.name, initials: u.name.split(" ").map((s) => s[0]).slice(0, 2).join("").toUpperCase(), color: "#0e7a9a" };
setClosers((cs) => [...cs, newCloser]);
if (window.SUPABASE) {
window.SUPABASE.from("closers").insert({ id: closerId, name: u.name, initials: newCloser.initials, color: "#0e7a9a", active: true, order_index: D.CLOSERS.length })
.then(({ error }) => { if (error) console.error("Error guardando closer:", error); });
}
}
const newUser = { id: newId, name: u.name, email: u.email, password: u.password, role: u.role, closerId: u.role === "admin" ? null : closerId, active: true, lastLogin: "nunca" };
setUsers((us) => [...us, newUser]);
if (window.SUPABASE) {
window.SUPABASE.from("crm_users").insert({ id: newId, name: u.name, email: u.email, password: u.password, role: u.role, closer_id: u.role === "admin" ? null : closerId, active: true })
.then(({ error }) => { if (error) console.error("Error guardando usuario:", error); });
}
pushToast("Usuario creado");
};
const handleToggleActive = (id) => {
setUsers((us) => us.map((u) => u.id === id ? { ...u, active: !u.active } : u));
const u = users.find((x) => x.id === id);
if (window.SUPABASE && u) {
window.SUPABASE.from("crm_users").update({ active: !u.active }).eq("id", id)
.then(({ error }) => { if (error) console.error("Error actualizando usuario:", error); });
}
};
const handleResetPassword = (id, newPassword) => {
setUsers((us) => us.map((u) => u.id === id ? { ...u, password: newPassword } : u));
if (window.SUPABASE) {
window.SUPABASE.from("crm_users").update({ password: newPassword }).eq("id", id)
.then(({ error }) => { if (error) console.error("Error reseteando contraseña:", error); });
}
pushToast("Contraseña actualizada");
};
const handleDeleteUser = (id) => {
setUsers((us) => us.filter((u) => u.id !== id));
if (window.SUPABASE) {
window.SUPABASE.from("crm_users").delete().eq("id", id)
.then(({ error }) => { if (error) console.error("Error eliminando usuario:", error); });
}
pushToast("Usuario eliminado");
};
const open = calls.find((c) => c.id === openId);
const counts = D.countByStage(visibleCalls);
const closer = closers.find((x) => x.id === currentUser.closerId);
const pageTitle = tab === "pipeline" ? "Pipeline" : tab === "llamadas" ? "Próximas llamadas" : tab === "analitica" ? "Analítica" : tab === "integraciones" ? "Integraciones" : "Usuarios";
return (
{/* Sidebar */}
{/* Main */}
REVOLVR/{pageTitle}
{tab !== "usuarios" && tab !== "integraciones" && isAdmin && {viewCloser === "all" ? "Todos" : closers.find((c) => c.id === viewCloser)?.name}}
{tab !== "usuarios" && tab !== "integraciones" &&
setFilters({ ...filters, q: e.target.value })} placeholder="Buscar nombre, email, industria…"/>
}
{/* User menu */}
{userMenu &&
{currentUser.name}
{currentUser.email}
{isAdmin ? "Administrador" : "Closer"}
}
{loadingLeads && (
)}
{tab === "pipeline" &&
}
{tab === "llamadas" && }
{tab === "analitica" && }
{tab === "usuarios" && isAdmin && }
{tab === "integraciones" && isAdmin && }
{open &&
setOpenId(null)}
onChangeStage={handleChangeStage} onChangeCloser={handleChangeCloser} onAddNote={handleAddNote} onEditNote={handleEditNote} onDeleteNote={handleDeleteNote} onUpdateSale={handleUpdateSale}/>}
{toasts.map((t) => (
))}
);
}
function NavItem({ icon, children, active, onClick, badge }) {
return (
);
}
window.CRMApp = CRMApp;