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>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
7cb6107cb4
commit
e98bfa0afb
@@ -49,6 +49,9 @@ tailwind.config = {
|
||||
<a href="add.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-[#005050]/10 text-primary font-bold text-sm">
|
||||
<span class="material-symbols-outlined text-[20px]" style="font-variation-settings:'FILL' 1;">add_circle</span>Nuevo movimiento
|
||||
</a>
|
||||
<a href="categorias.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||
<span class="material-symbols-outlined text-[20px]">category</span>Categorías
|
||||
</a>
|
||||
<div class="mt-auto">
|
||||
<button onclick="fetch('api/login.php?action=logout').then(()=>location.href='login.html')"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors w-full">
|
||||
|
||||
@@ -3,6 +3,7 @@ require_once __DIR__ . '/config.php';
|
||||
|
||||
ini_set('session.cookie_httponly', '1');
|
||||
ini_set('session.cookie_samesite', 'Lax');
|
||||
session_save_path(__DIR__ . '/../db/sessions');
|
||||
session_start();
|
||||
|
||||
if (!isset($_SESSION['qw_auth'])) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
require_once 'auth.php';
|
||||
header('Content-Type: application/json');
|
||||
require_once 'db.php';
|
||||
|
||||
$pdo = getDB();
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
if ($method === 'GET') {
|
||||
$stmt = $pdo->query("
|
||||
SELECT c.*, COUNT(m.id) as total_movimientos
|
||||
FROM categorias c
|
||||
LEFT JOIN movimientos m ON m.id_categoria = c.id
|
||||
GROUP BY c.id ORDER BY c.nombre
|
||||
");
|
||||
echo json_encode(['status' => 'success', 'categorias' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
|
||||
|
||||
} elseif ($method === 'POST') {
|
||||
$d = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($d['nombre']) || empty($d['icono'])) {
|
||||
http_response_code(400);
|
||||
die(json_encode(['status' => 'error', 'message' => 'Nombre e ícono son obligatorios']));
|
||||
}
|
||||
$stmt = $pdo->prepare("INSERT INTO categorias (nombre, icono, color_hex) VALUES (?, ?, ?)");
|
||||
$stmt->execute([trim($d['nombre']), trim($d['icono']), $d['color_hex'] ?? '#005050']);
|
||||
echo json_encode(['status' => 'success', 'id' => $pdo->lastInsertId()]);
|
||||
|
||||
} elseif ($method === 'PUT') {
|
||||
$d = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($d['id']) || empty($d['nombre']) || empty($d['icono'])) {
|
||||
http_response_code(400);
|
||||
die(json_encode(['status' => 'error', 'message' => 'id, nombre e ícono son obligatorios']));
|
||||
}
|
||||
$stmt = $pdo->prepare("UPDATE categorias SET nombre=?, icono=?, color_hex=? WHERE id=?");
|
||||
$stmt->execute([trim($d['nombre']), trim($d['icono']), $d['color_hex'] ?? '#005050', (int)$d['id']]);
|
||||
echo json_encode(['status' => 'success']);
|
||||
|
||||
} elseif ($method === 'DELETE') {
|
||||
$d = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($d['id'])) {
|
||||
http_response_code(400);
|
||||
die(json_encode(['status' => 'error', 'message' => 'Falta el ID']));
|
||||
}
|
||||
$count = $pdo->prepare("SELECT COUNT(*) FROM movimientos WHERE id_categoria = ?");
|
||||
$count->execute([(int)$d['id']]);
|
||||
if ($count->fetchColumn() > 0) {
|
||||
http_response_code(409);
|
||||
die(json_encode(['status' => 'error', 'message' => 'No se puede eliminar: tiene movimientos asociados']));
|
||||
}
|
||||
$pdo->prepare("DELETE FROM categorias WHERE id = ?")->execute([(int)$d['id']]);
|
||||
echo json_encode(['status' => 'success']);
|
||||
|
||||
} else {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Método no permitido']);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ require_once __DIR__ . '/config.php';
|
||||
|
||||
ini_set('session.cookie_httponly', '1');
|
||||
ini_set('session.cookie_samesite', 'Lax');
|
||||
session_save_path(__DIR__ . '/../db/sessions');
|
||||
session_start();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Categorías — Quiet Wealth</title>
|
||||
<script src="js/tailwind.cdn.js"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"background":"#f8faf9","primary-container":"#006a6a","surface-container-lowest":"#ffffff",
|
||||
"on-background":"#191c1c","surface-container-high":"#e6e9e8","surface-container-highest":"#e1e3e2",
|
||||
"surface-container-low":"#f2f4f3","on-primary":"#ffffff","on-surface":"#191c1c",
|
||||
"on-surface-variant":"#3e4948","outline-variant":"#bec9c8","on-error":"#ffffff",
|
||||
"surface-variant":"#e1e3e2","outline":"#6e7979","surface-container":"#eceeed",
|
||||
"error":"#ba1a1a","secondary":"#4d6357","surface":"#f8faf9",
|
||||
"primary":"#005050","on-secondary":"#ffffff"
|
||||
},
|
||||
fontFamily: { headline:["Manrope"], body:["Manrope"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { font-family:'Manrope',sans-serif; }
|
||||
.material-symbols-outlined { font-variation-settings:'FILL' 0,'wght' 400,'GRAD' 0,'opsz' 24; }
|
||||
#drawer { transition: transform 0.3s ease; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-background min-h-screen">
|
||||
|
||||
<!-- ═══════════ SIDEBAR ═══════════ -->
|
||||
<aside class="hidden md:flex flex-col fixed left-0 top-0 h-screen w-56 bg-surface-container-lowest border-r border-outline-variant z-40 py-7 px-4 gap-1">
|
||||
<div class="flex items-center gap-2 px-2 mb-7">
|
||||
<span class="material-symbols-outlined text-[#006a6a] text-2xl" style="font-variation-settings:'FILL' 1;">account_balance_wallet</span>
|
||||
<span class="font-extrabold text-[#006a6a] tracking-tight text-lg">Quiet Wealth</span>
|
||||
</div>
|
||||
<a href="index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||
<span class="material-symbols-outlined text-[20px]">home</span>Dashboard
|
||||
</a>
|
||||
<a href="historial.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||
<span class="material-symbols-outlined text-[20px]">history</span>Historial
|
||||
</a>
|
||||
<a href="add.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||
<span class="material-symbols-outlined text-[20px]">add_circle</span>Nuevo movimiento
|
||||
</a>
|
||||
<a href="categorias.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-[#005050]/10 text-primary font-bold text-sm">
|
||||
<span class="material-symbols-outlined text-[20px]" style="font-variation-settings:'FILL' 1;">category</span>Categorías
|
||||
</a>
|
||||
<div class="mt-auto">
|
||||
<button onclick="fetch('api/login.php?action=logout').then(()=>location.href='login.html')"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors w-full">
|
||||
<span class="material-symbols-outlined text-[20px]">logout</span>Salir
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ═══════════ CONTENIDO ═══════════ -->
|
||||
<div class="md:ml-56 min-h-screen flex flex-col">
|
||||
|
||||
<header class="bg-background sticky top-0 z-30 border-b border-outline-variant/30 px-6 md:px-8 py-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="window.location.href='index.html'" class="md:hidden p-2 -ml-2 hover:bg-surface-container rounded-full transition-colors">
|
||||
<span class="material-symbols-outlined text-primary">arrow_back</span>
|
||||
</button>
|
||||
<h1 class="text-xl font-bold text-on-surface">Categorías</h1>
|
||||
</div>
|
||||
<button onclick="abrirDrawer(null)"
|
||||
class="flex items-center gap-1.5 px-4 py-2 bg-primary text-white rounded-xl font-bold text-sm hover:bg-primary-container transition-colors shadow-sm">
|
||||
<span class="material-symbols-outlined text-[18px]">add</span>Nueva
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 px-6 md:px-8 py-6 pb-8">
|
||||
<div class="max-w-2xl mx-auto md:mx-0">
|
||||
<div id="ui-loading" class="py-16 flex flex-col items-center gap-3 text-on-surface-variant">
|
||||
<span class="material-symbols-outlined text-4xl opacity-40">hourglass_top</span>
|
||||
<p class="text-sm">Cargando categorías…</p>
|
||||
</div>
|
||||
<div id="ui-lista" class="hidden bg-surface-container-lowest rounded-[1.5rem] overflow-hidden divide-y divide-surface-container-low"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ DRAWER (agregar / editar) ═══════════ -->
|
||||
<div id="drawer-overlay" class="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 hidden" onclick="cerrarDrawer()"></div>
|
||||
|
||||
<div id="drawer" class="fixed bottom-0 inset-x-0 md:inset-x-auto md:right-0 md:top-0 md:w-96 bg-surface-container-lowest z-50 rounded-t-[2rem] md:rounded-none md:rounded-l-[2rem] shadow-2xl translate-y-full md:translate-y-0 md:translate-x-full flex flex-col">
|
||||
|
||||
<div class="flex items-center justify-between px-6 py-5 border-b border-outline-variant/30">
|
||||
<h2 id="drawer-title" class="text-lg font-bold text-on-surface">Nueva categoría</h2>
|
||||
<button onclick="cerrarDrawer()" class="p-2 hover:bg-surface-container rounded-full transition-colors">
|
||||
<span class="material-symbols-outlined text-on-surface-variant">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-5 space-y-5">
|
||||
|
||||
<!-- Nombre -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant">Nombre</label>
|
||||
<input id="drawer-nombre" type="text" placeholder="Ej: Salud, Gimnasio…"
|
||||
class="w-full bg-surface-container-low px-4 py-3 rounded-xl text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-primary/30"/>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="flex items-center gap-4 p-4 bg-surface-container-low rounded-xl">
|
||||
<div id="preview-icono" class="w-12 h-12 rounded-xl flex items-center justify-center" style="background:#005050">
|
||||
<span id="preview-icono-sym" class="material-symbols-outlined text-white text-2xl" style="font-variation-settings:'FILL' 1;">category</span>
|
||||
</div>
|
||||
<div>
|
||||
<p id="preview-nombre" class="font-bold text-on-surface text-sm">Vista previa</p>
|
||||
<p class="text-xs text-on-surface-variant">Así se verá en los movimientos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Íconos -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant">Ícono</label>
|
||||
<div id="icon-grid" class="grid grid-cols-6 gap-2">
|
||||
<!-- renderizado por JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colores -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant">Color</label>
|
||||
<div id="color-grid" class="flex flex-wrap gap-2">
|
||||
<!-- renderizado por JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-t border-outline-variant/30 flex gap-3">
|
||||
<button onclick="cerrarDrawer()" class="flex-1 py-3 bg-surface-container text-on-surface rounded-xl font-semibold text-sm">
|
||||
Cancelar
|
||||
</button>
|
||||
<button id="drawer-save" class="flex-1 py-3 bg-primary text-white rounded-xl font-bold text-sm active:scale-95 transition-transform">
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/categorias.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,2 +1,3 @@
|
||||
*.sqlite
|
||||
*.sqlite-*
|
||||
sessions/
|
||||
|
||||
@@ -47,6 +47,9 @@ tailwind.config = {
|
||||
<a href="add.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||
<span class="material-symbols-outlined text-[20px]">add_circle</span>Nuevo movimiento
|
||||
</a>
|
||||
<a href="categorias.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||
<span class="material-symbols-outlined text-[20px]">category</span>Categorías
|
||||
</a>
|
||||
<div class="mt-auto">
|
||||
<button onclick="fetch('api/login.php?action=logout').then(()=>location.href='login.html')"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors w-full">
|
||||
|
||||
@@ -49,6 +49,9 @@ tailwind.config = {
|
||||
<a href="add.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||
<span class="material-symbols-outlined text-[20px]">add_circle</span>Nuevo movimiento
|
||||
</a>
|
||||
<a href="categorias.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||
<span class="material-symbols-outlined text-[20px]">category</span>Categorías
|
||||
</a>
|
||||
<div class="mt-auto">
|
||||
<button onclick="logout()" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors w-full">
|
||||
<span class="material-symbols-outlined text-[20px]">logout</span>Salir
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user