- 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>
212 lines
9.3 KiB
JavaScript
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, '"')})"
|
|
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');
|
|
}
|
|
}
|