// ===================================================================== // MAESTRO — New sections: Tareas, Calendario, Estadísticas, Asistente, Configuración // ===================================================================== const { useState: useS, useMemo: useM, useEffect: useE, useRef: useR } = React; const Ds = window.MAESTRO_DATA; const Is = window.MAESTRO_ICONS; const { ChatPanel } = window.MAESTRO_CHAT; // ===================================================================== // TAREAS // ===================================================================== function NewTaskModal({ projects, onClose, onSave }) { const [projectId, setProjectId] = useS(projects[0]?.id || ""); const [taskName, setTaskName] = useS(""); const [taskType, setTaskType] = useS("task"); const [fechaInicio, setFechaInicio] = useS(""); const [fechaVencimiento, setFechaVencimiento] = useS(""); const [horaInicio, setHoraInicio] = useS(""); const [horaFin, setHoraFin] = useS(""); const [saving, setSaving] = useS(false); const inputStyle = { 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 }; const labelStyle = { fontSize: 12, color: "var(--text-muted)", display: "block", marginBottom: 4 }; const isMeeting = taskType === "meeting"; const submit = async (e) => { e.preventDefault(); if (!taskName.trim() || !projectId) return; setSaving(true); // Para reuniones: agregar horario a la descripción si se completó let finalName = taskName.trim(); if (isMeeting && (horaInicio || horaFin)) { const rango = horaInicio && horaFin ? `${horaInicio} - ${horaFin}` : horaInicio || horaFin; finalName = `${finalName} (${rango})`; } try { await onSave({ projectId, taskName: finalName, taskType, fechaInicio: isMeeting ? null : (fechaInicio || null), fechaVencimiento: fechaVencimiento || null, }); onClose(); } finally { setSaving(false); } }; const placeholders = { task: "Ej: Testear integración AFIP", meeting: "Ej: Reunión con cliente", event: "Ej: Presentación de informe" }; const btnLabel = isMeeting ? "Crear reunión" : taskType === "event" ? "Crear evento" : "Crear tarea"; return (
e.target === e.currentTarget && onClose()}>

Nueva tarea

{[["task","Tarea"], ["meeting","Reunión"], ["event","Evento"]].map(([v, l]) => ( ))}
setTaskName(e.target.value)} placeholder={placeholders[taskType]} required style={inputStyle} />
{isMeeting ? ( /* ── Reunión: fecha + horario ─────────────────────────── */
setFechaVencimiento(e.target.value)} style={inputStyle} />
setHoraInicio(e.target.value)} style={inputStyle} />
setHoraFin(e.target.value)} style={inputStyle} />
) : ( /* ── Tarea / Evento: fecha inicio + vencimiento ─────────── */
setFechaInicio(e.target.value)} style={inputStyle} />
setFechaVencimiento(e.target.value)} style={inputStyle} />
)}
); } function TareasSection({ projects, onOpenProject, onToggleTask, onAddTask }) { const [showModal, setShowModal] = useS(false); const [showFilterPanel, setShowFilterPanel] = useS(false); const filterRef = useR(null); // ── Filtros activos ────────────────────────────────────────────────────── const [fEstado, setFEstado] = useS("pendientes"); // all | pendientes | completadas const [fPrioridad, setFPrioridad] = useS("all"); // all | alta | media | baja const [fTipo, setFTipo] = useS("all"); // all | task | meeting | event const [fVence, setFVence] = useS("all"); // all | hoy | semana | vencidas | sin_fecha const [fProyecto, setFProyecto] = useS("all"); // all | project.id // Cerrar al click afuera useE(() => { const h = e => { if (filterRef.current && !filterRef.current.contains(e.target)) setShowFilterPanel(false); }; document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h); }, []); const [editingTaskId, setEditingTaskId] = useS(null); const [editTaskDesc, setEditTaskDesc] = useS(""); const [editTaskDate, setEditTaskDate] = useS(""); const [editTaskDateStart, setEditTaskDateStart] = useS(""); const [editTaskPriority, setEditTaskPriority] = useS("media"); const [savingEdit, setSavingEdit] = useS(false); const [confirmDeleteTaskId, setConfirmDeleteTaskId] = useS(null); const startEdit = (t) => { setEditingTaskId(t.id); setEditTaskDesc(t.name || ""); setEditTaskDate(t.fechaVencimiento || ""); setEditTaskDateStart(t.fechaInicio || ""); setEditTaskPriority(t.priority || "media"); }; const saveEdit = async (t) => { setSavingEdit(true); try { await Ds.API.updateTask(t.project.id, t.id, { descripcion: editTaskDesc.trim() || t.name, fecha_inicio: editTaskDateStart || null, fecha_vencimiento: editTaskDate || null, prioridad: editTaskPriority, }); setEditingTaskId(null); window.dispatchEvent(new CustomEvent("maestro:reloadProject", { detail: { projectId: t.project.id } })); } catch(e) { alert("Error: " + e.message); } finally { setSavingEdit(false); } }; const allTasks = useM(() => { const a = []; projects.forEach(p => { p.tasks.forEach(t => a.push({ ...t, project: p })); }); return a; }, [projects]); const isEmpty = allTasks.length === 0; const todayISO = new Date().toISOString().slice(0, 10); const weekEndISO = (() => { const d = new Date(); d.setDate(d.getDate() + (7 - d.getDay())); return d.toISOString().slice(0, 10); })(); const filtered = useM(() => { let r = allTasks; if (fEstado === "pendientes") r = r.filter(t => !t.done); if (fEstado === "completadas") r = r.filter(t => t.done); if (fPrioridad !== "all") r = r.filter(t => t.priority === fPrioridad); if (fTipo !== "all") r = r.filter(t => (t.taskType || "task") === fTipo); if (fVence === "hoy") r = r.filter(t => !t.done && t.fechaVencimiento === todayISO); if (fVence === "semana") r = r.filter(t => !t.done && t.fechaVencimiento && t.fechaVencimiento >= todayISO && t.fechaVencimiento <= weekEndISO); if (fVence === "vencidas") r = r.filter(t => !t.done && t.fechaVencimiento && t.fechaVencimiento < todayISO); if (fVence === "sin_fecha") r = r.filter(t => !t.fechaVencimiento); if (fProyecto !== "all") r = r.filter(t => t.project.id === fProyecto); return r; }, [allTasks, fEstado, fPrioridad, fTipo, fVence, fProyecto, todayISO, weekEndISO]); const activeFilterCount = [ fEstado !== "pendientes", fPrioridad !== "all", fTipo !== "all", fVence !== "all", fProyecto !== "all" ].filter(Boolean).length; const resetFilters = () => { setFEstado("pendientes"); setFPrioridad("all"); setFTipo("all"); setFVence("all"); setFProyecto("all"); }; const counts = { all: allTasks.length, pendientes: allTasks.filter(t => !t.done).length, completadas: allTasks.filter(t => t.done).length, }; // Group by project const grouped = useM(() => { const g = {}; filtered.forEach(t => { if (!g[t.project.id]) g[t.project.id] = { project: t.project, tasks: [] }; g[t.project.id].tasks.push(t); }); return Object.values(g); }, [filtered]); return (
{showModal && projects.length > 0 && ( setShowModal(false)} onSave={onAddTask} /> )}
Tareas

Todas las tareas

{isEmpty ? "Las tareas aparecen acá cuando las agregás a un proyecto" : `${counts.pendientes} pendientes · ${counts.completadas} completadas · ${counts.all} totales`}

{isEmpty ? (
{Is.task(28)}

Sin tareas todavía

Las tareas se crean dentro de cada proyecto.
Decile al asistente «creá una tarea en {""}» o usá el botón de arriba.

«creá una tarea: Testear integración» «mostrame las tareas de hoy»
) : ( <> {/* ── Barra de filtros ──────────────────────────────────────────── */}
{/* Chips rápidos estado */}
{/* Botón panel de filtros avanzados */}
{activeFilterCount > 0 && ( )} {/* Panel desplegable */} {showFilterPanel && (
{/* Prioridad */}
Prioridad
{[["all","Todas"],["alta","Alta 🔴"],["media","Media 🟡"],["baja","Baja 🟢"]].map(([v,l]) => ( ))}
{/* Tipo */}
Tipo de tarea
{[["all","Todas"],["task","Tarea"],["meeting","Reunión"],["event","Evento"]].map(([v,l]) => ( ))}
{/* Vencimiento */}
Vencimiento
{[["all","Todas"],["hoy","Hoy"],["semana","Esta semana"],["vencidas","Vencidas"],["sin_fecha","Sin fecha"]].map(([v,l]) => ( ))}
{/* Proyecto */}
Proyecto
{projects.map(p => ( ))}
{filtered.length} resultado{filtered.length !== 1 ? "s" : ""}
)}
{/* Tags de filtros activos */} {fPrioridad !== "all" && Prioridad: {fPrioridad} setFPrioridad("all")} style={{ cursor: "pointer", marginLeft: 4 }}>✕} {fTipo !== "all" && Tipo: {fTipo} setFTipo("all")} style={{ cursor: "pointer", marginLeft: 4 }}>✕} {fVence !== "all" && Vence: {fVence} setFVence("all")} style={{ cursor: "pointer", marginLeft: 4 }}>✕} {fProyecto !== "all" && {projects.find(p=>p.id===fProyecto)?.name} setFProyecto("all")} style={{ cursor: "pointer", marginLeft: 4 }}>✕}
{grouped.map(({ project, tasks }) => (
{project.blocked && } {project.name} {tasks.length} onOpenProject(project.id)}> Abrir proyecto →
{tasks.map(t => (
onOpenProject(project.id)}>
{ e.stopPropagation(); onToggleTask(project.id, t.id); }}> {t.done && Is.check(12)}
{t.name}
{t.hours > 0 ? `${t.hours}h trabajadas` : "sin iniciar"} · {t.estimated || "?"}h est. {t.fechaInicio && ▶ {t.fechaInicio}} {t.fechaVencimiento && ( ⏱ vence {t.fechaVencimiento} )}
{project.priority} {Ds.STATE_LABEL_SHORT[project.state]} {!t.done && ( )} {confirmDeleteTaskId === t.id ? : } {Is.arrowRight(14)}
{editingTaskId === t.id && (
e.stopPropagation()}>
Editar tarea
setEditTaskDesc(e.target.value)} placeholder="Descripción" style={{ padding: "6px 10px", borderRadius: "var(--r-sm)", border: "1px solid var(--border)", background: "var(--bg-surface)", color: "var(--text-primary)", fontSize: 13 }} />
setEditTaskDateStart(e.target.value)} style={{ padding: "6px 10px", borderRadius: "var(--r-sm)", border: "1px solid var(--border)", background: "var(--bg-surface)", color: "var(--text-primary)", fontSize: 13 }} />
setEditTaskDate(e.target.value)} style={{ padding: "6px 10px", borderRadius: "var(--r-sm)", border: "1px solid var(--border)", background: "var(--bg-surface)", color: "var(--text-primary)", fontSize: 13 }} />
{editTaskDate &&
💡 Se sincronizará automáticamente con Google Calendar
}
)}
))}
))} {filtered.length === 0 && (
Sin tareas que coincidan con este filtro
)}
)}
); } // ===================================================================== // CALENDARIO // ===================================================================== function CalendarioSection({ projects, onOpenProject, onSyncCalendar }) { const now = new Date(); const [view, setView] = useS({ month: now.getMonth(), year: now.getFullYear() }); const [googleEvents, setGoogleEvents] = useS([]); const [googleLoading, setGoogleLoading] = useS(false); const [googleEnabled, setGoogleEnabled] = useS(false); useE(() => { Ds.API.googleStatus().then(s => { setGoogleEnabled(s.enabled); if (s.enabled) { setGoogleLoading(true); Ds.API.googleCalendarUpcoming(90).then(evs => { setGoogleEvents(evs); }).catch(() => {}).finally(() => setGoogleLoading(false)); } }).catch(() => {}); }, []); const todayISO = now.toISOString().slice(0, 10); const todayD = now.getDate(), todayM = now.getMonth(), todayY = now.getFullYear(); // Parse ISO date string "YYYY-MM-DD" → { day, month, year } or null function parseISO(s) { if (!s || s === "—") return null; const [y, m, d] = s.split("-").map(Number); if (!y || !m || !d) return null; return { day: d, month: m - 1, year: y }; } // Build events from projects and tasks const events = useM(() => { const ev = []; projects.forEach(p => { // Project start date const start = parseISO(p.fechaInicio); if (start) ev.push({ ...start, type: "inicio", text: `Inicio · ${p.name}`, projectId: p.id }); // Project deadline const dl = parseISO(p.deadline); if (dl) ev.push({ ...dl, type: "deadline", text: `Vence · ${p.name}`, projectId: p.id }); // Tasks (p.tasks || []).forEach(t => { const tStart = parseISO(t.fechaInicio); if (tStart) ev.push({ ...tStart, type: "tarea", text: `▶ ${t.name || t.descripcion} (${p.name})`, projectId: p.id, taskId: t.id }); const tEnd = parseISO(t.fechaVencimiento); if (tEnd) ev.push({ ...tEnd, type: "vencimiento", text: `⏱ ${t.name || t.descripcion} (${p.name})`, projectId: p.id, taskId: t.id }); }); }); return ev; }, [projects]); const DOW = ["Lun", "Mar", "Mié", "Jue", "Vie", "Sáb", "Dom"]; const MONTHS = ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"]; const cells = useM(() => { const first = new Date(view.year, view.month, 1); let firstDow = (first.getDay() + 6) % 7; const daysInMonth = new Date(view.year, view.month + 1, 0).getDate(); const prevDays = new Date(view.year, view.month, 0).getDate(); const out = []; for (let i = firstDow - 1; i >= 0; i--) out.push({ d: prevDays - i, outside: true, m: view.month - 1, y: view.year }); for (let d = 1; d <= daysInMonth; d++) out.push({ d, outside: false, m: view.month, y: view.year }); while (out.length < 42) out.push({ d: out.length - daysInMonth - firstDow + 1, outside: true, m: view.month + 1, y: view.year }); return out; }, [view]); // Parsear eventos de Google Calendar para el mes actual (definido ANTES de eventsByDay) const googleEventsForMonth = useM(() => { return (googleEvents || []).map(e => { const d = parseISO(e.fecha ? e.fecha.slice(0, 10) : ""); if (!d) return null; return { ...d, type: "google", text: (e.titulo || "").replace("[MAESTRO] ", ""), link: e.link, id: e.id }; }).filter(Boolean); }, [googleEvents]); const eventsByDay = useM(() => { const map = {}; const allEvents = [ ...(events || []), ...(googleEventsForMonth || []), ]; allEvents.filter(e => e.month === view.month && e.year === view.year).forEach(e => { if (!map[e.day]) map[e.day] = []; map[e.day].push(e); }); return map; }, [events, googleEventsForMonth, view]); const isToday = (d, m, y) => d === todayD && m === todayM && y === todayY; const goMonth = (delta) => { let m = view.month + delta, y = view.year; if (m < 0) { m = 11; y--; } if (m > 11) { m = 0; y++; } setView({ month: m, year: y }); }; const upcoming = useM(() => { return events .filter(e => { const d = new Date(e.year, e.month, e.day); return d >= new Date(todayY, todayM, todayD); }) .sort((a, b) => new Date(a.year, a.month, a.day) - new Date(b.year, b.month, b.day)) .slice(0, 8); }, [events]); const typeColor = { deadline: "var(--danger)", vencimiento: "var(--danger)", inicio: "var(--success)", tarea: "var(--accent)", meeting: "var(--warning)", google: "var(--fg-subtle)", note: "var(--warning)", }; return (
Calendario

{MONTHS[view.month]} {view.year}

{events.filter(e => e.month === view.month && e.year === view.year).length} eventos este mes · fechas de proyectos y tareas

{onSyncCalendar && ( )}
Inicio proyecto Vencimiento Fecha tarea
{DOW.map(d =>
{d}
)} {cells.map((c, i) => { const evs = c.outside ? [] : (eventsByDay[c.d] || []); return (
{c.d}
{evs.slice(0, 3).map((e, ix) => (
e.projectId && onOpenProject(e.projectId)} title={e.text}> {e.text}
))} {evs.length > 3 &&
+{evs.length - 3} más
}
); })}

Próximos eventos

{upcoming.map((e, i) => (
e.projectId && onOpenProject(e.projectId)} style={{ cursor: e.projectId ? "pointer" : "default" }}>
{e.day}
{MONTHS[e.month].slice(0, 3)}
{e.text}
{e.type}
))} {upcoming.length === 0 && (
Sin eventos próximos — agregá fechas a tus proyectos y tareas
)}
{googleEnabled && (

📅 Google Calendar

{googleLoading && Cargando...}
{googleEvents.length === 0 && !googleLoading ? (
Sin eventos [MAESTRO] en Google Calendar en los próximos 90 días.
) : (
{googleEvents.map((e, i) => (
{e.fecha ? new Date(e.fecha + "T12:00:00").getDate() : "—"}
{e.fecha ? MONTHS[new Date(e.fecha + "T12:00:00").getMonth()].slice(0, 3) : ""}
{e.titulo.replace("[MAESTRO] ", "")}
{e.link && Ver →}
))}
)}
)}
); } // ===================================================================== // ESTADÍSTICAS // ===================================================================== function StatsSection({ projects, onRequestInsights }) { const isEmpty = projects.length === 0; const totalHours = projects.reduce((s, p) => s + p.actualHours, 0); const totalEstimated = projects.reduce((s, p) => s + p.estimatedHours, 0); const efficiency = totalHours > 0 ? (totalEstimated / totalHours) * 100 : 0; const totalProjects = projects.length; const blocked = projects.filter(p => p.blocked).length; const [insightsRequested, setInsightsRequested] = useS(false); // Pie data: by state const byState = useM(() => { const counts = {}; Ds.STATES.forEach(s => counts[s] = 0); projects.forEach(p => counts[p.state]++); return Ds.STATES.map(s => ({ state: s, count: counts[s] })); }, [projects]); // Bar data: hours per project sorted desc const sortedByHours = useM(() => [...projects].sort((a, b) => b.actualHours - a.actualHours).slice(0, 7), [projects]); const maxBar = Math.max(...sortedByHours.map(p => Math.max(p.actualHours, p.estimatedHours))); // Real velocity: group rawHours by week (last 4 weeks), compute avg h/day const { velocityData, velocityLabels } = useM(() => { const now = new Date(); const weeks = [0, 1, 2, 3].map(wk => { const start = new Date(now); start.setDate(now.getDate() - now.getDay() - wk * 7); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setDate(start.getDate() + 6); const startISO = start.toISOString().slice(0, 10); const endISO = end.toISOString().slice(0, 10); let total = 0; let days = new Set(); projects.forEach(p => { (p.rawHours || []).forEach(h => { if (h.fecha >= startISO && h.fecha <= endISO) { total += h.horas; days.add(h.fecha); } }); }); const avgPerDay = days.size > 0 ? total / days.size : 0; const label = wk === 0 ? "Esta sem." : `s -${wk}`; return { avg: parseFloat(avgPerDay.toFixed(1)), label }; }).reverse(); return { velocityData: weeks.map(w => w.avg), velocityLabels: weeks.map(w => w.label) }; }, [projects]); // Donut SVG const total = byState.reduce((s, x) => s + x.count, 0); let acc = 0; const stateColors = { analisis: "oklch(0.7 0.14 235)", desarrollo: "oklch(0.55 0.20 280)", testing: "oklch(0.74 0.16 75)", listo: "oklch(0.62 0.15 158)", produccion: "oklch(0.5 0.10 270)", }; const slices = byState.map(({ state, count }, i) => { const startPct = total ? acc / total : 0; acc += count; const endPct = total ? acc / total : 0; return { state, count, startPct, endPct, color: stateColors[state] }; }); const r1 = 60, r2 = 90; const cx = 95, cy = 95; const polar = (pct, r) => { const a = pct * Math.PI * 2 - Math.PI / 2; return [cx + Math.cos(a) * r, cy + Math.sin(a) * r]; }; const arc = (s) => { if (s.count === 0) return null; const [x1, y1] = polar(s.startPct, r2); const [x2, y2] = polar(s.endPct, r2); const [x3, y3] = polar(s.endPct, r1); const [x4, y4] = polar(s.startPct, r1); const large = s.endPct - s.startPct > 0.5 ? 1 : 0; return `M ${x1} ${y1} A ${r2} ${r2} 0 ${large} 1 ${x2} ${y2} L ${x3} ${y3} A ${r1} ${r1} 0 ${large} 0 ${x4} ${y4} Z`; }; // Line chart for velocity const lcW = 360, lcH = 180, lcPadL = 40, lcPadR = 20, lcPadT = 16, lcPadB = 28; const lcMaxY = Math.max(Math.max(...velocityData) * 1.2, 1); const lcPath = velocityData.map((v, i) => { const x = lcPadL + (i / (velocityData.length - 1)) * (lcW - lcPadL - lcPadR); const y = lcPadT + (1 - v / lcMaxY) * (lcH - lcPadT - lcPadB); return `${i === 0 ? "M" : "L"} ${x} ${y}`; }).join(" "); return (
Estadísticas

Tu performance

{isEmpty ? "Cargá proyectos y registrá horas para ver tu performance" : "Mayo 2026 · datos sincronizados hace 2 min"}

0 ? "down" : ""} /> { const allT = projects.flatMap(p => p.tasks); return allT.length > 0 ? Math.round(allT.filter(t => t.done).length / allT.length * 100) : 0; })()} unit={isEmpty ? "" : "%"} delta={isEmpty ? "sin tareas" : (() => { const allT = projects.flatMap(p => p.tasks); return `${allT.filter(t => t.done).length}/${allT.length} tareas`; })()} /> { if (isEmpty) return "sin datos"; const curr = velocityData[velocityData.length - 1] || 0; const prev = velocityData[velocityData.length - 2] || 0; if (prev === 0) return "primera semana"; const pct = Math.round(((curr - prev) / prev) * 100); return `${pct > 0 ? "+" : ""}${pct}% vs sem. pasada`; })()} deltaDir={(() => { if (isEmpty) return ""; const curr = velocityData[velocityData.length - 1] || 0; const prev = velocityData[velocityData.length - 2] || 0; return curr >= prev ? "up" : "down"; })()} />
{isEmpty ? (
{Is.trend(28)}

Sin datos para graficar

Las estadísticas aparecen cuando hay proyectos y horas registradas.
Vas a ver: distribución por estado, horas reales vs estimado,
tu velocity semanal y los insights del agente.

) : (

Horas por proyecto real vs estimado

{sortedByHours.map(p => { const ratio = p.actualHours / p.estimatedHours; const overEst = ratio > 1.1; const realPct = (p.actualHours / maxBar) * 100; const estPct = (p.estimatedHours / maxBar) * 100; return (
{p.name}
{p.actualHours}h / {p.estimatedHours}h
); })}

Distribución por estado

{slices.map(s => arc(s) && ( ))} {total} proyectos
{slices.map(s => (
{Ds.STATE_LABEL_SHORT[s.state]} {s.count} · {total ? Math.round((s.count / total) * 100) : 0}%
))}

Velocity semanal horas/día promedio · últimas 4 semanas

{velocityData.every(v => v === 0) ? (
Sin datos de horas — registrá horas en tus proyectos para ver la velocity
) : (
{/* Y axis labels */}
{[lcMaxY, lcMaxY * 0.5, 0].map((v, i) => (
{v.toFixed(1)}
))}
{/* Bars */} {velocityData.map((v, i) => { const pct = lcMaxY > 0 ? (v / lcMaxY) * 100 : 0; return (
{/* gridlines */} {[0, 0.25, 0.5, 0.75, 1].map(t => (
))}
{v}
0 ? 2 : 0)}%`, minHeight: v > 0 ? 4 : 0, background: "var(--accent)", borderRadius: "4px 4px 0 0", opacity: 0.85, transition: "height 300ms ease", }} />
{velocityLabels[i]}
); })}
)}

Actividad reciente todos los proyectos

{(() => { const allEvents = projects.flatMap(p => (p.history || []).map(h => ({ ...h, projectName: p.name, projectId: p.id })) ).sort((a, b) => (b.ts || 0) - (a.ts || 0)).slice(0, 15); if (allEvents.length === 0) return (
Sin actividad registrada todavía
); const typeColors = { state: "var(--accent)", hours: "var(--success)", time: "var(--success)", task: "var(--accent)", note: "var(--warning)", blocker: "var(--danger)" }; return (
{allEvents.map((h, i) => (
{h.time}
{h.projectName}
{h.text}
))}
); })()}

Insights del agente {onRequestInsights && ( )}

{insightsRequested ? (
{Is.sparkles(16)}
Análisis solicitado al asistente — revisá la pestaña Asistente
) : (
Hacé clic en "Solicitar análisis IA" para que el asistente analice tu performance y te dé recomendaciones personalizadas.
)}
)}
); } function InsightRow({ color, text }) { return (
{color === "danger" ? Is.alert(14) : color === "warning" ? Is.fire(14) : color === "info" ? Is.bolt(14) : Is.check(14)} {text}
); } function KPI({ label, icon, value, unit, delta, deltaDir }) { return (
{icon} {label}
{value} {unit && {unit}}
{delta && (
{deltaDir === "up" ? Is.trend(12) : deltaDir === "down" ? Is.trendDown(12) : null} {delta}
)}
); } // ===================================================================== // ASISTENTE (full-width chat) // ===================================================================== function AsistenteSection({ messages, onSend, onConfirm, onOpenProject, onNewConversation, sessions = [], onLoadSession, onDeleteSession, currentSessionId }) { const [deleteConfirm, setDeleteConfirm] = useS(null); const fmtTime = (iso) => { if (!iso) return ""; const d = new Date(iso); const now = new Date(); const diffMs = now - d; const diffDays = Math.floor(diffMs / 86400000); if (diffDays === 0) return "hoy"; if (diffDays === 1) return "ayer"; if (diffDays < 7) return `${diffDays}d`; return d.toLocaleDateString("es-AR", { day: "numeric", month: "short" }); }; const handleDelete = (e, id) => { e.stopPropagation(); if (deleteConfirm === id) { onDeleteSession && onDeleteSession(id); setDeleteConfirm(null); } else { setDeleteConfirm(id); } }; return (
); } // ===================================================================== // CONFIGURACIÓN // ===================================================================== function ConfigSection({ theme, setTheme, tweaks, setTweak, onLogout, projects = [] }) { const tweak = tweaks || {}; const density = tweak.density || "cozy"; const accentHue = tweak.accentHue || 280; const kanbanView = tweak.kanbanView || "columns"; const [tab, setTab] = useS("perfil"); const [notif, setNotif] = useS({ alerts: true, daily: true, blockers: true, mail: false }); return (
Configuración

Ajustes

Perfil, apariencia, notificaciones e integraciones

{tab === "perfil" && ( <>

Perfil

Tu información visible en la app y en la auditoría del chat.

AA

Seguridad

Tu sesión expira en 23 horas.

Cambiar contraseña
Última actualización hace 32 días
Cerrar todas las sesiones
Te deslogueás de todos los dispositivos
)} {tab === "apariencia" && ( <>

Tema

Cambiá entre claro y oscuro según tu ambiente.

Modo oscuro
Ideal para sesiones largas o iluminación baja
setTheme(theme === "dark" ? "light" : "dark")} />

Color de acento

El acento se usa en CTAs, gráficos y estados activos.

{[ { h: 280, name: "Violeta" }, { h: 250, name: "Índigo" }, { h: 210, name: "Cobalto" }, { h: 160, name: "Verde" }, { h: 30, name: "Naranja" }, { h: 5, name: "Coral" }, ].map(({ h, name }) => (

Densidad

Cuánto aire entre elementos.

{["compact", "cozy", "comfy"].map(d => ( ))}

Vista por defecto de proyectos

Cuál vista mostrar al entrar a la sección Proyectos.

{["columns", "list", "timeline"].map(v => ( ))}
)} {tab === "notificaciones" && (

Notificaciones

Qué te avisamos y cómo.

{[ { k: "alerts", label: "Proyectos en riesgo", desc: "Alertas cuando un proyecto supera el estimado o lleva +3 días sin movimiento" }, { k: "blockers", label: "Bloqueadores nuevos", desc: "Cuando aparece un impedimento en cualquier proyecto" }, { k: "daily", label: "Resumen diario", desc: "A las 9:00 AM, resumen del día anterior" }, { k: "mail", label: "Por email", desc: "Repetir las notificaciones críticas por mail" }, ].map(n => (
{n.label}
{n.desc}
setNotif(s => ({ ...s, [n.k]: !s[n.k] }))} />
))}
)} {tab === "integraciones" && } {tab === "asistente" && ( <>

Personalidad del agente

Cómo te habla tu asistente IA.

{["Cálido coach", "Directo técnico", "Conciso analítico"].map(t => ( ))}

Comportamiento

Pedir confirmación antes de cambios
Recomendado · evita modificaciones por error
Análisis proactivo
El agente inicia conversaciones cuando detecta riesgos
Avatares en chat
Mostrar iniciales junto a cada mensaje
setTweak("showAvatars", !tweak.showAvatars)} />
)} {tab === "datos" && ( <>

Exportar datos

Descargá tus proyectos, tareas, horas y conversaciones en formato JSON o CSV.

Zona peligrosa

Acciones que no se pueden deshacer.

Borrar historial de chat
Resetea la memoria del agente
)}
); } // ===================================================================== // GOOGLE OAUTH FIELDS // ===================================================================== function GoogleOAuthFields({ cfg, form, setF, saving, onSave, onConnect, onDisconnect }) { const oauthConnected = cfg?.google?.oauth_connected; const hasCredentials = form.google_oauth_client_id && form.google_oauth_client_secret; const inputStyle = { 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: 13 }; const labelStyle = { fontSize: 11, color: "var(--text-muted)", display: "block", marginBottom: 4, fontWeight: 500, textTransform: "uppercase", letterSpacing: "0.04em" }; const hintStyle = { fontSize: 11, color: "var(--text-muted)", marginTop: 4, lineHeight: 1.5 }; return (
{/* Estado OAuth */} {oauthConnected ? (
Conectado con tu cuenta de Google
Drive y Calendar personal activos
) : (
Cómo conectar tu cuenta personal:
  1. Andá a console.cloud.google.com → APIs → Credenciales
  2. Creá una credencial OAuth 2.0 (Web Application)
  3. En "URI de redireccionamiento autorizados" agregá: http://2.24.108.79:8000/settings/google/callback
  4. Copiá el Client ID y Client Secret abajo
  5. Hacé click en "Guardar y conectar con Google"
setF("google_oauth_client_id", e.target.value)} />
setF("google_oauth_client_secret", e.target.value)} />

Se abrirá una ventana de Google para que autorizces el acceso a tu Drive y Calendar personal.

)} {/* Opciones adicionales — siempre visibles */}
setF("google_drive_folder_id", e.target.value)} />

URL de la carpeta → último segmento. Dejalo vacío para usar la raíz.

setF("google_calendar_id", e.target.value)} />

"primary" = tu calendario principal.

); } // ===================================================================== // INTEGRACIONES TAB — lee y escribe .env via API // ===================================================================== function IntegracionesTab() { const [cfg, setCfg] = useS(null); const [open, setOpen] = useS(null); // qué card está expandida const [form, setForm] = useS({}); const [saving, setSaving] = useS(false); const [testing, setTesting] = useS(null); const [testResult, setTestResult] = useS({}); const [toast, setToast] = useS(null); const [googleSAEmail, setGoogleSAEmail] = useS(null); useE(() => { Ds.API.getIntegrations() .then(data => { setCfg(data); setForm(buildFormDefaults(data)); }) .catch(err => { setToast({ text: "No se pudo cargar la configuración: " + (err?.message || "error de red"), kind: "error" }); // Fallback para que no quede colgado en loading const empty = { env_file_exists: false, claude: {}, supabase: {}, google: {}, github: {}, whatsapp: {} }; setCfg(empty); setForm(buildFormDefaults(empty)); }); Ds.API.getGoogleServiceAccountEmail() .then(data => { if (data?.email) setGoogleSAEmail(data.email); }) .catch(() => {}); }, []); function buildFormDefaults(data) { if (!data) return {}; return { anthropic_api_key: data.claude?.api_key || "", supabase_url: data.supabase?.url || "", supabase_key: data.supabase?.key || "", google_oauth_client_id: data.google?.oauth_client_id || "", google_oauth_client_secret: data.google?.oauth_client_secret || "", google_credentials_path: data.google?.credentials_path || "", google_drive_folder_id: data.google?.drive_folder_id || "", google_calendar_id: data.google?.calendar_id || "primary", github_token: data.github?.token || "", whatsapp_provider: data.whatsapp?.provider || "twilio", twilio_account_sid: data.whatsapp?.twilio_account_sid || "", twilio_auth_token: data.whatsapp?.twilio_auth_token || "", twilio_from: data.whatsapp?.twilio_from || "", meta_token: data.whatsapp?.meta_token || "", meta_phone_id: data.whatsapp?.meta_phone_id || "", meta_verify_token: data.whatsapp?.verify_token || "", claude_model: data.claude?.model || "", }; } function setF(key, value) { setForm(f => ({ ...f, [key]: value })); } async function save(serviceKey, fields) { setSaving(true); try { const payload = {}; fields.forEach(k => { payload[k] = form[k]; }); const res = await Ds.API.saveIntegrations(payload); if (res.updated?.length) { setToast({ text: `✓ Guardado en .env — reiniciá el servidor para aplicar.`, kind: "success" }); // Actualización local optimista: marcar la integración como configurada setCfg(prev => { if (!prev) return prev; const next = { ...prev }; const serviceMap = { claude: "claude", supabase: "supabase", google: "google", github: "github", whatsapp: "whatsapp", }; const svc = serviceMap[serviceKey]; if (svc && next[svc]) { next[svc] = { ...next[svc], enabled: true, _pending_restart: true }; } return next; }); setOpen(null); } else { setToast({ text: res.message || "Sin cambios que guardar.", kind: "info" }); } } catch (e) { setToast({ text: "Error al guardar: " + e.message, kind: "error" }); } finally { setSaving(false); } } async function testConn(service) { setTesting(service); try { const res = await Ds.API.testService(service); setTestResult(r => ({ ...r, [service]: res })); } catch (e) { setTestResult(r => ({ ...r, [service]: { ok: false, message: e.message } })); } finally { setTesting(null); } } useE(() => { if (!toast) return; const id = setTimeout(() => setToast(null), 3500); return () => clearTimeout(id); }, [toast]); if (!cfg) return (
Cargando configuración…
); const integrations = [ { id: "claude", logo: "C", color: "linear-gradient(135deg, #D97757, #b9522a)", name: "Claude API", desc: "Motor de IA que alimenta el asistente conversacional.", enabled: cfg.claude?.enabled, _pending_restart: cfg.claude?._pending_restart, fields: ( <>
setF("anthropic_api_key", e.target.value)} />

Obtener en console.anthropic.com → API Keys. El valor actual está enmascarado si ya está configurado.

), saveFields: ["anthropic_api_key", "claude_model"], }, { id: "supabase", logo: "S", color: "linear-gradient(135deg, #3ECF8E, #1f7c5b)", name: "Supabase", desc: "Base de datos PostgreSQL en la nube. Reemplaza SQLite para producción.", enabled: cfg.supabase?.enabled, _pending_restart: cfg.supabase?._pending_restart, fields: ( <>
setF("supabase_url", e.target.value)} />
setF("supabase_key", e.target.value)} />

Para usar Supabase como BD, también cambiá DATABASE_URL a la connection string de PostgreSQL.

), saveFields: ["supabase_url", "supabase_key"], }, { id: "google", logo: "G", color: "linear-gradient(135deg, #4285F4, #34A853)", name: "Google Drive & Calendar", desc: "Carpetas por proyecto en Drive y eventos de deadline en Calendar.", enabled: cfg.google?.enabled, _pending_restart: cfg.google?._pending_restart, fields: ( save("google", fields)} onConnect={async () => { // Guardar client_id y secret primero await save("google", ["google_oauth_client_id", "google_oauth_client_secret"]); try { const res = await Ds.API.apiFetch("/settings/google/auth-url"); const popup = window.open(res.url, "google-oauth", "width=560,height=660,left=200,top=100"); const handler = (e) => { if (e.data?.type === "maestro:google-connected") { window.removeEventListener("message", handler); // Recargar cfg desde backend Ds.API.getIntegrations() .then(data => { setCfg(data); setForm(buildFormDefaults(data)); }); } }; window.addEventListener("message", handler); } catch(e) { setToast({ text: "Error: " + e.message, kind: "error" }); } }} onDisconnect={async () => { try { await Ds.API.apiFetch("/settings/google/disconnect", { method: "POST" }); Ds.API.getIntegrations().then(data => { setCfg(data); setForm(buildFormDefaults(data)); }); setToast({ text: "Cuenta de Google desconectada.", kind: "success" }); } catch(e) { setToast({ text: "Error: " + e.message, kind: "error" }); } }} /> ), saveFields: [], }, { id: "github", logo: "◆", color: "linear-gradient(135deg, #24292e, #444)", name: "GitHub", desc: "Vinculá proyectos a repositorios y consultá commits y PRs desde el chat.", enabled: cfg.github?.enabled, _pending_restart: cfg.github?._pending_restart, fields: (
setF("github_token", e.target.value)} />

Generalo en github.com/settings/tokens · Scopes: repo o public_repo

), saveFields: ["github_token"], }, { id: "whatsapp", logo: "W", color: "linear-gradient(135deg, #25D366, #128C7E)", name: "WhatsApp", desc: "Conectá el asistente a un número de WhatsApp para chatear desde el teléfono.", enabled: cfg.whatsapp?.enabled, _pending_restart: cfg.whatsapp?._pending_restart, fields: ( <>
{["twilio", "meta"].map(p => ( ))}
{form.whatsapp_provider === "twilio" ? ( <>
setF("twilio_account_sid", e.target.value)} />
setF("twilio_auth_token", e.target.value)} />
setF("twilio_from", e.target.value)} />
Webhook a configurar en Twilio Console:
POST https://tu-dominio.com/whatsapp/webhook
) : ( <>
setF("meta_token", e.target.value)} />
setF("meta_phone_id", e.target.value)} />
setF("meta_verify_token", e.target.value)} />
)} ), saveFields: ["whatsapp_provider", "twilio_account_sid", "twilio_auth_token", "twilio_from", "meta_token", "meta_phone_id", "meta_verify_token"], }, ]; return (
{toast && (
{toast.text}
)} {!cfg.env_file_exists && (
Archivo .env no encontrado

Al guardar cualquier integración se creará automáticamente desde .env.example.

)} {integrations.map(int => (
setOpen(open === int.id ? null : int.id)}>
{int.logo}
{int.name} {int._pending_restart && ( reinicio pendiente )} {testResult[int.id] && ( {testResult[int.id].ok ? "✓" : "✗"} {testResult[int.id].message} )}
{int.enabled ? (int._pending_restart ? "Guardado — reiniciá el servidor" : "Conectado") : "Sin configurar"} · {int.desc}
{open === int.id && (
{int.fields}
)}
))}
Nota: Los cambios se guardan en backend/.env y requieren reiniciar el servidor para aplicarse.
Los secretos (tokens, passwords) se muestran enmascarados y nunca salen del servidor.
); } // ===================================================================== // REUNIONES // ===================================================================== function NewMeetingModal({ projects, onClose, onSave }) { const [projectId, setProjectId] = useS(projects[0]?.id || ""); const [desc, setDesc] = useS(""); const [meetingWith, setMeetingWith] = useS(""); const [meetingDate, setMeetingDate] = useS(""); const [prepFile, setPrepFile] = useS(null); const [saving, setSaving] = useS(false); const inputStyle = { 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 }; const labelStyle = { fontSize: 12, color: "var(--fg-muted)", display: "block", marginBottom: 4 }; const submit = async (e) => { e.preventDefault(); if (!desc.trim() || !projectId) return; setSaving(true); try { await onSave({ projectId, taskName: desc.trim(), taskType: "meeting", meetingWith: meetingWith.trim() || null, fechaVencimiento: meetingDate || null, prepFile: prepFile || null }); onClose(); } finally { setSaving(false); } }; return (
e.target === e.currentTarget && onClose()}>

Nueva reunión

Agregá la reunión a un proyecto y dejate un archivo de preparación

setDesc(e.target.value)} placeholder="Ej: Revisión de avance del proyecto ARCA" required style={inputStyle} />
setMeetingWith(e.target.value)} placeholder="Ej: Juan Pérez, cliente ACME" style={inputStyle} />
setMeetingDate(e.target.value)} style={inputStyle} />
setPrepFile(e.target.files[0] || null)} />
); } function MeetingsSection({ projects, onOpenProject, onAddTask }) { const [filter, setFilter] = useS("proximas"); const [projFilter, setProjFilter] = useS("all"); const [showModal, setShowModal] = useS(false); const todayISO = new Date().toISOString().slice(0, 10); const allMeetings = useM(() => { const a = []; projects.forEach(p => { (p.tasks || []).forEach(t => { if (t.taskType === "meeting") a.push({ ...t, project: p }); }); }); return a.sort((a, b) => { if (!a.fechaVencimiento && !b.fechaVencimiento) return 0; if (!a.fechaVencimiento) return 1; if (!b.fechaVencimiento) return -1; return a.fechaVencimiento.localeCompare(b.fechaVencimiento); }); }, [projects]); const filtered = useM(() => { let r = allMeetings; if (filter === "proximas") r = r.filter(t => !t.done && (!t.fechaVencimiento || t.fechaVencimiento >= todayISO)); if (filter === "pasadas") r = r.filter(t => t.done || (t.fechaVencimiento && t.fechaVencimiento < todayISO)); if (projFilter !== "all") r = r.filter(t => t.project.id === projFilter); return r; }, [allMeetings, filter, projFilter, todayISO]); return (
{showModal && projects.length > 0 && ( setShowModal(false)} onSave={onAddTask} /> )}
Reuniones

Todas las reuniones

{allMeetings.length === 0 ? "Agendá reuniones asociadas a tus proyectos" : `${allMeetings.filter(t => !t.done && (!t.fechaVencimiento || t.fechaVencimiento >= todayISO)).length} próximas · ${allMeetings.filter(t => t.done || (t.fechaVencimiento && t.fechaVencimiento < todayISO)).length} pasadas`}

{[["proximas","Próximas"], ["pasadas","Pasadas"], ["all","Todas"]].map(([v,l]) => ( ))}
{projects.slice(0, 5).map(p => ( ))}
{filtered.length === 0 ? (
{Is.meeting(28)}

Sin reuniones {filter === "proximas" ? "próximas" : filter === "pasadas" ? "pasadas" : ""}

Usá el botón "Nueva reunión" para agendar una reunión dentro de un proyecto.

) : (
{filtered.map(t => { const isPast = t.fechaVencimiento && t.fechaVencimiento < todayISO; const isToday = t.fechaVencimiento === todayISO; return (
{Is.meeting(16)}
{t.name}
{t.meetingWith && ( {Is.users(11)} {t.meetingWith} )} {t.fechaVencimiento && ( {isToday ? "HOY" : t.fechaVencimiento} )} onOpenProject(t.project.id)}> {t.project.name} → {t.meetingPrepUrl && ( 📎 {t.meetingPrepFilename || "Archivo prep"} )}
); })}
)}
); } window.MAESTRO_SECTIONS = { TareasSection, CalendarioSection, StatsSection, AsistenteSection, ConfigSection, MeetingsSection, };