// admin.jsx — Panel de administración Banco Ortopédico (prototipo interactivo) const { useState, useEffect, useRef } = React; /* ------------------------------------------------------------------ */ /* Datos de ejemplo */ /* ------------------------------------------------------------------ */ const POSTS_INIT = [ { id: 1, texto: "¡Nueva silla de ruedas disponible en el Banco Ortopédico! Consulta por WhatsApp al 098 679 089.", canales: ["web", "fb", "ig"], fecha: "10/05/2026", img: true }, { id: 2, texto: "Gracias a todos los que participaron del Festival de India Muerta 🐎", canales: ["fb", "ig"], fecha: "03/03/2026", img: true }, ]; const ROL_BADGE = { superadmin: "out", admin: "info", consulta: "ok" }; /* ------------------------------------------------------------------ */ /* LOGIN */ /* ------------------------------------------------------------------ */ function Login({ onEnter }) { const [email, setEmail] = useState(""); const [pass, setPass] = useState(""); const [err, setErr] = useState(""); const submit = (e) => { e.preventDefault(); setErr(""); window.RotaryAPI.login(email, pass) .then(() => onEnter(email)) .catch(() => setErr("Credenciales inválidas")); }; return (
Rotary Club de Lascano

Panel de administración

Banco Ortopédico — Casa Rotaria de Lascano

setEmail(e.target.value)} placeholder="tucorreo@ejemplo.com" />
setPass(e.target.value)} placeholder="••••••••" />
{err &&
{err}
}
← Volver al portal público
); } /* ------------------------------------------------------------------ */ /* Helpers UI */ /* ------------------------------------------------------------------ */ function Panel({ title, action, children, pad }) { return (

{title}

{action}
{children}
); } function ChannelChip({ c }) { const map = { web: { bg: "var(--azure)", t: "W" }, fb: { bg: "#1877f2", t: "f" }, ig: { bg: "#d6249f", t: "◎" } }; const m = map[c]; return {m.t}; } /* ------------------------------------------------------------------ */ /* VISTAS */ /* ------------------------------------------------------------------ */ function Dashboard({ go }) { const [m, setM] = useState(null); const [arts, setArts] = useState([]); const [prest, setPrest] = useState([]); useEffect(() => { window.RotaryAPI.get("/metricas").then(setM).catch(() => {}); window.RotaryAPI.get("/articulos/admin").then(setArts).catch(() => {}); window.RotaryAPI.get("/prestamos?activos=true").then(setPrest).catch(() => {}); }, []); const kpis = [ { ico: "📦", bg: "#eaf2fb", c: "var(--azure)", b: arts.length, s: "Tipos de equipamiento" }, { ico: "🤝", bg: "#fdf0d6", c: "var(--gold-deep)", b: m ? m.prestamos_activos : "—", s: "Préstamos activos" }, { ico: "⚠️", bg: "#fbdfe4", c: "#b0123e", b: m ? m.prestamos_mas_30_dias : "—", s: "Préstamos +30 días" }, { ico: "🛠", bg: "#dcf3e8", c: "#0a7c52", b: m ? m.articulos_sin_stock : "—", s: "Sin stock" }, ]; return (
{kpis.map((k, i) => (
{k.ico}
{k.b}{k.s}
))}
go("prestamos")}>Ver todos}> {prest.map((p) => ( ))}
PersonaC.I.Inicio
{p.id}{p.persona_nombre} {p.persona_ci || "—"}{p.fecha_inicio}
{arts.map((a) => ( ))}
ArtículoDisponiblesTotalEstado
{a.nombre}{a.stock_disponible}{a.stock_total} {a.stock_disponible === 0 ? "Sin stock" : a.stock_disponible === 1 ? "Último" : "Disponible"}
); } function Articulos({ openModal }) { const [arts, setArts] = useState([]); const load = () => window.RotaryAPI.get("/articulos/admin").then(setArts).catch(() => {}); useEffect(() => { load(); }, []); const toggle = (a) => window.RotaryAPI.put("/articulos/" + a.id, { visible_publico: !a.visible_publico }).then(load); return (
openModal("articulo")}>+ Nuevo artículo}> {arts.map((a) => ( ))}
NombreUnidadDisp. / TotalVisible en portal
{a.nombre} {a.unidad} {a.stock_disponible} / {a.stock_total}
); } function Prestamos({ openModal }) { const [items, setItems] = useState([]); const load = () => window.RotaryAPI.get("/prestamos").then(setItems).catch(() => {}); useEffect(() => { load(); }, []); const cerrar = (id) => window.RotaryAPI.put("/prestamos/" + id + "/cerrar").then(load); return (
openModal("prestamo")}>+ Nuevo préstamo}> {items.map((p) => { const estado = p.fecha_devolucion ? "devuelto" : "activo"; return ( ); })}
PersonaC.I.InicioEstado
{p.id}{p.persona_nombre}{p.persona_ci || "—"}{p.fecha_inicio} {estado} {!p.fecha_devolucion && }
); } function Personas() { const [rows, setRows] = useState([]); useEffect(() => { window.RotaryAPI.get("/prestamos").then((ps) => { const map = {}; ps.forEach((p) => { const k = (p.persona_ci || p.persona_nombre); if (!map[k]) map[k] = { nombre: p.persona_nombre, ci: p.persona_ci || "—", tel: p.persona_telefono || "—", prestamos: 0 }; map[k].prestamos += 1; }); setRows(Object.values(map)); }).catch(() => {}); }, []); return (
{rows.map((p, i) => ( ))}
NombreC.I.TeléfonoPréstamos
{p.nombre}{p.ci}{p.tel}{p.prestamos}
); } function Reparacion({ openModal }) { const [items, setItems] = useState([]); const load = () => window.RotaryAPI.get("/reparaciones").then(setItems).catch(() => {}); useEffect(() => { load(); }, []); return (
openModal("reparacion")}>+ Enviar a reparación}> {items.map((r) => ( ))}
ArtículoEntradaResponsableCostoEstado
{r.articulo_nombre || ("#" + r.articulo_id)}{r.fecha_entrada}{r.responsable_nombre}{r.costo != null ? "$ " + r.costo : "—"} {r.fecha_retorno ? "retornado" : "en proceso"}
); } function Usuarios({ openModal }) { const [items, setItems] = useState([]); useEffect(() => { window.RotaryAPI.get("/usuarios").then(setItems).catch(() => {}); }, []); return (
openModal("usuario")}>+ Nuevo usuario}> {items.map((u) => ( ))}
NombreEmailRol
{u.nombre}{u.email} {u.rol}
); } /* ---- Publicaciones (web + redes) ---- */ function Publicaciones() { const [texto, setTexto] = useState(""); const [canales, setCanales] = useState({ web: true, fb: true, ig: false }); const [conImg, setConImg] = useState(true); const [posts, setPosts] = useState(POSTS_INIT); const [flash, setFlash] = useState(""); const toggleCh = (c) => setCanales({ ...canales, [c]: !canales[c] }); const activos = Object.keys(canales).filter((c) => canales[c]); const publicar = () => { if (!texto.trim() || activos.length === 0) return; const nuevo = { id: Date.now(), texto: texto.trim(), canales: activos, fecha: "Hoy", img: conImg }; setPosts([nuevo, ...posts]); setTexto(""); setFlash("✓ Publicado en: " + activos.map((c) => ({ web: "Web", fb: "Facebook", ig: "Instagram" }[c])).join(", ")); setTimeout(() => setFlash(""), 3500); }; return (
{/* composer */}

Nueva publicación

{conImg ? "Adjuntar imagen" : "Sin imagen"}
{flash &&
{flash}
}
{/* preview */}
R
Rotary Lascano
{activos.length ? activos.map((c) => ({ web: "Web", fb: "Facebook", ig: "Instagram" }[c])).join(" · ") : "Sin canal"}
{conImg &&
imagen de la publicación
}
{texto || "El texto de tu publicación aparecerá acá…"}
{posts.map((p) => (
{p.img &&
img
}
{p.texto}
{p.fecha}
{p.canales.map((c) => )}
))}
); } /* ------------------------------------------------------------------ */ /* TESORERÍA */ /* ------------------------------------------------------------------ */ function Tesoreria() { const COLORS = ['#0050a2','#019fcb','#17458f','#009999','#f7a81b','#d41367','#872175','#ff7600']; const [mes, setMes] = useState('2026-06'); const [fijos, setFijos] = useState([ { id:'f1', cat:'UTE', desc:'Energía eléctrica', monto:'' }, { id:'f2', cat:'OSE', desc:'Agua', monto:'' }, { id:'f3', cat:'Antel', desc:'Telefonía / Internet', monto:'' }, { id:'f4', cat:'Sueldos', desc:'', monto:'' }, ]); const [vars, setVars] = useState([ { id:'v1', cat:'Donaciones', desc:'', monto:'' }, { id:'v2', cat:'Florería', desc:'', monto:'' }, { id:'v3', cat:'Regalos', desc:'', monto:'' }, ]); const [docs, setDocs] = useState([]); const [proc, setProc] = useState(false); const [drag, setDrag] = useState(false); const [flash, setFlash] = useState(''); const pieRef = useRef(null), barRef = useRef(null); const pieI = useRef(null), barI = useRef(null); const all = [...fijos, ...vars]; const vals = all.map(i => parseFloat(i.monto) || 0); const total = vals.reduce((a, b) => a + b, 0); const fmt = n => '$ ' + n.toLocaleString('es-UY'); useEffect(() => { const C = window.Chart; if (!C || !pieRef.current || !barRef.current) return; if (pieI.current) { pieI.current.destroy(); pieI.current = null; } if (barI.current) { barI.current.destroy(); barI.current = null; } if (total === 0) return; const labels = all.map(i => i.cat); pieI.current = new C(pieRef.current, { type: 'doughnut', data: { labels, datasets: [{ data: vals, backgroundColor: COLORS, borderWidth: 2, borderColor: '#fff' }] }, options: { maintainAspectRatio: false, cutout: '65%', plugins: { legend: { position: 'right', labels: { boxWidth: 11, font: { size: 11, family: "'Open Sans',sans-serif" } } } } } }); barI.current = new C(barRef.current, { type: 'bar', data: { labels, datasets: [{ label: 'Gastos', data: vals, backgroundColor: COLORS, borderRadius: 6, borderSkipped: false }] }, options: { maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { grid: { display: false }, ticks: { font: { size: 11 } } }, y: { grid: { color: '#e9eef5' }, ticks: { font: { size: 11 } } } } } }); return () => { if (pieI.current) { pieI.current.destroy(); pieI.current = null; } if (barI.current) { barI.current.destroy(); barI.current = null; } }; }, [fijos, vars]); const handleFile = file => { setProc(true); setTimeout(() => { const mock = { UTE: (Math.random()*800+900).toFixed(0), Antel: (Math.random()*400+600).toFixed(0), OSE: (Math.random()*120+180).toFixed(0) }; setFijos(prev => prev.map(f => mock[f.cat] ? { ...f, monto: mock[f.cat] } : f)); setDocs(prev => [...prev, { name: file.name, txn: Object.keys(mock).length }]); setFlash('✓ ' + Object.keys(mock).length + ' transacciones extraídas de "' + file.name + '"'); setTimeout(() => setFlash(''), 4500); setProc(false); }, 2200); }; const updF = (id,k,v) => setFijos(p => p.map(f => f.id===id ? {...f,[k]:v} : f)); const updV = (id,k,v) => setVars(p => p.map(x => x.id===id ? {...x,[k]:v} : x)); return (
{/* KPIs */}
💰
{fmt(total)}Total del mes
🔒
{fmt(fijos.reduce((a,f)=>a+(parseFloat(f.monto)||0),0))}Gastos fijos
📊
{fmt(vars.reduce((a,v)=>a+(parseFloat(v.monto)||0),0))}Gastos variables
{/* Período */}
Período setMes(e.target.value)} style={{ border:'1.5px solid var(--line)', borderRadius:10, padding:'8px 12px', fontFamily:'var(--font)', fontSize:14, color:'var(--ink)', background:'#fff' }} />
{/* Zona de carga */} JPG · PNG · PDF}>
{ e.preventDefault(); setDrag(true); }} onDragLeave={() => setDrag(false)} onDrop={e => { e.preventDefault(); setDrag(false); if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]); }} onClick={() => document.getElementById('tFileIn').click()} style={{ border:`2px dashed ${drag?'var(--azure)':'var(--line)'}`, borderRadius:14, padding:'32px 24px', textAlign:'center', background:drag?'#eaf2fb':'var(--paper)', cursor:'pointer', transition:'all .2s' }}> { if (e.target.files[0]) handleFile(e.target.files[0]); e.target.value=''; }} /> {proc ? (
Procesando documento…

Extrayendo y clasificando transacciones

) : (
📄
Arrastrar o hacer clic para subir

Facturas, capturas o extractos bancarios en PDF

)}
{flash &&
{flash}
} {docs.length > 0 && (
{docs.map((d,i) => (
📎 {d.name} {d.txn} transacciones
))}
)}
{/* Grilla de gastos */}
{fijos.map(f => (
{f.cat}
updF(f.id,'desc',e.target.value)} placeholder="Descripción" style={{ border:'none', background:'transparent', fontSize:13, color:'var(--ink-soft)', width:'100%', fontFamily:'var(--font)', marginTop:2 }} />
$ updF(f.id,'monto',e.target.value)} placeholder="0" style={{ border:'1.5px solid var(--line)', borderRadius:8, padding:'6px 9px', fontSize:14, width:'100%', fontFamily:'var(--font)', color:'var(--ink)' }} />
))}
setVars(p => [...p, { id:'v'+Date.now(), cat:'Otro', desc:'', monto:'' }])}>+ Agregar}> {vars.map(v => (
updV(v.id,'cat',e.target.value)} style={{ border:'1.5px solid var(--line)', borderRadius:8, padding:'6px 10px', fontSize:13, fontFamily:'var(--font)', color:'var(--azure)', fontWeight:700 }} />
$ updV(v.id,'monto',e.target.value)} placeholder="0" style={{ border:'1.5px solid var(--line)', borderRadius:8, padding:'6px 9px', fontSize:14, width:'100%', fontFamily:'var(--font)', color:'var(--ink)' }} />
))}
{/* Gráficos */}
Por categoría
{total === 0 &&
Ingresa montos
para ver el gráfico
}
Desglose por rubro
{total === 0 &&
Ingresa montos
para ver el gráfico
}
); } /* ------------------------------------------------------------------ */ /* MODAL genérico — crea registros reales vía API */ /* ------------------------------------------------------------------ */ function Modal({ tipo, onClose, onSaved }) { const titulos = { articulo: "Nuevo artículo", prestamo: "Nuevo préstamo", reparacion: "Enviar a reparación", usuario: "Nuevo usuario", noticia: "Nueva noticia", evento: "Nuevo evento" }; const [form, setForm] = useState({}); const [arts, setArts] = useState([]); const [err, setErr] = useState(""); const set = (k) => (e) => setForm({ ...form, [k]: e.target.value }); useEffect(() => { if (tipo === "prestamo" || tipo === "reparacion") { window.RotaryAPI.get("/articulos/admin").then(setArts).catch(() => {}); } }, [tipo]); const guardar = () => { setErr(""); let path, body; if (tipo === "articulo") { path = "/articulos"; body = { nombre: form.nombre, unidad: form.unidad || "Unidad", stock_total: parseInt(form.stock_total || 0, 10), stock_disponible: parseInt(form.stock_disponible || form.stock_total || 0, 10), visible_publico: form.visible_publico !== "No", }; } else if (tipo === "prestamo") { path = "/prestamos"; body = { articulo_id: parseInt(form.articulo_id, 10), cantidad: parseInt(form.cantidad || 1, 10), persona_nombre: form.persona_nombre, persona_ci: form.persona_ci || null, persona_telefono: form.persona_telefono || null, persona_mail: form.persona_mail || null, fecha_inicio: form.fecha_inicio, }; } else if (tipo === "usuario") { path = "/usuarios"; body = { nombre: form.nombre, email: form.email, password: form.password, rol: form.rol || "admin" }; } else if (tipo === "reparacion") { path = "/reparaciones"; body = { articulo_id: parseInt(form.articulo_id, 10), fecha_entrada: form.fecha_entrada, responsable_nombre: form.responsable_nombre, costo: form.costo ? parseFloat(form.costo) : null, notas: form.notas || null, }; } else if (tipo === "noticia") { path = "/noticias"; body = { titulo: form.titulo, fecha: form.fecha, resumen: form.resumen || "", imagen_url: form.imagen_url || null, cuerpo: form.cuerpo || null, publicado: form.publicado !== "No", }; } else if (tipo === "evento") { path = "/agenda"; body = { titulo: form.titulo, fecha: form.fecha, hora: form.hora || null, lugar: form.lugar || null, descripcion: form.descripcion || null, publicado: form.publicado !== "No", }; } window.RotaryAPI.post(path, body).then(() => onSaved()).catch((e) => setErr(e.message || "Error al guardar")); }; return (
e.stopPropagation()}>

{titulos[tipo]}

{tipo === "articulo" && (<>
)} {tipo === "prestamo" && (<>
)} {tipo === "reparacion" && (<>
)} {tipo === "usuario" && (<>
)} {tipo === "noticia" && (<>
)} {tipo === "evento" && (<>
)} {err &&
{err}
}
); } /* ------------------------------------------------------------------ */ /* NOTICIAS admin */ /* ------------------------------------------------------------------ */ function NoticiasAdmin({ openModal }) { const [items, setItems] = useState([]); const load = () => window.RotaryAPI.get("/noticias/admin").then(setItems).catch(() => {}); useEffect(() => { load(); }, []); const borrar = (id) => window.RotaryAPI.del("/noticias/" + id).then(load); return (
openModal("noticia")}>+ Nueva noticia}> {items.map((n) => ( ))}
TítuloFechaPublicado
{n.titulo}{n.fecha} {n.publicado ? "sí" : "no"}
); } /* ------------------------------------------------------------------ */ /* AGENDA admin */ /* ------------------------------------------------------------------ */ function AgendaAdmin({ openModal }) { const [items, setItems] = useState([]); const load = () => window.RotaryAPI.get("/agenda/admin").then(setItems).catch(() => {}); useEffect(() => { load(); }, []); const borrar = (id) => window.RotaryAPI.del("/agenda/" + id).then(load); return (
openModal("evento")}>+ Nuevo evento}> {items.map((e) => ( ))}
TítuloFechaLugarPublicado
{e.titulo}{e.fecha}{e.lugar || "—"} {e.publicado ? "sí" : "no"}
); } /* ------------------------------------------------------------------ */ /* APP */ /* ------------------------------------------------------------------ */ const NAV = [ { sec: "Gestión", items: [ { id: "dashboard", ico: "▦", label: "Dashboard" }, { id: "articulos", ico: "📦", label: "Artículos" }, { id: "prestamos", ico: "🤝", label: "Préstamos" }, { id: "personas", ico: "👥", label: "Personas" }, { id: "reparacion", ico: "🛠", label: "En Reparación" }, ]}, { sec: "Comunicación", items: [ { id: "publicaciones", ico: "📣", label: "Publicaciones", tag: "Web+Redes" }, { id: "noticias", ico: "📰", label: "Noticias" }, { id: "agenda", ico: "📅", label: "Agenda" }, ]}, { sec: "Tesorería", items: [ { id: "tesoreria", ico: "💰", label: "Tesorería" }, ]}, { sec: "Sistema", items: [ { id: "usuarios", ico: "🔑", label: "Usuarios" }, ]}, ]; const TITULOS = { dashboard: "Dashboard", articulos: "Artículos", prestamos: "Préstamos", personas: "Personas", reparacion: "En Reparación", publicaciones: "Publicaciones", tesoreria: "Tesorería", usuarios: "Usuarios", noticias: "Noticias", agenda: "Agenda" }; function App() { const [user, setUser] = useState(null); const [view, setView] = useState("dashboard"); const [modal, setModal] = useState(null); const [sideOpen, setSideOpen] = useState(false); const [reloadKey, setReloadKey] = useState(0); useEffect(() => { if (window.RotaryAPI.token()) { window.RotaryAPI.me().then((u) => setUser(u.email)).catch(() => window.RotaryAPI.clearToken()); } }, []); const logout = () => { window.RotaryAPI.clearToken(); setUser(null); }; if (!user) return ; const go = (v) => { setView(v); setSideOpen(false); }; return (

{TITULOS[view]}

Casa Rotaria de Lascano
{(user[0] || "R").toUpperCase()}
{view === "dashboard" && } {view === "articulos" && } {view === "prestamos" && } {view === "personas" && } {view === "reparacion" && } {view === "publicaciones" && } {view === "tesoreria" && } {view === "usuarios" && } {view === "noticias" && } {view === "agenda" && }
{modal && setModal(null)} onSaved={() => { setModal(null); setReloadKey((k) => k + 1); }} />}
); } ReactDOM.createRoot(document.getElementById("root")).render();