// =====================================================================
// 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 (
{/* 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}"
)}
);
}
// -------- 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)}>
{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) => (
))}
{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) => (
))}
);
})}
);
}
// -------- 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()}>
);
}
// -------- 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 };