/* 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" /> ) : ( )} · 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.

); } return (
{/* ============ HEADER ============ */}
ads&law · SOP Creator
{providersCatalog.length > 0 && (
{providersCatalog.map(p => ( ))}
)}
Guardado · {lastUpdate}
{exportOpen && (
)}
{/* ============ 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(); }}>

Cambiar a Ollama

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.

)} {/* ============ MODAL: SUBIR A GOOGLE DRIVE ============ */} {driveModal.open && (
{ if (e.target === e.currentTarget) closeDriveModal(); }}>

Subir a Google Drive

{!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.

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 && (
Subido a Drive ✓
{driveModal.result.name}
Abrir en Google Docs
)}
{!driveModal.result ? ( <> ) : ( <> )}
)} setHistoryOpen(false)} onRestore={restoreVersion} onToast={showToast} />
); } ReactDOM.createRoot(document.getElementById("root")).render();