feat(tramites): app "Control de Trámites Legales" + backend

Migración del frontend localStorage a backend autenticado bajo Donna.

* SQL 004: tramites_legales (todos los campos por dept en columnas
  nullable + ENUMs Sí/No), tramites_obs (bitácora N:1), tramites_hours
  (registro de horas N:1), tramites_seq (contador por departamento).
  IDs server-side tipo ADM-001 / CRP-001 / MIG-001 / LIT-001 con
  SELECT FOR UPDATE para atomicidad.
* public/tramites/api.php: list/create/update/delete + set_status +
  add_obs + add_hours. Valida departamento y estatus contra los enums
  del frontend. Audita creado_por / actualizado_por con el email
  autenticado.
* views/tramites.html: artifact original adaptado. Quitado backup/restore
  localStorage (mysqldump diario ya hace ese trabajo). Cada mutación
  va vía fetch con manejo de 401 → redirect a login. Mapeo
  id<->codigo para preservar el contrato del artifact.
* Apps.php: la card "Gestión de Casos" placeholder se reemplaza por
  "Control de Trámites Legales" en estado live.
This commit is contained in:
Soporte ZH Consultores
2026-05-11 20:03:28 +00:00
parent da08e01f28
commit 1bb63f12f6
5 changed files with 1037 additions and 5 deletions
+291
View File
@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/Auth.php';
require_once __DIR__ . '/../../src/Db.php';
if (($_GET['action'] ?? '') === 'logout') {
header('Location: /auth/logout.php');
exit;
}
Auth::requireAuth();
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
const DEPTS = ['adm', 'corp', 'mig', 'lit'];
const PREFIX = ['adm' => 'ADM', 'corp' => 'CRP', 'mig' => 'MIG', 'lit' => 'LIT'];
const STATUS_MAP = [
'adm' => ['Solicitado','Recibido','En Proceso','Notificado','Recurso','Culminado','Facturado'],
'corp' => ['Solicitado','Recibido','Borrador','Versión de Firma','Firmado','Culminado','Facturado'],
'mig' => ['Solicitado','Recibido','Presentado','Notificado','Recurso','Culminado','Facturado'],
'lit' => ['Solicitado','Recibido','Presentado','Admitido','Contestado','Pruebas','Contrapruebas','Alegatos','Sentencia','Apelación'],
];
function ok($data = null): void {
echo json_encode(['ok' => true, 'data' => $data, 'user' => Auth::user()], JSON_UNESCAPED_UNICODE);
exit;
}
function err(string $msg, int $code = 400): void {
http_response_code($code);
echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
function body(): array {
return json_decode((string) file_get_contents('php://input'), true) ?? [];
}
function dept_of(array $b): string {
$d = strtolower((string)($b['departamento'] ?? $_GET['dept'] ?? ''));
if (!in_array($d, DEPTS, true)) err('Departamento inválido');
return $d;
}
function clean_yesno(?string $v): ?string {
if ($v === null) return null;
$v = trim($v);
if ($v === '') return null;
return in_array($v, ['Sí','No'], true) ? $v : null;
}
function clean_date(?string $v): ?string {
if (!$v) return null;
$v = trim($v);
if ($v === '') return null;
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) ? $v : null;
}
function next_codigo(PDO $pdo, string $dept): string {
$pdo->beginTransaction();
try {
$sel = $pdo->prepare('SELECT next_seq FROM tramites_seq WHERE departamento = :d FOR UPDATE');
$sel->execute([':d' => $dept]);
$n = (int) ($sel->fetchColumn() ?: 1);
$upd = $pdo->prepare('UPDATE tramites_seq SET next_seq = :n WHERE departamento = :d');
$upd->execute([':n' => $n + 1, ':d' => $dept]);
$pdo->commit();
return sprintf('%s-%03d', PREFIX[$dept], $n);
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
function hydrate(PDO $pdo, array $row): array {
$tid = (int) $row['id'];
$obs = $pdo->prepare('SELECT id, texto, abogado, autor_email, fecha FROM tramites_obs WHERE tramite_id = :t ORDER BY fecha ASC');
$obs->execute([':t' => $tid]);
$row['obs'] = $obs->fetchAll();
$hrs = $pdo->prepare('SELECT id, abogado, hrs, descripcion, autor_email, fecha FROM tramites_hours WHERE tramite_id = :t ORDER BY fecha ASC');
$hrs->execute([':t' => $tid]);
$row['hours'] = array_map(function ($h) {
$h['hrs'] = (float) $h['hrs'];
return $h;
}, $hrs->fetchAll());
return $row;
}
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
$user = Auth::user();
$pdo = Db::pdo();
try {
if ($method === 'GET' && $action === 'list') {
$rows = $pdo->query('SELECT * FROM tramites_legales ORDER BY id DESC')->fetchAll();
$grouped = ['adm' => [], 'corp' => [], 'mig' => [], 'lit' => []];
foreach ($rows as $r) {
$grouped[$r['departamento']][] = hydrate($pdo, $r);
}
ok($grouped);
}
if ($method === 'POST' && $action === 'create') {
$b = body();
$dept = dept_of($b);
$est = (string) ($b['estatus'] ?? 'Solicitado');
if (!in_array($est, STATUS_MAP[$dept], true)) $est = STATUS_MAP[$dept][0];
$codigo = next_codigo($pdo, $dept);
$stmt = $pdo->prepare(
'INSERT INTO tramites_legales
(codigo, departamento, cliente, abogado, estatus, instrucciones,
nombre, institucion, tipo_documento, tipo_tramite,
doc_completa, permiso_trabajo, dependientes, fecha_vencimiento,
esfera, instancia, circuito, juzgado, tipo_proceso,
incidentes, amparo, fecha_termino, creado_por)
VALUES
(:codigo, :dept, :cliente, :abogado, :estatus, :instrucciones,
:nombre, :institucion, :tipo_documento, :tipo_tramite,
:doc_completa, :permiso_trabajo, :dependientes, :fecha_vencimiento,
:esfera, :instancia, :circuito, :juzgado, :tipo_proceso,
:incidentes, :amparo, :fecha_termino, :user)'
);
$stmt->execute([
':codigo' => $codigo,
':dept' => $dept,
':cliente' => (string)($b['cliente'] ?? ''),
':abogado' => (string)($b['abogado'] ?? ''),
':estatus' => $est,
':instrucciones' => (string)($b['instrucciones'] ?? ''),
':nombre' => (string)($b['nombre'] ?? ''),
':institucion' => (string)($b['institucion'] ?? ''),
':tipo_documento'=> (string)($b['tipoDocumento'] ?? ''),
':tipo_tramite' => (string)($b['tipoTramite'] ?? ''),
':doc_completa' => clean_yesno($b['docCompleta'] ?? null),
':permiso_trabajo'=> clean_yesno($b['permisoTrabajo'] ?? null),
':dependientes' => clean_yesno($b['dependientes'] ?? null),
':fecha_vencimiento' => clean_date($b['fechaVencimiento'] ?? null),
':esfera' => in_array($b['esfera'] ?? '', ['Civil','Penal'], true) ? $b['esfera'] : null,
':instancia' => in_array($b['instancia'] ?? '', ['Primera','Segunda','CSJ'], true) ? $b['instancia'] : null,
':circuito' => (string)($b['circuito'] ?? ''),
':juzgado' => (string)($b['juzgado'] ?? ''),
':tipo_proceso' => (string)($b['tipoProceso'] ?? ''),
':incidentes' => clean_yesno($b['incidentes'] ?? null),
':amparo' => clean_yesno($b['amparo'] ?? null),
':fecha_termino' => clean_date($b['fechaTermino'] ?? null),
':user' => $user['email'],
]);
$id = (int) $pdo->lastInsertId();
$row = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
$row->execute([':id' => $id]);
ok(hydrate($pdo, $row->fetch()));
}
if ($method === 'PUT' && $action === 'update') {
$b = body();
$id = (int) ($b['_id'] ?? 0);
if (!$id) err('id requerido');
$cur = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
$cur->execute([':id' => $id]);
$before = $cur->fetch();
if (!$before) err('Trámite no encontrado', 404);
$dept = $before['departamento'];
$est = (string) ($b['estatus'] ?? $before['estatus']);
if (!in_array($est, STATUS_MAP[$dept], true)) $est = $before['estatus'];
$stmt = $pdo->prepare(
'UPDATE tramites_legales SET
cliente=:cliente, abogado=:abogado, estatus=:estatus, instrucciones=:instrucciones,
nombre=:nombre, institucion=:institucion,
tipo_documento=:tipo_documento, tipo_tramite=:tipo_tramite,
doc_completa=:doc_completa, permiso_trabajo=:permiso_trabajo,
dependientes=:dependientes, fecha_vencimiento=:fecha_vencimiento,
esfera=:esfera, instancia=:instancia, circuito=:circuito,
juzgado=:juzgado, tipo_proceso=:tipo_proceso,
incidentes=:incidentes, amparo=:amparo, fecha_termino=:fecha_termino,
actualizado_por=:user
WHERE id=:id'
);
$stmt->execute([
':cliente' => (string)($b['cliente'] ?? $before['cliente']),
':abogado' => (string)($b['abogado'] ?? $before['abogado']),
':estatus' => $est,
':instrucciones' => (string)($b['instrucciones'] ?? $before['instrucciones']),
':nombre' => (string)($b['nombre'] ?? $before['nombre']),
':institucion' => (string)($b['institucion'] ?? $before['institucion']),
':tipo_documento'=> (string)($b['tipoDocumento'] ?? $before['tipo_documento']),
':tipo_tramite' => (string)($b['tipoTramite'] ?? $before['tipo_tramite']),
':doc_completa' => clean_yesno($b['docCompleta'] ?? $before['doc_completa']),
':permiso_trabajo'=> clean_yesno($b['permisoTrabajo'] ?? $before['permiso_trabajo']),
':dependientes' => clean_yesno($b['dependientes'] ?? $before['dependientes']),
':fecha_vencimiento' => clean_date($b['fechaVencimiento'] ?? $before['fecha_vencimiento']),
':esfera' => in_array(($b['esfera'] ?? $before['esfera']), ['Civil','Penal'], true) ? ($b['esfera'] ?? $before['esfera']) : null,
':instancia' => in_array(($b['instancia'] ?? $before['instancia']), ['Primera','Segunda','CSJ'], true) ? ($b['instancia'] ?? $before['instancia']) : null,
':circuito' => (string)($b['circuito'] ?? $before['circuito']),
':juzgado' => (string)($b['juzgado'] ?? $before['juzgado']),
':tipo_proceso' => (string)($b['tipoProceso'] ?? $before['tipo_proceso']),
':incidentes' => clean_yesno($b['incidentes'] ?? $before['incidentes']),
':amparo' => clean_yesno($b['amparo'] ?? $before['amparo']),
':fecha_termino' => clean_date($b['fechaTermino'] ?? $before['fecha_termino']),
':user' => $user['email'],
':id' => $id,
]);
$row = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
$row->execute([':id' => $id]);
ok(hydrate($pdo, $row->fetch()));
}
if ($method === 'PATCH' && $action === 'set_status') {
$b = body();
$id = (int) ($b['_id'] ?? 0);
$val = (string) ($b['estatus'] ?? '');
if (!$id) err('id requerido');
$cur = $pdo->prepare('SELECT departamento FROM tramites_legales WHERE id = :id');
$cur->execute([':id' => $id]);
$dept = $cur->fetchColumn();
if (!$dept) err('Trámite no encontrado', 404);
if (!in_array($val, STATUS_MAP[$dept], true)) err('Estatus inválido para el departamento');
$pdo->prepare('UPDATE tramites_legales SET estatus = :s, actualizado_por = :u WHERE id = :id')
->execute([':s' => $val, ':u' => $user['email'], ':id' => $id]);
ok(['id' => $id, 'estatus' => $val]);
}
if ($method === 'POST' && $action === 'add_obs') {
$b = body();
$id = (int) ($b['_id'] ?? 0);
$texto = trim((string)($b['texto'] ?? ''));
$abog = trim((string)($b['abogado'] ?? ''));
if (!$id) err('id requerido');
if ($texto === '') err('texto requerido');
$pdo->prepare('INSERT INTO tramites_obs (tramite_id, texto, abogado, autor_email) VALUES (?,?,?,?)')
->execute([$id, $texto, $abog, $user['email']]);
$pdo->prepare('UPDATE tramites_legales SET actualizado_por = :u WHERE id = :id')
->execute([':u' => $user['email'], ':id' => $id]);
$row = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
$row->execute([':id' => $id]);
ok(hydrate($pdo, $row->fetch()));
}
if ($method === 'POST' && $action === 'add_hours') {
$b = body();
$id = (int) ($b['_id'] ?? 0);
$abog = trim((string)($b['abogado'] ?? ''));
$hrs = (float) ($b['hrs'] ?? 0);
$desc = trim((string)($b['descripcion'] ?? ''));
if (!$id) err('id requerido');
if ($hrs <= 0) err('horas debe ser > 0');
$pdo->prepare('INSERT INTO tramites_hours (tramite_id, abogado, hrs, descripcion, autor_email) VALUES (?,?,?,?,?)')
->execute([$id, $abog, $hrs, $desc, $user['email']]);
$pdo->prepare('UPDATE tramites_legales SET actualizado_por = :u WHERE id = :id')
->execute([':u' => $user['email'], ':id' => $id]);
$row = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
$row->execute([':id' => $id]);
ok(hydrate($pdo, $row->fetch()));
}
if ($method === 'DELETE' && $action === 'delete') {
$id = (int) ($_GET['id'] ?? 0);
if (!$id) err('id requerido');
$pdo->prepare('DELETE FROM tramites_legales WHERE id = :id')->execute([':id' => $id]);
ok(['deleted' => $id]);
}
err('Acción no reconocida', 404);
} catch (PDOException $e) {
error_log('tramites db error: ' . $e->getMessage());
err('Error de base de datos', 500);
} catch (Throwable $e) {
error_log('tramites error: ' . $e->getMessage());
err('Error interno', 500);
}
+8
View File
@@ -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/tramites.html');
+86
View File
@@ -0,0 +1,86 @@
-- ============================================================
-- Donna — Schema 004: Control de Trámites Legales
-- ============================================================
CREATE TABLE IF NOT EXISTS tramites_legales (
id INT AUTO_INCREMENT PRIMARY KEY,
codigo VARCHAR(20) NOT NULL UNIQUE,
departamento ENUM('adm','corp','mig','lit') NOT NULL,
-- Comunes
cliente VARCHAR(255) NOT NULL DEFAULT '',
abogado VARCHAR(100) NOT NULL DEFAULT '',
estatus VARCHAR(50) NOT NULL DEFAULT 'Solicitado',
instrucciones TEXT NOT NULL,
fecha_solicitud DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- adm + corp
nombre VARCHAR(255) NOT NULL DEFAULT '',
-- adm
institucion VARCHAR(255) NOT NULL DEFAULT '',
-- corp
tipo_documento VARCHAR(255) NOT NULL DEFAULT '',
-- mig
tipo_tramite VARCHAR(255) NOT NULL DEFAULT '',
doc_completa ENUM('Sí','No') NULL,
permiso_trabajo ENUM('Sí','No') NULL,
dependientes ENUM('Sí','No') NULL,
fecha_vencimiento DATE NULL,
-- lit
esfera ENUM('Civil','Penal') NULL,
instancia ENUM('Primera','Segunda','CSJ') NULL,
circuito VARCHAR(100) NOT NULL DEFAULT '',
juzgado VARCHAR(255) NOT NULL DEFAULT '',
tipo_proceso VARCHAR(255) NOT NULL DEFAULT '',
incidentes ENUM('Sí','No') NULL,
amparo ENUM('Sí','No') NULL,
fecha_termino DATE NULL,
-- Auditoría
creado_por VARCHAR(255) NOT NULL DEFAULT '',
actualizado_por 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_dept (departamento),
INDEX idx_estatus (estatus),
INDEX idx_abogado (abogado),
INDEX idx_cliente (cliente),
INDEX idx_fecha (fecha_solicitud DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS tramites_obs (
id INT AUTO_INCREMENT PRIMARY KEY,
tramite_id INT NOT NULL,
texto TEXT NOT NULL,
abogado VARCHAR(100) NOT NULL DEFAULT '',
autor_email VARCHAR(255) NOT NULL DEFAULT '',
fecha DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tramite (tramite_id),
INDEX idx_fecha (fecha DESC),
CONSTRAINT fk_tramites_obs FOREIGN KEY (tramite_id)
REFERENCES tramites_legales(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS tramites_hours (
id INT AUTO_INCREMENT PRIMARY KEY,
tramite_id INT NOT NULL,
abogado VARCHAR(100) NOT NULL DEFAULT '',
hrs DECIMAL(6,2) NOT NULL DEFAULT 0,
descripcion VARCHAR(500) NOT NULL DEFAULT '',
autor_email VARCHAR(255) NOT NULL DEFAULT '',
fecha DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tramite (tramite_id),
INDEX idx_fecha (fecha DESC),
CONSTRAINT fk_tramites_hours FOREIGN KEY (tramite_id)
REFERENCES tramites_legales(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Contador para generar códigos ADM-001 / CRP-001 / MIG-001 / LIT-001
CREATE TABLE IF NOT EXISTS tramites_seq (
departamento VARCHAR(10) PRIMARY KEY,
next_seq INT NOT NULL DEFAULT 1
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO tramites_seq (departamento, next_seq) VALUES
('adm', 1), ('corp', 1), ('mig', 1), ('lit', 1);
+5 -5
View File
@@ -13,12 +13,12 @@ final class Apps {
'status' => 'live',
],
[
'slug' => 'casos',
'title' => 'Gestión de Casos',
'description' => 'Expedientes, plazos procesales y notas de cada asunto.',
'slug' => 'tramites',
'title' => 'Control de Trámites Legales',
'description' => 'Administrativo, corporativo, migración y litigios. Estado, observaciones, horas e historial por trámite.',
'icon' => 'briefcase',
'href' => '#',
'status' => 'coming-soon',
'href' => '/tramites/',
'status' => 'live',
],
[
'slug' => 'agenda',
+647
View File
@@ -0,0 +1,647 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Control de Trámites Legales — AALN</title>
<style>
:root{--navy:#1B3A6B;--gold:#C9A84C;--gold-light:#f5e6c2;--red:#c0392b;--green:#27ae60;--blue:#2980b9;--gray:#6c757d;--bg:#f4f6fa;--white:#fff;--border:#dee2e6;--text:#212529;}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'Segoe UI',Arial,sans-serif;background:var(--bg);color:var(--text);font-size:13px;}
header{background:var(--navy);color:#fff;padding:14px 24px;display:flex;align-items:center;justify-content:space-between;box-shadow:0 2px 8px rgba(0,0,0,.3);}
header h1{font-size:17px;font-weight:700;letter-spacing:.5px;}
header h1 span{color:var(--gold);}
.hdr-btns{display:flex;gap:8px;align-items:center;}
.hdr-btn{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.25);color:#fff;padding:5px 12px;border-radius:4px;cursor:pointer;font-size:12px;transition:.2s;text-decoration:none;}
.hdr-btn:hover{background:var(--gold);border-color:var(--gold);color:var(--navy);}
.hdr-user{display:flex;align-items:center;gap:8px;font-size:11.5px;color:rgba(255,255,255,.85);padding:4px 10px;background:rgba(255,255,255,.08);border-radius:14px;}
.sync-dot{width:7px;height:7px;border-radius:50%;background:#4CAF50;}
.sync-dot.syncing{background:var(--gold);}
.sync-dot.error{background:#ef5350;}
.tabs{display:flex;background:var(--navy);padding:0 16px;gap:2px;border-bottom:3px solid var(--gold);}
.tab{padding:10px 18px;color:rgba(255,255,255,.65);cursor:pointer;font-size:12.5px;font-weight:600;border-radius:4px 4px 0 0;transition:.2s;white-space:nowrap;}
.tab:hover{color:#fff;background:rgba(255,255,255,.1);}
.tab.active{background:var(--gold);color:var(--navy);}
.panel{display:none;padding:16px;}
.panel.active{display:block;}
.toolbar{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center;}
.toolbar input,.toolbar select{padding:6px 10px;border:1px solid var(--border);border-radius:4px;font-size:12px;background:#fff;}
.toolbar input{flex:1;min-width:160px;}
.btn{padding:6px 14px;border:none;border-radius:4px;cursor:pointer;font-size:12px;font-weight:600;transition:.2s;}
.btn-primary{background:var(--navy);color:#fff;}
.btn-primary:hover{background:#14305a;}
.btn-gold{background:var(--gold);color:var(--navy);}
.btn-gold:hover{background:#b8923e;}
.btn-sm{padding:3px 9px;font-size:11px;}
.btn-danger{background:#e74c3c;color:#fff;}
.btn-danger:hover{background:#c0392b;}
.tbl-wrap{overflow-x:auto;background:#fff;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,.08);}
table{width:100%;border-collapse:collapse;font-size:12px;}
th{background:var(--navy);color:#fff;padding:8px 10px;text-align:left;font-weight:600;white-space:nowrap;}
td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:top;}
tr:hover>td{background:#f8f9ff;}
tr.expanded>td{background:#fffdf5;}
.toggle-btn{background:none;border:none;cursor:pointer;font-size:14px;color:var(--navy);font-weight:700;}
.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10.5px;font-weight:700;white-space:nowrap;}
.b-solicitado{background:#e8f4fd;color:#1a6898;}
.b-recibido{background:#e8f0e8;color:#2e6b2e;}
.b-enproceso,.b-borrador,.b-presentado{background:#fff3cd;color:#856404;}
.b-notificado,.b-admitido{background:#d1ecf1;color:#0c5460;}
.b-recurso,.b-apelacion{background:#f8d7da;color:#721c24;}
.b-culminado,.b-firmado,.b-sentencia{background:#d4edda;color:#155724;}
.b-facturado{background:#e2e3e5;color:#383d41;}
.b-versionfirma{background:#fce4ec;color:#880e4f;}
.b-contestado,.b-pruebas{background:#ede7f6;color:#4527a0;}
.b-contrapruebas,.b-alegatos{background:#e3f2fd;color:#0d47a1;}
.b-civil{background:#bbdefb;color:#0d47a1;}
.b-penal{background:#ffcdd2;color:#b71c1c;}
.b-primera{background:#fff9c4;color:#f57f17;}
.b-segunda{background:#ffe0b2;color:#e65100;}
.b-csj{background:#f3e5f5;color:#6a1b9a;}
.b-si{background:#d4edda;color:#155724;}
.b-no{background:#f8d7da;color:#721c24;}
.detail-row td{background:#fafbfc;padding:0;}
.detail-inner{padding:14px 16px;border-left:4px solid var(--gold);}
.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px;}
@media(max-width:700px){.detail-grid{grid-template-columns:1fr;}}
.detail-card{background:#fff;border:1px solid var(--border);border-radius:6px;padding:10px 14px;}
.detail-card h4{font-size:11px;font-weight:700;color:var(--navy);margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--border);padding-bottom:5px;}
.detail-card p{font-size:12px;margin-bottom:4px;line-height:1.5;}
.detail-card p b{color:var(--navy);}
.instr-banner{background:var(--gold-light);border:1px solid var(--gold);border-radius:5px;padding:8px 12px;font-size:12px;margin-bottom:12px;color:#6b4c00;}
.obs-list{list-style:none;max-height:140px;overflow-y:auto;}
.obs-list li{font-size:11.5px;padding:5px 0;border-bottom:1px dashed #eee;line-height:1.5;}
.obs-list li .obs-meta{font-size:10px;color:var(--gray);}
.add-obs{display:flex;gap:6px;margin-top:8px;}
.add-obs textarea{flex:1;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:5px 8px;resize:vertical;min-height:48px;}
.hours-list{list-style:none;}
.hours-list li{font-size:11.5px;padding:3px 0;display:flex;justify-content:space-between;border-bottom:1px dashed #eee;}
.hours-list li .hl-who{color:var(--navy);font-weight:600;}
.add-hours{display:flex;gap:6px;margin-top:8px;align-items:center;flex-wrap:wrap;}
.add-hours select,.add-hours input{font-size:12px;padding:4px 8px;border:1px solid var(--border);border-radius:4px;}
.total-hrs{font-size:11px;font-weight:700;color:var(--navy);margin-top:6px;}
.progress-wrap{margin:8px 0;}
.progress-label{font-size:10px;color:var(--gray);margin-bottom:3px;}
.progress-bar{height:8px;background:#e9ecef;border-radius:4px;overflow:hidden;}
.progress-fill{height:100%;border-radius:4px;background:var(--gold);transition:.3s;}
.vence-alert{color:var(--red);font-weight:700;font-size:10.5px;}
.last-upd{font-size:10px;color:var(--gray);font-style:italic;}
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:1000;align-items:center;justify-content:center;}
.modal-overlay.open{display:flex;}
.modal{background:#fff;border-radius:8px;width:96%;max-width:580px;max-height:90vh;overflow-y:auto;box-shadow:0 8px 32px rgba(0,0,0,.25);}
.modal-header{background:var(--navy);color:#fff;padding:14px 18px;display:flex;justify-content:space-between;align-items:center;border-radius:8px 8px 0 0;position:sticky;top:0;z-index:1;}
.modal-header h3{font-size:14px;}
.modal-close{background:none;border:none;color:#fff;font-size:20px;cursor:pointer;line-height:1;}
.modal-body{padding:18px;}
.form-group{margin-bottom:12px;}
.form-group label{display:block;font-size:11.5px;font-weight:700;color:var(--navy);margin-bottom:4px;text-transform:uppercase;letter-spacing:.4px;}
.form-group input,.form-group select,.form-group textarea{width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:4px;font-size:12.5px;}
.form-group textarea{resize:vertical;min-height:56px;}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
.modal-footer{padding:12px 18px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px;}
.counts{display:flex;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
.count-card{background:#fff;border-radius:6px;padding:8px 14px;border-left:4px solid var(--navy);box-shadow:0 1px 3px rgba(0,0,0,.07);font-size:12px;}
.count-card b{font-size:18px;display:block;color:var(--navy);}
.empty-state{text-align:center;padding:40px;color:var(--gray);font-size:13px;}
.toast{position:fixed;bottom:24px;right:24px;background:#1B3A6B;color:#fff;padding:10px 18px;border-radius:6px;font-size:12px;box-shadow:0 4px 14px rgba(0,0,0,.2);border-left:3px solid var(--gold);transform:translateY(20px);opacity:0;transition:.25s;z-index:2000;}
.toast.show{transform:translateY(0);opacity:1;}
.toast.error{border-left-color:#ef5350;}
</style>
</head>
<body>
<header>
<h1>⚖️ <span>AALN</span> — Control de Trámites Legales</h1>
<div class="hdr-btns">
<span class="hdr-user">
<span class="sync-dot" id="sync-dot"></span>
<span id="sync-label">Cargando…</span>
</span>
<a class="hdr-btn" href="/">← Portal</a>
<a class="hdr-btn" href="/auth/logout.php" onclick="return confirm('¿Cerrar sesión?')">Salir</a>
</div>
</header>
<div class="tabs">
<div class="tab active" onclick="switchTab('adm')">🏛 Administrativo</div>
<div class="tab" onclick="switchTab('corp')">⚖️ Corp · Com · Civil</div>
<div class="tab" onclick="switchTab('mig')">✈️ Migración</div>
<div class="tab" onclick="switchTab('lit')">⚔️ Litigios</div>
</div>
<div id="panel-adm" class="panel active"></div>
<div id="panel-corp" class="panel"></div>
<div id="panel-mig" class="panel"></div>
<div id="panel-lit" class="panel"></div>
<div class="modal-overlay" id="modal">
<div class="modal">
<div class="modal-header">
<h3 id="modal-title">Nuevo Trámite</h3>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="modal-body" id="modal-body"></div>
<div class="modal-footer">
<button class="btn" onclick="closeModal()">Cancelar</button>
<button class="btn btn-primary" onclick="saveModal()">Guardar</button>
</div>
</div>
</div>
<div id="toast" class="toast"></div>
<script>
const ABOGADOS = ['Arias','Abrego','Lopez','Altafulla','Álvarez','Gutiérrez','Cedeño','Mariscal','Ávila','Agudo','Martínez','De Gracia','Góndola','González','Valdés'];
const PROVINCIAS = ['Bocas del Toro','Chiriquí','Coclé','Colón','Darién','Herrera','Los Santos','Panamá','Panamá Oeste','Veraguas','Comarca Ngäbe-Buglé','Comarca Guna Yala','Comarca Emberá-Wounaan'];
const STATUS_MAP = {
adm: ['Solicitado','Recibido','En Proceso','Notificado','Recurso','Culminado','Facturado'],
corp: ['Solicitado','Recibido','Borrador','Versión de Firma','Firmado','Culminado','Facturado'],
mig: ['Solicitado','Recibido','Presentado','Notificado','Recurso','Culminado','Facturado'],
lit: ['Solicitado','Recibido','Presentado','Admitido','Contestado','Pruebas','Contrapruebas','Alegatos','Sentencia','Apelación']
};
const DEPT_LABELS = {adm:'Trámite Admin',corp:'Corp/Com/Civil',mig:'Migración',lit:'Litigios'};
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:''};
function esc(s){return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function setSync(state,label){
const dot=document.getElementById('sync-dot');
dot.className='sync-dot'+(state!=='ok'?' '+state:'');
document.getElementById('sync-label').textContent=label;
}
function toast(msg,type='ok'){
const el=document.getElementById('toast');
el.textContent=msg;
el.className='toast show'+(type==='error'?' error':'');
setTimeout(()=>{el.className='toast'+(type==='error'?' error':'');},3000);
}
function fmtDate(dt){
if(!dt) return '';
const d=new Date(dt); if(isNaN(d)) return dt;
return d.toLocaleDateString('es-PA',{day:'2-digit',month:'2-digit',year:'numeric'})
+' '+d.toLocaleTimeString('es-PA',{hour:'2-digit',minute:'2-digit'});
}
function fmtDateOnly(dt){
if(!dt) return '';
if(/^\d{4}-\d{2}-\d{2}$/.test(dt)) {
const [y,m,d]=dt.split('-');
return `${d}/${m}/${y}`;
}
return dt;
}
async function apiCall(method, action, body=null, query=''){
const url = API + '?action=' + action + (query?'&'+query:'');
const opts = {method, headers:{'Accept':'application/json'}, credentials:'same-origin'};
if(body){ opts.headers['Content-Type']='application/json'; opts.body=JSON.stringify(body); }
const res = await fetch(url, opts);
let j;
try { j = await res.json(); } catch(e){ throw new Error('Respuesta inválida del servidor'); }
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 adaptRow(r){
return {
...r,
_id: r.id,
id : r.codigo,
fechaSolicitud: fmtDate(r.fecha_solicitud),
docCompleta: r.doc_completa,
permisoTrabajo: r.permiso_trabajo,
dependientes: r.dependientes,
fechaVencimiento: r.fecha_vencimiento || '',
tipoDocumento: r.tipo_documento,
tipoTramite: r.tipo_tramite,
tipoProceso: r.tipo_proceso,
fechaTermino: r.fecha_termino || '',
obs: (r.obs||[]).map(o=>({...o, fecha:fmtDate(o.fecha)})),
hours: (r.hours||[]).map(h=>({...h, fecha:fmtDate(h.fecha)})),
};
}
async function loadAll(){
setSync('syncing','Cargando…');
try {
const payload = await apiCall('GET','list');
if(payload === null) return;
data = {
adm: (payload.adm || []).map(adaptRow),
corp: (payload.corp || []).map(adaptRow),
mig: (payload.mig || []).map(adaptRow),
lit: (payload.lit || []).map(adaptRow),
};
setSync('ok', authUser.email ? authUser.email.split('@')[0] : 'Conectado');
renderAll();
} catch(e){
setSync('error','Sin conexión');
toast('Error al cargar: '+e.message,'error');
}
}
function switchTab(dept){
currentDept = dept;
document.querySelectorAll('.tab').forEach((t,i)=>{
t.classList.toggle('active',['adm','corp','mig','lit'][i]===dept);
});
document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
document.getElementById('panel-'+dept).classList.add('active');
renderPanel(dept);
}
function renderAll(){['adm','corp','mig','lit'].forEach(renderPanel);}
function renderPanel(dept){
const panel=document.getElementById('panel-'+dept);
const rows=data[dept];
const statuses=STATUS_MAP[dept];
const search=(panel.querySelector('.search-input')||{value:''}).value.toLowerCase();
const fStatus=(panel.querySelector('.f-status')||{value:''}).value;
const fAbog=(panel.querySelector('.f-abog')||{value:''}).value;
const fEsfera=(panel.querySelector('.f-esfera')||{value:''}).value;
let filtered=rows.filter(r=>{
const txt=((r.nombre||r.tipoTramite||r.tipoProceso||'')+(r.cliente||'')+(r.abogado||'')+(r.id||'')).toLowerCase();
if(search && !txt.includes(search)) return false;
if(fStatus && r.estatus!==fStatus) return false;
if(fAbog && r.abogado!==fAbog) return false;
if(dept==='lit' && fEsfera && r.esfera!==fEsfera) return false;
return true;
});
const total=rows.length;
const activos=rows.filter(r=>r.estatus!=='Culminado'&&r.estatus!=='Facturado').length;
const culminados=rows.filter(r=>r.estatus==='Culminado'||r.estatus==='Facturado').length;
panel.innerHTML=`
<div class="counts">
<div class="count-card"><b>${total}</b>Total</div>
<div class="count-card"><b>${activos}</b>Activos</div>
<div class="count-card"><b>${culminados}</b>Cerrados</div>
</div>
<div class="toolbar">
<input class="search-input" placeholder="Buscar..." oninput="renderPanel('${dept}')" value="${esc(search)}">
<select class="f-status" onchange="renderPanel('${dept}')">
<option value="">— Estatus —</option>
${statuses.map(s=>`<option${fStatus===s?' selected':''}>${s}</option>`).join('')}
</select>
<select class="f-abog" onchange="renderPanel('${dept}')">
<option value="">— Abogado —</option>
${ABOGADOS.map(a=>`<option${fAbog===a?' selected':''}>${a}</option>`).join('')}
</select>
${dept==='lit'?`<select class="f-esfera" onchange="renderPanel('${dept}')">
<option value="">— Esfera —</option>
<option${fEsfera==='Civil'?' selected':''}>Civil</option><option${fEsfera==='Penal'?' selected':''}>Penal</option>
</select>`:''}
<button class="btn btn-primary" onclick="openModal('${dept}',null)">+ Nuevo</button>
</div>
<div class="tbl-wrap">
<table>
<thead><tr>${buildHeaders(dept)}</tr></thead>
<tbody id="tbody-${dept}">
${filtered.length?filtered.map(r=>buildRow(dept,r)).join(''):`<tr><td colspan="20"><div class="empty-state">Sin registros. Haz clic en <b>+ Nuevo</b> para agregar.</div></td></tr>`}
</tbody>
</table>
</div>`;
}
function buildHeaders(dept){
const base=`<th></th><th>ID</th>`;
if(dept==='adm') return base+`<th>Institución</th><th>Nombre Trámite</th><th>Cliente</th><th>Abogado</th><th>Estatus</th><th>Fecha Solicitud</th><th>Horas</th><th>Acciones</th>`;
if(dept==='corp') return base+`<th>Tipo Documento</th><th>Nombre Trámite</th><th>Cliente</th><th>Abogado</th><th>Estatus</th><th>Fecha Solicitud</th><th>Horas</th><th>Acciones</th>`;
if(dept==='mig') return base+`<th>Tipo Trámite</th><th>Cliente</th><th>Abogado</th><th>Doc. Completa</th><th>Perm. Trabajo</th><th>Dependientes</th><th>Estatus</th><th>Fecha Solicitud</th><th>Vencimiento</th><th>Horas</th><th>Acciones</th>`;
if(dept==='lit') return base+`<th>Proceso / Juzgado</th><th>Cliente</th><th>Abogado</th><th>Esfera</th><th>Instancia</th><th>Doc. Completa</th><th>Incidentes</th><th>Amparo</th><th>Estatus</th><th>Fecha Solicitud</th><th>Término</th><th>Horas</th><th>Acciones</th>`;
}
function statusSlug(s){return (s||'').toLowerCase().replace(/\s+/g,'').replace(/\./g,'').replace(/[éèê]/g,'e').replace(/[áàâ]/g,'a').replace(/[íì]/g,'i').replace(/[óò]/g,'o').replace(/[úù]/g,'u');}
function statusBadge(s){return `<span class="badge b-${statusSlug(s)}">${esc(s)}</span>`;}
function yesnoBadge(v){return v?`<span class="badge ${v==='Sí'?'b-si':'b-no'}">${esc(v)}</span>`:'—';}
function totalHours(r){
if(!r.hours||!r.hours.length) return 0;
return r.hours.reduce((s,h)=>s+(parseFloat(h.hrs)||0),0).toFixed(1);
}
function venceAlert(fecha){
if(!fecha) return '';
const diff=(new Date(fecha)-new Date())/(1000*60*60*24);
if(diff<0) return `<br><span class="vence-alert">⚠ Vencido</span>`;
if(diff<=30) return `<br><span class="vence-alert">⚠ ${Math.round(diff)}d</span>`;
return '';
}
function buildRow(dept,r){
let cells='';
if(dept==='adm') cells=`<td>${esc(r.institucion)||'—'}</td><td>${esc(r.nombre)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td><td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${totalHours(r)} h</td>`;
if(dept==='corp') cells=`<td>${esc(r.tipoDocumento)||'—'}</td><td>${esc(r.nombre)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td><td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${totalHours(r)} h</td>`;
if(dept==='mig') cells=`<td>${esc(r.tipoTramite)||'—'}</td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td><td>${yesnoBadge(r.docCompleta)}</td><td>${yesnoBadge(r.permisoTrabajo)}</td><td>${yesnoBadge(r.dependientes)}</td><td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${fmtDateOnly(r.fechaVencimiento)||'—'}${venceAlert(r.fechaVencimiento)}</td><td>${totalHours(r)} h</td>`;
if(dept==='lit'){
const esfera=r.esfera||'';
const inst=r.instancia||'';
cells=`<td><b>${esc(r.tipoProceso)||'—'}</b><br><small>${esc(r.juzgado)||'—'} · ${esc(r.circuito)||'—'}</small></td><td>${esc(r.cliente)||'—'}</td><td>${esc(r.abogado)||'—'}</td><td>${esfera?`<span class="badge b-${esfera.toLowerCase()}">${esfera}</span>`:'—'}</td><td>${inst?`<span class="badge b-${statusSlug(inst)}">${inst}</span>`:'—'}</td><td>${yesnoBadge(r.docCompleta)}</td><td>${yesnoBadge(r.incidentes)}</td><td>${yesnoBadge(r.amparo)}</td><td>${statusBadge(r.estatus)}</td><td>${esc(r.fechaSolicitud)||'—'}</td><td>${fmtDateOnly(r.fechaTermino)||'—'}${venceAlert(r.fechaTermino)}</td><td>${totalHours(r)} h</td>`;
}
const actions=`<td><button class="btn btn-gold btn-sm" onclick="openModal('${dept}','${r.id}');event.stopPropagation()">✎</button> <button class="btn btn-danger btn-sm" onclick="deleteRow('${dept}','${r.id}');event.stopPropagation()">✕</button></td>`;
return `<tr id="row-${r.id}" onclick="toggleDetail(event,'${dept}','${r.id}')">
<td><button class="toggle-btn" id="tog-${r.id}">▶</button></td>
<td><b>${esc(r.id)}</b></td>
${cells}${actions}
</tr>
<tr class="detail-row" id="det-${r.id}" style="display:none">
<td colspan="20">${buildDetail(dept,r)}</td>
</tr>`;
}
function buildDetail(dept,r){
let progress='';
if(dept==='lit'){
const steps=STATUS_MAP.lit;
const idx=steps.indexOf(r.estatus);
const pct=idx>=0?Math.round(((idx+1)/steps.length)*100):0;
const col=pct>=70?'#27ae60':pct>=40?'#2980b9':'var(--gold)';
progress=`<div class="progress-wrap">
<div class="progress-label">Avance procesal: ${pct}% — ${esc(r.estatus)||'—'}</div>
<div class="progress-bar"><div class="progress-fill" style="width:${pct}%;background:${col}"></div></div>
</div>`;
}
let ficha='';
if(dept==='adm') ficha=`<p><b>Institución:</b> ${esc(r.institucion)||'—'}</p><p><b>Nombre:</b> ${esc(r.nombre)||'—'}</p>`;
if(dept==='corp') ficha=`<p><b>Tipo Documento:</b> ${esc(r.tipoDocumento)||'—'}</p><p><b>Nombre:</b> ${esc(r.nombre)||'—'}</p>`;
if(dept==='mig') ficha=`<p><b>Tipo:</b> ${esc(r.tipoTramite)||'—'}</p><p><b>Doc. Completa:</b> ${esc(r.docCompleta)||'—'}</p><p><b>Permiso Trabajo:</b> ${esc(r.permisoTrabajo)||'—'}</p><p><b>Dependientes:</b> ${esc(r.dependientes)||'—'}</p><p><b>Vencimiento:</b> ${fmtDateOnly(r.fechaVencimiento)||'—'}</p>`;
if(dept==='lit') ficha=`<p><b>Esfera:</b> ${esc(r.esfera)||'—'}</p><p><b>Instancia:</b> ${esc(r.instancia)||'—'}</p><p><b>Circuito:</b> ${esc(r.circuito)||'—'}</p><p><b>Juzgado:</b> ${esc(r.juzgado)||'—'}</p><p><b>Tipo Proceso:</b> ${esc(r.tipoProceso)||'—'}</p><p><b>Incidentes:</b> ${esc(r.incidentes)||'—'} | <b>Amparo:</b> ${esc(r.amparo)||'—'}</p><p><b>Término:</b> ${fmtDateOnly(r.fechaTermino)||'—'}</p>`;
const obs=(r.obs||[]).map(o=>`<li>${esc(o.texto)}<br><span class="obs-meta">${esc(o.abogado)} · ${esc(o.fecha)}${o.autor_email?` · <i>${esc(o.autor_email.split('@')[0])}</i>`:''}</span></li>`).join('');
const lastUpd=r.obs&&r.obs.length?`<div class="last-upd">Última actualización: ${esc(r.obs[r.obs.length-1].fecha)}</div>`:'';
const hrs=(r.hours||[]).map(h=>`<li><span class="hl-who">${esc(h.abogado)}</span><span>${h.hrs} h · ${esc(h.fecha)}</span></li>`).join('');
const totalH=totalHours(r);
const statusSel=`<select onchange="changeStatus('${dept}','${r.id}',this.value)" onclick="event.stopPropagation()">
${STATUS_MAP[dept].map(s=>`<option${r.estatus===s?' selected':''}>${esc(s)}</option>`).join('')}
</select>`;
return `<div class="detail-inner">
${progress}
${r.instrucciones?`<div class="instr-banner">📌 <b>Instrucciones:</b> ${esc(r.instrucciones)}</div>`:''}
<div class="detail-grid">
<div class="detail-card">
<h4>Ficha del Trámite</h4>
${ficha}
<p><b>Cliente:</b> ${esc(r.cliente)||'—'}</p>
<p><b>Abogado:</b> ${esc(r.abogado)||'—'}</p>
<p><b>Fecha Solicitud:</b> ${esc(r.fechaSolicitud)||'—'}</p>
<p><b>Estatus:</b> ${statusSel}</p>
</div>
<div class="detail-card">
<h4>Observaciones</h4>
${obs?`<ul class="obs-list">${obs}</ul>`:'<p style="color:var(--gray);font-size:11px">Sin observaciones.</p>'}
${lastUpd}
<div class="add-obs" onclick="event.stopPropagation()">
<textarea id="obs-txt-${r.id}" placeholder="Nueva observación..."></textarea>
<select id="obs-abog-${r.id}">${ABOGADOS.map(a=>`<option>${a}</option>`).join('')}</select>
<button class="btn btn-primary btn-sm" onclick="addObs('${dept}','${r.id}')">+</button>
</div>
</div>
<div class="detail-card">
<h4>Horas Incurridas</h4>
${hrs?`<ul class="hours-list">${hrs}</ul>`:'<p style="color:var(--gray);font-size:11px">Sin horas registradas.</p>'}
<div class="total-hrs">Total: ${totalH} h</div>
<div class="add-hours" onclick="event.stopPropagation()">
<select id="hr-abog-${r.id}">${ABOGADOS.map(a=>`<option>${a}</option>`).join('')}</select>
<input type="number" id="hr-val-${r.id}" min="0.1" step="0.1" placeholder="Horas" style="width:70px">
<input type="text" id="hr-desc-${r.id}" placeholder="Descripción" style="flex:1">
<button class="btn btn-primary btn-sm" onclick="addHours('${dept}','${r.id}')">+</button>
</div>
</div>
</div>
</div>`;
}
function findRow(dept,codigo){return data[dept].find(x=>x.id===codigo);}
function toggleDetail(e,dept,codigo){
if(e.target.tagName==='BUTTON'||e.target.tagName==='SELECT'||e.target.tagName==='TEXTAREA'||e.target.tagName==='INPUT') return;
const det=document.getElementById('det-'+codigo);
const tog=document.getElementById('tog-'+codigo);
const row=document.getElementById('row-'+codigo);
if(!det) return;
const open=det.style.display==='table-row';
det.style.display=open?'none':'table-row';
tog.textContent=open?'▶':'▼';
row.classList.toggle('expanded',!open);
}
async function changeStatus(dept,codigo,val){
const r=findRow(dept,codigo); if(!r) return;
const prev=r.estatus;
r.estatus=val;
try {
await apiCall('PATCH','set_status',{_id:r._id, estatus:val});
toast('Estatus actualizado');
renderPanel(dept);
} catch(e){
r.estatus=prev;
toast('Error: '+e.message,'error');
renderPanel(dept);
}
}
async function addObs(dept,codigo){
const r=findRow(dept,codigo); if(!r) return;
const txt =document.getElementById('obs-txt-' +codigo);
const abog=document.getElementById('obs-abog-'+codigo);
if(!txt||!txt.value.trim()){toast('Escribe la observación','error');return;}
try {
const updated = await apiCall('POST','add_obs',{_id:r._id, texto:txt.value.trim(), abogado:abog.value});
Object.assign(r, adaptRow(updated));
renderPanel(dept);
setTimeout(()=>reopenDetail(codigo), 30);
toast('Observación añadida');
} catch(e){ toast('Error: '+e.message,'error'); }
}
async function addHours(dept,codigo){
const r=findRow(dept,codigo); if(!r) return;
const abog=document.getElementById('hr-abog-'+codigo);
const val =document.getElementById('hr-val-' +codigo);
const desc=document.getElementById('hr-desc-'+codigo);
if(!val||!val.value||parseFloat(val.value)<=0){toast('Horas debe ser > 0','error');return;}
try {
const updated = await apiCall('POST','add_hours',{_id:r._id, abogado:abog.value, hrs:parseFloat(val.value), descripcion:desc?desc.value:''});
Object.assign(r, adaptRow(updated));
renderPanel(dept);
setTimeout(()=>reopenDetail(codigo), 30);
toast('Horas registradas');
} catch(e){ toast('Error: '+e.message,'error'); }
}
function reopenDetail(codigo){
const det=document.getElementById('det-'+codigo);
const tog=document.getElementById('tog-'+codigo);
const row=document.getElementById('row-'+codigo);
if(det){det.style.display='table-row';if(tog)tog.textContent='▼';if(row)row.classList.add('expanded');}
}
async function deleteRow(dept,codigo){
const r=findRow(dept,codigo); if(!r) return;
if(!confirm('¿Eliminar trámite '+codigo+'? Esta acción no se puede deshacer (incluye observaciones e horas).')) return;
try {
await apiCall('DELETE','delete',null,'id='+r._id);
data[dept]=data[dept].filter(x=>x._id!==r._id);
renderPanel(dept);
toast('Trámite eliminado');
} catch(e){ toast('Error: '+e.message,'error'); }
}
let _modalDept='';
function openModal(dept,codigo){
_modalDept=dept;
const r=codigo?findRow(dept,codigo):null;
editingId=r?r._id:null;
document.getElementById('modal-title').textContent=r?('Editar '+r.id):('Nuevo Trámite — '+DEPT_LABELS[dept]);
document.getElementById('modal-body').innerHTML=buildForm(dept,r);
document.getElementById('modal').classList.add('open');
}
function closeModal(){document.getElementById('modal').classList.remove('open');editingId=null;}
function sel(arr,val,id){return `<select id="${id}">${arr.map(a=>`<option${a===val?' selected':''}>${a}</option>`).join('')}</select>`;}
function selBlank(arr,val,id){
const opts = `<option value=""></option>` + arr.map(a=>`<option${a===val?' selected':''}>${a}</option>`).join('');
return `<select id="${id}">${opts}</select>`;
}
function inp(id,val,type='text',ph=''){return `<input type="${type}" id="${id}" value="${esc(val||'')}" placeholder="${esc(ph)}">`;}
function fld(label,html){return `<div class="form-group"><label>${label}</label>${html}</div>`;}
function buildForm(dept,r){
const abogSel=sel(ABOGADOS,r?.abogado,'f-abogado');
const instrTxt=`<textarea id="f-instrucciones">${esc(r?.instrucciones||'')}</textarea>`;
const clienteInp=inp('f-cliente',r?.cliente,'text','Nombre del cliente');
const estatusSel=sel(STATUS_MAP[dept],r?.estatus,'f-estatus');
if(dept==='adm') return `
${fld('Institución',inp('f-institucion',r?.institucion,'text','Ej: Municipio de Panamá'))}
${fld('Nombre del Trámite',inp('f-nombre',r?.nombre,'text','Nombre descriptivo'))}
${fld('Instrucciones Especiales',instrTxt)}
<div class="form-row">
${fld('Cliente',clienteInp)}
${fld('Abogado Solicitante',abogSel)}
</div>
${fld('Estatus',estatusSel)}`;
if(dept==='corp') return `
${fld('Tipo de Documento',inp('f-tipoDocumento',r?.tipoDocumento,'text','Ej: Contrato, Acta, Estatutos...'))}
${fld('Nombre del Trámite',inp('f-nombre',r?.nombre,'text','Nombre descriptivo'))}
${fld('Instrucciones Especiales',instrTxt)}
<div class="form-row">
${fld('Cliente',clienteInp)}
${fld('Abogado Solicitante',abogSel)}
</div>
${fld('Estatus',estatusSel)}`;
if(dept==='mig') return `
${fld('Tipo de Trámite',inp('f-tipoTramite',r?.tipoTramite,'text','Ej: Residencia, Visa, Naturalización...'))}
${fld('Instrucciones Especiales',instrTxt)}
<div class="form-row">
${fld('Cliente',clienteInp)}
${fld('Abogado Solicitante',abogSel)}
</div>
<div class="form-row">
${fld('Documentación Completa',selBlank(SI_NO,r?.docCompleta,'f-docCompleta'))}
${fld('Permiso de Trabajo',selBlank(SI_NO,r?.permisoTrabajo,'f-permisoTrabajo'))}
</div>
<div class="form-row">
${fld('Dependientes',selBlank(SI_NO,r?.dependientes,'f-dependientes'))}
${fld('Fecha de Vencimiento',inp('f-fechaVencimiento',r?.fechaVencimiento,'date'))}
</div>
${fld('Estatus',estatusSel)}`;
if(dept==='lit') return `
<div class="form-row">
${fld('Esfera',selBlank(['Civil','Penal'],r?.esfera,'f-esfera'))}
${fld('Instancia',selBlank(['Primera','Segunda','CSJ'],r?.instancia,'f-instancia'))}
</div>
<div class="form-row">
${fld('Circuito (Provincia)',selBlank(PROVINCIAS,r?.circuito,'f-circuito'))}
${fld('Juzgado',inp('f-juzgado',r?.juzgado,'text','Ej: 3er Juzgado Civil'))}
</div>
${fld('Tipo de Proceso',inp('f-tipoProceso',r?.tipoProceso,'text','Ej: Ejecutivo, Ordinario, Penal...'))}
${fld('Instrucciones Especiales',instrTxt)}
<div class="form-row">
${fld('Cliente',clienteInp)}
${fld('Abogado Solicitante',abogSel)}
</div>
<div class="form-row">
${fld('Documentación Completa',selBlank(SI_NO,r?.docCompleta,'f-docCompleta'))}
${fld('Incidentes',selBlank(SI_NO,r?.incidentes,'f-incidentes'))}
</div>
<div class="form-row">
${fld('Amparo',selBlank(SI_NO,r?.amparo,'f-amparo'))}
${fld('Fecha de Término',inp('f-fechaTermino',r?.fechaTermino,'date'))}
</div>
${fld('Estatus',estatusSel)}`;
}
function g(id){const el=document.getElementById(id);return el?el.value:'';}
async function saveModal(){
const dept=_modalDept;
const payload = {
departamento: dept,
cliente: g('f-cliente'),
abogado: g('f-abogado'),
estatus: g('f-estatus'),
instrucciones: g('f-instrucciones'),
};
if(dept==='adm'){ payload.institucion=g('f-institucion'); payload.nombre=g('f-nombre'); }
if(dept==='corp'){ payload.tipoDocumento=g('f-tipoDocumento'); payload.nombre=g('f-nombre'); }
if(dept==='mig'){
payload.tipoTramite = g('f-tipoTramite');
payload.docCompleta = g('f-docCompleta');
payload.permisoTrabajo = g('f-permisoTrabajo');
payload.dependientes = g('f-dependientes');
payload.fechaVencimiento= g('f-fechaVencimiento');
}
if(dept==='lit'){
payload.esfera = g('f-esfera');
payload.instancia = g('f-instancia');
payload.circuito = g('f-circuito');
payload.juzgado = g('f-juzgado');
payload.tipoProceso = g('f-tipoProceso');
payload.docCompleta = g('f-docCompleta');
payload.incidentes = g('f-incidentes');
payload.amparo = g('f-amparo');
payload.fechaTermino = g('f-fechaTermino');
}
try {
let saved;
if(editingId){
payload._id = editingId;
saved = await apiCall('PUT','update',payload);
} else {
saved = await apiCall('POST','create',payload);
}
const adapted = adaptRow(saved);
const idx = data[dept].findIndex(r => r._id === adapted._id);
if(idx >= 0) data[dept][idx] = adapted; else data[dept].unshift(adapted);
closeModal();
renderPanel(dept);
toast(editingId?'Trámite actualizado':'Trámite creado: '+adapted.id);
} catch(e){
toast('Error al guardar: '+e.message,'error');
}
}
loadAll();
</script>
</body>
</html>