diff --git a/public/calendario/api.php b/public/calendario/api.php new file mode 100644 index 0000000..421ba52 --- /dev/null +++ b/public/calendario/api.php @@ -0,0 +1,168 @@ +true, 'data'=>$d, 'user'=>Auth::user()], JSON_UNESCAPED_UNICODE); exit; } +function err(string $m, int $c = 400): void { http_response_code($c); echo json_encode(['ok'=>false, 'error'=>$m], JSON_UNESCAPED_UNICODE); exit; } +function body(): array { return json_decode((string) file_get_contents('php://input'), true) ?? []; } + +function clean_date(?string $v): ?string { + if (!$v) return null; + return preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) ? $v : null; +} +function clean_time(?string $v): ?string { + if (!$v) return null; + return preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $v) ? substr($v, 0, 5) : null; +} + +$method = $_SERVER['REQUEST_METHOD']; +$action = $_GET['action'] ?? ''; +$user = Auth::user(); +$pdo = Db::pdo(); + +try { + if ($method === 'GET' && $action === 'list') { + $from = clean_date($_GET['from'] ?? null); + $to = clean_date($_GET['to'] ?? null); + $where = ['1=1']; + $params = []; + if ($from) { $where[] = 'fecha >= :from'; $params[':from'] = $from; } + if ($to) { $where[] = 'fecha <= :to'; $params[':to'] = $to; } + $sql = 'SELECT * FROM calendar_events WHERE ' . implode(' AND ', $where) . ' ORDER BY fecha ASC, hora_inicio ASC'; + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $rows = $stmt->fetchAll(); + foreach ($rows as &$r) { $r['id'] = (int)$r['id']; } + ok($rows); + } + + if ($method === 'POST' && $action === 'create') { + $b = body(); + $titulo = trim((string)($b['titulo'] ?? '')); + $descripcion = trim((string)($b['descripcion'] ?? '')); + $tipo = in_array($b['tipo'] ?? '', TIPOS, true) ? $b['tipo'] : 'evento'; + $fecha = clean_date($b['fecha'] ?? null); + $horaInicio = clean_time($b['hora_inicio'] ?? null); + $horaFin = clean_time($b['hora_fin'] ?? null); + $ubicacion = trim((string)($b['ubicacion'] ?? '')); + + if ($titulo === '') err('Título requerido'); + if (!$fecha) err('Fecha requerida'); + if ($horaFin && !$horaInicio) err('Si hay hora fin debe haber hora inicio'); + if ($horaInicio && $horaFin && strcmp($horaFin, $horaInicio) < 0) { + err('Hora fin debe ser >= hora inicio'); + } + + $stmt = $pdo->prepare( + 'INSERT INTO calendar_events + (titulo, descripcion, tipo, fecha, hora_inicio, hora_fin, ubicacion, creado_por, creado_por_nombre) + VALUES + (:titulo, :desc, :tipo, :fecha, :hi, :hf, :ub, :cp, :cpn)' + ); + $stmt->execute([ + ':titulo' => $titulo, + ':desc' => $descripcion ?: null, + ':tipo' => $tipo, + ':fecha' => $fecha, + ':hi' => $horaInicio, + ':hf' => $horaFin, + ':ub' => $ubicacion ?: null, + ':cp' => $user['email'], + ':cpn' => $user['name'] ?: $user['email'], + ]); + $id = (int)$pdo->lastInsertId(); + + AuditLog::log('calendario', 'create', 'evento', (string)$id, $titulo, ['tipo'=>$tipo, 'fecha'=>$fecha]); + + $sel = $pdo->prepare('SELECT * FROM calendar_events WHERE id = :id'); + $sel->execute([':id' => $id]); + $row = $sel->fetch(); + $row['id'] = (int)$row['id']; + ok($row); + } + + if ($method === 'PUT' && $action === 'update') { + $b = body(); + $id = (int)($b['_id'] ?? 0); + if (!$id) err('id requerido'); + + $sel = $pdo->prepare('SELECT * FROM calendar_events WHERE id = :id'); + $sel->execute([':id' => $id]); + $before = $sel->fetch(); + if (!$before) err('Evento no encontrado', 404); + + if (!Auth::isSocio() && $before['creado_por'] !== $user['email']) { + err('Solo el creador (o un Socio/Admin) puede editar este evento', 403); + } + + $titulo = trim((string)($b['titulo'] ?? $before['titulo'])); + $descripcion = isset($b['descripcion']) ? trim((string)$b['descripcion']) : ($before['descripcion'] ?? ''); + $tipo = in_array($b['tipo'] ?? $before['tipo'], TIPOS, true) ? ($b['tipo'] ?? $before['tipo']) : $before['tipo']; + $fecha = clean_date($b['fecha'] ?? null) ?: $before['fecha']; + $horaInicio = array_key_exists('hora_inicio', $b) ? clean_time($b['hora_inicio']) : $before['hora_inicio']; + $horaFin = array_key_exists('hora_fin', $b) ? clean_time($b['hora_fin']) : $before['hora_fin']; + $ubicacion = isset($b['ubicacion']) ? trim((string)$b['ubicacion']) : ($before['ubicacion'] ?? ''); + + if ($titulo === '') err('Título requerido'); + if ($horaFin && !$horaInicio) err('Si hay hora fin debe haber hora inicio'); + if ($horaInicio && $horaFin && strcmp($horaFin, $horaInicio) < 0) { + err('Hora fin debe ser >= hora inicio'); + } + + $stmt = $pdo->prepare( + 'UPDATE calendar_events SET + titulo = :titulo, descripcion = :desc, tipo = :tipo, fecha = :fecha, + hora_inicio = :hi, hora_fin = :hf, ubicacion = :ub + WHERE id = :id' + ); + $stmt->execute([ + ':titulo' => $titulo, ':desc' => $descripcion ?: null, ':tipo' => $tipo, + ':fecha' => $fecha, ':hi' => $horaInicio, ':hf' => $horaFin, + ':ub' => $ubicacion ?: null, ':id' => $id, + ]); + + AuditLog::log('calendario', 'update', 'evento', (string)$id, $titulo); + + $sel->execute([':id' => $id]); + $row = $sel->fetch(); + $row['id'] = (int)$row['id']; + ok($row); + } + + if ($method === 'DELETE' && $action === 'delete') { + $id = (int)($_GET['id'] ?? 0); + if (!$id) err('id requerido'); + + $sel = $pdo->prepare('SELECT id, titulo, creado_por FROM calendar_events WHERE id = :id'); + $sel->execute([':id' => $id]); + $before = $sel->fetch(); + if (!$before) err('Evento no encontrado', 404); + + if (!Auth::isSocio() && $before['creado_por'] !== $user['email']) { + err('Solo el creador (o un Socio/Admin) puede eliminar este evento', 403); + } + + $pdo->prepare('DELETE FROM calendar_events WHERE id = :id')->execute([':id' => $id]); + AuditLog::log('calendario', 'delete', 'evento', (string)$id, $before['titulo']); + ok(['deleted' => $id]); + } + + err('Acción no reconocida', 404); + +} catch (PDOException $e) { + error_log('calendario db error: ' . $e->getMessage()); + err('Error de base de datos', 500); +} catch (Throwable $e) { + error_log('calendario error: ' . $e->getMessage()); + err('Error interno', 500); +} diff --git a/sql/011_calendar_events.sql b/sql/011_calendar_events.sql new file mode 100644 index 0000000..c4a9150 --- /dev/null +++ b/sql/011_calendar_events.sql @@ -0,0 +1,25 @@ +-- ============================================================ +-- Donna — Schema 011: calendar_events (entradas manuales del calendario) +-- ============================================================ +-- Reservas de sala, cumpleaños, recordatorios y eventos sueltos que +-- no son trámites ni escrituras. + +CREATE TABLE IF NOT EXISTS calendar_events ( + id INT AUTO_INCREMENT PRIMARY KEY, + titulo VARCHAR(255) NOT NULL DEFAULT '', + descripcion TEXT NULL, + tipo ENUM('reserva_sala','cumpleanos','recordatorio','evento') + NOT NULL DEFAULT 'evento', + fecha DATE NOT NULL, + hora_inicio TIME NULL, + hora_fin TIME NULL, + ubicacion VARCHAR(255) NULL, + creado_por VARCHAR(255) NOT NULL DEFAULT '', + creado_por_nombre VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_fecha (fecha), + INDEX idx_creado_por (creado_por), + INDEX idx_tipo (tipo) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/views/calendario.html b/views/calendario.html index 4478947..2240d97 100644 --- a/views/calendario.html +++ b/views/calendario.html @@ -90,16 +90,47 @@ header { background:var(--navy); border-bottom:3px solid var(--gold); padding:0 .chip.on { background:#fff; border-color:currentColor; } .chip.off { background:var(--gray-100); color:var(--gray-400) !important; opacity:.65; } .chip.off .cdot { background:var(--gray-400) !important; } -.chip[data-src="esc"] { color:var(--c-esc); } -.chip[data-src="adm"] { color:var(--c-adm); } -.chip[data-src="corp"] { color:var(--c-corp); } -.chip[data-src="mig"] { color:var(--c-mig); } -.chip[data-src="lit"] { color:var(--c-lit); } -.chip[data-src="esc"] .cdot { background:var(--c-esc); } -.chip[data-src="adm"] .cdot { background:var(--c-adm); } -.chip[data-src="corp"] .cdot { background:var(--c-corp); } -.chip[data-src="mig"] .cdot { background:var(--c-mig); } -.chip[data-src="lit"] .cdot { background:var(--c-lit); } +.chip[data-src="esc"] { color:var(--c-esc); } +.chip[data-src="adm"] { color:var(--c-adm); } +.chip[data-src="corp"] { color:var(--c-corp); } +.chip[data-src="mig"] { color:var(--c-mig); } +.chip[data-src="lit"] { color:var(--c-lit); } +.chip[data-src="manual"] { color:#27ae60; } +.chip[data-src="esc"] .cdot { background:var(--c-esc); } +.chip[data-src="adm"] .cdot { background:var(--c-adm); } +.chip[data-src="corp"] .cdot { background:var(--c-corp); } +.chip[data-src="mig"] .cdot { background:var(--c-mig); } +.chip[data-src="lit"] .cdot { background:var(--c-lit); } +.chip[data-src="manual"] .cdot { background:#27ae60; } + +/* Botón Nuevo evento manual */ +.btn-new-event { padding:7px 12px; background:#27ae60; color:#fff; border:none; border-radius:8px; + font-size:12.5px; font-weight:500; cursor:pointer; display:inline-flex; align-items:center; gap:5px; + transition:background .15s; } +.btn-new-event:hover { background:#1d8049; } +.btn-new-event svg { width:14px; height:14px; } + +/* Modal de evento manual */ +.evt-form .fg { margin-bottom:14px; } +.evt-form .fg label { display:block; font-size:10.5px; font-weight:600; color:var(--navy); + margin-bottom:5px; text-transform:uppercase; letter-spacing:.08em; } +.evt-form .fg input, .evt-form .fg select, .evt-form .fg textarea { + width:100%; padding:8px 11px; border:1px solid var(--gray-200); border-radius:8px; + font-family:'DM Sans',sans-serif; font-size:13px; background:#fff; +} +.evt-form .fg textarea { resize:vertical; min-height:60px; } +.evt-form .fg input:focus, .evt-form .fg select:focus, .evt-form .fg textarea:focus { + outline:none; border-color:var(--navy-l); box-shadow:0 0 0 3px rgba(27,58,107,.10); +} +.evt-form .fg-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; } +.evt-form .fg-check { display:flex; align-items:center; gap:8px; font-size:13px; color:var(--gray-700); cursor:pointer; } +.evt-form .fg-check input { width:auto; margin:0; } +.btn-cancel { padding:8px 16px; background:transparent; color:var(--gray-700); border:1px solid var(--gray-200); border-radius:8px; font-size:13px; } +.btn-cancel:hover { background:var(--gray-100); } +.btn-save { padding:8px 20px; background:var(--navy); color:#fff; border:none; border-radius:8px; font-size:13px; font-weight:500; } +.btn-save:hover { background:var(--navy-d); } +.btn-delete-evt { padding:8px 14px; background:#fff; border:1px solid #e74c3c; color:#c0392b; border-radius:8px; font-size:13px; } +.btn-delete-evt:hover { background:#c0392b; color:#fff; } .cal-kpis { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; padding:14px 24px 0; } .cal-kpi { background:#fff; border:1px solid var(--gray-100); border-radius:10px; padding:12px 16px; @@ -311,7 +342,12 @@ footer.legend .pill { display:inline-block; padding:2px 8px; border-radius:20px; Corporativo Migración Litigios + Eventos +
@@ -346,19 +382,82 @@ footer.legend .pill { display:inline-block; padding:2px 8px; border-radius:20px; -
+ + +