Práctica: Sistema de Lista de Tareas con PHP, MariaDB y JavaScript Vanilla
Asignatura: Base de Datos
Tecnologías: PHP · MariaDB · HTML · CSS · JavaScript (Vanilla)
Tipo de API: REST con sesiones PHP
1. Objetivo
Desarrollar una aplicación web de lista de tareas multi-usuario con autenticación propia, que permita a cada usuario gestionar sus tareas de forma personalizada y segura. La aplicación expondrá una API REST en PHP que consuma el frontend en JavaScript sin ningún framework adicional.
Al finalizar la práctica el alumno será capaz de:
- Diseñar y crear una base de datos relacional normalizada en 3FN.
- Implementar una API REST con PHP puro usando los métodos HTTP
GET,POST,PUTyDELETE. - Conectar el frontend con la API mediante
fetchy manejar respuestas JSON. - Aplicar autenticación con sesiones PHP y hash
bcryptpara contraseñas. - Controlar el acceso por roles (
admin/usuario).
2. Estructura de carpetas
lista_tareas/
│
├── index.html # SPA: pantalla de auth + pantalla principal
│
├── css/
│ └── style.css # Estilos completos de la aplicación
│
├── js/
│ └── app.js # Toda la lógica del frontend (Vanilla JS)
│
└── api/
├── conexion_db.php # Función get_db() – conexión PDO a MariaDB
├── auth.php # Endpoints: login, registro, logout, sesión
├── tareas.php # CRUD de tareas (requiere sesión activa)
└── usuarios.php # CRUD de usuarios (requiere sesión activa)
3. Métodos HTTP utilizados
El protocolo HTTP define un conjunto de métodos (también llamados verbos) que indican la acción que se desea realizar sobre un recurso del servidor. En una API REST cada método tiene una semántica clara:
| Método | Propósito general | ¿Envía cuerpo? |
|---|---|---|
GET |
Leer un recurso. No modifica datos en el servidor. | No |
POST |
Crear un nuevo recurso. Los datos se envían en el cuerpo de la petición. | Sí |
PUT |
Actualizar un recurso existente de forma completa. | Sí |
PATCH |
Actualizar parcialmente un recurso (solo los campos enviados). | Sí |
DELETE |
Eliminar un recurso del servidor. | No |
En este proyecto se utilizan
GET,POST,PUTyDELETE. El servidor responde siempre con JSON y un código de estado HTTP que indica el resultado (200 OK,201 Created,400 Bad Request,401 Unauthorized,403 Forbidden,404 Not Found,405 Method Not Allowed,409 Conflict).
Endpoints del proyecto
| Método | Endpoint | Descripción |
|---|---|---|
GET |
api/auth.php?accion=sesion |
Verifica si hay una sesión activa |
POST |
api/auth.php?accion=register |
Registra un nuevo usuario |
POST |
api/auth.php?accion=login |
Inicia sesión (crea la sesión PHP) |
POST |
api/auth.php?accion=logout |
Cierra la sesión |
GET |
api/tareas.php |
Lista todas las tareas del usuario |
GET |
api/tareas.php?id=N |
Obtiene una tarea específica |
POST |
api/tareas.php |
Crea una nueva tarea |
PUT |
api/tareas.php?id=N |
Actualiza título, cuerpo o estado |
DELETE |
api/tareas.php?id=N |
Elimina una tarea |
GET |
api/usuarios.php |
Lista usuarios (admin: todos; usuario: uno) |
GET |
api/usuarios.php?id=N |
Obtiene un usuario |
POST |
api/usuarios.php |
Crea un usuario (solo admin) |
PUT |
api/usuarios.php?id=N |
Actualiza datos de un usuario |
DELETE |
api/usuarios.php?id=N |
Elimina un usuario |
4. Modelo de base de datos
La base de datos lista_tareas cuenta con dos tablas en 3FN con integridad referencial:
CREATE TABLE usuarios (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL, -- hash bcrypt
rol ENUM('admin','usuario') NOT NULL DEFAULT 'usuario',
creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE tareas (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
usuario_id INT UNSIGNED NOT NULL,
titulo VARCHAR(200) NOT NULL,
cuerpo TEXT NOT NULL,
completada TINYINT(1) NOT NULL DEFAULT 0,
creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
completado_en DATETIME NULL DEFAULT NULL,
CONSTRAINT fk_tareas_usuario
FOREIGN KEY (usuario_id) REFERENCES usuarios(id)
ON UPDATE CASCADE ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Relación: Un usuario tiene muchas tareas (1 : N). Al eliminar un usuario, sus tareas se eliminan en cascada.
5. Desarrollo paso a paso
Paso 1 – Preparar el entorno
- Instala XAMPP (Apache + MariaDB + PHP) o equivalente.
- Crea la carpeta del proyecto dentro de
htdocs/:htdocs/lista_tareas/ - Asegúrate de que el servidor Apache y MariaDB estén corriendo.
Paso 2 – Crear la base de datos
- Abre phpMyAdmin o un cliente MySQL/MariaDB.
- Crea el archivo
db/schema.sqlcon el siguiente contenido y ejecútalo:
db/schema.sql
-- ============================================================
-- Sistema de Lista de Tareas
-- Base de datos: MariaDB
-- Normalización: 3FN | Integridad referencial con FK
-- ============================================================
CREATE DATABASE IF NOT EXISTS lista_tareas
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE lista_tareas;
-- ------------------------------------------------------------
-- Tabla: usuarios
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS usuarios (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
rol ENUM('admin','usuario') NOT NULL DEFAULT 'usuario',
creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ------------------------------------------------------------
-- Tabla: tareas
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS tareas (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
usuario_id INT UNSIGNED NOT NULL,
titulo VARCHAR(200) NOT NULL,
cuerpo TEXT NOT NULL,
completada TINYINT(1) NOT NULL DEFAULT 0,
creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
completado_en DATETIME NULL DEFAULT NULL,
CONSTRAINT fk_tareas_usuario
FOREIGN KEY (usuario_id)
REFERENCES usuarios(id)
ON UPDATE CASCADE
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- Crea el archivo
api/conexion_db.php:
api/conexion_db.php
<?php
// Conexión a MariaDB usando PDO
define('DB_HOST', 'localhost');
define('DB_PORT', '3306');
define('DB_NAME', 'lista_tareas');
define('DB_USER', 'root');
define('DB_PASS', 'tu_contraseña');
function get_db() {
static $pdo = null;
if ($pdo === null) {
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT
. ';dbname=' . DB_NAME . ';charset=utf8mb4';
$opciones = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
$pdo = new PDO($dsn, DB_USER, DB_PASS, $opciones);
}
return $pdo;
}
Buena práctica: Nunca almacenes credenciales en código que subas a un repositorio público. Considera usar variables de entorno o un archivo
.envignorado por.gitignore.
Paso 3 – Endpoint de autenticación (api/auth.php)
Crea api/auth.php. Este archivo maneja cuatro acciones según el parámetro ?accion=:
| Acción | Método | Descripción |
|---|---|---|
register |
POST | Valida datos, hashea la contraseña con bcrypt e inserta el usuario |
login |
POST | Verifica credenciales con password_verify() y guarda la sesión |
logout |
POST | Destruye la sesión con session_destroy() |
sesion |
GET | Devuelve si el usuario está autenticado y sus datos |
api/auth.php
<?php
// Maneja: login, logout, registro y sesión activa
session_start();
require_once __DIR__ . '/conexion_db.php';
header('Content-Type: application/json');
$metodo = $_SERVER['REQUEST_METHOD'];
$accion = $_GET['accion'] ?? '';
// ── Registro ──────────────────────────────────────────────
if ($metodo === 'POST' && $accion === 'register') {
$datos = json_decode(file_get_contents('php://input'), true);
$nombre = trim($datos['nombre'] ?? '');
$email = trim($datos['email'] ?? '');
$password = trim($datos['password'] ?? '');
if ($nombre === '' || $email === '' || $password === '') {
http_response_code(400);
echo json_encode(['error' => 'Todos los campos son requeridos.']);
exit;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
http_response_code(400);
echo json_encode(['error' => 'Email inválido.']);
exit;
}
$pdo = get_db();
$stmt = $pdo->prepare('SELECT id FROM usuarios WHERE email = ?');
$stmt->execute([$email]);
if ($stmt->fetch()) {
http_response_code(409);
echo json_encode(['error' => 'El email ya está registrado.']);
exit;
}
$hash = password_hash($password, PASSWORD_BCRYPT);
$stmt = $pdo->prepare('INSERT INTO usuarios (nombre, email, password) VALUES (?, ?, ?)');
$stmt->execute([$nombre, $email, $hash]);
echo json_encode(['mensaje' => 'Usuario registrado correctamente.']);
exit;
}
// ── Login ─────────────────────────────────────────────────
if ($metodo === 'POST' && $accion === 'login') {
$datos = json_decode(file_get_contents('php://input'), true);
$email = trim($datos['email'] ?? '');
$password = trim($datos['password'] ?? '');
if ($email === '' || $password === '') {
http_response_code(400);
echo json_encode(['error' => 'Email y contraseña son requeridos.']);
exit;
}
$pdo = get_db();
$stmt = $pdo->prepare('SELECT id, nombre, email, password, rol FROM usuarios WHERE email = ?');
$stmt->execute([$email]);
$usuario = $stmt->fetch();
if (!$usuario || !password_verify($password, $usuario['password'])) {
http_response_code(401);
echo json_encode(['error' => 'Credenciales incorrectas.']);
exit;
}
$_SESSION['usuario_id'] = $usuario['id'];
$_SESSION['usuario_nombre'] = $usuario['nombre'];
$_SESSION['usuario_email'] = $usuario['email'];
$_SESSION['usuario_rol'] = $usuario['rol'];
echo json_encode([
'mensaje' => 'Sesión iniciada.',
'usuario' => [
'id' => $usuario['id'],
'nombre' => $usuario['nombre'],
'email' => $usuario['email'],
'rol' => $usuario['rol'],
],
]);
exit;
}
// ── Logout ────────────────────────────────────────────────
if ($metodo === 'POST' && $accion === 'logout') {
session_destroy();
echo json_encode(['mensaje' => 'Sesión cerrada.']);
exit;
}
// ── Sesión activa ─────────────────────────────────────────
if ($metodo === 'GET' && $accion === 'sesion') {
if (isset($_SESSION['usuario_id'])) {
echo json_encode([
'autenticado' => true,
'usuario' => [
'id' => $_SESSION['usuario_id'],
'nombre' => $_SESSION['usuario_nombre'],
'email' => $_SESSION['usuario_email'],
'rol' => $_SESSION['usuario_rol'],
],
]);
} else {
echo json_encode(['autenticado' => false]);
}
exit;
}
http_response_code(404);
echo json_encode(['error' => 'Acción no encontrada.']);
Paso 4 – CRUD de tareas (api/tareas.php)
Crea api/tareas.php. Al inicio del archivo verifica que exista sesión activa; en caso contrario responde 401 Unauthorized.
Seguridad: siempre incluye
AND usuario_id = ?en las consultas de modificación para evitar que un usuario edite tareas ajenas.
api/tareas.php
<?php
// CRUD de tareas (requiere sesión activa)
session_start();
require_once __DIR__ . '/conexion_db.php';
header('Content-Type: application/json');
if (!isset($_SESSION['usuario_id'])) {
http_response_code(401);
echo json_encode(['error' => 'No autenticado.']);
exit;
}
$usuario_id = (int) $_SESSION['usuario_id'];
$metodo = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
$pdo = get_db();
// ── GET /api/tareas.php → listar tareas del usuario
// ── GET /api/tareas.php?id=N → obtener una tarea
if ($metodo === 'GET') {
if ($id) {
$stmt = $pdo->prepare('SELECT * FROM tareas WHERE id = ? AND usuario_id = ?');
$stmt->execute([$id, $usuario_id]);
$tarea = $stmt->fetch();
if (!$tarea) {
http_response_code(404);
echo json_encode(['error' => 'Tarea no encontrada.']);
exit;
}
echo json_encode($tarea);
} else {
$stmt = $pdo->prepare('SELECT * FROM tareas WHERE usuario_id = ? ORDER BY creado_en DESC');
$stmt->execute([$usuario_id]);
echo json_encode($stmt->fetchAll());
}
exit;
}
// ── POST /api/tareas.php → crear tarea
if ($metodo === 'POST') {
$datos = json_decode(file_get_contents('php://input'), true);
$titulo = trim($datos['titulo'] ?? '');
$cuerpo = trim($datos['cuerpo'] ?? '');
$completada = (int)(bool) $datos['completada'];
$completado_en = null;
if ($completada === 1) {
$completado_en = date('Y-m-d H:i:s');
}
if ($titulo === '' || $cuerpo === '') {
http_response_code(400);
echo json_encode(['error' => 'Título y cuerpo son requeridos.']);
exit;
}
$stmt = $pdo->prepare(
'INSERT INTO tareas (usuario_id, titulo, cuerpo, completada, completado_en) VALUES (?, ?, ?, ?, ?)'
);
$stmt->execute([$usuario_id, $titulo, $cuerpo, $completada, $completado_en]);
echo json_encode(['mensaje' => 'Tarea creada.', 'id' => (int) $pdo->lastInsertId()]);
exit;
}
// ── PUT /api/tareas.php?id=N → actualizar tarea
if ($metodo === 'PUT') {
if (!$id) {
http_response_code(400);
echo json_encode(['error' => 'Se requiere el id de la tarea.']);
exit;
}
// Verificar que la tarea pertenece al usuario
$stmt = $pdo->prepare('SELECT id, completada FROM tareas WHERE id = ? AND usuario_id = ?');
$stmt->execute([$id, $usuario_id]);
$tarea = $stmt->fetch();
if (!$tarea) {
http_response_code(404);
echo json_encode(['error' => 'Tarea no encontrada.']);
exit;
}
$datos = json_decode(file_get_contents('php://input'), true);
$titulo = trim($datos['titulo'] ?? '');
$cuerpo = trim($datos['cuerpo'] ?? '');
$completada = isset($datos['completada']) ? (int)(bool) $datos['completada'] : (int) $tarea['completada'];
if ($titulo === '' || $cuerpo === '') {
http_response_code(400);
echo json_encode(['error' => 'Título y cuerpo son requeridos.']);
exit;
}
// Gestionar fecha de completado
$completado_en = null;
if ($completada === 1) {
// Si antes no estaba completada, registrar la fecha ahora
if ((int) $tarea['completada'] === 0) {
$completado_en = date('Y-m-d H:i:s');
} else {
// Ya estaba completada, mantener la fecha original
$stmt2 = $pdo->prepare('SELECT completado_en FROM tareas WHERE id = ?');
$stmt2->execute([$id]);
$completado_en = $stmt2->fetchColumn();
}
}
$stmt = $pdo->prepare(
'UPDATE tareas SET titulo = ?, cuerpo = ?, completada = ?, completado_en = ? WHERE id = ? AND usuario_id = ?'
);
$stmt->execute([$titulo, $cuerpo, $completada, $completado_en, $id, $usuario_id]);
echo json_encode(['mensaje' => 'Tarea actualizada.']);
exit;
}
// ── DELETE /api/tareas.php?id=N → eliminar tarea
if ($metodo === 'DELETE') {
if (!$id) {
http_response_code(400);
echo json_encode(['error' => 'Se requiere el id de la tarea.']);
exit;
}
$stmt = $pdo->prepare('DELETE FROM tareas WHERE id = ? AND usuario_id = ?');
$stmt->execute([$id, $usuario_id]);
if ($stmt->rowCount() === 0) {
http_response_code(404);
echo json_encode(['error' => 'Tarea no encontrada.']);
exit;
}
echo json_encode(['mensaje' => 'Tarea eliminada.']);
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Método no permitido.']);
Paso 5 – CRUD de usuarios (api/usuarios.php)
Crea api/usuarios.php. Aplica control de acceso basado en roles:
| Acción | admin |
usuario (propio) |
|---|---|---|
| Listar todos | ✅ | ❌ (solo el suyo) |
| Ver uno ajeno | ✅ | ❌ (403) |
| Crear usuario | ✅ | ❌ |
| Editar datos | ✅ | ✅ (solo el suyo) |
| Cambiar rol | ✅ | ❌ |
| Eliminar usuario | ✅ | ✅ (solo el suyo) |
api/usuarios.php
<?php
// CRUD de usuarios (requiere sesión activa)
session_start();
require_once __DIR__ . '/conexion_db.php';
header('Content-Type: application/json');
if (!isset($_SESSION['usuario_id'])) {
http_response_code(401);
echo json_encode(['error' => 'No autenticado.']);
exit;
}
$metodo = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
$pdo = get_db();
$mi_id = (int) $_SESSION['usuario_id'];
$es_admin = $_SESSION['usuario_rol'] === 'admin';
// ── GET /api/usuarios.php → listar (admin: todos | usuario: solo el suyo)
// ── GET /api/usuarios.php?id=N → obtener uno
if ($metodo === 'GET') {
if ($id) {
if (!$es_admin && $id !== $mi_id) {
http_response_code(403);
echo json_encode(['error' => 'No tienes permiso para ver este usuario.']);
exit;
}
$stmt = $pdo->prepare('SELECT id, nombre, email, rol, creado_en FROM usuarios WHERE id = ?');
$stmt->execute([$id]);
$usuario = $stmt->fetch();
if (!$usuario) {
http_response_code(404);
echo json_encode(['error' => 'Usuario no encontrado.']);
exit;
}
echo json_encode($usuario);
} else {
if ($es_admin) {
$stmt = $pdo->query('SELECT id, nombre, email, rol, creado_en FROM usuarios ORDER BY creado_en DESC');
echo json_encode($stmt->fetchAll());
} else {
$stmt = $pdo->prepare('SELECT id, nombre, email, rol, creado_en FROM usuarios WHERE id = ?');
$stmt->execute([$mi_id]);
echo json_encode([$stmt->fetch()]);
}
}
exit;
}
// ── POST /api/usuarios.php → crear usuario (solo admin)
if ($metodo === 'POST') {
if (!$es_admin) {
http_response_code(403);
echo json_encode(['error' => 'Solo un administrador puede crear usuarios.']);
exit;
}
$datos = json_decode(file_get_contents('php://input'), true);
$nombre = trim($datos['nombre'] ?? '');
$email = trim($datos['email'] ?? '');
$password = trim($datos['password'] ?? '');
$rol = in_array($datos['rol'] ?? '', ['admin', 'usuario']) ? $datos['rol'] : 'usuario';
if ($nombre === '' || $email === '' || $password === '') {
http_response_code(400);
echo json_encode(['error' => 'Todos los campos son requeridos.']);
exit;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
http_response_code(400);
echo json_encode(['error' => 'Email inválido.']);
exit;
}
$stmt = $pdo->prepare('SELECT id FROM usuarios WHERE email = ?');
$stmt->execute([$email]);
if ($stmt->fetch()) {
http_response_code(409);
echo json_encode(['error' => 'El email ya está registrado.']);
exit;
}
$hash = password_hash($password, PASSWORD_BCRYPT);
$stmt = $pdo->prepare('INSERT INTO usuarios (nombre, email, password, rol) VALUES (?, ?, ?, ?)');
$stmt->execute([$nombre, $email, $hash, $rol]);
echo json_encode(['mensaje' => 'Usuario creado.', 'id' => (int) $pdo->lastInsertId()]);
exit;
}
// ── PUT /api/usuarios.php?id=N → actualizar usuario
if ($metodo === 'PUT') {
if (!$id) {
http_response_code(400);
echo json_encode(['error' => 'Se requiere el id del usuario.']);
exit;
}
if (!$es_admin && $id !== $mi_id) {
http_response_code(403);
echo json_encode(['error' => 'No tienes permiso para editar este usuario.']);
exit;
}
$datos = json_decode(file_get_contents('php://input'), true);
$nombre = trim($datos['nombre'] ?? '');
$email = trim($datos['email'] ?? '');
$password = trim($datos['password'] ?? '');
if ($nombre === '' || $email === '') {
http_response_code(400);
echo json_encode(['error' => 'Nombre y email son requeridos.']);
exit;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
http_response_code(400);
echo json_encode(['error' => 'Email inválido.']);
exit;
}
$stmt = $pdo->prepare('SELECT id FROM usuarios WHERE email = ? AND id != ?');
$stmt->execute([$email, $id]);
if ($stmt->fetch()) {
http_response_code(409);
echo json_encode(['error' => 'El email ya pertenece a otro usuario.']);
exit;
}
// Solo el admin puede cambiar el rol
if ($es_admin && in_array($datos['rol'] ?? '', ['admin', 'usuario'])) {
$rol = $datos['rol'];
if ($password !== '') {
$hash = password_hash($password, PASSWORD_BCRYPT);
$stmt = $pdo->prepare('UPDATE usuarios SET nombre = ?, email = ?, password = ?, rol = ? WHERE id = ?');
$stmt->execute([$nombre, $email, $hash, $rol, $id]);
} else {
$stmt = $pdo->prepare('UPDATE usuarios SET nombre = ?, email = ?, rol = ? WHERE id = ?');
$stmt->execute([$nombre, $email, $rol, $id]);
}
} else {
if ($password !== '') {
$hash = password_hash($password, PASSWORD_BCRYPT);
$stmt = $pdo->prepare('UPDATE usuarios SET nombre = ?, email = ?, password = ? WHERE id = ?');
$stmt->execute([$nombre, $email, $hash, $id]);
} else {
$stmt = $pdo->prepare('UPDATE usuarios SET nombre = ?, email = ? WHERE id = ?');
$stmt->execute([$nombre, $email, $id]);
}
}
echo json_encode(['mensaje' => 'Usuario actualizado.']);
exit;
}
// ── DELETE /api/usuarios.php?id=N → eliminar usuario
if ($metodo === 'DELETE') {
if (!$id) {
http_response_code(400);
echo json_encode(['error' => 'Se requiere el id del usuario.']);
exit;
}
if (!$es_admin && $id !== $mi_id) {
http_response_code(403);
echo json_encode(['error' => 'No tienes permiso para eliminar este usuario.']);
exit;
}
$stmt = $pdo->prepare('DELETE FROM usuarios WHERE id = ?');
$stmt->execute([$id]);
if ($stmt->rowCount() === 0) {
http_response_code(404);
echo json_encode(['error' => 'Usuario no encontrado.']);
exit;
}
if ($mi_id === $id) {
session_destroy();
}
echo json_encode(['mensaje' => 'Usuario eliminado.']);
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Método no permitido.']);
Paso 6 – Interfaz de usuario (index.html y css/style.css)
La SPA (Single Page Application) tiene dos "pantallas" gestionadas con la clase CSS oculto:
- Pantalla de autenticación – formularios de login y registro con tabs.
- Pantalla principal – header con saludo + botones de navegación, sección de tareas y sección de usuarios.
Modales utilizados:
- #modal-tarea – crear / editar tarea.
- #modal-usuario – crear / editar usuario.
- #modal-confirmar – confirmación antes de eliminar.
Paso 7 – Lógica del frontend (js/app.js)
Toda la comunicación con la API se realiza con fetch. La función http() centraliza las opciones comunes y lanza un error si la respuesta no es exitosa. El estado global mantiene en memoria al usuario activo, el filtro seleccionado y la última lista de tareas cargada.
js/app.js
// ════════════════════════════════════════════════
// app.js – Lista de Tareas (Vanilla JS)
// ════════════════════════════════════════════════
const API = {
auth: 'api/auth.php',
tareas: 'api/tareas.php',
usuarios: 'api/usuarios.php',
};
// ── Helpers HTTP ────────────────────────────────────────────
async function http(url, opciones = {}) {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
...opciones,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error del servidor');
return data;
}
const get = (url) => http(url, { method: 'GET' });
const post = (url, body) => http(url, { method: 'POST', body: JSON.stringify(body) });
const put = (url, body) => http(url, { method: 'PUT', body: JSON.stringify(body) });
const del = (url) => http(url, { method: 'DELETE' });
// ── Formato de fecha ────────────────────────────────────────
function formatFecha(str) {
if (!str) return '—';
const d = new Date(str.replace(' ', 'T'));
return d.toLocaleString('es', { day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit' });
}
// ── Estado global ────────────────────────────────────────────
let usuarioActual = null;
let filtroActual = 'todas';
let tareasCache = [];
// ════════════════════════════════════════════════
// AUTH
// ════════════════════════════════════════════════
async function verificarSesion() {
try {
const data = await get(`${API.auth}?accion=sesion`);
if (data.autenticado) {
usuarioActual = data.usuario;
mostrarApp();
} else {
mostrarAuth();
}
} catch {
mostrarAuth();
}
}
function mostrarAuth() {
document.getElementById('pantalla-auth').classList.remove('oculto');
document.getElementById('pantalla-app').classList.add('oculto');
}
function mostrarApp() {
document.getElementById('pantalla-auth').classList.add('oculto');
document.getElementById('pantalla-app').classList.remove('oculto');
document.getElementById('saludo').textContent = `Hola, ${usuarioActual.nombre}`;
document.getElementById('btn-usuarios').classList.toggle('oculto', usuarioActual.rol !== 'admin');
cargarTareas();
}
// ── Tabs login / registro ──────────────────────────────────
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('activo'));
tab.classList.add('activo');
const cual = tab.dataset.tab;
document.getElementById('form-login').classList.toggle('oculto', cual !== 'login');
document.getElementById('form-registro').classList.toggle('oculto', cual !== 'registro');
});
});
// ── Form login ─────────────────────────────────────────────
document.getElementById('form-login').addEventListener('submit', async e => {
e.preventDefault();
const errEl = document.getElementById('error-login');
errEl.classList.add('oculto');
try {
const data = await post(`${API.auth}?accion=login`, {
email: document.getElementById('login-email').value,
password: document.getElementById('login-pass').value,
});
usuarioActual = data.usuario;
mostrarApp();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('oculto');
}
});
// ── Form registro ──────────────────────────────────────────
document.getElementById('form-registro').addEventListener('submit', async e => {
e.preventDefault();
const errEl = document.getElementById('error-registro');
const okEl = document.getElementById('ok-registro');
errEl.classList.add('oculto');
okEl.classList.add('oculto');
try {
await post(`${API.auth}?accion=register`, {
nombre: document.getElementById('reg-nombre').value,
email: document.getElementById('reg-email').value,
password: document.getElementById('reg-pass').value,
});
okEl.textContent = 'Cuenta creada. Ahora puedes iniciar sesión.';
okEl.classList.remove('oculto');
document.getElementById('form-registro').reset();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('oculto');
}
});
// ── Mi perfil ───────────────────────────────────────────────
document.getElementById('btn-mi-perfil').addEventListener('click', () => {
abrirModalUsuario(usuarioActual.id);
});
// ── Logout ─────────────────────────────────────────────────
document.getElementById('btn-logout').addEventListener('click', async () => {
await post(`${API.auth}?accion=logout`, {});
usuarioActual = null;
mostrarAuth();
});
// ════════════════════════════════════════════════
// TAREAS
// ════════════════════════════════════════════════
async function cargarTareas() {
try {
tareasCache = await get(API.tareas);
renderTareas();
} catch (err) {
document.getElementById('lista-tareas').innerHTML =
`<p class="mensaje-error">${err.message}</p>`;
}
}
function renderTareas() {
const lista = document.getElementById('lista-tareas');
let tareas = tareasCache;
if (filtroActual === 'pendientes') tareas = tareas.filter(t => !+t.completada);
if (filtroActual === 'completadas') tareas = tareas.filter(t => +t.completada);
if (tareas.length === 0) {
lista.innerHTML = `
<div class="estado-vacio">
<p>📋</p>
<p>No hay tareas ${filtroActual !== 'todas' ? 'en esta categoría' : 'aún'}.</p>
</div>`;
return;
}
lista.innerHTML = tareas.map(t => `
<div class="tarea-card ${+t.completada ? 'completada' : ''}" data-id="${t.id}">
<input type="checkbox" class="tarea-check" ${+t.completada ? 'checked' : ''}
data-id="${t.id}" title="Marcar completada" />
<div class="tarea-info">
<p class="tarea-titulo">${escHtml(t.titulo)}</p>
<p class="tarea-cuerpo">${escHtml(t.cuerpo)}</p>
<div class="tarea-fechas">
<span class="tarea-fecha">Creada: <span>${formatFecha(t.creado_en)}</span></span>
${+t.completada
? `<span class="tarea-fecha">Completada: <span>${formatFecha(t.completado_en)}</span></span>`
: ''}
</div>
</div>
<div class="tarea-acciones">
<button class="btn btn-secundario btn-editar-tarea" data-id="${t.id}">✏️ Editar</button>
<button class="btn btn-peligro btn-eliminar-tarea" data-id="${t.id}">🗑️ Borrar</button>
</div>
</div>
`).join('');
// Checkboxes de completado rápido
lista.querySelectorAll('.tarea-check').forEach(cb => {
cb.addEventListener('change', () => toggleCompletada(cb.dataset.id, cb.checked));
});
// Botones editar / eliminar
lista.querySelectorAll('.btn-editar-tarea').forEach(btn => {
btn.addEventListener('click', () => abrirModalTarea(btn.dataset.id));
});
lista.querySelectorAll('.btn-eliminar-tarea').forEach(btn => {
btn.addEventListener('click', () => confirmarEliminar('tarea', btn.dataset.id));
});
}
// ── Toggle completada desde checkbox ────────────────────────
async function toggleCompletada(id, completada) {
const tarea = tareasCache.find(t => t.id == id);
if (!tarea) return;
try {
await put(`${API.tareas}?id=${id}`, {
titulo: tarea.titulo,
cuerpo: tarea.cuerpo,
completada: completada,
});
await cargarTareas();
} catch (err) {
alert(err.message);
}
}
// ── Filtros ─────────────────────────────────────────────────
document.querySelectorAll('.filtro').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filtro').forEach(b => b.classList.remove('activo'));
btn.classList.add('activo');
filtroActual = btn.dataset.filtro;
renderTareas();
});
});
// ── Modal tarea: abrir ──────────────────────────────────────
function abrirModalTarea(id = null) {
const modal = document.getElementById('modal-tarea');
const form = document.getElementById('form-tarea');
form.reset();
document.getElementById('error-tarea').classList.add('oculto');
if (id) {
const t = tareasCache.find(x => x.id == id);
document.getElementById('modal-tarea-titulo').textContent = 'Editar tarea';
document.getElementById('tarea-id').value = t.id;
document.getElementById('tarea-titulo').value = t.titulo;
document.getElementById('tarea-cuerpo').value = t.cuerpo;
document.getElementById('tarea-completada').checked = !!+t.completada;
} else {
document.getElementById('modal-tarea-titulo').textContent = 'Nueva tarea';
document.getElementById('tarea-id').value = '';
document.getElementById('tarea-completada').checked = false;
}
modal.classList.remove('oculto');
}
document.getElementById('btn-nueva-tarea').addEventListener('click', () => abrirModalTarea());
// ── Modal tarea: guardar ────────────────────────────────────
document.getElementById('form-tarea').addEventListener('submit', async e => {
e.preventDefault();
const errEl = document.getElementById('error-tarea');
errEl.classList.add('oculto');
const id = document.getElementById('tarea-id').value;
const titulo = document.getElementById('tarea-titulo').value.trim();
const cuerpo = document.getElementById('tarea-cuerpo').value.trim();
const completada = document.getElementById('tarea-completada').checked;
try {
if (id) {
await put(`${API.tareas}?id=${id}`, { titulo, cuerpo, completada });
} else {
await post(API.tareas, { titulo, cuerpo, completada });
}
cerrarModal('modal-tarea');
await cargarTareas();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('oculto');
}
});
// ════════════════════════════════════════════════
// USUARIOS
// ════════════════════════════════════════════════
document.getElementById('btn-usuarios').addEventListener('click', () => {
document.getElementById('seccion-tareas').classList.add('oculto');
document.getElementById('seccion-usuarios').classList.remove('oculto');
cargarUsuarios();
});
document.getElementById('btn-volver-tareas').addEventListener('click', () => {
document.getElementById('seccion-usuarios').classList.add('oculto');
document.getElementById('seccion-tareas').classList.remove('oculto');
});
async function cargarUsuarios() {
const wrap = document.getElementById('tabla-usuarios-wrap');
const esAdmin = usuarioActual.rol === 'admin';
try {
const usuarios = await get(API.usuarios);
if (!usuarios.length) {
wrap.innerHTML = `<div class="estado-vacio"><p>👤</p><p>No hay usuarios registrados.</p></div>`;
return;
}
wrap.innerHTML = `
<table>
<thead>
<tr>
<th>#</th>
<th>Nombre</th>
<th>Email</th>
<th>Rol</th>
<th>Registrado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
${usuarios.map(u => `
<tr>
<td>${u.id}</td>
<td>${escHtml(u.nombre)}</td>
<td>${escHtml(u.email)}</td>
<td><span class="badge-rol badge-${u.rol}">${u.rol === 'admin' ? 'Admin' : 'Usuario'}</span></td>
<td>${formatFecha(u.creado_en)}</td>
<td>
<div class="acciones-td">
<button class="btn btn-secundario btn-editar-usuario" data-id="${u.id}">✏️</button>
${esAdmin || u.id == usuarioActual.id
? `<button class="btn btn-peligro btn-eliminar-usuario" data-id="${u.id}">🗑️</button>`
: ''}
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>`;
wrap.querySelectorAll('.btn-editar-usuario').forEach(btn => {
btn.addEventListener('click', () => abrirModalUsuario(btn.dataset.id));
});
wrap.querySelectorAll('.btn-eliminar-usuario').forEach(btn => {
btn.addEventListener('click', () => confirmarEliminar('usuario', btn.dataset.id));
});
} catch (err) {
wrap.innerHTML = `<p class="mensaje-error">${err.message}</p>`;
}
}
// ── Modal usuario: abrir ────────────────────────────────────
async function abrirModalUsuario(id = null) {
const modal = document.getElementById('modal-usuario');
const form = document.getElementById('form-usuario');
const hint = document.getElementById('pass-hint');
const campoRol = document.getElementById('campo-rol');
const esAdmin = usuarioActual.rol === 'admin';
form.reset();
document.getElementById('error-usuario').classList.add('oculto');
// El select de rol solo lo ve el admin
campoRol.classList.toggle('oculto', !esAdmin);
if (id) {
document.getElementById('modal-usuario-titulo').textContent = 'Editar usuario';
document.getElementById('usuario-id').value = id;
hint.style.display = 'inline';
try {
const u = await get(`${API.usuarios}?id=${id}`);
document.getElementById('usuario-nombre').value = u.nombre;
document.getElementById('usuario-email').value = u.email;
if (esAdmin) document.getElementById('usuario-rol').value = u.rol;
} catch (err) {
alert(err.message);
return;
}
} else {
document.getElementById('modal-usuario-titulo').textContent = 'Nuevo usuario';
document.getElementById('usuario-id').value = '';
hint.style.display = 'none';
}
modal.classList.remove('oculto');
}
document.getElementById('btn-nuevo-usuario').addEventListener('click', () => abrirModalUsuario());
// ── Modal usuario: guardar ──────────────────────────────────
document.getElementById('form-usuario').addEventListener('submit', async e => {
e.preventDefault();
const errEl = document.getElementById('error-usuario');
errEl.classList.add('oculto');
const id = document.getElementById('usuario-id').value;
const nombre = document.getElementById('usuario-nombre').value.trim();
const email = document.getElementById('usuario-email').value.trim();
const password = document.getElementById('usuario-pass').value;
const rol = document.getElementById('usuario-rol')?.value ?? 'usuario';
try {
if (id) {
await put(`${API.usuarios}?id=${id}`, { nombre, email, password, rol });
} else {
await post(API.usuarios, { nombre, email, password, rol });
}
cerrarModal('modal-usuario');
cargarUsuarios();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('oculto');
}
});
// ════════════════════════════════════════════════
// CONFIRMAR ELIMINACIÓN
// ════════════════════════════════════════════════
let _confirmarCallback = null;
function confirmarEliminar(tipo, id) {
const msg = tipo === 'tarea'
? '¿Eliminar esta tarea? Esta acción no se puede deshacer.'
: '¿Eliminar este usuario? También se eliminarán todas sus tareas.';
document.getElementById('confirmar-mensaje').textContent = msg;
document.getElementById('modal-confirmar').classList.remove('oculto');
_confirmarCallback = async () => {
try {
if (tipo === 'tarea') {
await del(`${API.tareas}?id=${id}`);
cerrarModal('modal-confirmar');
await cargarTareas();
} else {
await del(`${API.usuarios}?id=${id}`);
cerrarModal('modal-confirmar');
cargarUsuarios();
}
} catch (err) {
alert(err.message);
}
};
}
document.getElementById('btn-confirmar-si').addEventListener('click', () => {
if (_confirmarCallback) _confirmarCallback();
});
document.getElementById('btn-confirmar-no').addEventListener('click', () => {
cerrarModal('modal-confirmar');
});
// ════════════════════════════════════════════════
// UTILES MODALES
// ════════════════════════════════════════════════
function cerrarModal(id) {
document.getElementById(id).classList.add('oculto');
}
// Botones con data-cierra
document.querySelectorAll('[data-cierra]').forEach(btn => {
btn.addEventListener('click', () => cerrarModal(btn.dataset.cierra));
});
// Cerrar al hacer clic fuera del modal
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', e => {
if (e.target === overlay) cerrarModal(overlay.id);
});
});
// ── Sanitizar HTML ────────────────────────────────────────────
function escHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
// ════════════════════════════════════════════════
// INICIO
// ════════════════════════════════════════════════
verificarSesion();
Paso 8 – Prueba de la aplicación
- Accede a
http://localhost/lista_tareas/en el navegador. - Regístrate con un nuevo usuario.
- Inicia sesión.
- Crea, edita y elimina tareas.
- Asigna rol
adminal primer usuario directamente en la base de datos:sql UPDATE usuarios SET rol = 'admin' WHERE id = 1; - Con el admin, gestiona usuarios desde el panel de "Gestionar usuarios".
6. Actividad adicional – Buscador de tareas
Como actividad extra, implementa un buscador que permita filtrar las tareas por título o por texto del cuerpo. Puedes elegir cualquiera de las dos estrategias siguientes (o ambas):
Opción A – Búsqueda en el cliente
Filtrar la lista de tareas directamente en el navegador sobre el arreglo tareasCache, sin hacer una nueva petición a la API. La búsqueda debe activarse en tiempo real mientras el usuario escribe.
Aspectos a considerar: - ¿Dónde y cómo agregas el campo de búsqueda en el HTML? - ¿Qué estilos le das para que encaje con el diseño existente? - ¿Cómo integras el filtro de búsqueda con los filtros de estado ya existentes (Todas / Pendientes / Completadas)? - ¿Cómo te aseguras de que la búsqueda sea insensible a mayúsculas y minúsculas?
Opción B – Búsqueda en el servidor
Agregar soporte para recibir un parámetro de búsqueda en el endpoint GET api/tareas.php y filtrar los resultados directamente en la consulta SQL usando el operador LIKE.
Aspectos a considerar:
- ¿Qué parámetro de URL utilizarás para enviar el término de búsqueda?
- ¿Cómo modificas la consulta SQL para buscar en titulo y en cuerpo al mismo tiempo?
- ¿Qué pasa si el parámetro de búsqueda viene vacío o no se envía?
- ¿Qué ventajas tiene esta opción frente a la búsqueda en cliente cuando hay miles de tareas?
Resultado esperado
Independientemente de la opción elegida, el buscador debe:
- Mostrar únicamente las tareas cuyo título o cuerpo contengan el texto buscado.
- Funcionar de forma compatible con los filtros de estado existentes.
- Mostrar un mensaje claro cuando no haya resultados para el término buscado.
- Comportarse correctamente con búsquedas vacías (mostrar todas las tareas).
7. Entregables
- [ ] Archivo de respaldo completo de la base de datos.
- [ ] Carpeta
api/con los cuatro archivos PHP. - [ ] Archivo
index.htmlcon la estructura de la SPA. - [ ] Archivo
js/app.jscon toda la lógica del frontend. - [ ] Archivo
css/style.csscon los estilos. - [ ] Actividad adicional: buscador funcionando (cliente o servidor).
- [ ] Capturas de pantalla mostrando: login, lista de tareas, crear tarea, buscador activo.