// ===================================================================== // MAESTRO — Header + Home + Projects section // ===================================================================== const { useState, useEffect, useMemo, useRef, useCallback } = React; const D = window.MAESTRO_DATA; const I = window.MAESTRO_ICONS; const NAV = [ { id: "inicio", label: "Inicio", icon: () => I.spark(14) }, { id: "proyectos", label: "Proyectos", icon: () => I.folder(14) }, { id: "tareas", label: "Tareas", icon: () => I.task(14) }, { id: "calendario", label: "Calendario", icon: () => I.clock(14) }, { id: "stats", label: "Estadísticas", icon: () => I.trend(14) }, { id: "asistente", label: "Asistente", icon: () => I.sparkles(14) }, { id: "config", label: "Config", icon: () => I.cog(14) }, ]; // ── Notification Bell ───────────────────────────────────────────────────────── const NOTIF_COLORS = { tarea_vencida: "var(--danger)", tarea_proxima: "var(--warning)", proyecto_bloqueado: "#f97316", sistema: "var(--accent)", }; const NOTIF_ICONS = { tarea_vencida: "🔴", tarea_proxima: "🟡", proyecto_bloqueado: "🚧", sistema: "ℹ️", }; function NotificationBell({ onOpenProject }) { const [open, setOpen] = useState(false); const [notifs, setNotifs] = useState([]); const [unread, setUnread] = useState(0); const ref = useRef(null); const load = async () => { try { const data = await D.API.getNotifications(); setNotifs(data || []); setUnread((data || []).filter(n => !n.leida).length); } catch(e) {} }; useEffect(() => { load(); const interval = setInterval(load, 60000); // poll cada 60s return () => clearInterval(interval); }, []); // Cerrar al hacer click afuera useEffect(() => { const handler = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); const markRead = async (id) => { await D.API.markNotificationRead(id); setNotifs(prev => prev.map(n => n.id === id ? { ...n, leida: true } : n)); setUnread(prev => Math.max(0, prev - 1)); }; const markAll = async () => { await D.API.markAllNotificationsRead(); setNotifs(prev => prev.map(n => ({ ...n, leida: true }))); setUnread(0); }; const remove = async (e, id) => { e.stopPropagation(); await D.API.deleteNotification(id); const removed = notifs.find(n => n.id === id); setNotifs(prev => prev.filter(n => n.id !== id)); if (removed && !removed.leida) setUnread(prev => Math.max(0, prev - 1)); }; const handleClick = async (n) => { if (!n.leida) await markRead(n.id); if (n.project_id && onOpenProject) { onOpenProject(n.project_id); setOpen(false); } }; const timeAgo = (dt) => { const mins = Math.floor((Date.now() - new Date(dt)) / 60000); if (mins < 1) return "ahora"; if (mins < 60) return `${mins}m`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h`; return `${Math.floor(hrs / 24)}d`; }; return (
{open && (
{/* Header */}
Notificaciones {unread > 0 && {unread}} {unread > 0 && ( )}
{/* Lista */}
{notifs.length === 0 ? (
🎉 Sin notificaciones pendientes
) : notifs.map(n => (
handleClick(n)} style={{ display: "flex", gap: 10, padding: "10px 14px", cursor: n.project_id ? "pointer" : "default", background: n.leida ? "transparent" : "color-mix(in srgb, var(--accent) 4%, transparent)", borderBottom: "1px solid var(--border)", transition: "background .1s" }} onMouseEnter={e => e.currentTarget.style.background = "var(--bg-subtle)"} onMouseLeave={e => e.currentTarget.style.background = n.leida ? "transparent" : "color-mix(in srgb, var(--accent) 4%, transparent)"}> {NOTIF_ICONS[n.tipo] || "ℹ️"}
{n.titulo}
{n.mensaje &&
{n.mensaje}
}
{timeAgo(n.created_at)}
{!n.leida &&
}
))}
)}
); } function Header({ view, setView, theme, setTheme, onLogout, alertCount, projects = [], onOpenProject }) { const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [selectedIdx, setSelectedIdx] = useState(0); const searchRef = useRef(null); useEffect(() => { if (searchOpen && searchRef.current) searchRef.current.focus(); }, [searchOpen]); useEffect(() => { if (!searchOpen) { setSearchQuery(""); setSelectedIdx(0); } }, [searchOpen]); useEffect(() => { setSelectedIdx(0); }, [searchQuery]); // Ctrl+K global shortcut useEffect(() => { const onKey = e => { if ((e.ctrlKey || e.metaKey) && e.key === "k") { e.preventDefault(); setSearchOpen(v => !v); } if (e.key === "Escape") setSearchOpen(false); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); const searchResults = useMemo(() => { const q = searchQuery.trim().toLowerCase(); if (!q || q.length < 2) return []; const results = []; projects.forEach(p => { if (p.name.toLowerCase().includes(q) || (p.description || "").toLowerCase().includes(q) || p.tags.some(t => t.toLowerCase().includes(q))) { results.push({ type: "proyecto", label: p.name, sub: p.description?.slice(0, 60) || "", id: p.id }); } p.tasks.forEach(t => { if (t.name.toLowerCase().includes(q)) results.push({ type: "tarea", label: t.name, sub: p.name, id: p.id }); }); (p.notes || []).forEach(n => { if (n.text.toLowerCase().includes(q)) results.push({ type: "nota", label: n.text.slice(0, 60), sub: p.name, id: p.id }); }); }); return results.slice(0, 10); }, [searchQuery, projects]); const handleResultClick = (r) => { onOpenProject(r.id); setSearchOpen(false); setSearchQuery(""); }; const handleKeyDown = (e) => { if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIdx(i => Math.min(i + 1, searchResults.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIdx(i => Math.max(i - 1, 0)); } else if (e.key === "Enter" && searchResults[selectedIdx]) handleResultClick(searchResults[selectedIdx]); else if (e.key === "Escape") setSearchOpen(false); }; return (
MAESTRO v0.1 · BETA
{/* Global search */}
setSearchQuery(e.target.value)} onFocus={() => setSearchOpen(true)} placeholder="Buscar…" onKeyDown={handleKeyDown} style={{ width: searchOpen ? 220 : 130, paddingLeft: 26, paddingRight: searchOpen ? 8 : 36, paddingTop: 5, paddingBottom: 5, borderRadius: "var(--r-sm)", border: "1px solid var(--border)", background: "var(--bg-base)", color: "var(--text-primary)", fontSize: 13, outline: "none", transition: "width 0.2s" }} /> {!searchOpen && ( ⌘K )}
{searchOpen && searchQuery && ( )}
{searchOpen && searchResults.length > 0 && (
{searchResults.map((r, i) => { const typeColor = r.type === "proyecto" ? "var(--accent)" : r.type === "nota" ? "var(--warning)" : "var(--fg-muted)"; return (
handleResultClick(r)} style={{ padding: "9px 14px", cursor: "pointer", borderBottom: i < searchResults.length - 1 ? "1px solid var(--border)" : "none", display: "flex", gap: 10, alignItems: "center", background: i === selectedIdx ? "var(--bg-subtle)" : "transparent" }} onMouseEnter={() => setSelectedIdx(i)}> {r.type}
{r.label}
{r.sub &&
{r.sub}
}
{i === selectedIdx && }
); })}
↑↓ navegar↵ abrirEsc cerrar
)} {searchOpen && searchQuery.length >= 2 && searchResults.length === 0 && (
Sin resultados para "{searchQuery}"
)}
Alexis
AA
); } // -------- Sparkline -------- function Sparkline({ data, color = "var(--accent)" }) { const w = 70, h = 26; const max = Math.max(...data), min = Math.min(...data); const range = max - min || 1; const pts = data.map((v, i) => `${(i / (data.length - 1)) * w},${h - ((v - min) / range) * (h - 4) - 2}`).join(" "); const last = pts.split(" ").pop().split(","); return ( ); } function KPI({ label, icon, value, unit, delta, deltaDir, spark }) { return (
{icon} {label}
{value} {unit && {unit}}
{delta && (
{deltaDir === "up" ? I.trend(12) : deltaDir === "down" ? I.trendDown(12) : null} {delta}
)} {spark && }
); } // -------- Alerts banner -------- function Alerts({ projects, onOpenProject }) { const risks = projects.filter(p => p.blocked || p.actualHours > p.estimatedHours * 1.4 || p.daysInState > 4); if (risks.length === 0) return null; return (
{I.alert(16)} Atención requerida · {risks.length} {risks.length === 1 ? "proyecto" : "proyectos"}
actualizado hace 2 min
{risks.map(p => (
onOpenProject(p.id)}>
{p.blocked ? I.blocked(14) : I.alert(14)}
{p.name} {" — "} {p.blocked ? bloqueado {p.blockers[0]?.days || "?"}d · {p.blockers[0]?.desc} : p.actualHours > p.estimatedHours * 1.4 ? {p.actualHours}h vs {p.estimatedHours}h estimadas (+{Math.round((p.actualHours / p.estimatedHours - 1) * 100)}%) : {p.daysInState} días en {D.STATE_LABEL_SHORT[p.state].toLowerCase()} sin movimiento}
{p.lastUpdate} →
))}
); } // -------- Home section -------- function HomeSection({ projects, onOpenProject, onToggleTask, onJumpView, todayHours, tasksDone, projectsTouched }) { const activeCount = projects.filter(p => p.state !== "produccion").length; const blockedCount = projects.filter(p => p.blocked).length; const allTasks = projects.flatMap(p => p.tasks); const totalTasks = allTasks.length; const doneTasks = allTasks.filter(t => t.done).length; const avgVelocity = projects.length ? (projects.reduce((s, p) => s + (p.velocity || 0), 0) / projects.length).toFixed(1) : "—"; // Today's tasks: pending tasks across projects, sorted (blocked projects first) const todayTasks = useMemo(() => { const all = []; projects.forEach(p => { p.tasks.filter(t => !t.done).slice(0, 3).forEach(t => { all.push({ ...t, project: p }); }); }); all.sort((a, b) => { if (a.project.blocked !== b.project.blocked) return a.project.blocked ? -1 : 1; const pr = { alta: 0, media: 1, baja: 2 }; return pr[a.project.priority] - pr[b.project.priority]; }); return all.slice(0, 6); }, [projects]); // Upcoming deadlines — deadline is ISO "YYYY-MM-DD" or null const MONTHS_SHORT = ["Ene","Feb","Mar","Abr","May","Jun","Jul","Ago","Sep","Oct","Nov","Dic"]; const upcoming = useMemo(() => { return projects .filter(p => p.deadline && p.deadline !== "—") .map(p => { const parts = p.deadline.split("-"); const day = parts[2] ? String(parseInt(parts[2])) : p.deadline; const month = parts[1] ? MONTHS_SHORT[parseInt(parts[1]) - 1] || "" : ""; return { project: p, day, month }; }) .slice(0, 5); }, [projects]); const isEmpty = projects.length === 0; return (
Inicio

{isEmpty ? "Bienvenido, Alexis" : "Buen día, Alexis"}

{new Date().toLocaleDateString("es-AR", { weekday: "long", day: "numeric", month: "long" })} {!isEmpty && ` · ${activeCount} proyectos activos · ${doneTasks} tareas listas hoy`} {isEmpty && " · todavía no hay proyectos cargados"}

● en vivo 14:30
0 ? "down" : ""} />
{isEmpty && (
{I.sparkles(28)}

Arrancá tu primer proyecto

Crealo desde el asistente con lenguaje natural, o ingresalo manualmente.
El dashboard, las tareas y las estadísticas se van llenando solas.

«creá un proyecto APODERAR» «agregá una tarea de testing» «¿qué puedo hacer acá?»
)} {!isEmpty && (

Tareas para hoy

{todayTasks.map(t => (
onOpenProject(t.project.id)}>
{ e.stopPropagation(); onToggleTask(t.project.id, t.id); }}> {t.done && I.check(11)}
{t.name} {t.project.name} {t.estimated || t.hours}h
))} {todayTasks.length === 0 && (
Sin tareas pendientes 🎉
)}

Próximos vencimientos {upcoming.length}

{upcoming.map(({ project, day, month }) => (
onOpenProject(project.id)}>
{day}
{month}
{project.blocked && } {project.name}
{D.STATE_LABEL[project.state]} · {project.actualHours}h / {project.estimatedHours}h
{project.priority}
))} {upcoming.length === 0 && (
Sin deadlines próximos
)}
)}
); } // -------- Project card -------- function ProjectCard({ project, onOpen, onDragStart, onDragEnd }) { const ratio = project.actualHours / project.estimatedHours; const overClass = ratio > 1.3 ? "over" : ratio > 1 ? "warn" : ""; return (
onDragStart(e, project)} onDragEnd={onDragEnd} onClick={() => onOpen(project.id)} >
{project.blocked && } {project.name}
{project.priority}

{project.description}

{project.actualHours}h / {project.estimatedHours}h {ratio > 1 ? `+${Math.round((ratio - 1) * 100)}%` : `${Math.round(ratio * 100)}%`}
{project.tags.map(t => {t})}
{project.lastUpdate}
); } // -------- Kanban Columns -------- function KanbanColumns({ projects, onOpen, onMove }) { const [dragId, setDragId] = useState(null); const [dragOver, setDragOver] = useState(null); const grouped = useMemo(() => { const g = {}; D.STATES.forEach(s => g[s] = []); projects.forEach(p => g[p.state].push(p)); return g; }, [projects]); const onDragStart = (e, p) => { setDragId(p.id); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", p.id); e.currentTarget.classList.add("dragging"); }; const onDragEnd = e => { setDragId(null); setDragOver(null); e.currentTarget.classList.remove("dragging"); }; const onDragOver = (e, s) => { e.preventDefault(); setDragOver(s); }; const onDrop = (e, s) => { e.preventDefault(); const id = e.dataTransfer.getData("text/plain") || dragId; if (id) onMove(id, s); setDragOver(null); }; return (
{D.STATES.map(state => (
onDragOver(e, state)} onDragLeave={() => setDragOver(null)} onDrop={e => onDrop(e, state)} >
{D.STATE_LABEL_SHORT[state]} {grouped[state].length}
{grouped[state].map(p => ( ))} {grouped[state].length === 0 && (
Sin proyectos
)}
))}
); } // -------- Kanban List -------- function KanbanList({ projects, onOpen }) { const grouped = useMemo(() => { const g = {}; D.STATES.forEach(s => g[s] = []); projects.forEach(p => g[p.state].push(p)); return g; }, [projects]); return (
Proyecto Horas Días Deadline
{D.STATES.map(state => grouped[state].length > 0 && (
{D.STATE_LABEL_SHORT[state]} {grouped[state].length}
{grouped[state].map(p => { const ratio = p.actualHours / p.estimatedHours; return (
onOpen(p.id)}>
{p.blocked && } {p.name} · {p.tags.join(", ")}
1.3 ? { color: "var(--danger)" } : {}}> {p.actualHours}h / {p.estimatedHours}h
{p.daysInState}d en {D.STATE_LABEL_SHORT[p.state].toLowerCase()}
{p.deadline}
{I.arrowRight(14)}
); })}
))}
); } // -------- Kanban Timeline -------- function KanbanTimeline({ projects, onOpen }) { return (
{D.STATES.map((s) => (
{D.STATE_LABEL_SHORT[s]}
))}
{projects.map(p => { const stageIdx = D.STATES.indexOf(p.state); const fillPct = ((stageIdx + 0.5) / D.STATES.length) * 100; return (
onOpen(p.id)} style={{ cursor: "pointer" }}>
{p.blocked && } {p.name} {p.priority}
{D.STATES.map((_, i) => (
{i === stageIdx && (
)}
))}
); })}
); } // -------- New Project Modal -------- function NewProjectModal({ onClose, onSave }) { const [name, setName] = useState(""); const [desc, setDesc] = useState(""); const [priority, setPriority] = useState("media"); const [hours, setHours] = useState(""); const [fechaInicio, setFechaInicio] = useState(""); const [deadline, setDeadline] = useState(""); const [saving, setSaving] = useState(false); const submit = async (e) => { e.preventDefault(); if (!name.trim()) return; setSaving(true); try { await onSave({ name: name.trim(), description: desc.trim() || undefined, priority, estimatedHours: hours ? parseFloat(hours) : undefined, fechaInicio: fechaInicio || null, deadline: deadline || null }); onClose(); } finally { setSaving(false); } }; return (
e.target === e.currentTarget && onClose()}>

Nuevo proyecto

setName(e.target.value)} placeholder="Ej: APODERAR" required style={{ width: "100%", boxSizing: "border-box", padding: "8px 10px", borderRadius: "var(--r-sm)", border: "1px solid var(--border)", background: "var(--bg-base)", color: "var(--text-primary)", fontSize: 14 }} />
setDesc(e.target.value)} placeholder="Descripción breve (opcional)" style={{ width: "100%", boxSizing: "border-box", padding: "8px 10px", borderRadius: "var(--r-sm)", border: "1px solid var(--border)", background: "var(--bg-base)", color: "var(--text-primary)", fontSize: 14 }} />
setHours(e.target.value)} placeholder="Ej: 10" style={{ width: "100%", boxSizing: "border-box", padding: "8px 10px", borderRadius: "var(--r-sm)", border: "1px solid var(--border)", background: "var(--bg-base)", color: "var(--text-primary)", fontSize: 14 }} />
setFechaInicio(e.target.value)} style={{ width: "100%", boxSizing: "border-box", padding: "8px 10px", borderRadius: "var(--r-sm)", border: "1px solid var(--border)", background: "var(--bg-base)", color: "var(--text-primary)", fontSize: 14 }} />
setDeadline(e.target.value)} style={{ width: "100%", boxSizing: "border-box", padding: "8px 10px", borderRadius: "var(--r-sm)", border: "1px solid var(--border)", background: "var(--bg-base)", color: "var(--text-primary)", fontSize: 14 }} />
); } // -------- Projects section -------- function ProjectsSection({ projects, kanbanView, setKanbanView, onOpenProject, onMoveProject, onAddProject }) { const [showArchived, setShowArchived] = useState(false); const activeProjects = useMemo(() => projects.filter(p => showArchived ? p.archived : !p.archived), [projects, showArchived]); const archivedCount = useMemo(() => projects.filter(p => p.archived).length, [projects]); const isEmpty = activeProjects.length === 0; const [showModal, setShowModal] = useState(false); const [selectedTags, setSelectedTags] = useState([]); const allTags = useMemo(() => { const set = new Set(); activeProjects.forEach(p => p.tags.forEach(t => set.add(t))); return [...set].sort(); }, [activeProjects]); const visibleProjects = useMemo(() => { if (selectedTags.length === 0) return activeProjects; return activeProjects.filter(p => selectedTags.some(t => p.tags.includes(t))); }, [activeProjects, selectedTags]); const toggleTag = (tag) => { setSelectedTags(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]); }; const handleSave = async (payload) => { await onAddProject(payload); }; return (
{showModal && setShowModal(false)} onSave={handleSave} />}
Proyectos

Estado de proyectos

{isEmpty ? (showArchived ? "Sin proyectos archivados" : "Todavía no creaste ningún proyecto") : `${visibleProjects.length}${selectedTags.length ? ` de ${activeProjects.length}` : ""} proyectos${showArchived ? " archivados" : ""} · arrastrá entre columnas para cambiar de estado`}

{archivedCount > 0 && ( )} {!isEmpty && (
)}
{!isEmpty && allTags.length > 0 && (
Tags: {allTags.map(tag => ( ))} {selectedTags.length > 0 && ( )}
)} {isEmpty && (
{I.folder(28)}

Sin proyectos todavía

Cuando crees el primero vas a ver acá el Kanban con sus 5 estados:
análisis → desarrollo → testing → listo → producción.

)} {!isEmpty && kanbanView === "columns" && ( )} {!isEmpty && kanbanView === "list" && ( )} {!isEmpty && kanbanView === "timeline" && ( )}
); } window.MAESTRO_DASH = { Header, HomeSection, ProjectsSection, NAV };