feat(calendario): app Calendario con 4 vistas + deep-link a apps origen
Cliente-side: el calendario hace fetch a /escrituras/api.php?action=list y /tramites/api.php?action=list y agrega los eventos en memoria. Sin backend nuevo (cero costo de mantenimiento, datos siempre frescos). Eventos generados automáticamente: * Escrituras → fecha_solicitud * Trámites adm/corp/mig/lit → fecha_solicitud * Trámites mig → fecha_vencimiento (resaltado como deadline) * Trámites lit → fecha_termino (resaltado como deadline) Vistas: Año (12 mini-meses con dots por evento + marca roja para vencimientos), Mes (grid con chips ≤3 por día), Semana (7 columnas), Día (lista de cards). Hoy resaltado en todas. Resaltado de vencimientos: * Vencido (días<0, status abierto) → rojo + animación flash * ≤7d → naranja * >7d → color por fuente Funcionalidad adicional: * Strip "Próximos vencimientos" con los 8 deadlines más urgentes * KPIs: total · ≤7d · ≤30d · vencidos abiertos * Filtros por fuente (chips) + búsqueda por texto * Atajos: ←/→ mueve período, T hoy, Y/M/W/D cambia vista, Esc cierra * Auto-refresh cada 60s en segundo plano (pausa si la modal está abierta) * Misma barra superior estandarizada que Escrituras y Trámites Popup ViewOnly al hacer click en un evento muestra ficha completa con todos los campos relevantes según fuente y dept. Dos botones: "Regresar al calendario" cierra · "Editar evento" navega a /escrituras/?edit=ID o /tramites/?edit=CODIGO. Deep-link: escrituras.html y tramites.html detectan ?edit=X al cargar y auto-abren la modal de edición sobre esa fila. URL se limpia con history.replaceState para que un refresh no re-abra la modal. Apps.php: card placeholder "Agenda" reemplazada por "Calendario" live.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/Auth.php';
|
||||
Auth::requireAuth();
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
readfile(__DIR__ . '/../../views/calendario.html');
|
||||
+5
-5
@@ -21,12 +21,12 @@ final class Apps {
|
||||
'status' => 'live',
|
||||
],
|
||||
[
|
||||
'slug' => 'agenda',
|
||||
'title' => 'Agenda',
|
||||
'description' => 'Calendario compartido de audiencias, reuniones y vencimientos.',
|
||||
'slug' => 'calendario',
|
||||
'title' => 'Calendario',
|
||||
'description' => 'Vista visual de eventos de Escrituras y Trámites: solicitudes y vencimientos por año, mes, semana y día.',
|
||||
'icon' => 'calendar',
|
||||
'href' => '#',
|
||||
'status' => 'coming-soon',
|
||||
'href' => '/calendario/',
|
||||
'status' => 'live',
|
||||
],
|
||||
[
|
||||
'slug' => 'clientes',
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Calendario — AALN Donna</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--navy:#1B3A6B; --navy-d:#112446; --navy-l:#2D5090;
|
||||
--gold:#C9A84C; --gold-l:#E8C96A; --gold-d:#9E7C2E;
|
||||
--off:#F7F6F3;
|
||||
--gray-100:#EEEDE9; --gray-200:#D6D4CC; --gray-400:#9B9A96;
|
||||
--gray-700:#4A4946; --gray-900:#1A1916;
|
||||
--red:#c0392b; --orange:#e67e22; --green:#27ae60; --blue:#2980b9;
|
||||
--c-esc:#1B3A6B; --c-adm:#6c5b95; --c-corp:#9E7C2E; --c-mig:#2980b9; --c-lit:#722f37;
|
||||
--shadow:0 1px 3px rgba(0,0,0,.06),0 4px 16px rgba(0,0,0,.06);
|
||||
}
|
||||
*,*::before,*::after { box-sizing:border-box; margin:0; padding:0; }
|
||||
body { font-family:'DM Sans',system-ui,sans-serif; color:var(--gray-900); background:var(--off); font-size:13.5px; }
|
||||
a { color:inherit; text-decoration:none; }
|
||||
button { font-family:inherit; cursor:pointer; }
|
||||
|
||||
header { background:var(--navy); border-bottom:3px solid var(--gold); padding:0 24px;
|
||||
display:flex; align-items:center; justify-content:space-between; height:58px;
|
||||
position:sticky; top:0; z-index:200; }
|
||||
.header-left { display:flex; align-items:center; gap:14px; }
|
||||
.btn-back { display:inline-flex; align-items:center; gap:6px;
|
||||
background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.18);
|
||||
color:rgba(255,255,255,.9); padding:6px 12px 6px 9px; border-radius:8px;
|
||||
font-size:12px; transition:all .15s; white-space:nowrap; }
|
||||
.btn-back:hover { background:var(--gold); border-color:var(--gold); color:var(--navy); }
|
||||
.btn-back svg { width:14px; height:14px; }
|
||||
.logo { display:flex; align-items:center; gap:14px; }
|
||||
.logo-mark { width:32px; height:32px; background:var(--gold); border-radius:6px;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-family:'DM Serif Display',serif; font-size:15px; color:var(--navy); }
|
||||
.logo-text { display:flex; flex-direction:column; line-height:1.2; }
|
||||
.logo-firm { font-size:11px; font-weight:600; color:var(--gold); letter-spacing:.08em; text-transform:uppercase; }
|
||||
.logo-tool { font-size:13px; font-weight:400; color:rgba(255,255,255,.85); }
|
||||
.header-right { display:flex; align-items:center; gap:16px; }
|
||||
.sync-status { display:flex; align-items:center; gap:6px; font-size:11px; color:rgba(255,255,255,.55); }
|
||||
.sync-dot { width:7px; height:7px; border-radius:50%; background:#4CAF50; animation:pulse 2.5s ease-in-out infinite; }
|
||||
.sync-dot.syncing { background:var(--gold); }
|
||||
.sync-dot.error { background:#ef5350; animation:none; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
||||
.user-chip { display:flex; align-items:center; gap:8px; background:rgba(255,255,255,.1);
|
||||
border:1px solid rgba(255,255,255,.15); border-radius:20px; padding:5px 12px 5px 6px; }
|
||||
.user-avatar { width:24px; height:24px; border-radius:50%; background:var(--gold); color:var(--navy);
|
||||
display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:700; }
|
||||
.user-name { font-size:12px; color:rgba(255,255,255,.85); max-width:160px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.btn-logout { font-size:11px; padding:4px 10px; border:1px solid rgba(255,255,255,.2); border-radius:6px;
|
||||
background:transparent; color:rgba(255,255,255,.7); transition:all .15s; }
|
||||
.btn-logout:hover { background:rgba(255,255,255,.1); color:#fff; }
|
||||
|
||||
.cal-toolbar { background:#fff; border-bottom:1px solid var(--gray-100); padding:12px 24px;
|
||||
display:flex; align-items:center; gap:14px; flex-wrap:wrap; box-shadow:var(--shadow); }
|
||||
.view-tabs { display:flex; background:var(--gray-100); border-radius:8px; padding:2px; }
|
||||
.view-tabs button { padding:6px 14px; background:transparent; border:none; border-radius:6px;
|
||||
font-size:12.5px; color:var(--gray-700); transition:all .15s; }
|
||||
.view-tabs button:hover { background:rgba(0,0,0,.04); }
|
||||
.view-tabs button.active { background:var(--navy); color:#fff; }
|
||||
.date-nav { display:flex; align-items:center; gap:6px; }
|
||||
.date-nav button { width:30px; height:30px; border:1px solid var(--gray-200); background:#fff;
|
||||
border-radius:6px; color:var(--gray-700); font-size:18px; transition:all .15s;
|
||||
display:inline-flex; align-items:center; justify-content:center; }
|
||||
.date-nav button:hover { background:var(--off); border-color:var(--navy-l); color:var(--navy); }
|
||||
.date-nav .today-btn { width:auto; padding:0 12px; font-size:12px; font-weight:500; }
|
||||
.period-label { font-family:'DM Serif Display',serif; font-size:18px; color:var(--navy);
|
||||
margin-left:4px; min-width:200px; text-transform:capitalize; }
|
||||
.search-box { flex:1; min-width:200px; max-width:300px; position:relative; }
|
||||
.search-box input { width:100%; padding:7px 10px 7px 30px; border:1px solid var(--gray-200);
|
||||
border-radius:8px; font-family:inherit; font-size:12.5px; background:var(--off); }
|
||||
.search-box input:focus { outline:none; border-color:var(--navy-l); background:#fff;
|
||||
box-shadow:0 0 0 3px rgba(27,58,107,.08); }
|
||||
.search-box svg { position:absolute; left:9px; top:50%; transform:translateY(-50%);
|
||||
width:14px; height:14px; color:var(--gray-400); pointer-events:none; }
|
||||
.src-filters { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.chip { display:inline-flex; align-items:center; gap:5px; padding:4px 10px; border-radius:20px;
|
||||
font-size:11.5px; font-weight:600; cursor:pointer; user-select:none; transition:all .15s;
|
||||
border:1.5px solid transparent; }
|
||||
.chip .cdot { width:8px; height:8px; border-radius:50%; }
|
||||
.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); }
|
||||
|
||||
.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;
|
||||
box-shadow:var(--shadow); border-left:4px solid var(--navy); }
|
||||
.cal-kpi.warn { border-left-color:var(--orange); }
|
||||
.cal-kpi.danger { border-left-color:var(--red); }
|
||||
.cal-kpi-label { font-size:10.5px; font-weight:600; color:var(--gray-400); text-transform:uppercase; letter-spacing:.06em; }
|
||||
.cal-kpi-val { font-family:'DM Serif Display',serif; font-size:26px; color:var(--navy); line-height:1.1; margin-top:2px; }
|
||||
.cal-kpi.warn .cal-kpi-val { color:var(--orange); }
|
||||
.cal-kpi.danger .cal-kpi-val { color:var(--red); }
|
||||
@media (max-width:720px) { .cal-kpis { grid-template-columns:repeat(2,1fr); } }
|
||||
|
||||
.upcoming-strip { padding:14px 24px 0; overflow-x:auto; }
|
||||
.upcoming-strip h4 { font-size:11px; font-weight:600; color:var(--gray-400); letter-spacing:.08em;
|
||||
text-transform:uppercase; margin-bottom:8px; }
|
||||
.upcoming-row { display:flex; gap:10px; min-width:fit-content; padding-bottom:4px; }
|
||||
.up-card { flex:0 0 230px; background:#fff; border:1px solid var(--gray-100);
|
||||
border-left:4px solid var(--orange); border-radius:8px; padding:8px 12px;
|
||||
cursor:pointer; transition:all .15s; box-shadow:var(--shadow); }
|
||||
.up-card.overdue { border-left-color:var(--red); }
|
||||
.up-card:hover { transform:translateY(-1px); box-shadow:0 4px 16px rgba(17,36,70,.12); border-color:var(--navy-l); }
|
||||
.up-card-date { font-family:'DM Serif Display',serif; font-size:13px; color:var(--navy); }
|
||||
.up-card-rem { font-size:10.5px; font-weight:700; color:var(--orange); letter-spacing:.05em; text-transform:uppercase; margin-left:6px; }
|
||||
.up-card.overdue .up-card-rem { color:var(--red); }
|
||||
.up-card-title { font-size:12px; color:var(--gray-700); margin-top:4px; overflow:hidden;
|
||||
text-overflow:ellipsis; white-space:nowrap; }
|
||||
.up-card-meta { font-size:10.5px; color:var(--gray-400); margin-top:2px; overflow:hidden;
|
||||
text-overflow:ellipsis; white-space:nowrap; }
|
||||
.up-empty { padding:8px 14px; font-size:12px; color:var(--gray-400); }
|
||||
|
||||
main { padding:16px 24px 24px; }
|
||||
|
||||
.year-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; }
|
||||
@media (max-width:1100px) { .year-grid { grid-template-columns:repeat(3,1fr); } }
|
||||
@media (max-width:780px) { .year-grid { grid-template-columns:repeat(2,1fr); } }
|
||||
@media (max-width:480px) { .year-grid { grid-template-columns:1fr; } }
|
||||
.year-mini { background:#fff; border:1px solid var(--gray-100); border-radius:10px; padding:12px;
|
||||
cursor:pointer; transition:all .15s; box-shadow:var(--shadow); }
|
||||
.year-mini:hover { transform:translateY(-2px); box-shadow:0 6px 20px rgba(17,36,70,.12); border-color:var(--navy-l); }
|
||||
.year-mini-title { font-family:'DM Serif Display',serif; font-size:15px; color:var(--navy);
|
||||
text-transform:capitalize; margin-bottom:8px; }
|
||||
.year-mini-grid { display:grid; grid-template-columns:repeat(7,1fr); gap:2px; }
|
||||
.year-mini-dow { font-size:9px; color:var(--gray-400); text-align:center; padding:2px 0;
|
||||
font-weight:600; text-transform:uppercase; }
|
||||
.year-mini-day { aspect-ratio:1; display:flex; align-items:center; justify-content:center;
|
||||
font-size:10px; color:var(--gray-700); border-radius:3px; position:relative; }
|
||||
.year-mini-day.dim { color:var(--gray-200); }
|
||||
.year-mini-day.today { background:var(--navy); color:#fff; font-weight:700; }
|
||||
.year-mini-day .dots { position:absolute; bottom:1px; left:50%; transform:translateX(-50%);
|
||||
display:flex; gap:1px; }
|
||||
.year-mini-day .dot { width:3px; height:3px; border-radius:50%; }
|
||||
.year-mini-day.has-deadline::after {
|
||||
content:''; position:absolute; top:1px; right:1px; width:5px; height:5px; border-radius:50%; background:var(--red);
|
||||
}
|
||||
|
||||
.month-grid { background:var(--gray-200); border:1px solid var(--gray-200); border-radius:10px;
|
||||
overflow:hidden; box-shadow:var(--shadow); display:grid;
|
||||
grid-template-columns:repeat(7,1fr); gap:1px; }
|
||||
.month-dow { background:var(--navy); color:rgba(255,255,255,.85); padding:8px;
|
||||
font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.06em; text-align:center; }
|
||||
.month-cell { background:#fff; min-height:118px; padding:6px; cursor:pointer;
|
||||
transition:background .15s; display:flex; flex-direction:column; gap:2px; overflow:hidden; }
|
||||
.month-cell:hover { background:#FAFBFD; }
|
||||
.month-cell.dim { background:#F6F5F0; }
|
||||
.month-cell.dim .cell-num { color:var(--gray-400); }
|
||||
.month-cell.today { background:#FFFBEB; }
|
||||
.month-cell .cell-num { font-size:11.5px; font-weight:600; color:var(--gray-700); margin-bottom:2px; }
|
||||
.month-cell.today .cell-num { display:inline-flex; align-items:center; justify-content:center;
|
||||
background:var(--navy); color:#fff; border-radius:50%;
|
||||
width:22px; height:22px; font-size:11px; align-self:flex-start; }
|
||||
.evt-chip { font-size:10.5px; padding:2px 6px; border-radius:4px; color:#fff; cursor:pointer;
|
||||
overflow:hidden; white-space:nowrap; text-overflow:ellipsis; line-height:1.3;
|
||||
border-left:3px solid rgba(0,0,0,.18); }
|
||||
.evt-chip:hover { filter:brightness(1.1); }
|
||||
.evt-chip.overdue { animation:flash 1.6s ease-in-out infinite; }
|
||||
@keyframes flash { 0%,100%{filter:brightness(1)} 50%{filter:brightness(1.15)} }
|
||||
.evt-more { font-size:10px; color:var(--gray-400); padding-left:2px; }
|
||||
|
||||
.week-grid { background:var(--gray-200); border:1px solid var(--gray-200); border-radius:10px;
|
||||
overflow:hidden; box-shadow:var(--shadow); display:grid;
|
||||
grid-template-columns:repeat(7,1fr); gap:1px; min-height:520px; }
|
||||
.week-col { background:#fff; display:flex; flex-direction:column; }
|
||||
.week-col-head { background:var(--navy); color:#fff; padding:10px 8px; text-align:center; }
|
||||
.week-col-head.today { background:var(--gold-d); }
|
||||
.week-col-day { font-size:10.5px; text-transform:uppercase; letter-spacing:.08em; opacity:.85; }
|
||||
.week-col-num { font-family:'DM Serif Display',serif; font-size:22px; line-height:1.1; margin-top:2px; }
|
||||
.week-col-body { padding:8px; flex:1; display:flex; flex-direction:column; gap:6px; cursor:pointer; }
|
||||
.week-empty { font-size:11px; color:var(--gray-400); padding:6px; text-align:center; }
|
||||
.week-evt { padding:7px 10px; border-radius:6px; color:#fff; cursor:pointer; font-size:11px;
|
||||
border-left:4px solid rgba(0,0,0,.18); transition:transform .1s; }
|
||||
.week-evt:hover { transform:translateY(-1px); }
|
||||
.week-evt .tt { font-size:11px; font-weight:600; line-height:1.25; }
|
||||
.week-evt .meta { font-size:10px; opacity:.85; margin-top:2px; }
|
||||
|
||||
.day-view { max-width:780px; margin:0 auto; }
|
||||
.day-header { font-family:'DM Serif Display',serif; font-size:30px; color:var(--navy);
|
||||
text-transform:capitalize; margin-bottom:6px; }
|
||||
.day-sub { font-size:12px; color:var(--gray-400); margin-bottom:14px; }
|
||||
.day-evt { display:grid; grid-template-columns:14px 1fr auto; gap:14px; background:#fff;
|
||||
padding:14px 16px; margin-bottom:10px; border-radius:10px; box-shadow:var(--shadow);
|
||||
cursor:pointer; transition:transform .15s; }
|
||||
.day-evt:hover { transform:translateX(3px); }
|
||||
.day-evt-bar { background:var(--navy); border-radius:14px; }
|
||||
.day-evt-title { font-family:'DM Serif Display',serif; font-size:17px; color:var(--navy); line-height:1.2; }
|
||||
.day-evt-meta { font-size:12px; color:var(--gray-700); margin-top:4px; }
|
||||
.day-evt-badges { display:flex; flex-direction:column; align-items:flex-end; gap:4px; }
|
||||
.evt-tag { font-size:10px; font-weight:700; padding:2px 8px; border-radius:20px;
|
||||
letter-spacing:.05em; text-transform:uppercase; }
|
||||
|
||||
.cal-empty { text-align:center; padding:40px; color:var(--gray-400); font-size:13px;
|
||||
background:#fff; border-radius:10px; border:1px dashed var(--gray-200); }
|
||||
|
||||
.modal-overlay { display:none; position:fixed; inset:0; background:rgba(17,36,70,.6);
|
||||
z-index:1000; align-items:center; justify-content:center; padding:24px; }
|
||||
.modal-overlay.open { display:flex; }
|
||||
.modal { background:#fff; border-radius:14px; width:100%; max-width:520px; max-height:90vh;
|
||||
overflow:hidden; display:flex; flex-direction:column; box-shadow:0 20px 60px rgba(0,0,0,.3); }
|
||||
.modal-head { background:var(--navy); color:#fff; padding:16px 22px; display:flex;
|
||||
justify-content:space-between; align-items:center; border-bottom:2px solid var(--gold); }
|
||||
.modal-head h3 { font-family:'DM Serif Display',serif; font-size:18px; font-weight:400; margin:0; max-width:84%; }
|
||||
.modal-head .close { background:transparent; border:none; color:rgba(255,255,255,.7);
|
||||
font-size:22px; line-height:1; }
|
||||
.modal-head .close:hover { color:#fff; }
|
||||
.modal-body { padding:18px 22px; overflow-y:auto; flex:1; }
|
||||
.modal-body .row { display:flex; justify-content:space-between; align-items:flex-start; gap:14px;
|
||||
padding:9px 0; border-bottom:1px solid var(--gray-100); }
|
||||
.modal-body .row:last-child { border-bottom:none; }
|
||||
.modal-body .row .k { font-size:10.5px; font-weight:700; color:var(--navy);
|
||||
text-transform:uppercase; letter-spacing:.08em; min-width:130px; flex-shrink:0; }
|
||||
.modal-body .row .v { font-size:13px; color:var(--gray-900); text-align:right; flex:1; word-break:break-word; }
|
||||
.modal-body .src-tag { display:inline-block; padding:3px 10px; border-radius:20px; font-size:10.5px;
|
||||
font-weight:700; letter-spacing:.05em; text-transform:uppercase; color:#fff; }
|
||||
.modal-foot { padding:14px 22px; border-top:1px solid var(--gray-100); background:var(--off);
|
||||
display:flex; justify-content:space-between; gap:10px; }
|
||||
.btn-secondary { padding:8px 16px; background:transparent; color:var(--gray-700);
|
||||
border:1px solid var(--gray-200); border-radius:8px; font-size:13px; }
|
||||
.btn-secondary:hover { background:var(--gray-100); }
|
||||
.btn-primary { padding:8px 20px; background:var(--navy); color:#fff; border:none;
|
||||
border-radius:8px; font-size:13px; font-weight:500; }
|
||||
.btn-primary:hover { background:var(--navy-d); }
|
||||
|
||||
.toast { position:fixed; bottom:24px; right:24px; background:var(--navy-d); color:#fff;
|
||||
padding:11px 18px; border-radius:8px; font-size:12.5px; border-left:3px solid var(--gold);
|
||||
box-shadow:0 4px 20px rgba(0,0,0,.2); transform:translateY(20px); opacity:0;
|
||||
transition:all .25s; z-index:2000; }
|
||||
.toast.show { transform:translateY(0); opacity:1; }
|
||||
.toast.error { border-left-color:#ef5350; }
|
||||
|
||||
footer.legend { text-align:center; padding:14px 24px 24px; font-size:11px; color:var(--gray-400); }
|
||||
footer.legend .pill { display:inline-block; padding:2px 8px; border-radius:20px; color:#fff;
|
||||
font-weight:600; margin:0 2px; font-size:10.5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<a class="btn-back" href="/" title="Volver al portal Donna">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
Portal
|
||||
</a>
|
||||
<div class="logo">
|
||||
<div class="logo-mark">A</div>
|
||||
<div class="logo-text">
|
||||
<span class="logo-firm">AALN Law</span>
|
||||
<span class="logo-tool">Calendario</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="sync-status">
|
||||
<div class="sync-dot" id="sync-dot"></div>
|
||||
<span id="sync-label">Conectando…</span>
|
||||
</div>
|
||||
<div class="user-chip">
|
||||
<div class="user-avatar" id="user-avatar">?</div>
|
||||
<span class="user-name" id="user-name">…</span>
|
||||
</div>
|
||||
<a class="btn-logout" href="/auth/logout.php" onclick="return confirm('¿Cerrar sesión?');">Cerrar sesión</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="cal-toolbar">
|
||||
<div class="view-tabs" id="view-tabs">
|
||||
<button data-view="year">Año</button>
|
||||
<button data-view="month" class="active">Mes</button>
|
||||
<button data-view="week">Semana</button>
|
||||
<button data-view="day">Día</button>
|
||||
</div>
|
||||
<div class="date-nav">
|
||||
<button id="nav-prev" title="Anterior (←)">‹</button>
|
||||
<button class="today-btn" id="nav-today" title="Hoy (T)">Hoy</button>
|
||||
<button id="nav-next" title="Siguiente (→)">›</button>
|
||||
</div>
|
||||
<span class="period-label" id="period-label">—</span>
|
||||
<div class="search-box">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
<input type="search" id="search" placeholder="Buscar evento, cliente o abogado…">
|
||||
</div>
|
||||
<div class="src-filters" id="src-filters">
|
||||
<span class="chip on" data-src="esc"><span class="cdot"></span>Escrituras</span>
|
||||
<span class="chip on" data-src="adm"><span class="cdot"></span>Admin</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="lit"><span class="cdot"></span>Litigios</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cal-kpis">
|
||||
<div class="cal-kpi"><div class="cal-kpi-label">Total eventos</div><div class="cal-kpi-val" id="k-total">—</div></div>
|
||||
<div class="cal-kpi warn"><div class="cal-kpi-label">Vencen ≤ 7 días</div><div class="cal-kpi-val" id="k-7d">—</div></div>
|
||||
<div class="cal-kpi warn"><div class="cal-kpi-label">Vencen ≤ 30 días</div><div class="cal-kpi-val" id="k-30d">—</div></div>
|
||||
<div class="cal-kpi danger"><div class="cal-kpi-label">Vencidos abiertos</div><div class="cal-kpi-val" id="k-overdue">—</div></div>
|
||||
</div>
|
||||
|
||||
<div class="upcoming-strip">
|
||||
<h4>Próximos vencimientos</h4>
|
||||
<div class="upcoming-row" id="upcoming-row"><div class="up-empty">Cargando…</div></div>
|
||||
</div>
|
||||
|
||||
<main id="cal-main"></main>
|
||||
|
||||
<footer class="legend">
|
||||
<span class="pill" style="background:var(--c-esc)">Escrituras</span>
|
||||
<span class="pill" style="background:var(--c-adm)">Admin</span>
|
||||
<span class="pill" style="background:var(--c-corp)">Corp</span>
|
||||
<span class="pill" style="background:var(--c-mig)">Migración</span>
|
||||
<span class="pill" style="background:var(--c-lit)">Litigios</span>
|
||||
<span class="pill" style="background:var(--orange)">≤7d</span>
|
||||
<span class="pill" style="background:var(--red)">Vencido</span>
|
||||
· Atajos: <b>← →</b> mueve período · <b>T</b> hoy · <b>Y/M/W/D</b> cambia vista · <b>Esc</b> cierra
|
||||
</footer>
|
||||
|
||||
<div class="modal-overlay" id="evt-modal" onclick="if(event.target.id==='evt-modal')closeEvent()">
|
||||
<div class="modal">
|
||||
<header class="modal-head">
|
||||
<h3 id="evt-title">Detalle del evento</h3>
|
||||
<button class="close" onclick="closeEvent()" title="Cerrar (Esc)">✕</button>
|
||||
</header>
|
||||
<div class="modal-body" id="evt-body"></div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn-secondary" onclick="closeEvent()">← Regresar al calendario</button>
|
||||
<button class="btn-primary" id="btn-edit">Editar evento</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
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_COLORS = { esc:'#1B3A6B', adm:'#6c5b95', corp:'#9E7C2E', mig:'#2980b9', lit:'#722f37' };
|
||||
const COLOR_OVERDUE = '#c0392b';
|
||||
const COLOR_UPCOMING = '#e67e22';
|
||||
const MES_ES = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre'];
|
||||
const DOW_FULL = ['lunes','martes','miércoles','jueves','viernes','sábado','domingo'];
|
||||
const DOW_SHORT = ['Lun','Mar','Mié','Jue','Vie','Sáb','Dom'];
|
||||
const CLOSED_STATUS = new Set(['Culminado','Facturado','Inscrito']);
|
||||
|
||||
let events = [];
|
||||
let view = 'month';
|
||||
let cursor = stripTime(new Date());
|
||||
let filters = new Set(['esc','adm','corp','mig','lit']);
|
||||
let search = '';
|
||||
let selected = null;
|
||||
let authUser = { email:'', name:'' };
|
||||
|
||||
function $(id){ return document.getElementById(id); }
|
||||
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function stripTime(d){ const o=new Date(d); o.setHours(0,0,0,0); return o; }
|
||||
function ymd(d){
|
||||
if(!d) return '';
|
||||
if(typeof d==='string'){
|
||||
if(/^\d{4}-\d{2}-\d{2}/.test(d)) return d.slice(0,10);
|
||||
d = new Date(d);
|
||||
}
|
||||
if(isNaN(d)) return '';
|
||||
const m=String(d.getMonth()+1).padStart(2,'0');
|
||||
const day=String(d.getDate()).padStart(2,'0');
|
||||
return `${d.getFullYear()}-${m}-${day}`;
|
||||
}
|
||||
function parseYMD(s){
|
||||
if(!s) return null;
|
||||
const [y,m,d] = s.split('-').map(Number);
|
||||
if(!y) return null;
|
||||
return new Date(y, (m||1)-1, d||1);
|
||||
}
|
||||
function addDays(d, n){ const o=new Date(d); o.setDate(o.getDate()+n); return o; }
|
||||
function addMonths(d, n){ return new Date(d.getFullYear(), d.getMonth()+n, 1); }
|
||||
function startOfMonth(d){ return new Date(d.getFullYear(), d.getMonth(), 1); }
|
||||
function startOfWeek(d){ const day=(d.getDay()+6)%7; return addDays(d, -day); }
|
||||
function sameDate(a,b){ return ymd(a)===ymd(b); }
|
||||
function daysToToday(s){
|
||||
const today = stripTime(new Date());
|
||||
const date = parseYMD(s);
|
||||
if(!date) return null;
|
||||
return Math.round((date - today)/(1000*60*60*24));
|
||||
}
|
||||
function setSync(state, label){
|
||||
const dot=$('sync-dot'); if(!dot) return;
|
||||
dot.className='sync-dot'+(state!=='ok'?' '+state:'');
|
||||
$('sync-label').textContent=label;
|
||||
}
|
||||
function toast(msg, type='ok'){
|
||||
const el=$('toast'); el.textContent=msg;
|
||||
el.className='toast show'+(type==='error'?' error':'');
|
||||
setTimeout(()=>el.className='toast'+(type==='error'?' error':''),3000);
|
||||
}
|
||||
function initials(text){
|
||||
const parts=String(text||'').replace(/[@._-]/g,' ').trim().split(/\s+/);
|
||||
return ((parts[0]||'').charAt(0)+(parts[1]||'').charAt(0)).toUpperCase()||'?';
|
||||
}
|
||||
function setUserChip(){
|
||||
const email=authUser.email||''; const name=authUser.name||email;
|
||||
if($('user-avatar')) $('user-avatar').textContent=initials(name||email);
|
||||
if($('user-name')) { $('user-name').textContent=name||email||'Conectado'; $('user-name').title=email; }
|
||||
}
|
||||
|
||||
async function apiCall(url){
|
||||
const res = await fetch(url, {headers:{Accept:'application/json'}, credentials:'same-origin'});
|
||||
let j;
|
||||
try { j = await res.json(); } catch(e){ throw new Error('JSON inválido'); }
|
||||
if(res.status===401 && j.auth_required){
|
||||
window.location.href = j.login_url || '/auth/login.php?return_to=' + encodeURIComponent(location.pathname);
|
||||
return null;
|
||||
}
|
||||
if(!res.ok || !j.ok) throw new Error(j.error || ('HTTP '+res.status));
|
||||
if(j.user) authUser = j.user;
|
||||
return j.data;
|
||||
}
|
||||
|
||||
function titleOf(t, dept){
|
||||
return t.nombre || t.tipo_tramite || t.tipo_proceso || (DEPT_LABELS[dept]||'Trámite');
|
||||
}
|
||||
|
||||
function flatten(escrituras, tramites){
|
||||
const out = [];
|
||||
for(const e of (escrituras||[])){
|
||||
if(e.fecha_solicitud){
|
||||
out.push({
|
||||
key:'esc-sol-'+e.id, source:'esc', type:'solicitud',
|
||||
date: ymd(e.fecha_solicitud),
|
||||
title: e.tramite || 'Trámite sin nombre',
|
||||
cliente:e.cliente, abogado:e.abogado, estatus:e.estatus,
|
||||
raw:e, ref_id:e.id,
|
||||
nav: '/escrituras/?edit='+e.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
for(const dept of ['adm','corp','mig','lit']){
|
||||
for(const t of (tramites?.[dept]||[])){
|
||||
const navUrl = '/tramites/?edit='+encodeURIComponent(t.codigo);
|
||||
if(t.fecha_solicitud){
|
||||
out.push({
|
||||
key:'tra-sol-'+t.id, source:dept, type:'solicitud',
|
||||
date: ymd(t.fecha_solicitud),
|
||||
title: titleOf(t, dept),
|
||||
cliente:t.cliente, abogado:t.abogado, estatus:t.estatus,
|
||||
raw:t, ref_id:t.id, codigo:t.codigo, nav:navUrl,
|
||||
});
|
||||
}
|
||||
if(dept==='mig' && t.fecha_vencimiento){
|
||||
out.push({
|
||||
key:'tra-venc-'+t.id, source:'mig', type:'vencimiento',
|
||||
date: t.fecha_vencimiento,
|
||||
title: titleOf(t, 'mig')+' — vencimiento',
|
||||
cliente:t.cliente, abogado:t.abogado, estatus:t.estatus,
|
||||
raw:t, ref_id:t.id, codigo:t.codigo, nav:navUrl,
|
||||
});
|
||||
}
|
||||
if(dept==='lit' && t.fecha_termino){
|
||||
out.push({
|
||||
key:'tra-term-'+t.id, source:'lit', type:'termino',
|
||||
date: t.fecha_termino,
|
||||
title: titleOf(t, 'lit')+' — término procesal',
|
||||
cliente:t.cliente, abogado:t.abogado, estatus:t.estatus,
|
||||
raw:t, ref_id:t.id, codigo:t.codigo, nav:navUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
.filter(e => e.date)
|
||||
.map(e => Object.assign(e, { daysOut: daysToToday(e.date) }))
|
||||
.sort((a,b)=> a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
async function loadAll(){
|
||||
setSync('syncing','Cargando…');
|
||||
try {
|
||||
const [escData, traData] = await Promise.all([
|
||||
apiCall('/escrituras/api.php?action=list'),
|
||||
apiCall('/tramites/api.php?action=list'),
|
||||
]);
|
||||
if(escData===null || traData===null) return;
|
||||
events = flatten(escData, traData);
|
||||
setUserChip();
|
||||
setSync('ok', events.length + ' eventos cargados');
|
||||
render();
|
||||
updateKPIs();
|
||||
updateUpcoming();
|
||||
} catch(e){
|
||||
setSync('error','Sin conexión');
|
||||
toast('Error al cargar: '+e.message,'error');
|
||||
}
|
||||
}
|
||||
|
||||
function visibleEvents(){
|
||||
const q = search.toLowerCase().trim();
|
||||
return events.filter(e=>{
|
||||
if(!filters.has(e.source)) return false;
|
||||
if(q){
|
||||
const blob = ((e.title||'')+' '+(e.cliente||'')+' '+(e.abogado||'')+' '+(e.codigo||'')).toLowerCase();
|
||||
if(!blob.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
function eventsOnDate(d){
|
||||
const k = ymd(d);
|
||||
return visibleEvents().filter(e => e.date===k);
|
||||
}
|
||||
function isDeadline(e){ return e.type==='vencimiento' || e.type==='termino'; }
|
||||
function isOpen(e){ return !CLOSED_STATUS.has(e.estatus||''); }
|
||||
function colorFor(e){
|
||||
if(isDeadline(e) && e.daysOut!==null && isOpen(e)){
|
||||
if(e.daysOut<0) return COLOR_OVERDUE;
|
||||
if(e.daysOut<=7) return COLOR_UPCOMING;
|
||||
}
|
||||
return SRC_COLORS[e.source] || '#666';
|
||||
}
|
||||
function badgeFor(e){
|
||||
if(isDeadline(e) && e.daysOut!==null && isOpen(e)){
|
||||
if(e.daysOut<0) return {label:'Vencido', bg:COLOR_OVERDUE};
|
||||
if(e.daysOut===0) return {label:'Hoy', bg:COLOR_UPCOMING};
|
||||
if(e.daysOut<=7) return {label:'≤'+e.daysOut+'d', bg:COLOR_UPCOMING};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateKPIs(){
|
||||
const v = visibleEvents();
|
||||
const deadlines = v.filter(e => isDeadline(e) && isOpen(e));
|
||||
$('k-total').textContent = v.length;
|
||||
$('k-7d').textContent = deadlines.filter(e => e.daysOut!==null && e.daysOut>=0 && e.daysOut<=7).length;
|
||||
$('k-30d').textContent = deadlines.filter(e => e.daysOut!==null && e.daysOut>=0 && e.daysOut<=30).length;
|
||||
$('k-overdue').textContent = deadlines.filter(e => e.daysOut!==null && e.daysOut<0).length;
|
||||
}
|
||||
function updateUpcoming(){
|
||||
const wrap = $('upcoming-row');
|
||||
const deadlines = visibleEvents()
|
||||
.filter(e => isDeadline(e) && isOpen(e) && e.daysOut!==null)
|
||||
.sort((a,b)=>a.daysOut-b.daysOut)
|
||||
.slice(0,8);
|
||||
if(!deadlines.length){
|
||||
wrap.innerHTML='<div class="up-empty">No hay vencimientos próximos en los filtros actuales.</div>';
|
||||
return;
|
||||
}
|
||||
wrap.innerHTML = deadlines.map(e=>{
|
||||
const over = e.daysOut<0;
|
||||
const rem = over ? 'Hace '+Math.abs(e.daysOut)+'d'
|
||||
: e.daysOut===0 ? 'HOY'
|
||||
: 'En '+e.daysOut+'d';
|
||||
const d = parseYMD(e.date);
|
||||
const ds = d ? d.toLocaleDateString('es-PA',{day:'2-digit',month:'short'}) : e.date;
|
||||
return `<div class="up-card ${over?'overdue':''}" onclick='openEvent("${e.key}")'>
|
||||
<div class="up-card-date">${esc(ds)} <span class="up-card-rem">${esc(rem)}</span></div>
|
||||
<div class="up-card-title" title="${esc(e.title)}">${esc(e.title)}</div>
|
||||
<div class="up-card-meta">${esc(e.cliente||'—')} · ${esc(e.abogado||'—')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function setView(v){
|
||||
view = v;
|
||||
document.querySelectorAll('#view-tabs button').forEach(b=>b.classList.toggle('active', b.dataset.view===v));
|
||||
render();
|
||||
}
|
||||
function setCursor(d){ cursor = stripTime(d); render(); }
|
||||
function navPrev(){
|
||||
if(view==='year') cursor=new Date(cursor.getFullYear()-1, 0, 1);
|
||||
else if(view==='month') cursor=addMonths(cursor,-1);
|
||||
else if(view==='week') cursor=addDays(cursor,-7);
|
||||
else if(view==='day') cursor=addDays(cursor,-1);
|
||||
setCursor(cursor);
|
||||
}
|
||||
function navNext(){
|
||||
if(view==='year') cursor=new Date(cursor.getFullYear()+1, 0, 1);
|
||||
else if(view==='month') cursor=addMonths(cursor,1);
|
||||
else if(view==='week') cursor=addDays(cursor,7);
|
||||
else if(view==='day') cursor=addDays(cursor,1);
|
||||
setCursor(cursor);
|
||||
}
|
||||
function navToday(){ setCursor(new Date()); }
|
||||
|
||||
function periodLabel(){
|
||||
if(view==='year') return String(cursor.getFullYear());
|
||||
if(view==='month') return MES_ES[cursor.getMonth()] + ' ' + cursor.getFullYear();
|
||||
if(view==='week'){
|
||||
const a=startOfWeek(cursor); const b=addDays(a,6);
|
||||
return a.getDate()+' '+MES_ES[a.getMonth()].slice(0,3)+' — '+b.getDate()+' '+MES_ES[b.getMonth()].slice(0,3)+' '+b.getFullYear();
|
||||
}
|
||||
return DOW_FULL[(cursor.getDay()+6)%7] + ' ' + cursor.getDate() + ' de ' + MES_ES[cursor.getMonth()] + ' ' + cursor.getFullYear();
|
||||
}
|
||||
|
||||
function render(){
|
||||
$('period-label').textContent = periodLabel();
|
||||
if(view==='year') renderYear();
|
||||
else if(view==='month') renderMonth();
|
||||
else if(view==='week') renderWeek();
|
||||
else renderDay();
|
||||
}
|
||||
|
||||
function renderYear(){
|
||||
const year = cursor.getFullYear();
|
||||
const today = stripTime(new Date());
|
||||
const byDate = {};
|
||||
for(const e of visibleEvents()){ (byDate[e.date] = byDate[e.date] || []).push(e); }
|
||||
const months = Array.from({length:12}, (_,m) => {
|
||||
const start = startOfWeek(new Date(year, m, 1));
|
||||
const cells = [];
|
||||
for(let i=0;i<42;i++){
|
||||
const d = addDays(start, i);
|
||||
const inMonth = d.getMonth()===m && d.getFullYear()===year;
|
||||
const key = ymd(d);
|
||||
const evs = byDate[key]||[];
|
||||
const hasDeadline = evs.some(e => isDeadline(e) && isOpen(e));
|
||||
const dots = evs.slice(0,4).map(e =>
|
||||
`<span class="dot" style="background:${colorFor(e)}"></span>`
|
||||
).join('');
|
||||
cells.push(`<div class="year-mini-day ${inMonth?'':'dim'} ${sameDate(d,today)?'today':''} ${hasDeadline?'has-deadline':''}">${d.getDate()}<span class="dots">${dots}</span></div>`);
|
||||
}
|
||||
return `<div class="year-mini" onclick="jumpToMonth(${m})">
|
||||
<div class="year-mini-title">${MES_ES[m]}</div>
|
||||
<div class="year-mini-grid">
|
||||
${DOW_SHORT.map(d=>`<div class="year-mini-dow">${d}</div>`).join('')}
|
||||
${cells.join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
$('cal-main').innerHTML = `<div class="year-grid">${months}</div>`;
|
||||
}
|
||||
function jumpToMonth(m){
|
||||
cursor = new Date(cursor.getFullYear(), m, 1);
|
||||
setView('month');
|
||||
}
|
||||
|
||||
function renderMonth(){
|
||||
const today = stripTime(new Date());
|
||||
const start = startOfWeek(startOfMonth(cursor));
|
||||
const cells = [];
|
||||
for(let i=0;i<42;i++){
|
||||
const d = addDays(start, i);
|
||||
const inMonth = d.getMonth()===cursor.getMonth();
|
||||
const isToday = sameDate(d, today);
|
||||
const evs = eventsOnDate(d);
|
||||
const max = 3;
|
||||
const chips = evs.slice(0,max).map(e=>{
|
||||
const b = badgeFor(e);
|
||||
const cls = (b && b.bg===COLOR_OVERDUE) ? 'evt-chip overdue' : 'evt-chip';
|
||||
const label = b ? (e.title+' · '+b.label) : e.title;
|
||||
return `<div class="${cls}" style="background:${colorFor(e)}" onclick='event.stopPropagation();openEvent("${e.key}")' title="${esc(label)} — ${esc(e.cliente||'')}">${esc(e.title)}</div>`;
|
||||
}).join('');
|
||||
const more = evs.length>max ? `<div class="evt-more">+ ${evs.length-max} más</div>` : '';
|
||||
cells.push(`<div class="month-cell ${inMonth?'':'dim'} ${isToday?'today':''}" onclick="goToDay('${ymd(d)}')">
|
||||
<span class="cell-num">${d.getDate()}</span>
|
||||
${chips}${more}
|
||||
</div>`);
|
||||
}
|
||||
$('cal-main').innerHTML = `<div class="month-grid">
|
||||
${DOW_SHORT.map(d=>`<div class="month-dow">${d}</div>`).join('')}
|
||||
${cells.join('')}
|
||||
</div>`;
|
||||
}
|
||||
function goToDay(ymdStr){
|
||||
cursor = parseYMD(ymdStr);
|
||||
setView('day');
|
||||
}
|
||||
|
||||
function renderWeek(){
|
||||
const today = stripTime(new Date());
|
||||
const start = startOfWeek(cursor);
|
||||
const cols = [];
|
||||
for(let i=0;i<7;i++){
|
||||
const d = addDays(start, i);
|
||||
const isToday = sameDate(d, today);
|
||||
const evs = eventsOnDate(d);
|
||||
const items = evs.length ? evs.map(e=>{
|
||||
const b = badgeFor(e);
|
||||
return `<div class="week-evt" style="background:${colorFor(e)}" onclick='event.stopPropagation();openEvent("${e.key}")'>
|
||||
<div class="tt">${esc(e.title)}</div>
|
||||
<div class="meta">${esc(e.cliente||'—')}${b?' · '+esc(b.label):''}</div>
|
||||
</div>`;
|
||||
}).join('') : '<div class="week-empty">Sin eventos</div>';
|
||||
cols.push(`<div class="week-col">
|
||||
<div class="week-col-head ${isToday?'today':''}">
|
||||
<div class="week-col-day">${DOW_SHORT[i]}</div>
|
||||
<div class="week-col-num">${d.getDate()}</div>
|
||||
</div>
|
||||
<div class="week-col-body" onclick="goToDay('${ymd(d)}')">${items}</div>
|
||||
</div>`);
|
||||
}
|
||||
$('cal-main').innerHTML = `<div class="week-grid">${cols.join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderDay(){
|
||||
const today = stripTime(new Date());
|
||||
const evs = eventsOnDate(cursor);
|
||||
const isToday = sameDate(cursor, today);
|
||||
const heading = DOW_FULL[(cursor.getDay()+6)%7] + ' ' + cursor.getDate() + ' de ' + MES_ES[cursor.getMonth()];
|
||||
let html = `<div class="day-view">
|
||||
<div class="day-header">${heading}${isToday?' · Hoy':''}</div>
|
||||
<div class="day-sub">${evs.length} ${evs.length===1?'evento':'eventos'}</div>`;
|
||||
if(!evs.length){
|
||||
html += `<div class="cal-empty">No hay eventos este día.</div>`;
|
||||
} else {
|
||||
html += evs.map(e=>{
|
||||
const b = badgeFor(e);
|
||||
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>`;
|
||||
return `<div class="day-evt" onclick='openEvent("${e.key}")'>
|
||||
<div class="day-evt-bar" style="background:${colorFor(e)}"></div>
|
||||
<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>
|
||||
<div class="day-evt-badges">${tag}${srcTag}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
html += '</div>';
|
||||
$('cal-main').innerHTML = html;
|
||||
}
|
||||
|
||||
function openEvent(key){
|
||||
const e = events.find(x => x.key===key);
|
||||
if(!e){ toast('Evento no encontrado','error'); return; }
|
||||
selected = e;
|
||||
$('evt-title').textContent = e.title;
|
||||
$('evt-body').innerHTML = renderEventBody(e);
|
||||
$('btn-edit').onclick = ()=> { window.location.href = e.nav; };
|
||||
$('evt-modal').classList.add('open');
|
||||
}
|
||||
function closeEvent(){
|
||||
selected = null;
|
||||
$('evt-modal').classList.remove('open');
|
||||
}
|
||||
function renderEventBody(e){
|
||||
const srcColor = SRC_COLORS[e.source];
|
||||
const srcLabel = SRC_LABELS[e.source];
|
||||
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 dStr = d ? d.toLocaleDateString('es-PA',{weekday:'long', day:'2-digit', month:'long', year:'numeric'}) : e.date;
|
||||
const r = e.raw || {};
|
||||
|
||||
const rows = [];
|
||||
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>`]);
|
||||
rows.push([tEvt, `<b style="text-transform:capitalize">${esc(dStr)}</b>`]);
|
||||
if(e.daysOut!==null && isDeadline(e)){
|
||||
let lbl;
|
||||
if(!isOpen(e)) lbl = `<span style="color:var(--gray-400)">Cerrado (${esc(e.estatus)})</span>`;
|
||||
else if(e.daysOut<0) lbl = `<span style="color:${COLOR_OVERDUE}"><b>Vencido hace ${Math.abs(e.daysOut)} día(s)</b></span>`;
|
||||
else if(e.daysOut===0) lbl = `<span style="color:${COLOR_UPCOMING}"><b>Vence HOY</b></span>`;
|
||||
else lbl = `Faltan <b>${e.daysOut}</b> día(s)`;
|
||||
rows.push(['Restante', lbl]);
|
||||
}
|
||||
rows.push(['Cliente', esc(e.cliente||'—')]);
|
||||
rows.push(['Abogado', esc(e.abogado||'—')]);
|
||||
rows.push(['Estatus', esc(e.estatus||'—')]);
|
||||
|
||||
if(e.source==='esc'){
|
||||
if(r.notaria) rows.push(['Notaría', esc(r.notaria)]);
|
||||
if(r.inscribe) rows.push(['Se inscribe', esc(r.inscribe)]);
|
||||
if(r.altera_turno) rows.push(['Altera turno', esc(r.altera_turno)]);
|
||||
if(r.entrada) rows.push(['Entrada', '<span style="font-variant-numeric:tabular-nums">'+esc(r.entrada)+'</span>']);
|
||||
}
|
||||
if(e.source==='adm' && r.institucion) rows.push(['Institución', esc(r.institucion)]);
|
||||
if(e.source==='corp' && r.tipo_documento) rows.push(['Tipo doc.', esc(r.tipo_documento)]);
|
||||
if(e.source==='mig'){
|
||||
if(r.tipo_tramite) rows.push(['Tipo trámite', esc(r.tipo_tramite)]);
|
||||
if(r.doc_completa) rows.push(['Doc. completa', esc(r.doc_completa)]);
|
||||
if(r.permiso_trabajo) rows.push(['Permiso trabajo', esc(r.permiso_trabajo)]);
|
||||
if(r.dependientes) rows.push(['Dependientes', esc(r.dependientes)]);
|
||||
}
|
||||
if(e.source==='lit'){
|
||||
if(r.tipo_proceso) rows.push(['Tipo proceso', esc(r.tipo_proceso)]);
|
||||
if(r.juzgado) rows.push(['Juzgado', esc(r.juzgado)]);
|
||||
if(r.esfera) rows.push(['Esfera', esc(r.esfera)]);
|
||||
if(r.instancia) rows.push(['Instancia', esc(r.instancia)]);
|
||||
if(r.circuito) rows.push(['Circuito', esc(r.circuito)]);
|
||||
}
|
||||
if(r.instrucciones) rows.push(['Instrucciones', '<span style="white-space:pre-wrap">'+esc(r.instrucciones)+'</span>']);
|
||||
if(r.observaciones) rows.push(['Observaciones', '<span style="white-space:pre-wrap">'+esc(r.observaciones)+'</span>']);
|
||||
|
||||
return rows.map(([k,v])=>`<div class="row"><div class="k">${k}</div><div class="v">${v}</div></div>`).join('');
|
||||
}
|
||||
|
||||
document.querySelectorAll('#view-tabs button').forEach(b=>{
|
||||
b.addEventListener('click', ()=> setView(b.dataset.view));
|
||||
});
|
||||
$('nav-prev').addEventListener('click', navPrev);
|
||||
$('nav-next').addEventListener('click', navNext);
|
||||
$('nav-today').addEventListener('click', navToday);
|
||||
$('search').addEventListener('input', e=>{
|
||||
search = e.target.value;
|
||||
render(); updateKPIs(); updateUpcoming();
|
||||
});
|
||||
document.querySelectorAll('.chip[data-src]').forEach(ch=>{
|
||||
ch.addEventListener('click', ()=>{
|
||||
const s = ch.dataset.src;
|
||||
if(filters.has(s)) filters.delete(s); else filters.add(s);
|
||||
ch.classList.toggle('on', filters.has(s));
|
||||
ch.classList.toggle('off', !filters.has(s));
|
||||
render(); updateKPIs(); updateUpcoming();
|
||||
});
|
||||
});
|
||||
document.addEventListener('keydown', e=>{
|
||||
if(/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) {
|
||||
if(e.key==='Escape' && e.target.id==='search') {
|
||||
e.target.value=''; search=''; render(); updateKPIs(); updateUpcoming();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(e.key==='Escape'){ closeEvent(); return; }
|
||||
if(e.key==='ArrowLeft') navPrev();
|
||||
if(e.key==='ArrowRight') navNext();
|
||||
if(e.key==='t'||e.key==='T') navToday();
|
||||
if(e.key==='y'||e.key==='Y') setView('year');
|
||||
if(e.key==='m'||e.key==='M') setView('month');
|
||||
if(e.key==='w'||e.key==='W') setView('week');
|
||||
if(e.key==='d'||e.key==='D') setView('day');
|
||||
});
|
||||
|
||||
setInterval(()=>{ if(!$('evt-modal').classList.contains('open')) loadAll(); }, 60000);
|
||||
|
||||
loadAll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -612,6 +612,7 @@ let pollTimer = null;
|
||||
let editModalOpen = false; // modal de edición/nuevo abierta
|
||||
let editingId = null; // id del trámite siendo editado (null = nuevo)
|
||||
let pendingRefresh = false; // poll trajo datos durante una edición
|
||||
let firstLoadDone = false; // deep-link ?edit=ID solo aplica en la primera carga
|
||||
|
||||
function esc(s) { return (s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function fmtDate(dt) {
|
||||
@@ -672,6 +673,10 @@ async function loadAll(showOverlay=false) {
|
||||
} else {
|
||||
render();
|
||||
setSyncState('ok','Sincronizado');
|
||||
if (!firstLoadDone) {
|
||||
firstLoadDone = true;
|
||||
checkDeepLink();
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
setSyncState('error','Sin conexión');
|
||||
@@ -729,6 +734,23 @@ function doLogout() {
|
||||
if (confirm('¿Cerrar sesión?')) window.location.href = 'api.php?action=logout';
|
||||
}
|
||||
|
||||
// Si el usuario llegó desde el Calendario con ?edit=ID, abre la modal sobre esa fila
|
||||
function checkDeepLink() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const raw = params.get('edit');
|
||||
if (!raw) return;
|
||||
const id = parseInt(raw, 10);
|
||||
if (Number.isNaN(id)) return;
|
||||
const r = allRows.find(x => x.id === id);
|
||||
if (r) {
|
||||
openEditModal(id);
|
||||
} else {
|
||||
toast('Trámite #' + id + ' no encontrado','error');
|
||||
}
|
||||
// Limpiar la URL para no re-disparar en refresh
|
||||
history.replaceState({}, '', location.pathname);
|
||||
}
|
||||
|
||||
// ── EDIT MODAL ───────────────────────────────────────────────────────────
|
||||
function openEditModal(id) {
|
||||
editingId = id;
|
||||
|
||||
+26
-3
@@ -194,9 +194,10 @@ const SI_NO = ['Sí','No'];
|
||||
const API = 'api.php';
|
||||
|
||||
let data = {adm:[],corp:[],mig:[],lit:[]};
|
||||
let currentDept = 'adm';
|
||||
let editingId = null;
|
||||
let authUser = {email:'', name:''};
|
||||
let currentDept = 'adm';
|
||||
let editingId = null;
|
||||
let authUser = {email:'', name:''};
|
||||
let firstLoadDone = false; // deep-link ?edit=CODIGO solo aplica en la primera carga
|
||||
|
||||
function esc(s){return (s==null?'':String(s)).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');}
|
||||
function setSync(state,label){
|
||||
@@ -288,6 +289,10 @@ async function loadAll(){
|
||||
setSync('ok', 'Sincronizado');
|
||||
setUserChip();
|
||||
renderAll();
|
||||
if (!firstLoadDone) {
|
||||
firstLoadDone = true;
|
||||
checkDeepLink();
|
||||
}
|
||||
} catch(e){
|
||||
setSync('error','Sin conexión');
|
||||
toast('Error al cargar: '+e.message,'error');
|
||||
@@ -304,6 +309,24 @@ function switchTab(dept){
|
||||
renderPanel(dept);
|
||||
}
|
||||
|
||||
// Si el usuario llegó desde el Calendario con ?edit=ADM-001, busca el dept y abre el modal
|
||||
function checkDeepLink(){
|
||||
const params = new URLSearchParams(location.search);
|
||||
const code = params.get('edit');
|
||||
if(!code) return;
|
||||
for(const dept of ['adm','corp','mig','lit']){
|
||||
const r = data[dept].find(x => x.id === code);
|
||||
if(r){
|
||||
switchTab(dept);
|
||||
openModal(dept, code);
|
||||
history.replaceState({}, '', location.pathname);
|
||||
return;
|
||||
}
|
||||
}
|
||||
toast('Trámite ' + code + ' no encontrado','error');
|
||||
history.replaceState({}, '', location.pathname);
|
||||
}
|
||||
|
||||
function renderAll(){['adm','corp','mig','lit'].forEach(renderPanel);}
|
||||
|
||||
function renderPanel(dept){
|
||||
|
||||
Reference in New Issue
Block a user