Files
rm-PAGOs/pagos/js/categorias.js
T
Ricardo MonlaandClaude Sonnet 4.6 e98bfa0afb Feature: CRUD de categorías + sesiones persistentes
- categorias.html: lista con conteo de movimientos, drawer para agregar/editar
- api/categorias.php: GET/POST/PUT/DELETE (DELETE bloqueado si tiene movimientos)
- categorias.js: grid de 30 íconos Material Symbols + paleta de 12 colores, preview en vivo
- Sidebar actualizado en todas las páginas con link a Categorías
- Sesiones PHP guardadas en pagos/db/sessions/ (bind mount) → sobreviven reinicios Docker
- Fix selector de categorías en add.html: sesiones persistentes resuelven el Cargando…

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:49:27 -03:00

212 lines
9.3 KiB
JavaScript

const ICONOS = [
'restaurant','home','directions_car','bolt','movie','shopping_bag',
'local_grocery_store','medical_services','school','flight','sports_esports',
'coffee','fitness_center','pets','phone_android','work','savings','credit_card',
'local_gas_station','music_note','sports_soccer','family_restroom',
'local_pharmacy','computer','child_care','celebration','beach_access',
'attach_money','bar_chart','payments'
];
const COLORES = [
'#005050','#4d6357','#264b5c','#3e4948','#006a6a','#2d6a4f',
'#c0392b','#e67e22','#8b6914','#2980b9','#6c3483','#555555'
];
let editandoId = null;
let iconoSelecto = ICONOS[0];
let colorSelecto = COLORES[0];
// ─── Inicialización ───────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
renderIconGrid();
renderColorGrid();
document.getElementById('drawer-nombre').addEventListener('input', actualizarPreview);
document.getElementById('drawer-save').addEventListener('click', guardar);
cargarCategorias();
});
// ─── Cargar y renderizar lista ────────────────────────────────────
async function cargarCategorias() {
const r = await apiFetch('api/categorias.php');
if (!r) return;
const data = await r.json();
document.getElementById('ui-loading').classList.add('hidden');
const lista = document.getElementById('ui-lista');
if (!data.categorias.length) {
lista.classList.remove('hidden');
lista.innerHTML = `<div class="py-12 flex flex-col items-center gap-2 text-on-surface-variant">
<span class="material-symbols-outlined text-4xl opacity-30">category</span>
<p class="text-sm">Sin categorías</p></div>`;
return;
}
lista.classList.remove('hidden');
lista.innerHTML = data.categorias.map(c => {
const tieneMovs = parseInt(c.total_movimientos) > 0;
return `
<div class="flex items-center gap-4 px-5 py-3.5">
<div class="w-11 h-11 rounded-xl flex items-center justify-center flex-shrink-0"
style="background:${c.color_hex}">
<span class="material-symbols-outlined text-white text-[20px]"
style="font-variation-settings:'FILL' 1;">${c.icono}</span>
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-on-surface text-sm">${c.nombre}</p>
<p class="text-xs text-on-surface-variant">${c.total_movimientos} movimiento${c.total_movimientos != 1 ? 's' : ''}</p>
</div>
<div class="flex items-center gap-1">
<button onclick="abrirDrawer(${JSON.stringify(c).replace(/"/g, '&quot;')})"
class="p-2 rounded-xl hover:bg-surface-container text-on-surface-variant transition-colors">
<span class="material-symbols-outlined text-[18px]">edit</span>
</button>
<button onclick="eliminar(${c.id}, '${c.nombre.replace("'","\\\'")}')"
class="p-2 rounded-xl transition-colors ${tieneMovs ? 'text-outline cursor-not-allowed opacity-40' : 'hover:bg-[#ffdad6] hover:text-[#ba1a1a] text-on-surface-variant'}"
${tieneMovs ? 'disabled title="Tiene movimientos asociados"' : ''}>
<span class="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
</div>`;
}).join('');
}
// ─── Drawer ───────────────────────────────────────────────────────
function abrirDrawer(cat) {
editandoId = cat ? cat.id : null;
iconoSelecto = cat ? cat.icono : ICONOS[0];
colorSelecto = cat ? cat.color_hex : COLORES[0];
document.getElementById('drawer-title').textContent = cat ? 'Editar categoría' : 'Nueva categoría';
document.getElementById('drawer-nombre').value = cat ? cat.nombre : '';
actualizarSeleccionIcono();
actualizarSeleccionColor();
actualizarPreview();
document.getElementById('drawer-overlay').classList.remove('hidden');
const drawer = document.getElementById('drawer');
drawer.classList.remove('translate-y-full','md:translate-x-full');
setTimeout(() => document.getElementById('drawer-nombre').focus(), 300);
}
function cerrarDrawer() {
document.getElementById('drawer-overlay').classList.add('hidden');
const drawer = document.getElementById('drawer');
drawer.classList.add('translate-y-full','md:translate-x-full');
}
// ─── Íconos ───────────────────────────────────────────────────────
function renderIconGrid() {
document.getElementById('icon-grid').innerHTML = ICONOS.map(ic => `
<button onclick="seleccionarIcono('${ic}')" id="ico-${ic}"
class="h-10 rounded-xl flex items-center justify-center transition-all border-2 border-transparent hover:bg-surface-container">
<span class="material-symbols-outlined text-on-surface-variant text-[20px]"
style="font-variation-settings:'FILL' 1;">${ic}</span>
</button>`).join('');
}
function seleccionarIcono(ic) {
iconoSelecto = ic;
actualizarSeleccionIcono();
actualizarPreview();
}
function actualizarSeleccionIcono() {
document.querySelectorAll('#icon-grid button').forEach(b => {
const ic = b.id.replace('ico-', '');
b.classList.toggle('bg-primary/10', ic === iconoSelecto);
b.classList.toggle('border-primary', ic === iconoSelecto);
b.querySelector('span').style.color = ic === iconoSelecto ? '#005050' : '';
});
}
// ─── Colores ──────────────────────────────────────────────────────
function renderColorGrid() {
document.getElementById('color-grid').innerHTML = COLORES.map(col => `
<button onclick="seleccionarColor('${col}')" id="col-${col.replace('#','')}"
class="w-9 h-9 rounded-xl transition-all ring-2 ring-transparent ring-offset-2"
style="background:${col}"></button>`).join('');
}
function seleccionarColor(col) {
colorSelecto = col;
actualizarSeleccionColor();
actualizarPreview();
}
function actualizarSeleccionColor() {
document.querySelectorAll('#color-grid button').forEach(b => {
const col = '#' + b.id.replace('col-', '');
b.style.outline = col === colorSelecto ? `3px solid ${col}` : 'none';
b.style.outlineOffset = '2px';
});
}
// ─── Preview ──────────────────────────────────────────────────────
function actualizarPreview() {
const nombre = document.getElementById('drawer-nombre').value.trim() || 'Vista previa';
document.getElementById('preview-nombre').textContent = nombre;
document.getElementById('preview-icono').style.background = colorSelecto;
document.getElementById('preview-icono-sym').textContent = iconoSelecto;
}
// ─── Guardar ──────────────────────────────────────────────────────
async function guardar() {
const nombre = document.getElementById('drawer-nombre').value.trim();
if (!nombre) {
rmModal.alert('El nombre de la categoría no puede estar vacío.', 'Campo requerido');
return;
}
const btn = document.getElementById('drawer-save');
btn.disabled = true; btn.textContent = 'Guardando…';
const payload = { nombre, icono: iconoSelecto, color_hex: colorSelecto };
const method = editandoId ? 'PUT' : 'POST';
if (editandoId) payload.id = editandoId;
try {
const r = await apiFetch('api/categorias.php', {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!r) return;
const data = await r.json();
if (data.status === 'success') {
cerrarDrawer();
rmModal.toast(editandoId ? 'Categoría actualizada' : 'Categoría creada');
await cargarCategorias();
} else {
await rmModal.alert(data.message, 'Error');
}
} catch {
await rmModal.alert('Error de conexión', 'Error');
} finally {
btn.disabled = false; btn.textContent = 'Guardar';
}
}
// ─── Eliminar ─────────────────────────────────────────────────────
async function eliminar(id, nombre) {
const ok = await rmModal.confirm(`¿Eliminar la categoría "${nombre}"?`, 'Eliminar categoría');
if (!ok) return;
const r = await apiFetch('api/categorias.php', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
if (!r) return;
const data = await r.json();
if (data.status === 'success') {
rmModal.toast('Categoría eliminada');
await cargarCategorias();
} else {
rmModal.toast(data.message, 'error');
}
}