// 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 (
);
}
/* ------------------------------------------------------------------ */
/* 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) => (
))}
go("prestamos")}>Ver todos}>
| Nº | Persona | C.I. | Inicio |
{prest.map((p) => (
| {p.id} | {p.persona_nombre} |
{p.persona_ci || "—"} | {p.fecha_inicio} |
))}
| Artículo | Disponibles | Total | Estado |
{arts.map((a) => (
| {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}>
| Nombre | Unidad | Disp. / Total | Visible en portal | |
{arts.map((a) => (
| {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}>
| Nº | Persona | C.I. | Inicio | Estado | |
{items.map((p) => {
const estado = p.fecha_devolucion ? "devuelto" : "activo";
return (
| {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 (
| Nombre | C.I. | Teléfono | Préstamos |
{rows.map((p, i) => (
| {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}>
| Artículo | Entrada | Responsable | Costo | Estado |
{items.map((r) => (
| {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}>
| Nombre | Email | Rol |
{items.map((u) => (
| {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.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 => (
))}
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]}
);
}
/* ------------------------------------------------------------------ */
/* 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}>
| Título | Fecha | Publicado | |
{items.map((n) => (
| {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}>
| Título | Fecha | Lugar | Publicado | |
{items.map((e) => (
| {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();