feat(calendario): eventos manuales — reservas, cumpleaños, recordatorios

- Nueva tabla calendar_events (tipo, fecha, hora_inicio/fin, ubicación,
  descripción, creador) — migración 011
- API CRUD /calendario/api.php (list/create/update/delete) con AuditLog
- Permisos: cualquiera autenticado crea; solo creador o Socio/Admin
  edita y elimina
- UI calendario: chip "Eventos", botón "+ Nuevo evento" en toolbar y
  en vista Día, modal con tipo/fecha/horario/ubicación, opción
  "Todo el día"
- Detalle de evento manual muestra creador, horario y ubicación;
  acciones Editar/Eliminar inline (sin navegación) si tiene permiso
- Colores por tipo: reserva sala, cumpleaños, recordatorio, evento
This commit is contained in:
Soporte ZH Consultores
2026-06-04 20:49:23 -05:00
parent cd7595832f
commit d26ab5e855
3 changed files with 520 additions and 28 deletions
+168
View File
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/Auth.php';
require_once __DIR__ . '/../../src/Db.php';
require_once __DIR__ . '/../../src/AuditLog.php';
Auth::requireAuth();
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
const TIPOS = ['reserva_sala','cumpleanos','recordatorio','evento'];
function ok($d = null): void { echo json_encode(['ok'=>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);
}
+25
View File
@@ -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;
+327 -28
View File
@@ -90,16 +90,47 @@ header { background:var(--navy); border-bottom:3px solid var(--gold); padding:0
.chip.on { background:#fff; border-color:currentColor; } .chip.on { background:#fff; border-color:currentColor; }
.chip.off { background:var(--gray-100); color:var(--gray-400) !important; opacity:.65; } .chip.off { background:var(--gray-100); color:var(--gray-400) !important; opacity:.65; }
.chip.off .cdot { background:var(--gray-400) !important; } .chip.off .cdot { background:var(--gray-400) !important; }
.chip[data-src="esc"] { color:var(--c-esc); } .chip[data-src="esc"] { color:var(--c-esc); }
.chip[data-src="adm"] { color:var(--c-adm); } .chip[data-src="adm"] { color:var(--c-adm); }
.chip[data-src="corp"] { color:var(--c-corp); } .chip[data-src="corp"] { color:var(--c-corp); }
.chip[data-src="mig"] { color:var(--c-mig); } .chip[data-src="mig"] { color:var(--c-mig); }
.chip[data-src="lit"] { color:var(--c-lit); } .chip[data-src="lit"] { color:var(--c-lit); }
.chip[data-src="esc"] .cdot { background:var(--c-esc); } .chip[data-src="manual"] { color:#27ae60; }
.chip[data-src="adm"] .cdot { background:var(--c-adm); } .chip[data-src="esc"] .cdot { background:var(--c-esc); }
.chip[data-src="corp"] .cdot { background:var(--c-corp); } .chip[data-src="adm"] .cdot { background:var(--c-adm); }
.chip[data-src="mig"] .cdot { background:var(--c-mig); } .chip[data-src="corp"] .cdot { background:var(--c-corp); }
.chip[data-src="lit"] .cdot { background:var(--c-lit); } .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-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; .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;
<span class="chip on" data-src="corp"><span class="cdot"></span>Corporativo</span> <span class="chip on" data-src="corp"><span class="cdot"></span>Corporativo</span>
<span class="chip on" data-src="mig"><span class="cdot"></span>Migración</span> <span class="chip on" data-src="mig"><span class="cdot"></span>Migración</span>
<span class="chip on" data-src="lit"><span class="cdot"></span>Litigios</span> <span class="chip on" data-src="lit"><span class="cdot"></span>Litigios</span>
<span class="chip on" data-src="manual"><span class="cdot"></span>Eventos</span>
</div> </div>
<button class="btn-new-event" onclick="openNewEvent()" title="Reservar sala, agregar cumpleaños o recordatorio">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14M5 12h14"/></svg>
Nuevo evento
</button>
</div> </div>
<div class="cal-kpis"> <div class="cal-kpis">
@@ -346,19 +382,82 @@ footer.legend .pill { display:inline-block; padding:2px 8px; border-radius:20px;
<button class="close" onclick="closeEvent()" title="Cerrar (Esc)">✕</button> <button class="close" onclick="closeEvent()" title="Cerrar (Esc)">✕</button>
</header> </header>
<div class="modal-body" id="evt-body"></div> <div class="modal-body" id="evt-body"></div>
<div class="modal-foot"> <div class="modal-foot" id="evt-foot">
<button class="btn-secondary" onclick="closeEvent()">← Regresar al calendario</button> <button class="btn-secondary" onclick="closeEvent()">← Regresar al calendario</button>
<button class="btn-primary" id="btn-edit">Editar evento</button> <button class="btn-primary" id="btn-edit">Editar evento</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Modal: crear/editar evento manual del calendario -->
<div class="modal-overlay" id="ev-form-modal">
<div class="modal">
<header class="modal-head">
<h3 id="ev-form-title">Nuevo evento</h3>
<button class="close" onclick="closeEventForm()" title="Cerrar (Esc)">✕</button>
</header>
<div class="modal-body evt-form">
<div class="fg">
<label>Título *</label>
<input type="text" id="ev-titulo" placeholder="Ej: Reunión con cliente, Cumpleaños de Mario, Sala de conferencias">
</div>
<div class="fg-row">
<div class="fg">
<label>Tipo</label>
<select id="ev-tipo">
<option value="evento">Evento</option>
<option value="reserva_sala">Reserva de sala</option>
<option value="cumpleanos">Cumpleaños</option>
<option value="recordatorio">Recordatorio</option>
</select>
</div>
<div class="fg">
<label>Fecha *</label>
<input type="date" id="ev-fecha">
</div>
</div>
<div class="fg">
<label class="fg-check"><input type="checkbox" id="ev-allday" onchange="toggleAllDay()"> Todo el día</label>
</div>
<div class="fg-row" id="ev-times">
<div class="fg">
<label>Hora inicio</label>
<input type="time" id="ev-hi">
</div>
<div class="fg">
<label>Hora fin (opcional)</label>
<input type="time" id="ev-hf">
</div>
</div>
<div class="fg">
<label>Ubicación</label>
<input type="text" id="ev-ub" placeholder="Sala 1, oficina, etc.">
</div>
<div class="fg">
<label>Descripción</label>
<textarea id="ev-desc" placeholder="Notas adicionales…"></textarea>
</div>
</div>
<div class="modal-foot" style="justify-content:space-between;">
<button class="btn-delete-evt" id="ev-delete-btn" onclick="deleteCurrentEvent()" style="display:none;">Eliminar evento</button>
<div style="display:flex;gap:10px;margin-left:auto;">
<button class="btn-cancel" onclick="closeEventForm()">Cancelar</button>
<button class="btn-save" id="ev-save-btn" onclick="saveEventForm()">Guardar</button>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script> <script>
const DEPT_LABELS = {adm:'Administrativo', corp:'Corporativo', mig:'Migración', lit:'Litigios'}; const DEPT_LABELS = {adm:'Administrativo', corp:'Corporativo', mig:'Migración', lit:'Litigios'};
const SRC_LABELS = {esc:'Escrituras', adm:'Trámites Admin', corp:'Trámites Corp', mig:'Trámites Mig', lit:'Trámites Lit'}; const SRC_LABELS = {esc:'Escrituras', adm:'Trámites Admin', corp:'Trámites Corp', mig:'Trámites Mig', lit:'Trámites Lit', manual:'Evento'};
const SRC_COLORS = { esc:'#1B3A6B', adm:'#6c5b95', corp:'#9E7C2E', mig:'#2980b9', lit:'#722f37' }; const SRC_COLORS = { esc:'#1B3A6B', adm:'#6c5b95', corp:'#9E7C2E', mig:'#2980b9', lit:'#722f37', manual:'#27ae60' };
const MANUAL_TIPO_COLORS = { reserva_sala:'#2e7d8f', cumpleanos:'#c2185b', recordatorio:'#e67e22', evento:'#27ae60' };
const MANUAL_TIPO_LABELS = { reserva_sala:'Reserva de sala', cumpleanos:'Cumpleaños', recordatorio:'Recordatorio', evento:'Evento' };
const MY_EMAIL_CAL = (window.DONNA && window.DONNA.me) || '';
const IS_SOCIO_CAL = !!(window.DONNA && window.DONNA.isSocio);
const COLOR_OVERDUE = '#c0392b'; const COLOR_OVERDUE = '#c0392b';
const COLOR_UPCOMING = '#e67e22'; const COLOR_UPCOMING = '#e67e22';
const MES_ES = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre']; const MES_ES = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre'];
@@ -369,7 +468,8 @@ const CLOSED_STATUS = new Set(['Culminado','Facturado','Inscrito']);
let events = []; let events = [];
let view = 'month'; let view = 'month';
let cursor = stripTime(new Date()); let cursor = stripTime(new Date());
let filters = new Set(['esc','adm','corp','mig','lit']); let filters = new Set(['esc','adm','corp','mig','lit','manual']);
let editingEvId = null; // id del evento manual siendo editado (null = nuevo)
let search = ''; let search = '';
let selected = null; let selected = null;
let authUser = { email:'', name:'' }; let authUser = { email:'', name:'' };
@@ -433,8 +533,15 @@ function setUserChip(){
if($('user-name')) { $('user-name').textContent=name||email||'Conectado'; $('user-name').title=email; } if($('user-name')) { $('user-name').textContent=name||email||'Conectado'; $('user-name').title=email; }
} }
async function apiCall(url){ async function apiCall(url, opts){
const res = await fetch(url, {headers:{Accept:'application/json'}, credentials:'same-origin'}); const method = (opts && opts.method) || 'GET';
const headers = {Accept:'application/json'};
const init = {method, headers, credentials:'same-origin'};
if (opts && opts.body) {
headers['Content-Type'] = 'application/json';
init.body = opts.body;
}
const res = await fetch(url, init);
let j; let j;
try { j = await res.json(); } catch(e){ throw new Error('JSON inválido'); } try { j = await res.json(); } catch(e){ throw new Error('JSON inválido'); }
if(res.status===401 && j.auth_required){ if(res.status===401 && j.auth_required){
@@ -450,7 +557,7 @@ function titleOf(t, dept){
return t.nombre || t.tipo_tramite || t.tipo_proceso || (DEPT_LABELS[dept]||'Trámite'); return t.nombre || t.tipo_tramite || t.tipo_proceso || (DEPT_LABELS[dept]||'Trámite');
} }
function flatten(escrituras, tramites){ function flatten(escrituras, tramites, manualEvents){
const out = []; const out = [];
for(const e of (escrituras||[])){ for(const e of (escrituras||[])){
if(e.fecha_solicitud){ if(e.fecha_solicitud){
@@ -496,6 +603,28 @@ function flatten(escrituras, tramites){
} }
} }
} }
// Eventos manuales (calendar_events)
for(const m of (manualEvents||[])){
const dateStr = ymd(m.fecha);
if (!dateStr) continue;
let timeRange = '';
if (m.hora_inicio) {
timeRange = m.hora_inicio.substring(0,5);
if (m.hora_fin) timeRange += '–' + m.hora_fin.substring(0,5);
}
const title = (timeRange ? timeRange + ' · ' : '') + (m.titulo || 'Sin título');
out.push({
key:'man-'+m.id, source:'manual', type:m.tipo || 'evento',
date: dateStr, title: title,
cliente: m.ubicacion || '',
abogado: m.creado_por_nombre || m.creado_por || '',
estatus: '', raw:m, ref_id:m.id,
nav: null, // manual events use inline edit, no navigation
manual_id: m.id, manual_titulo: m.titulo, manual_tipo: m.tipo,
manual_hi: m.hora_inicio, manual_hf: m.hora_fin,
manual_creado_por: m.creado_por,
});
}
return out return out
.filter(e => e.date) .filter(e => e.date)
.map(e => Object.assign(e, { daysOut: daysToToday(e.date) })) .map(e => Object.assign(e, { daysOut: daysToToday(e.date) }))
@@ -505,12 +634,13 @@ function flatten(escrituras, tramites){
async function loadAll(){ async function loadAll(){
setSync('syncing','Cargando…'); setSync('syncing','Cargando…');
try { try {
const [escData, traData] = await Promise.all([ const [escData, traData, manData] = await Promise.all([
apiCall('/escrituras/api.php?action=list'), apiCall('/escrituras/api.php?action=list'),
apiCall('/tramites/api.php?action=list'), apiCall('/tramites/api.php?action=list'),
apiCall('/calendario/api.php?action=list'),
]); ]);
if(escData===null || traData===null) return; if(escData===null || traData===null || manData===null) return;
events = flatten(escData, traData); events = flatten(escData, traData, manData);
setUserChip(); setUserChip();
setSync('ok', events.length + ' eventos cargados'); setSync('ok', events.length + ' eventos cargados');
render(); render();
@@ -544,6 +674,7 @@ function colorFor(e){
if(e.daysOut<0) return COLOR_OVERDUE; if(e.daysOut<0) return COLOR_OVERDUE;
if(e.daysOut<=7) return COLOR_UPCOMING; if(e.daysOut<=7) return COLOR_UPCOMING;
} }
if (e.source === 'manual') return MANUAL_TIPO_COLORS[e.type] || SRC_COLORS.manual;
return SRC_COLORS[e.source] || '#666'; return SRC_COLORS[e.source] || '#666';
} }
function badgeFor(e){ function badgeFor(e){
@@ -725,8 +856,12 @@ function renderDay(){
const evs = eventsOnDate(cursor); const evs = eventsOnDate(cursor);
const isToday = sameDate(cursor, today); const isToday = sameDate(cursor, today);
const heading = DOW_FULL[(cursor.getDay()+6)%7] + ' ' + cursor.getDate() + ' de ' + MES_ES[cursor.getMonth()]; const heading = DOW_FULL[(cursor.getDay()+6)%7] + ' ' + cursor.getDate() + ' de ' + MES_ES[cursor.getMonth()];
const dayYmd = ymd(cursor);
let html = `<div class="day-view"> let html = `<div class="day-view">
<div class="day-header">${heading}${isToday?' · Hoy':''}</div> <div class="day-header" style="display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;">
<span>${heading}${isToday?' · Hoy':''}</span>
<button class="btn-new-event" onclick='openNewEvent("${dayYmd}")'>+ Nuevo evento</button>
</div>
<div class="day-sub">${evs.length} ${evs.length===1?'evento':'eventos'}</div>`; <div class="day-sub">${evs.length} ${evs.length===1?'evento':'eventos'}</div>`;
if(!evs.length){ if(!evs.length){
html += `<div class="cal-empty">No hay eventos este día.</div>`; html += `<div class="cal-empty">No hay eventos este día.</div>`;
@@ -734,12 +869,17 @@ function renderDay(){
html += evs.map(e=>{ html += evs.map(e=>{
const b = badgeFor(e); const b = badgeFor(e);
const tag = b ? `<span class="evt-tag" style="background:${b.bg};color:#fff">${esc(b.label)}</span>` : ''; const tag = b ? `<span class="evt-tag" style="background:${b.bg};color:#fff">${esc(b.label)}</span>` : '';
const srcTag = `<span class="evt-tag" style="background:${SRC_COLORS[e.source]||'#888'};color:#fff">${esc(SRC_LABELS[e.source]||'')}</span>`; const srcBg = (e.source==='manual') ? (MANUAL_TIPO_COLORS[e.type] || SRC_COLORS.manual) : (SRC_COLORS[e.source]||'#888');
const srcLbl = (e.source==='manual') ? (MANUAL_TIPO_LABELS[e.type] || 'Evento') : (SRC_LABELS[e.source]||'');
const srcTag = `<span class="evt-tag" style="background:${srcBg};color:#fff">${esc(srcLbl)}</span>`;
const meta = (e.source==='manual')
? `${esc(e.abogado||'—')}${e.raw && e.raw.ubicacion ? ' · '+esc(e.raw.ubicacion) : ''}`
: `${esc(e.cliente||'—')} · ${esc(e.abogado||'—')} · ${esc(e.estatus||'')}`;
return `<div class="day-evt" onclick='openEvent("${e.key}")'> return `<div class="day-evt" onclick='openEvent("${e.key}")'>
<div class="day-evt-bar" style="background:${colorFor(e)}"></div> <div class="day-evt-bar" style="background:${colorFor(e)}"></div>
<div> <div>
<div class="day-evt-title">${esc(e.title)}</div> <div class="day-evt-title">${esc(e.title)}</div>
<div class="day-evt-meta">${esc(e.cliente||'—')} · ${esc(e.abogado||'—')} · ${esc(e.estatus||'')}</div> <div class="day-evt-meta">${meta}</div>
</div> </div>
<div class="day-evt-badges">${tag}${srcTag}</div> <div class="day-evt-badges">${tag}${srcTag}</div>
</div>`; </div>`;
@@ -753,23 +893,178 @@ function openEvent(key){
const e = events.find(x => x.key===key); const e = events.find(x => x.key===key);
if(!e){ toast('Evento no encontrado','error'); return; } if(!e){ toast('Evento no encontrado','error'); return; }
selected = e; selected = e;
$('evt-title').textContent = e.title; $('evt-title').textContent = e.manual_titulo || e.title;
$('evt-body').innerHTML = renderEventBody(e); $('evt-body').innerHTML = renderEventBody(e);
$('btn-edit').onclick = ()=> { window.location.href = e.nav; }; const foot = $('evt-foot');
if (e.source === 'manual') {
const canEdit = IS_SOCIO_CAL || (e.manual_creado_por && e.manual_creado_por === MY_EMAIL_CAL);
foot.innerHTML = canEdit
? `<button class="btn-delete-evt" onclick="deleteManualFromDetail()">Eliminar</button>
<div style="display:flex;gap:10px;margin-left:auto;">
<button class="btn-secondary" onclick="closeEvent()">Cerrar</button>
<button class="btn-primary" onclick="editManualFromDetail()">Editar evento</button>
</div>`
: `<button class="btn-secondary" onclick="closeEvent()" style="margin-left:auto;">Cerrar</button>`;
} else {
foot.innerHTML =
`<button class="btn-secondary" onclick="closeEvent()">← Regresar al calendario</button>
<button class="btn-primary" id="btn-edit" onclick="if(selected&&selected.nav)window.location.href=selected.nav;">Editar evento</button>`;
}
$('evt-modal').classList.add('open'); $('evt-modal').classList.add('open');
} }
function editManualFromDetail(){
const e = selected; if (!e) return;
closeEvent();
openEditEvent(e.manual_id);
}
function deleteManualFromDetail(){
const e = selected; if (!e) return;
if (!confirm('¿Eliminar este evento del calendario?')) return;
const id = e.manual_id;
closeEvent();
doDeleteManual(id);
}
function closeEvent(){ function closeEvent(){
selected = null; selected = null;
$('evt-modal').classList.remove('open'); $('evt-modal').classList.remove('open');
} }
// ===== Eventos manuales: crear / editar / eliminar =====
function openNewEvent(presetDate){
editingEvId = null;
$('ev-form-title').textContent = 'Nuevo evento';
$('ev-titulo').value = '';
$('ev-tipo').value = 'evento';
$('ev-fecha').value = presetDate || ymd(new Date());
$('ev-allday').checked = false;
$('ev-hi').value = '';
$('ev-hf').value = '';
$('ev-ub').value = '';
$('ev-desc').value = '';
$('ev-delete-btn').style.display = 'none';
$('ev-save-btn').textContent = 'Crear evento';
toggleAllDay();
$('ev-form-modal').classList.add('open');
setTimeout(()=>$('ev-titulo').focus(), 50);
}
function openEditEvent(id){
const e = events.find(x => x.source==='manual' && x.manual_id===id);
if(!e){ toast('Evento no encontrado','error'); return; }
const r = e.raw || {};
editingEvId = id;
$('ev-form-title').textContent = 'Editar evento';
$('ev-titulo').value = r.titulo || '';
$('ev-tipo').value = r.tipo || 'evento';
$('ev-fecha').value = ymd(r.fecha);
const noHours = !r.hora_inicio && !r.hora_fin;
$('ev-allday').checked = noHours;
$('ev-hi').value = r.hora_inicio ? r.hora_inicio.substring(0,5) : '';
$('ev-hf').value = r.hora_fin ? r.hora_fin.substring(0,5) : '';
$('ev-ub').value = r.ubicacion || '';
$('ev-desc').value = r.descripcion || '';
const canDelete = IS_SOCIO_CAL || (r.creado_por && r.creado_por === MY_EMAIL_CAL);
$('ev-delete-btn').style.display = canDelete ? '' : 'none';
$('ev-save-btn').textContent = 'Guardar cambios';
toggleAllDay();
$('ev-form-modal').classList.add('open');
setTimeout(()=>$('ev-titulo').focus(), 50);
}
function closeEventForm(){
$('ev-form-modal').classList.remove('open');
editingEvId = null;
}
function toggleAllDay(){
const allDay = $('ev-allday').checked;
$('ev-times').style.display = allDay ? 'none' : '';
if (allDay) { $('ev-hi').value = ''; $('ev-hf').value = ''; }
}
async function saveEventForm(){
const titulo = $('ev-titulo').value.trim();
const fecha = $('ev-fecha').value;
if (!titulo){ toast('Título requerido','error'); $('ev-titulo').focus(); return; }
if (!fecha) { toast('Fecha requerida','error'); $('ev-fecha').focus(); return; }
const allDay = $('ev-allday').checked;
const hi = allDay ? '' : $('ev-hi').value;
const hf = allDay ? '' : $('ev-hf').value;
if (hf && !hi){ toast('Si hay hora fin, también debe haber hora inicio','error'); return; }
if (hi && hf && hf < hi){ toast('Hora fin debe ser >= hora inicio','error'); return; }
const payload = {
titulo, fecha,
tipo: $('ev-tipo').value,
hora_inicio: hi || null,
hora_fin: hf || null,
ubicacion: $('ev-ub').value.trim(),
descripcion: $('ev-desc').value.trim(),
};
let url, method;
if (editingEvId){
payload._id = editingEvId;
url = '/calendario/api.php?action=update';
method = 'PUT';
} else {
url = '/calendario/api.php?action=create';
method = 'POST';
}
const btn = $('ev-save-btn'); const prev = btn.textContent;
btn.disabled = true; btn.textContent = 'Guardando…';
try {
const r = await apiCall(url, { method, body: JSON.stringify(payload) });
if (r === null) return;
toast(editingEvId ? 'Evento actualizado' : 'Evento creado');
closeEventForm();
await loadAll();
} catch(e){
toast('Error al guardar: '+e.message,'error');
} finally {
btn.disabled = false; btn.textContent = prev;
}
}
function deleteCurrentEvent(){
if (!editingEvId) return;
if (!confirm('¿Eliminar este evento del calendario?')) return;
const id = editingEvId;
closeEventForm();
doDeleteManual(id);
}
async function doDeleteManual(id){
try {
const r = await apiCall('/calendario/api.php?action=delete&id='+id, { method:'DELETE' });
if (r === null) return;
toast('Evento eliminado');
await loadAll();
} catch(e){
toast('Error al eliminar: '+e.message,'error');
}
}
function renderEventBody(e){ function renderEventBody(e){
const srcColor = SRC_COLORS[e.source]; const srcColor = (e.source==='manual') ? (MANUAL_TIPO_COLORS[e.type] || SRC_COLORS.manual) : SRC_COLORS[e.source];
const srcLabel = SRC_LABELS[e.source]; const srcLabel = (e.source==='manual') ? (MANUAL_TIPO_LABELS[e.type] || 'Evento') : SRC_LABELS[e.source];
const tEvt = ({solicitud:'Fecha de Solicitud', vencimiento:'Fecha de Vencimiento', termino:'Fecha de Término'})[e.type] || e.type; const tEvt = ({solicitud:'Fecha de Solicitud', vencimiento:'Fecha de Vencimiento', termino:'Fecha de Término'})[e.type] || e.type;
const d = parseYMD(e.date); const d = parseYMD(e.date);
const dStr = d ? d.toLocaleDateString('es-PA',{weekday:'long', day:'2-digit', month:'long', year:'numeric'}) : e.date; const dStr = d ? d.toLocaleDateString('es-PA',{weekday:'long', day:'2-digit', month:'long', year:'numeric'}) : e.date;
const r = e.raw || {}; const r = e.raw || {};
if (e.source === 'manual') {
const rows = [];
rows.push(['Tipo', `<span class="src-tag" style="background:${srcColor}">${esc(srcLabel)}</span>`]);
rows.push(['Fecha', `<b style="text-transform:capitalize">${esc(dStr)}</b>`]);
let hr = '';
if (e.manual_hi) {
hr = e.manual_hi.substring(0,5);
if (e.manual_hf) hr += ' – ' + e.manual_hf.substring(0,5);
} else {
hr = '<span style="color:var(--gray-400)">Todo el día</span>';
}
rows.push(['Horario', hr]);
if (r.ubicacion) rows.push(['Ubicación', esc(r.ubicacion)]);
rows.push(['Creado por', esc(e.abogado || '—')]);
if (r.descripcion) rows.push(['Descripción', '<span style="white-space:pre-wrap">'+esc(r.descripcion)+'</span>']);
return rows.map(([k,v])=>`<div class="row"><div class="k">${k}</div><div class="v">${v}</div></div>`).join('');
}
const rows = []; const rows = [];
rows.push(['Origen', `<span class="src-tag" style="background:${srcColor}">${esc(srcLabel)}</span>`]); rows.push(['Origen', `<span class="src-tag" style="background:${srcColor}">${esc(srcLabel)}</span>`]);
if(e.codigo) rows.push(['Código', `<b>${esc(e.codigo)}</b>`]); if(e.codigo) rows.push(['Código', `<b>${esc(e.codigo)}</b>`]);
@@ -839,7 +1134,11 @@ document.addEventListener('keydown', e=>{
} }
return; return;
} }
if(e.key==='Escape'){ closeEvent(); return; } if(e.key==='Escape'){
if ($('ev-form-modal').classList.contains('open')) closeEventForm();
else closeEvent();
return;
}
if(e.key==='ArrowLeft') navPrev(); if(e.key==='ArrowLeft') navPrev();
if(e.key==='ArrowRight') navNext(); if(e.key==='ArrowRight') navNext();
if(e.key==='t'||e.key==='T') navToday(); if(e.key==='t'||e.key==='T') navToday();