/* global React, ReactDOM, Icon, Sidebar, Editor, DocumentPreview, HistoryPanel, SOPStore, SOPExport */
/* Main app */
const { useState, useEffect, useRef, useMemo, useCallback } = React;
/* Editable title strip shown above the live preview. */
function DocTitleBar({ doc, onChange }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(doc.title || "");
const inputRef = useRef(null);
useEffect(() => { if (!editing) setDraft(doc.title || ""); }, [doc.title, editing]);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
const commit = () => {
const v = draft.trim();
if (v && v !== doc.title) onChange({ ...doc, title: v, updatedAt: Date.now() });
setEditing(false);
};
const stepCount = doc.phases?.reduce((n, p) => n + (p.steps?.length || 0), 0) || 0;
return (
{editing ? (
setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") { setDraft(doc.title || ""); setEditing(false); }
}}
placeholder="Título del SOP"
/>
) : (
setEditing(true)} title="Editar título">
{doc.title?.trim() || Sin título }
)}
·
v{doc.version || "1.0"}
·
{stepCount} pasos
);
}
function App() {
// ----- mode (Simple = IA-first / Avanzado = editor manual) -----
const [mode, setMode] = useState(() => {
try { return localStorage.getItem("adslaw_sop_mode") || "simple"; } catch (e) { return "simple"; }
});
useEffect(() => { try { localStorage.setItem("adslaw_sop_mode", mode); } catch (e) {} }, [mode]);
// ----- doc collection -----
const [docs, setDocs] = useState(() => {
const loaded = window.SOPStore.load();
if (loaded.length > 0) return loaded;
// bootstrap with one example doc so the user immediately sees what the
// output looks like
return [window.SOPStore.exampleDoc()];
});
const [activeId, setActiveId] = useState(() => {
const stored = window.SOPStore.loadActiveId();
return stored || null;
});
// ensure activeId is valid
useEffect(() => {
if (!docs.length) return;
if (!activeId || !docs.find(d => d.id === activeId)) {
setActiveId(docs[0].id);
}
}, [docs, activeId]);
// persist on every change (debounced)
useEffect(() => {
const t = setTimeout(() => {
window.SOPStore.save(docs);
window.SOPStore.saveActiveId(activeId);
}, 200);
return () => clearTimeout(t);
}, [docs, activeId]);
const activeDoc = docs.find(d => d.id === activeId) || docs[0];
// ----- historial: snapshot la versión PREVIA antes de aplicar un cambio -----
// Estrategia: cuando updateActive se llama, guardamos en historial el doc
// que estaba ANTES, etiquetado con el motivo del cambio si nos lo pasaron.
// Esto da una línea de tiempo de "estados pasados" desde los que restaurar.
const updateActive = (next, changeLabel) => {
setDocs(currentDocs => {
const prev = currentDocs.find(d => d.id === next.id);
if (prev) {
try { window.SOPStore.pushVersion(prev.id, prev, changeLabel || "Edición"); }
catch (e) { /* historial es best-effort */ }
}
return currentDocs.map(d => d.id === next.id ? next : d);
});
};
// Wrapper que el SimpleEditor/Editor usan: detectan a grandes rasgos qué tipo
// de edición fue. Para edits inline (typing en un campo) lo dejamos como
// "Edición" — el dedup del storage agrupa cambios contiguos por updatedAt.
const updateActiveAuto = (next) => {
// Detección heurística del label según el delta vs el doc actual.
let label = "Edición";
if (activeDoc) {
const oldPhases = (activeDoc.phases || []).length;
const newPhases = (next.phases || []).length;
if (Math.abs(oldPhases - newPhases) > 0) label = newPhases > oldPhases ? "Fases añadidas" : "Fases eliminadas";
else if ((activeDoc.title || "") !== (next.title || "")) label = "Título cambiado";
else {
const oldSteps = (activeDoc.phases || []).reduce((n, p) => n + (p.steps?.length || 0), 0);
const newSteps = (next.phases || []).reduce((n, p) => n + (p.steps?.length || 0), 0);
if (oldSteps !== newSteps) label = newSteps > oldSteps ? "Pasos añadidos" : "Pasos eliminados";
}
}
updateActive(next, label);
};
const createNew = () => {
const d = window.SOPStore.emptyDoc();
setDocs(prev => [d, ...prev]);
setActiveId(d.id);
};
const duplicate = () => {
if (!activeDoc) return;
const copy = { ...JSON.parse(JSON.stringify(activeDoc)), id: window.SOPStore.uid(), title: (activeDoc.title || "SOP") + " (copia)", createdAt: Date.now(), updatedAt: Date.now() };
setDocs(prev => [copy, ...prev]);
setActiveId(copy.id);
showToast("Duplicado ✓");
};
const deleteDoc = (id) => {
setDocs(prev => prev.filter(d => d.id !== id));
if (id === activeId) setActiveId(null);
// El historial se mantiene aunque el doc se borre: si después se restaura
// el doc desde un export JSON con el mismo id, vuelve a tener historial.
};
// Restaurar una versión desde el HistoryPanel: reemplaza el doc actual con
// el snapshot recuperado, agrega un updatedAt actual, y guarda la versión
// que se estaba reemplazando para no perderla.
const restoreVersion = (restored) => {
if (!restored) return;
const next = { ...restored, updatedAt: Date.now() };
updateActive(next, "Versión restaurada");
};
const renameDoc = (id, newTitle) => {
setDocs(prev => prev.map(d => d.id === id ? { ...d, title: newTitle, updatedAt: Date.now() } : d));
showToast("Renombrado ✓");
};
// ----- toast -----
const [toast, setToast] = useState(null);
const showToast = (msg) => {
setToast(msg);
setTimeout(() => setToast(null), 2200);
};
// ----- history panel -----
const [historyOpen, setHistoryOpen] = useState(false);
// ----- provider toggle (Codex via suscripción ChatGPT / Ollama fallback) -----
// Default = codex porque la calidad y la velocidad son mejores. Ollama queda
// como fallback para cuando codex no esté disponible (token vencido, etc).
// El provider activo se persiste en localStorage y se usa para todas las
// llamadas a /api/complete (modos Simple y Avanzado).
const [provider, setProvider] = useState(() => {
try { return localStorage.getItem("adslaw_sop_provider") || "codex"; } catch (e) { return "codex"; }
});
useEffect(() => {
try { localStorage.setItem("adslaw_sop_provider", provider); } catch (e) {}
// Lo expone también en window para que claude-shim.js lo lea sin necesitar
// tocar localStorage en cada request.
window.__SOP_PROVIDER = provider;
}, [provider]);
// Catalogo de providers desde el backend (para saber cuál está disponible)
const [providersCatalog, setProvidersCatalog] = useState([]);
useEffect(() => {
fetch("/api/agent/providers").then(r => r.json()).then(d => {
const list = d.providers || [];
// Re-ordenar: codex primero, ollama después. El backend devuelve en
// orden de inserción; el toggle del header respeta este array.
list.sort((a, b) => {
if (a.id === "codex") return -1;
if (b.id === "codex") return 1;
return 0;
});
setProvidersCatalog(list);
// Si el provider activo no está disponible, preferimos codex > ollama.
const found = list.find(p => p.id === provider);
if (!found || !found.available) {
const codex = list.find(p => p.id === "codex" && p.available);
const avail = codex || list.find(p => p.available);
if (avail) setProvider(avail.id);
}
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ----- popup de confirmación al cambiar a Ollama (calidad/velocidad menor) -----
const [providerSwitchModal, setProviderSwitchModal] = useState({ open: false, targetId: null });
const onProviderClick = (p) => {
if (!p.available || p.id === provider) return;
// Si pasa de codex → ollama, pedir confirmación.
if (provider === "codex" && p.id === "ollama") {
setProviderSwitchModal({ open: true, targetId: p.id });
return;
}
setProvider(p.id);
};
const confirmProviderSwitch = () => {
const target = providerSwitchModal.targetId;
setProviderSwitchModal({ open: false, targetId: null });
if (target) setProvider(target);
};
const cancelProviderSwitch = () => setProviderSwitchModal({ open: false, targetId: null });
// ----- export menu -----
const [exportOpen, setExportOpen] = useState(false);
const exportRef = useRef(null);
useEffect(() => {
if (!exportOpen) return;
const onClick = (e) => {
if (exportRef.current && !exportRef.current.contains(e.target)) setExportOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, [exportOpen]);
const doExport = (fmt) => {
setExportOpen(false);
if (!activeDoc) return;
if (fmt === "pdf") {
// Slight delay so the menu closes before the print dialog
setTimeout(() => window.SOPExport.exportPDF(), 100);
} else if (fmt === "docx") {
window.SOPExport.exportDOCX(activeDoc);
showToast(".doc descargado ✓");
} else if (fmt === "html") {
window.SOPExport.exportHTML(activeDoc);
showToast("HTML descargado ✓");
} else if (fmt === "json") {
window.SOPExport.exportJSON(activeDoc);
showToast("JSON descargado ✓");
} else if (fmt === "drive") {
openDriveModal();
}
};
// ----- subir a Drive -----
// LAST_DRIVE_FOLDER se persiste en localStorage para no tener que volver a
// pegar la URL cada vez que querés subir un SOP a la misma carpeta.
const DRIVE_LAST_KEY = "sopcreator.drive.lastFolderUrl";
const [driveModal, setDriveModal] = useState({ open: false, folderUrl: "", uploading: false, error: null, result: null });
const openDriveModal = () => {
setExportOpen(false);
const last = localStorage.getItem(DRIVE_LAST_KEY) || "";
setDriveModal({ open: true, folderUrl: last, uploading: false, error: null, result: null });
};
const closeDriveModal = () => setDriveModal(s => ({ ...s, open: false }));
const submitDriveUpload = async () => {
if (!activeDoc) return;
const folderUrl = driveModal.folderUrl.trim();
if (!folderUrl) { setDriveModal(s => ({ ...s, error: "Pegá la URL de la carpeta de Drive." })); return; }
setDriveModal(s => ({ ...s, uploading: true, error: null, result: null }));
try {
// Reusamos buildDocxHTML para mandar el mismo HTML que el botón Word.
// Drive lo convierte a Google Doc nativo via mimeType.
const html = window.SOPExport.buildHTML(activeDoc);
const filename = (activeDoc.title || "SOP sin título").slice(0, 100);
const res = await fetch("/api/upload-to-drive", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename, folder_url: folderUrl, html }),
});
const json = await res.json();
if (!res.ok) throw new Error(json?.detail || `HTTP ${res.status}`);
localStorage.setItem(DRIVE_LAST_KEY, folderUrl);
setDriveModal(s => ({ ...s, uploading: false, result: json }));
showToast("Subido a Drive ✓");
} catch (e) {
setDriveModal(s => ({ ...s, uploading: false, error: String(e.message || e) }));
}
};
// ----- last update string -----
// OJO: debe ir ANTES de cualquier early return para no violar el orden de
// hooks de React. Si activeDoc es undefined (todos los SOPs borrados),
// devolvemos string vacío.
const lastUpdate = useMemo(() => {
if (!activeDoc?.updatedAt) return "";
const diff = (Date.now() - activeDoc.updatedAt) / 1000;
if (diff < 5) return "ahora";
if (diff < 60) return `hace ${Math.round(diff)} s`;
if (diff < 3600) return `hace ${Math.round(diff / 60)} min`;
return `hace ${Math.round(diff / 3600)} h`;
}, [activeDoc?.updatedAt]);
if (!activeDoc) {
return (
No hay SOP activo.
Crear primer SOP
);
}
return (
{/* ============ HEADER ============ */}
ads&law
·
SOP Creator
setMode("simple")}>
Simple
setMode("advanced")}>
Avanzado
{providersCatalog.length > 0 && (
{providersCatalog.map(p => (
onProviderClick(p)}
disabled={!p.available}
title={p.available ? `${p.label} · ${p.model}` : `${p.label} · no configurado`}
>
{p.id === "codex" ? : }
{p.label.split(" ")[0]}
))}
)}
Guardado · {lastUpdate}
setHistoryOpen(true)} title="Historial de versiones">
Historial
Duplicar
setExportOpen(o => !o)}>
Exportar
{exportOpen && (
doExport("pdf")}>
Imprimir / Guardar como PDF
⌘P
doExport("docx")}>
.doc
Word · Google Docs
doExport("drive")}>
Subir a Google Drive
Como Google Doc
doExport("html")}>
HTML
doExport("json")}>
JSON
backup
)}
{/* ============ SIDEBAR ============ */}
{/* ============ MAIN ============ */}
{mode === "simple"
?
:
}
{toast && (
{toast}
)}
{/* Panel de actividad de la IA — escucha los eventos del shim y muestra
progreso en vivo cuando Simple o Avanzado llaman a window.claude.complete.
El modo Agente tiene su propio activity log dentro de AgentMode. */}
{/* ============ MODAL: CAMBIAR A OLLAMA (degradación) ============ */}
{providerSwitchModal.open && (
{ if (e.target === e.currentTarget) cancelProviderSwitch(); }}>
Estás a punto de cambiar el modelo de IA de Codex a Ollama .
La calidad y velocidad de Ollama son ligeramente menores . Usalo solo cuando Codex no esté disponible (por ejemplo si el login venció).
Podés volver a Codex cuando quieras desde el mismo toggle del header.
Cancelar
Cambiar a Ollama igualmente
)}
{/* ============ MODAL: SUBIR A GOOGLE DRIVE ============ */}
{driveModal.open && (
{ if (e.target === e.currentTarget) closeDriveModal(); }}>
{!driveModal.result && (
<>
Pegá la URL de la carpeta de Drive donde querés subir {activeDoc?.title || "este SOP"} .
Se sube como Google Doc nativo , no como Word.
URL de la carpeta
setDriveModal(s => ({ ...s, folderUrl: e.target.value, error: null }))}
onKeyDown={(e) => { if (e.key === "Enter" && !driveModal.uploading) submitDriveUpload(); }}
disabled={driveModal.uploading}
/>
La cuenta automatizaciones@adsandlaw.com tiene que tener permiso de Editor en esa carpeta.
{driveModal.error && (
{driveModal.error}
)}
>
)}
{driveModal.result && (
)}
{!driveModal.result ? (
<>
Cancelar
{driveModal.uploading ? (
<> Subiendo…>
) : (
<> Subir>
)}
>
) : (
<>
Cerrar
setDriveModal(s => ({ ...s, result: null }))}>
Subir otra vez
>
)}
)}
setHistoryOpen(false)}
onRestore={restoreVersion}
onToast={showToast}
/>
);
}
ReactDOM.createRoot(document.getElementById("root")).render( );