>
)}
{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 }) => (
setTweak("accentHue", h)}
style={{ background: `oklch(0.55 0.20 ${h})` }} />
))}
Densidad
Cuánto aire entre elementos.
{["compact", "cozy", "comfy"].map(d => (
setTweak("density", d)}>
{d === "compact" ? "Compacto" : d === "cozy" ? "Cómodo" : "Amplio"}
))}
Vista por defecto de proyectos
Cuál vista mostrar al entrar a la sección Proyectos.
{["columns", "list", "timeline"].map(v => (
setTweak("kanbanView", v)}>
{v === "columns" ? "Columnas" : v === "list" ? "Lista" : "Timeline"}
))}
>
)}
{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 => (
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 => (
setTweak("agentTone", t)}>
{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.
{
const data = JSON.stringify(projects.map(p => ({ id: p.id, name: p.name, state: p.state, priority: p.priority, actualHours: p.actualHours, estimatedHours: p.estimatedHours, tasks: p.tasks, blocked: p.blocked })), null, 2);
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a"); a.href = url; a.download = "maestro-proyectos.json"; a.click();
URL.revokeObjectURL(url);
}}>Exportar JSON
Ds.exportProjectsCSV(projects)}>Proyectos CSV
Ds.exportTasksCSV(projects)}>Tareas CSV
Zona peligrosa
Acciones que no se pueden deshacer.
Borrar historial de chat
Resetea la memoria del agente
Borrar
>
)}
);
}
// =====================================================================
// 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
Desconectar
) : (
)}
{/* Opciones adicionales — siempre visibles */}
onSave(["google_drive_folder_id", "google_calendar_id"])}
disabled={saving}
style={{ alignSelf: "flex-start", padding: "7px 16px", borderRadius: "var(--r-sm)", border: "1px solid var(--border)", background: "var(--bg-subtle)", color: "var(--text-primary)", fontSize: 13, cursor: "pointer", fontWeight: 500 }}>
{saving ? "Guardando..." : "Guardar configuración de carpeta/calendario"}
);
}
// =====================================================================
// 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 (
);
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: (
<>
Modelo
setF("claude_model", e.target.value)}>
Claude Haiku 4.5 (rápido y económico)
Claude Sonnet 4.6 (equilibrado)
Claude Opus 4.7 (más potente)
>
),
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: (
<>
Project URL
setF("supabase_url", e.target.value)} />
Anon / Service Key
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: (
),
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: (
<>
Proveedor
{["twilio", "meta"].map(p => (
setF("whatsapp_provider", p)}>
{p === "twilio" ? "Twilio (recomendado)" : "Meta Cloud API"}
))}
{form.whatsapp_provider === "twilio" ? (
<>
Número WhatsApp de MAESTRO
setF("twilio_from", e.target.value)} />
Webhook a configurar en Twilio Console:
POST https://tu-dominio.com/whatsapp/webhook
>
) : (
<>
Access Token
setF("meta_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}
{ e.stopPropagation(); testConn(int.id); }}
disabled={testing === int.id}>
{testing === int.id ? "…" : "Probar"}
{ e.stopPropagation(); setOpen(open === int.id ? null : int.id); }}>
{open === int.id ? "Cerrar" : "Configurar"}
{open === int.id && (
{int.fields}
save(int.id, int.saveFields)}
disabled={saving}>
{saving ? "Guardando…" : "Guardar en .env"}
setOpen(null)}>
Cancelar
)}
))}
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
);
}
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`}
projects.length > 0 ? setShowModal(true) : null}>
{Is.meeting(14)} Nueva reunión
{[["proximas","Próximas"], ["pasadas","Pasadas"], ["all","Todas"]].map(([v,l]) => (
setFilter(v)}>{l}
))}
setProjFilter("all")}>Todos los proyectos
{projects.slice(0, 5).map(p => (
setProjFilter(p.id)}>{p.name}
))}
{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,
};