/* ============================================================================
 * ENAPRO · Portal de Agencias — interfaz (React)
 *
 * Este archivo se puede editar y ver el cambio simplemente refrescando el
 * navegador (F5): no hay compilación ni build. Babel lo traduce al abrir.
 *
 * Reglas del proyecto que se respetan acá:
 *  · Nada de confirm()/alert() del navegador: siempre modales in-app.
 *  · El resaltado de "destino modificado" es del lado ENAPRO, no de la agencia.
 *  · Filtros con etiqueta descriptiva explícita.
 *  · Orden de columnas del panel ENAPRO fijo:
 *    ESTADO · ENTRADA · SALIDA · BUQUE · BANDERA · PROCEDENCIA · DESTINO ·
 *    AGENCIA · TRN · ZONA · MUELLE · IMO · ITC · COMENTARIOS (+ Acciones)
 * ==========================================================================*/
const { useState, useEffect, useMemo, useCallback, useRef } = React;

/* ====== LISTAS DE REFERENCIA ====== */
/* "Consultado" = ya se le mando el correo a la agencia y se espera respuesta.
 * Es un Pendiente al que ya le preguntamos: para la agencia se comporta igual
 * (lo ve en "Por confirmar" y lo puede completar), la diferencia es que ENAPRO
 * sabe a quien ya le escribio. En el Excel esa palabra ya significaba esto. */
/* CABOTAJE es un estado propio desde el 28/09/2026. Antes era un "No
 * corresponde" con el motivo anotado aparte, y los dos casos —el buque que no
 * paga por tratamiento de bandera y el que directamente no va a facturarse—
 * quedaban en el mismo montón. Ahora cada uno tiene su cuadro.
 * El orden de esta lista es el orden de los cuadros de arriba. */
const ESTADOS = ["Pendiente", "Consultado", "Confirmado", "Rechazado", "Facturado", "Cabotaje", "No corresponde"];
// Los dos estados en los que el movimiento todavia espera que la agencia cargue
// los datos. Donde diga "esta pendiente", van los dos.
const ESTADOS_PENDIENTES = ["Pendiente", "Consultado"];
// Un movimiento sin dueña. Es la otra mitad de la clave del filtro cruzado.
function sinAgencia(s) {
  return String(s.agencia || "").trim().toUpperCase() === "SIN ASIGNAR" || !String(s.agencia || "").trim();
}
// Clave de la casilla del filtro cruzado: "Rechazado|sin", "Facturado|con"…
function casillaDe(s) {
  return s.estado + "|" + (sinAgencia(s) ? "sin" : "con");
}
/* SIN DUEÑO: las casillas del cruce que van en el correo a TODAS las agencias.
 * Los rechazados (tengan agencia anotada o no: alguna ya dijo que no es suya) y
 * los que esperan datos sin agencia. NO entran los "No corresponde" sin agencia:
 * ésos ya están cerrados y no se reclaman.
 *
 * Está acá arriba, una sola vez, porque lo usan tres cosas que tienen que decir
 * lo mismo siempre: la tarjeta "Sin asignar", el botón del panel de filtros y el
 * armado del correo en el servidor (misma regla que esAReclamar + sePuedeConsultar). */
const CASILLAS_SIN_ASIGNAR = ["Rechazado|con", "Rechazado|sin", "Pendiente|sin", "Consultado|sin"];
// Deja escondidas todas las casillas MENOS las de la lista.
function soloEstasCasillas(claves) {
  const o = {};
  ESTADOS.forEach((e) => ["con", "sin"].forEach((lado) => {
    const k = e + "|" + lado;
    if (!claves.includes(k)) o[k] = true;
  }));
  return o;
}
/* Un movimiento "a reclamar": lo devolvio otra agencia o no tiene duena. Estos
 * no van en el correo de una agencia sino en el que se manda a todas en copia
 * oculta, para que cada una mire si alguno es suyo. */
function esAReclamar(s) {
  return s.estado === "Rechazado" || String(s.agencia || "").trim().toUpperCase() === "SIN ASIGNAR";
}
// Los que se pueden tildar para consultar: los que esperan datos y los que hay
// que difundir a todas.
function sePuedeConsultar(s) {
  return ESTADOS_PENDIENTES.includes(s.estado) || s.estado === "Rechazado";
}
/* ====== PALETA DE LA MARCA (lámina institucional del ENAPRO) ======
 *   #FFCB02 Pantone 116 C    amarillo
 *   #FF7402 Pantone 1505 C   naranja
 *   #FF6D7E Pantone 1777 C   rosa
 *   #E42153 Pantone 1925 C   carmín
 *   #3C3AE5 Pantone 2728 C   violeta
 *   #494963 Pantone 5265 C   gris  (encabezado de las tablas y botón de exportar)
 *
 * La franja de arriba de los nueve cuadros sale de esa lámina: son nueve puntos
 * repartidos a lo largo del degradé amarillo → violeta, así el primero queda
 * amarillo y el último violeta, como en la lámina. El orden es el de los
 * cuadros, que NO es el de ESTADOS: "Sin asignar" se mete entre Cabotaje y No
 * corresponde, y el Total va al final (ver CUADROS, más abajo).
 *
 * OJO: esto es decoración de los cuadros, NO el color de los estados. Las
 * pastillas de la tabla (Consultado violeta, Rechazado rojo…) siguen con sus
 * colores de siempre a propósito: ahí el color es lo que te deja distinguir una
 * fila de otra de un vistazo, y la paleta de la marca es toda cálida. */
const PARADAS_MARCA = ["#FFCB02", "#FF7402", "#FF6D7E", "#E42153", "#3C3AE5"];

/* Reparte N puntos a lo largo del degradé de la marca. Antes esto era una lista
 * fija de nueve colores escritos a mano; ahora se calcula porque los cuadros se
 * pueden esconder (ver CUADROS, abajo) y con ocho o con seis la fila tiene que
 * seguir yendo de amarillo a violeta, no quedarse cortada en carmín.
 *
 * Con nueve da exactamente los mismos nueve colores de antes — la lista vieja
 * era justamente esta cuenta hecha a mano. */
function franjaCuadros(n) {
  const p = PARADAS_MARCA.map((h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]);
  if (n <= 1) return [PARADAS_MARCA[0]];
  const out = [];
  for (let i = 0; i < n; i++) {
    const x = (i / (n - 1)) * (p.length - 1);
    const a = p[Math.floor(x)], b = p[Math.min(Math.ceil(x), p.length - 1)], t = x - Math.floor(x);
    out.push("#" + a.map((v, k) => Math.round(v + (b[k] - v) * t).toString(16).padStart(2, "0")).join(""));
  }
  return out;
}

/* LOS CUADROS DE ARRIBA, en el orden en que van.
 *
 * No es el orden de ESTADOS y no son todos estados: "Sin asignar" es un cruce
 * (rechazados + sin agencia) y "Total" es una suma. Por eso la lista se escribe
 * acá y no se recorre ESTADOS a secas.
 *
 * Esta misma lista la usan la fila de cuadros Y el panel de "Cuadros" que deja
 * esconderlos, así que agregar uno nuevo se hace en un solo lugar. */
const CUADROS = [
  { clave: "Pendiente", label: "Pendiente" },
  { clave: "Consultado", label: "Consultado" },
  { clave: "Confirmado", label: "Confirmado" },
  { clave: "Rechazado", label: "Rechazado" },
  { clave: "Facturado", label: "Facturado" },
  { clave: "Cabotaje", label: "Cabotaje" },
  { clave: "sinAsignar", label: "Sin asignar" },
  { clave: "No corresponde", label: "No corresponde" },
  { clave: "total", label: "Total" },
];

/* El degradé de la lámina, de punta a punta. Se usa en las dos franjas de la
 * marca: la que cierra la tarjeta de ingreso y la que cierra la barra de
 * arriba. Va con los colores escritos y NO con variables de tema: es el
 * degradé institucional y se ve igual de día y de noche. */
const DEGRADE_MARCA = "linear-gradient(90deg,#FFCB02 0%,#FF7402 27%,#FF6D7E 50%,#E42153 72%,#3C3AE5 100%)";

const ESTADO_COLOR = {
  Pendiente: { bg: "var(--f-fef3c7)", fg: "var(--t-92400e)", dot: "var(--f-d97706)" },
  Confirmado: { bg: "var(--f-dcfce7)", fg: "var(--t-166534)", dot: "var(--f-16a34a)" },
  Rechazado: { bg: "var(--f-fee2e2)", fg: "var(--t-991b1b)", dot: "var(--f-dc2626)" },
  Facturado: { bg: "var(--f-dbeafe)", fg: "var(--t-1e40af)", dot: "var(--f-2563eb)" },
  Consultado: { bg: "var(--f-ede9fe)", fg: "var(--t-5b21b6)", dot: "var(--f-7c3aed)" },
  Cabotaje: { bg: "var(--f-e0f2fe)", fg: "var(--t-075985)", dot: "var(--f-2563eb)" },
  "No corresponde": { bg: "var(--f-f1f5f9)", fg: "var(--t-475569)", dot: "var(--f-64748b)" },
};
let PAISES = ["Afganistán","Albania","Alemania","Angola","Arabia Saudita","Argelia","Argentina","Armenia","Australia","Austria","Azerbaiyán","Bahamas","Bangladés","Barbados","Baréin","Bélgica","Belice","Benín","Bielorrusia","Birmania","Bolivia","Bosnia y Herzegovina","Botsuana","Brasil","Brunéi","Bulgaria","Burkina Faso","Burundi","Bután","Cabo Verde","Camboya","Camerún","Canadá","Catar","Chad","Chile","China","Chipre","Colombia","Comoras","Corea del Norte","Corea del Sur","Costa de Marfil","Costa Rica","Croacia","Cuba","Dinamarca","Dominica","Ecuador","Egipto","El Salvador","Emiratos Árabes Unidos","Eritrea","Eslovaquia","Eslovenia","España","Estados Unidos","Estonia","Etiopía","Filipinas","Finlandia","Fiyi","Francia","Gabón","Gambia","Georgia","Ghana","Gibraltar","Grecia","Guatemala","Guinea","Guinea-Bisáu","Guinea Ecuatorial","Guyana","Haití","Honduras","Hong Kong","Hungría","India","Indonesia","Irak","Irán","Irlanda","Isla de Man","Islandia","Islas Caimán","Islas Marshall","Israel","Italia","Jamaica","Japón","Jordania","Kazajistán","Kenia","Kirguistán","Kiribati","Kuwait","Laos","Lesoto","Letonia","Líbano","Liberia","Libia","Liechtenstein","Lituania","Luxemburgo","Madagascar","Malasia","Malaui","Maldivas","Malí","Malta","Marruecos","Mauricio","Mauritania","México","Moldavia","Mónaco","Mongolia","Montenegro","Mozambique","Namibia","Nauru","Nepal","Nicaragua","Níger","Nigeria","Noruega","Nueva Zelanda","Omán","Países Bajos","Pakistán","Panamá","Papúa Nueva Guinea","Paraguay","Perú","Polonia","Portugal","Reino Unido","República Centroafricana","República Checa","República del Congo","República Democrática del Congo","República Dominicana","Ruanda","Rumanía","Rusia","Samoa","San Cristóbal y Nieves","San Marino","San Vicente y las Granadinas","Santa Lucía","Santo Tomé y Príncipe","Senegal","Serbia","Seychelles","Sierra Leona","Singapur","Siria","Somalia","Sri Lanka","Sudáfrica","Sudán","Sudán del Sur","Suecia","Suiza","Surinam","Tailandia","Tanzania","Tayikistán","Timor Oriental","Togo","Tonga","Trinidad y Tobago","Túnez","Turkmenistán","Turquía","Tuvalu","Ucrania","Uganda","Uruguay","Uzbekistán","Vanuatu","Venezuela","Vietnam","Yemen","Yibuti","Zambia","Zimbabue"];
// Además de países, el Excel usa puertos y plazas argentinas como destino.
let DESTINOS_EXTRA = ["ROSARIO","SAN LORENZO","SAN NICOLAS","VILLA CONSTITUCION","BAHIA BLANCA","BUENOS AIRES","CAMPANA","DOCK SUD","LA PLATA","NECOCHEA","SANTA FE","ZARATE","RECALADA","SAN MARTIN","PUERTO MADRYN","USHUAIA"];
// Palabra que ENAPRO tiene que escribir en la columna ESTADO del Excel para
// cada estado del portal. Espeja ESTADO_PORTAL_A_EXCEL de lib/sync.js.
const ESTADO_EN_EXCEL = {
  // Pendiente escribe "Pendiente" (15/09/2026): ahora que "Consultado" del
  // Excel entra al portal como Consultado, cada estado escribe su propio
  // nombre. Si no, un Pendiente se iba al Excel como "Consultado" y volvia
  // convertido en Consultado sin que se le hubiera mandado ningun correo.
  Pendiente: "Pendiente",
  Consultado: "Consultado",
  Confirmado: "Confirmado",
  Rechazado: "Rechazado",
  Facturado: "Facturar",
  "No corresponde": "No corresponde",
};
// Tope del comentario. Antes NO había ninguno: ni en pantalla ni en el
// servidor, así que un pegado largo entraba entero a data/db.json y al respaldo.
// Y una celda de Excel no admite más de 32.767 caracteres: con un comentario más
// largo que eso, el respaldo .xlsx se rompía entero. 500 alcanza de sobra para
// "la bandera correcta es Panamá; el muelle es 442 VICENTIN".
const MAX_COMENTARIO = 500;

/* ZONAS, MUELLES_POR_ZONA, ZONA_DE_MUELLE y zonaDeMuelle() NO se declaran acá:
 * las define /muelles.js, que index.html carga antes de este archivo y que el
 * servidor genera a partir de lib/muelles.js.
 *
 * Estaban acá hasta el 04/09/2026, y ése era el problema: el servidor no las
 * tenía, así que no podía completar la zona de un movimiento a partir del
 * muelle. Copiarlas al servidor habría dejado dos listas destinadas a
 * desincronizarse. Ahora hay una sola, del lado del servidor, y el navegador
 * la recibe servida. */
const TIPOS_ARCHIVO = ".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png";

/* Columnas del panel ENAPRO. El ORDEN es fijo y obligatorio (regla del
 * proyecto): PASAVANTE va antes de IMO, como pidió el usuario.
 *
 * · `clave`  identifica la columna para poder ocultarla.
 * · `w`      ancho relativo. No hace falta que sumen 100: al ocultar columnas se
 *            renormalizan solas, así el ancho sobrante lo reparten las visibles.
 * · `fijo`   columnas que no se pueden ocultar (sin ellas la tabla no se usa).
 * · `celda`  cómo se dibuja el dato. Tener el render acá permite ocultar
 *            columnas sin repetir la fila entera en cada pestaña.
 */
const COLS_ADMIN = [
  // Casilla para elegir a que movimientos mandarles la consulta por correo.
  // Es fija: no se puede ocultar desde el menu de columnas.
  { clave: "sel", label: "", w: 2.2, fijo: true },
  { clave: "estado", label: "Estado", w: 8.5, fijo: true },
  { clave: "entrada", label: "Entrada", w: 5.6, nowrap: true },
  { clave: "salida", label: "Salida", w: 5.6, nowrap: true },
  { clave: "buque", label: "Buque", w: 9, fijo: true },
  { clave: "band", label: "Bandera", w: 5.9 },
  { clave: "procedencia", label: "Procedencia", w: 6.2 },
  { clave: "destino", label: "Destino", w: 6.4 },
  { clave: "agencia", label: "Agencia", w: 7.2 },
  { clave: "trn", label: "TRN", w: 4.6, nowrap: true },
  { clave: "zona", label: "Zona", w: 5.6 },
  { clave: "muelle", label: "Muelle", w: 8.2 },
  { clave: "pasavante", label: "Pasavante", w: 5.2, nowrap: true },
  { clave: "imo", label: "IMO", w: 5.2, nowrap: true },
  { clave: "itc", label: "Adj.", w: 3.4 },
  { clave: "comentario", label: "Comentarios", w: 5.1 },
  { clave: "acciones", label: "Acciones", w: 11.5, fijo: true },
];
/* Columnas de las tablas del PANEL DE AGENCIAS. Mismo mecanismo que arriba, con
 * su propia lista porque la agencia ve menos datos y en otro orden: el orden del
 * viaje (de dónde vino, a dónde fue).
 *
 * `soloEstado` marca la columna que sólo tiene sentido cuando la lista mezcla
 * estados: en "Por confirmar" todo está Pendiente y en "Rechazados" todo está
 * Rechazado, así que ahí la columna no se dibuja ni aparece en el menú.
 * `soloMios` es lo mismo para la lista de rechazados, que es de todas las
 * agencias: el pasavante de un movimiento ajeno no se muestra (y el servidor
 * tampoco lo manda, ver estadoCompleto).
 * No llevan `w`: esta tabla usa anchos naturales y desliza si hace falta.
 */
const COLS_AGENCIA = [
  { clave: "buque", label: "Buque", fijo: true },
  { clave: "estado", label: "Estado", soloEstado: true },
  { clave: "band", label: "Bandera" },
  { clave: "entrada", label: "Entrada" },
  { clave: "salida", label: "Salida" },
  /* Procedencia y Destino van SEPARADAS (25/09/2026): antes iban juntas en una
     sola columna "Procedencia → Destino". La agencia ve la misma tabla que
     ENAPRO, un dato por columna, así se puede ordenar y copiar de a uno. */
  { clave: "procedencia", label: "Procedencia" },
  { clave: "destino", label: "Destino" },
  { clave: "zona", label: "Zona" },
  { clave: "muelle", label: "Muelle" },
  { clave: "motivo", label: "Por qué está acá", soloReclamar: true },
  { clave: "pasavante", label: "Pasavante", soloMios: true },
  { clave: "adjuntos", label: "Adjuntos" },
];
// Qué columnas van en cada pestaña de la agencia.
function colsAgencia(mostrarEstado, listaAjena) {
  return COLS_AGENCIA.filter((c) =>
    (!c.soloEstado || mostrarEstado) && (!c.soloMios || !listaAjena) && (!c.soloReclamar || listaAjena));
}

/* Preferencia de columnas ocultas. Se guarda en el navegador para que no haya
 * que volver a destildar IMO en cada F5. Va con try/catch: si el navegador no
 * deja usar localStorage, simplemente no se recuerda.
 *
 * Dos claves distintas: ENAPRO y las agencias tienen columnas con el mismo
 * nombre (Bandera, Muelle, Pasavante…) y si compartieran la preferencia,
 * esconder una en un panel la escondería en el otro. */
const CLAVE_COLUMNAS = "enapro.columnasOcultas";
const CLAVE_COLUMNAS_AG = "enapro.columnasOcultasAgencia";
function leerColumnas(clave) {
  try {
    const v = JSON.parse(window.localStorage.getItem(clave || CLAVE_COLUMNAS) || "{}");
    return v && typeof v === "object" ? v : {};
  } catch (e) { return {}; }
}
function guardarColumnas(clave, v) {
  try { window.localStorage.setItem(clave || CLAVE_COLUMNAS, JSON.stringify(v)); } catch (e) {}
}

/* Cuadros escondidos (pedido del usuario, 28/09/2026: "podría tener la opción
 * de ocultar cuadros de datos?").
 *
 * Mismo mecanismo que las columnas —se guarda en el navegador de cada uno, así
 * que cada usuario del ENAPRO lo deja como quiere y a los demás no les cambia.
 *
 * ESCONDER ES SÓLO VISUAL y esto importa: los movimientos siguen estando, el
 * filtro por estado los sigue ofreciendo y el TOTAL SIGUE CONTÁNDOLOS. Si el
 * Total bajara al esconder un cuadro, el número dejaría de ser un dato y
 * pasaría a depender de cómo tenga cada uno configurada la pantalla. */
const CLAVE_CUADROS = "enapro.cuadrosOcultos";
function leerCuadrosOcultos() {
  try {
    const v = JSON.parse(window.localStorage.getItem(CLAVE_CUADROS) || "{}");
    return v && typeof v === "object" ? v : {};
  } catch (e) { return {}; }
}
function guardarCuadrosOcultos(v) {
  try { window.localStorage.setItem(CLAVE_CUADROS, JSON.stringify(v)); } catch (e) {}
}

/* MODO COMPACTO. Achica todo lo que hay ARRIBA de la tabla para que la primera
 * fila quede lo mas alta posible en la pantalla.
 *
 * Para que: ENAPRO carga la facturacion en otro programa y trabaja con las dos
 * ventanas encimadas —el portal arriba, el de facturacion abajo— copiando los
 * datos de un movimiento por vez. Cada pixel que gana la tabla es un renglon
 * mas que se ve sin tapar el otro programa. Medido: la primera fila arrancaba
 * en y=461 de 1000, o sea que casi la mitad de la pantalla era encabezado.
 *
 * Se esconden los contadores por estado y la fila de referencias, y se achica la
 * barra de arriba. Nada de eso cambia QUE movimientos se ven: la lista es
 * exactamente la misma, solo empieza mas arriba. Por eso SI se recuerda para la
 * proxima vez, al reves que el filtro de estados, que si esconde filas. */
const CLAVE_COMPACTO = "enapro.modoCompacto";
function leerCompacto() {
  try { return window.localStorage.getItem(CLAVE_COMPACTO) === "1"; } catch (e) { return false; }
}
function guardarCompacto(v) {
  try { window.localStorage.setItem(CLAVE_COMPACTO, v ? "1" : "0"); } catch (e) {}
}

/* ====== utilidades ====== */
function fmt(d) {
  if (!d) return "—";
  const [y, m, day] = String(d).slice(0, 10).split("-");
  return day && m && y ? `${day}/${m}/${y}` : d;
}
// ¿Es un año de cuatro cifras? Si no, la fecha vino mal del Excel (aparecieron
// casos como "206-07-10") y hay que mostrarla completa, no abreviada, para que
// se note el error en lugar de disimularlo.
function anioValido(y) {
  return /^\d{4}$/.test(String(y || ""));
}
// Versión corta para las tablas: dd/mm/aa. Ahorra ancho de columna.
function fmtCorto(d) {
  if (!d) return "—";
  const [y, m, day] = String(d).slice(0, 10).split("-");
  if (!day || !m || !y) return d;
  return anioValido(y) ? `${day}/${m}/${y.slice(2)}` : `${day}/${m}/${y}`;
}
function fmtHora(iso) {
  if (!iso) return "nunca";
  const d = new Date(iso);
  return d.toLocaleString("es-AR", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
}
// Días enteros desde una fecha. null si no hay fecha o no se entiende.
function diasDesde(iso) {
  const t = Date.parse(iso || "");
  if (isNaN(t)) return null;
  return Math.floor((Date.now() - t) / 86400000);
}
// "hoy" / "ayer" / "hace 5 días", para los tooltips.
function haceCuanto(iso) {
  const d = diasDesde(iso);
  if (d === null) return "";
  if (d <= 0) return "hoy";
  if (d === 1) return "ayer";
  return "hace " + d + " días";
}
/* Igual que haceCuanto pero con minutos: para cosas que pasaron recién (quién
 * está conectado ahora, quién tocó la ficha mientras la tenías abierta). Ahí
 * "hace 0 días" no dice nada. */
function haceRato(iso) {
  const t = Date.parse(iso);
  if (isNaN(t)) return "";
  const min = Math.floor((Date.now() - t) / 60000);
  if (min < 1) return "recién";
  if (min === 1) return "hace 1 minuto";
  if (min < 60) return "hace " + min + " minutos";
  const h = Math.floor(min / 60);
  if (h === 1) return "hace 1 hora";
  if (h < 24) return "hace " + h + " horas";
  return haceCuanto(iso);
}
/* Cuántos días se considera que una consulta sigue "fresca".
 * Dentro de ese plazo, volver a mandarla es repetir; pasado, ya es razonable
 * volver a preguntar y la marca se apaga sola para no ensuciar la pantalla. */
const DIAS_CONSULTA_FRESCA = 7;
// dd/mm corto, para la marca al lado del estado.
function ddmmCorto(iso) {
  const d = new Date(iso);
  if (isNaN(d.getTime())) return "";
  return String(d.getDate()).padStart(2, "0") + "/" + String(d.getMonth() + 1).padStart(2, "0");
}
/* El pasavante como número. Se ordena numérico y no como texto: hoy son todos
 * de 5 cifras y daría igual, pero el día que pasen a 6 el orden alfabético
 * pondría "100001" antes que "64822". */
function pasavanteNum(v) {
  const n = parseInt(String(v == null ? "" : v).replace(/\D/g, ""), 10);
  return isNaN(n) ? null : n;
}
function sortShips(list, mode) {
  const arr = [...list];
  if (mode === "salida_desc") arr.sort((a, b) => String(b.salida || "").localeCompare(String(a.salida || "")));
  else if (mode === "salida_asc") arr.sort((a, b) => String(a.salida || "").localeCompare(String(b.salida || "")));
  else if (mode === "reciente") arr.sort((a, b) => (b.id || 0) - (a.id || 0));
  /* Por agencia, alfabetico. Sirve para preparar los correos: las filas de la
   * misma agencia quedan juntas y se tildan de un tiron. Dentro de cada agencia
   * manda la salida mas nueva, que es el orden de siempre. Los que no tienen
   * agencia van al final: no son de nadie todavia. */
  else if (mode === "agencia_asc" || mode === "agencia_desc") {
    const dir = mode === "agencia_asc" ? 1 : -1;
    const clave = (s) => {
      const a = String(s.agencia || "").trim().toUpperCase();
      return (!a || a === "SIN ASIGNAR") ? null : a;
    };
    arr.sort((a, b) => {
      const x = clave(a), y = clave(b);
      if (x === null && y === null) return String(b.salida || "").localeCompare(String(a.salida || ""));
      if (x === null) return 1;
      if (y === null) return -1;
      if (x !== y) return x.localeCompare(y, "es") * dir;
      return String(b.salida || "").localeCompare(String(a.salida || ""));
    });
  }
  else if (mode === "pasavante_desc" || mode === "pasavante_asc") {
    // Los que no tienen pasavante van SIEMPRE al final, en los dos sentidos: son
    // los que todavía no se facturaron y no tiene sentido que encabecen la lista.
    const dir = mode === "pasavante_asc" ? 1 : -1;
    arr.sort((a, b) => {
      const x = pasavanteNum(a.pasavante), y = pasavanteNum(b.pasavante);
      if (x === null && y === null) return String(b.salida || "").localeCompare(String(a.salida || ""));
      if (x === null) return 1;
      if (y === null) return -1;
      return (x - y) * dir;
    });
  }
  return arr;
}

/* ---------------------------------------------------------------------------
 * El nombre del buque, sin las aclaraciones entre paréntesis.
 *
 * En el Excel hay nombres como "IONA ISLAND (reclama WAVE)": el paréntesis es una
 * nota de quien cargó la fila, no parte del nombre del barco, y lo mismo ya está
 * escrito en la columna COMENTARIO. En pantalla va sólo el nombre.
 *
 * Se limpia SÓLO para mostrar: el valor guardado no se toca (es la clave con la
 * que se empareja la fila del Excel) y el buscador sigue encontrando por el texto
 * completo, así que buscar "WAVE" sigue trayendo ese movimiento.
 * ------------------------------------------------------------------------- */
function nombreLimpio(v) {
  return String(v == null ? "" : v).replace(/\s*\([^)]*\)/g, " ").replace(/\s+/g, " ").trim();
}

// Lo que decía el paréntesis, y sólo si no está ya en ninguno de los dos
// comentarios. Así sacarlo del nombre no puede hacer perder información.
function notaDelNombre(s) {
  const m = String(s.buque || "").match(/\(([^)]*)\)/g);
  if (!m) return "";
  const norm = (x) => String(x || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toUpperCase();
  // Palabra por palabra, no la frase entera: el nombre dice "(reclama WAVE)" y el
  // comentario "Reclama agencia WAVE" — es la misma información con una palabra
  // más en el medio, así que comparar las frases completas no la reconocía.
  const yaEsta = norm((s.comentario || "") + " " + (s.comentarioExcel || "")).replace(/[^A-Z0-9]+/g, " ");
  const cubierta = (t) => norm(t).split(/[^A-Z0-9]+/).filter((w) => w.length > 2)
    .every((w) => yaEsta.includes(w));
  const faltan = m.map((t) => t.replace(/[()]/g, "").trim()).filter((t) => t && !cubierta(t));
  return faltan.join(" · ");
}

/* Los avisos que todavía valen la pena mostrar. Cuando ENAPRO trabaja el
 * movimiento (le cambia el estado o lo edita) el servidor anota qué avisos había
 * en ese momento; esos ya los vio y no tienen que quedar molestando para
 * siempre. Si después aparece uno NUEVO, ese sí se muestra. */
function avisosVigentes(s) {
  const avisos = s.avisos || [];
  if (!avisos.length || !s.revisado) return avisos;
  const vistos = s.revisado.avisos || [];
  return avisos.filter((a) => !vistos.includes(a.codigo));
}

/* ====== cliente de la API ====== */
async function pedir(ruta, opciones) {
  const o = Object.assign({ credentials: "same-origin" }, opciones || {});
  if (o.json) {
    o.method = o.method || "POST";
    o.headers = { "Content-Type": "application/json" };
    o.body = JSON.stringify(o.json);
    delete o.json;
  }
  let r;
  try {
    r = await fetch(ruta, o);
  } catch (e) {
    // fetch lanza TypeError "Failed to fetch" cuando NO pudo llegar al servidor.
    // Es lo que pasa si el portal está cerrado o si se está reiniciando justo en
    // ese momento. El mensaje crudo del navegador no le dice nada a nadie.
    // Sirve para la PC y para internet: ahí no hay "ventana negra" que mirar.
    const err = new Error("No se pudo conectar con el portal. Revisá tu conexión a internet; si el portal se está actualizando, esperá unos segundos y probá otra vez.");
    err.sinConexion = true;
    throw err;
  }
  let cuerpo = null;
  try { cuerpo = await r.json(); } catch (e) { cuerpo = {}; }
  if (!r.ok) {
    const err = new Error(
      // En el ingreso, el 401 es "correo o contraseña incorrectos" y hay que
      // mostrar eso; en cualquier otro lado quiere decir que la sesión se cerró.
      r.status === 401 && ruta !== "/api/login"
        ? "La sesión se cerró. Volvé a entrar."
        : cuerpo.error || "Error " + r.status
    );
    err.status = r.status;
    // El 409 es el choque de dos personas editando lo mismo: viaja con el dato
    // de quién lo tocó y cuándo, para poder mostrar el aviso (ver PISADAS).
    if (cuerpo && cuerpo.pisada) err.pisada = cuerpo.pisada;
    throw err;
  }
  return cuerpo;
}

/* ====== MODO CLARO / MODO NOCHE ======
 * Los colores viven en index.html, en dos juegos. Acá sólo se le pone (o se le
 * saca) el atributo data-tema="noche" al <html> y listo: cambia todo el portal
 * de golpe, sin volver a dibujar nada.
 *
 * El tema se guarda en el USUARIO (servidor), así lo tenés igual desde
 * cualquier computadora. La copia en localStorage es sólo para no ver un
 * destello blanco mientras el portal averigua quién sos: ver el <script> del
 * index.html. */
const CLAVE_TEMA = "enapro_tema";
function aplicarTema(tema) {
  const t = tema === "noche" ? "noche" : "claro";
  if (t === "noche") document.documentElement.setAttribute("data-tema", "noche");
  else document.documentElement.removeAttribute("data-tema");
  try { localStorage.setItem(CLAVE_TEMA, t); } catch (e) {}
}

/* Las dos tarjetas para elegir. Se usan igual en la ruedita de ENAPRO y en el
 * panel de las agencias, así el control es el mismo para todos. */
function ElegirTema({ tema, onElegir, compacto }) {
  const opcion = (valor, titulo, icono, colores) => {
    const puesto = tema === valor;
    return (
      <button key={valor} onClick={() => onElegir(valor)}
        style={{ flex: 1, minWidth: compacto ? 120 : 150, cursor: "pointer", textAlign: "left",
                 fontFamily: "inherit", fontSize: 13, fontWeight: puesto ? 800 : 700,
                 borderRadius: 12, padding: compacto ? "10px 11px" : "14px 12px",
                 border: "2px solid " + (puesto ? "var(--b-2563eb)" : "var(--b-e4e8f0)"),
                 background: colores.caja, color: colores.letra }}>
        {!compacto && (
          <div style={{ height: 34, borderRadius: 7, background: colores.papel,
                        border: "1px solid " + colores.borde, marginBottom: 9,
                        display: "flex", alignItems: "center", padding: "0 7px", gap: 5 }}>
            <span style={{ width: 26, height: 6, borderRadius: 6, background: colores.barra }} />
            <span style={{ width: 52, height: 5, borderRadius: 5, background: colores.linea }} />
            <span style={{ width: 34, height: 5, borderRadius: 5, background: colores.linea }} />
          </div>
        )}
        {icono} {titulo} {puesto ? "✓" : ""}
      </button>
    );
  };
  return (
    <div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
      {/* Las miniaturas van con los colores escritos a mano a propósito: tienen
          que mostrar cómo es CADA tema, no seguir al que está puesto. */}
      {opcion("claro", "Modo claro", "☀", { caja: "#f6f7fb", letra: "#1c2430", papel: "#ffffff", borde: "#e4e8f0", barra: "#1f4b7a", linea: "#d7dde7" })}
      {opcion("noche", "Modo noche", "☾", { caja: "#101927", letra: "#e7eef8", papel: "#161f2c", borde: "#2b3a50", barra: "#2f6ea8", linea: "#33435c" })}
    </div>
  );
}

/* ============================== APP ============================== */
function App() {
  const [datos, setDatos] = useState(null);
  const [cargando, setCargando] = useState(true);
  const [toast, setToast] = useState(null);
  // Modo compacto: vive aca porque lo prende la barra de arriba y lo usa el
  // panel de ENAPRO. Se recuerda entre sesiones (ver leerCompacto).
  const [compacto, setCompactoEstado] = useState(() => leerCompacto());
  const setCompacto = (v) => { setCompactoEstado(v); guardarCompacto(v); };
  const [config, setConfig] = useState(false);

  const notify = useCallback((msg) => {
    setToast(msg);
    setTimeout(() => setToast(null), 3200);
  }, []);

  useEffect(() => {
    pedir("/api/estado")
      .then((d) => setDatos(d))
      .catch(() => setDatos(null))
      .finally(() => setCargando(false));
  }, []);

  /* El tema del usuario manda sobre lo que hubiera guardado esta computadora:
     si entrás en otra máquina, lo tenés igual. Corre cada vez que llegan datos
     nuevos, así al elegirlo se aplica solo. */
  useEffect(() => {
    if (datos && datos.sesion) aplicarTema(datos.sesion.tema);
  }, [datos && datos.sesion && datos.sesion.tema]);

  const accion = useCallback(async (ruta, opciones, mensajeOk) => {
    try {
      const d = await pedir(ruta, opciones);
      if (d && d.sesion) setDatos(d);
      if (mensajeOk) notify(mensajeOk);
      return d;
    } catch (e) {
      /* El choque de dos personas editando lo mismo (409) NO va como cartelito
         volador: la ficha muestra el aviso completo, con quién lo tocó y los dos
         botones. El toast tapaba justamente ese aviso. */
      if (!(opciones && opciones.calla409 && e.status === 409)) notify("⚠️ " + e.message);
      // Con la sesión cerrada no sirve seguir mostrando la pantalla vieja: los
      // datos que se ven ya no se pueden tocar. Se vuelve al login.
      if (e.status === 401) setDatos(null);
      throw e;
    }
  }, [notify]);

  if (cargando) return <div style={{ ...S.loginWrap }}><div style={{ color: "var(--t-ffffff)" }}>Cargando…</div></div>;
  if (!datos) return <Login onEntrar={setDatos} notify={notify} toast={toast} />;

  const ses = datos.sesion;
  /* Las listas editables (países, lugares, muelles) reemplazan a las de fábrica
     apenas llegan del servidor. Va antes de dibujar: si no, el primer render
     arma los desplegables con la lista vieja. */
  aplicarListas(datos.listas);
  return (
    <div style={S.app}>
      <TopBar session={ses} sync={datos.sync} compacto={compacto} setCompacto={setCompacto}
              onConfig={() => setConfig(true)}
              onLogout={async () => { await pedir("/api/logout", { method: "POST" }); aplicarTema("claro"); setDatos(null); }} />
      {/* La franja de la marca cierra la barra de arriba, de punta a punta.
          Reemplaza a la línea gris que separaba la barra del resto: no agrega
          una cosa nueva a la pantalla, le cambia el color a la que ya estaba.
          Es la misma para ENAPRO y para las agencias, a propósito. */}
      <div style={S.barraTope} />
      {/* Portal de la PC con el de internet ya andando (29/09/2026). Si se
          trabaja acá por costumbre, lo hecho no lo ve nadie y los datos se
          separan del portal de verdad sin que se note. Este cartel lo evita. */}
      {datos.urlProduccion && (
        <div style={{ background: "var(--f-fef3c7)", borderBottom: "1px solid var(--b-fde68a)", color: "var(--t-78350f)",
                      padding: "9px 20px", fontSize: 13, textAlign: "center" }}>
          <b>Este es el portal de PRUEBA de tu computadora.</b> Lo que hagas acá no lo ven las agencias ni queda en el portal de internet.
          {" "}El de verdad está en <a href={datos.urlProduccion} style={{ color: "var(--t-78350f)", fontWeight: 700 }}>{datos.urlProduccion.replace(/^https?:\/\//, "")}</a>.
        </div>
      )}
      {ses.isEnapro ? (
        <EnaproPanel datos={datos} accion={accion} notify={notify} compacto={compacto} />
      ) : (
        <AgencyPanel datos={datos} accion={accion} notify={notify} />
      )}
      {config && <ConfigModal ses={ses} datos={datos} accion={accion} notify={notify} onClose={() => setConfig(false)} />}
      {/* Primer ingreso de una cuenta de ENAPRO: elegir contraseña antes de
          poder hacer nada. La de las agencias vive en su propio panel. */}
      {ses.isEnapro && ses.mustChange && (
        <PasswordModal forced accion={accion} onClose={() => {}} />
      )}
      {toast && <div style={S.toast}>{toast}</div>}
    </div>
  );
}

/* ============================== LOGIN ============================== */
function Login({ onEntrar, notify, toast }) {
  const [email, setEmail] = useState("");
  const [pass, setPass] = useState("");
  const [enviando, setEnviando] = useState(false);
  const [accesos, setAccesos] = useState(null);

  useEffect(() => {
    pedir("/api/accesos-prueba").then(setAccesos).catch(() => setAccesos(null));
  }, []);

  async function submit() {
    if (enviando) return;
    setEnviando(true);
    try {
      const d = await pedir("/api/login", { json: { email, password: pass } });
      onEntrar(d);
    } catch (e) {
      notify(e.message);
    } finally {
      setEnviando(false);
    }
  }
  return (
    <div style={S.loginWrap}>
      <div style={S.loginCard}>
        <div style={S.brandRow}>
          <LogoEnapro tam={64} placa={false} />
          <div>
            <div style={S.brandTitle}>ENAPRO · Gestión de Buques</div>
            <div style={S.brandSub}>Portal de rentas para agencias marítimas — Movimientos zonas Rosario - San Lorenzo</div>
          </div>
        </div>
        <label style={S.lab}>Correo electrónico</label>
        <input style={S.input} value={email} placeholder="usuario@agencia.com.ar" onChange={(e) => setEmail(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submit()} />
        <label style={S.lab}>Contraseña</label>
        <input style={S.input} type="password" value={pass} placeholder="••••••••" onChange={(e) => setPass(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submit()} />
        {/* En el gris institucional (Pantone 5265 C), el mismo del encabezado de
            las tablas y del botón de exportar. */}
        <button style={{ ...S.btnPrimary, background: "var(--f-494963)", width: "100%", padding: 12, fontSize: 14, marginTop: 4 }}
                onClick={submit} disabled={enviando}>{enviando ? "Ingresando…" : "Ingresar"}</button>
        {/* La lista sale de config.json → usuariosPrueba, no está escrita acá:
            así agregar una agencia para probar es una línea de config y esta
            caja no se desactualiza. Se oculta con mostrarEnLogin: false. */}
        {accesos && (accesos.admin.length > 0 || accesos.agencias.length > 0) && (
          <div style={S.demoBox}>
            <div style={{ fontWeight: 700, marginBottom: 6, color: "var(--t-0c4a6e)" }}>Accesos para probar (entorno local)</div>
            {accesos.admin.map((a) => (
              <div key={a.email} style={S.demoRow}>
                <span>{a.etiqueta}:</span>
                <code style={S.code} onClick={() => { setEmail(a.email); setPass(a.password); }}>{a.email}</code>
              </div>
            ))}
            {accesos.agencias.length > 0 && (
              <div style={{ fontSize: 12, fontWeight: 700, color: "var(--t-0c4a6e)", margin: "8px 0 4px" }}>Agencias (para ver lo que ve cada una)</div>
            )}
            {accesos.agencias.map((a) => (
              <div key={a.email} style={S.demoRow}>
                <span>{a.etiqueta}:</span>
                <code style={S.code} onClick={() => { setEmail(a.email); setPass(a.password); }}>{a.email}</code>
              </div>
            ))}
            <div style={{ fontSize: 12, color: "var(--t-64748b)", marginTop: 6 }}>Tocá un correo para autocompletar. Se configuran en <b>config.json</b>. El portal no envía correos.</div>
          </div>
        )}
        {/* La franja de la marca cierra la tarjeta, como en las piezas gráficas
            del ENAPRO. Va como último hijo y la tarjeta tiene padding-bottom 0
            y overflow hidden (ver S.loginCard), así toca los dos bordes y sigue
            la curva de las esquinas. */}
        <div style={S.barraMarca} />
      </div>
      {toast && <div style={S.toast}>{toast}</div>}
    </div>
  );
}

/* ====== logo del ENAPRO ======
 * Las olas del logo institucional, trazadas como SVG a partir del logo oficial.
 * Va en vector y no como imagen: el mismo dibujo se usa a 26px en la barra de
 * arriba y a 44px en la pantalla de ingreso, y en vector se ve nítido en los dos
 * (y en pantallas de alta resolución), sin archivos que servir ni que se pierdan
 * al copiar la carpeta.
 *
 * Va sobre una placa CLARA: el rojo del logo sobre el azul oscuro de la barra
 * queda apagado y a 26px las olas casi no se leen. La placa blanca lo despega y
 * es además como se usa el logo en papelería. */
function LogoEnapro({ tam = 28, placa = true, radio = 8 }) {
  const dibujo = (
    <svg viewBox="0 0 552 540" width={Math.round(tam * 0.72)} height={Math.round(tam * 0.72)}
         style={{ display: "block" }} aria-label="ENAPRO">
      <defs>
        <linearGradient id="olasEnapro" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#E8354A" />
          <stop offset="1" stopColor="#D6285A" />
        </linearGradient>
      </defs>
      <g fill="url(#olasEnapro)">
        <path d="M 131.7 2.0 c -0.5 1.8 -1.4 2.0 -11.6 2.0 -10.0 -0.0 -11.1 0.2 -12.1 2.0 -0.8 1.6 -2.1 2.0 -6.0 2.0 -3.9 -0.0 -5.2 0.4 -6.0 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.7 1.2 -2.1 2.0 -3.8 2.0 -1.9 -0.0 -3.7 1.2 -6.2 4.0 -2.2 2.5 -4.4 4.0 -5.8 4.0 -1.6 -0.0 -4.9 2.5 -10.2 8.0 -5.3 5.5 -8.6 8.0 -10.2 8.0 -1.4 -0.0 -3.6 1.5 -5.8 4.0 -2.5 2.8 -4.3 4.0 -6.2 4.0 -1.7 -0.0 -3.1 0.8 -3.8 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.8 1.6 -2.1 2.0 -6.0 2.0 -3.9 -0.0 -5.2 0.4 -6.0 2.0 -0.9 1.7 -2.1 2.0 -8.5 2.0 l -7.5 -0.0 0.0 40.0 0.0 40.0 9.5 -0.0 c 8.4 -0.0 9.5 -0.2 10.5 -2.0 0.8 -1.6 2.1 -2.0 6.0 -2.0 3.9 -0.0 5.2 -0.4 6.0 -2.0 0.6 -1.2 2.1 -2.0 3.6 -2.0 1.9 -0.0 4.3 -1.7 8.4 -6.0 3.6 -3.8 6.6 -6.0 8.0 -6.0 1.4 -0.0 4.9 -2.8 10.0 -8.0 5.3 -5.5 8.6 -8.0 10.2 -8.0 1.4 -0.0 3.6 -1.5 5.8 -4.0 2.5 -2.8 4.3 -4.0 6.2 -4.0 1.7 -0.0 3.1 -0.8 3.8 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.8 -1.6 2.1 -2.0 6.0 -2.0 3.9 -0.0 5.2 -0.4 6.0 -2.0 1.0 -1.8 2.1 -2.0 12.0 -2.0 9.9 -0.0 11.0 -0.2 12.0 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 0.7 4.0 2.0 1.0 1.8 2.1 2.0 14.0 2.0 11.9 -0.0 13.0 0.2 14.0 2.0 0.8 1.6 2.1 2.0 6.0 2.0 3.9 -0.0 5.2 0.4 6.0 2.0 0.7 1.3 2.1 2.0 4.0 2.0 1.9 -0.0 3.3 0.7 4.0 2.0 0.7 1.2 2.1 2.0 3.8 2.0 1.9 -0.0 3.7 1.2 6.2 4.0 2.2 2.5 4.4 4.0 5.9 4.0 1.5 -0.0 4.3 2.0 8.1 6.0 3.7 3.8 6.6 6.0 8.0 6.0 1.4 -0.0 4.3 2.2 8.0 6.0 4.1 4.3 6.5 6.0 8.4 6.0 1.5 -0.0 3.0 0.8 3.6 2.0 0.7 1.3 2.1 2.0 4.0 2.0 1.9 -0.0 3.3 0.7 4.0 2.0 0.8 1.6 2.1 2.0 6.0 2.0 3.9 -0.0 5.2 0.4 6.0 2.0 1.0 1.9 2.1 2.0 16.0 2.0 13.9 -0.0 15.0 -0.1 16.0 -2.0 0.9 -1.7 2.1 -2.0 8.0 -2.0 5.9 -0.0 7.1 -0.3 8.0 -2.0 0.6 -1.2 2.1 -2.0 3.6 -2.0 1.9 -0.0 4.3 -1.7 8.4 -6.0 3.6 -3.8 6.6 -6.0 8.0 -6.0 1.4 -0.0 4.9 -2.8 10.0 -8.0 5.3 -5.5 8.6 -8.0 10.2 -8.0 1.4 -0.0 3.6 -1.5 5.8 -4.0 2.5 -2.8 4.3 -4.0 6.2 -4.0 1.7 -0.0 3.1 -0.8 3.8 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.8 -1.6 2.1 -2.0 6.0 -2.0 3.9 -0.0 5.2 -0.4 6.0 -2.0 1.0 -1.8 2.1 -2.0 12.0 -2.0 9.9 -0.0 11.0 -0.2 12.0 -2.0 0.8 -1.6 2.1 -2.0 6.0 -2.0 3.9 -0.0 5.2 0.4 6.0 2.0 1.0 1.8 2.1 2.0 12.0 2.0 9.9 -0.0 11.0 0.2 12.0 2.0 0.8 1.6 2.1 2.0 6.0 2.0 3.9 -0.0 5.2 0.4 6.0 2.0 0.7 1.3 2.1 2.0 4.0 2.0 1.9 -0.0 3.3 0.7 4.0 2.0 0.7 1.3 2.1 2.0 4.0 2.0 1.9 -0.0 3.3 0.7 4.0 2.0 0.6 1.2 2.1 2.0 3.6 2.0 1.9 -0.0 4.7 2.1 10.4 8.0 5.1 5.2 8.6 8.0 10.0 8.0 1.4 -0.0 4.4 2.2 8.0 6.0 4.1 4.3 6.5 6.0 8.4 6.0 1.5 -0.0 3.0 0.8 3.6 2.0 0.7 1.3 2.1 2.0 4.0 2.0 1.9 -0.0 3.3 0.7 4.0 2.0 0.8 1.6 2.1 2.0 6.0 2.0 3.9 -0.0 5.2 0.4 6.0 2.0 0.9 1.7 2.1 2.0 8.5 2.0 l 7.5 -0.0 0.0 -40.0 0.0 -40.0 -7.5 -0.0 c -6.4 -0.0 -7.6 -0.3 -8.5 -2.0 -0.8 -1.6 -2.1 -2.0 -6.0 -2.0 -3.9 -0.0 -5.2 -0.4 -6.0 -2.0 -0.7 -1.3 -2.1 -2.0 -4.0 -2.0 -1.9 -0.0 -3.3 -0.7 -4.0 -2.0 -0.7 -1.2 -2.1 -2.0 -3.8 -2.0 -1.9 -0.0 -3.7 -1.2 -6.2 -4.0 -2.2 -2.5 -4.4 -4.0 -5.8 -4.0 -1.6 -0.0 -4.9 -2.5 -10.2 -8.0 -5.3 -5.5 -8.6 -8.0 -10.2 -8.0 -1.4 -0.0 -3.6 -1.5 -5.8 -4.0 -2.5 -2.8 -4.3 -4.0 -6.2 -4.0 -1.7 -0.0 -3.1 -0.8 -3.8 -2.0 -0.7 -1.3 -2.1 -2.0 -4.0 -2.0 -1.9 -0.0 -3.3 -0.7 -4.0 -2.0 -0.8 -1.6 -2.1 -2.0 -6.0 -2.0 -3.9 -0.0 -5.2 -0.4 -6.0 -2.0 -1.0 -1.9 -2.1 -2.0 -30.0 -2.0 -27.9 -0.0 -29.0 0.1 -30.0 2.0 -0.8 1.6 -2.1 2.0 -6.0 2.0 -3.9 -0.0 -5.2 0.4 -6.0 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.7 1.2 -2.1 2.0 -3.8 2.0 -1.9 -0.0 -3.7 1.2 -6.2 4.0 -2.2 2.5 -4.4 4.0 -5.8 4.0 -1.6 -0.0 -4.9 2.5 -10.2 8.0 -5.3 5.5 -8.6 8.0 -10.2 8.0 -1.4 -0.0 -3.6 1.5 -5.8 4.0 -2.5 2.8 -4.3 4.0 -6.2 4.0 -1.7 -0.0 -3.1 0.8 -3.8 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.8 1.6 -2.1 2.0 -6.0 2.0 -3.9 -0.0 -5.2 0.4 -6.0 2.0 -1.0 1.9 -2.1 2.0 -16.0 2.0 -13.9 -0.0 -15.0 -0.1 -16.0 -2.0 -0.8 -1.6 -2.1 -2.0 -6.0 -2.0 -3.9 -0.0 -5.2 -0.4 -6.0 -2.0 -0.7 -1.3 -2.1 -2.0 -4.0 -2.0 -1.9 -0.0 -3.3 -0.7 -4.0 -2.0 -0.6 -1.2 -2.1 -2.0 -3.6 -2.0 -1.9 -0.0 -4.3 -1.7 -8.4 -6.0 -3.7 -3.8 -6.6 -6.0 -8.0 -6.0 -1.4 -0.0 -4.3 -2.2 -8.0 -6.0 -3.8 -4.0 -6.6 -6.0 -8.1 -6.0 -1.5 -0.0 -3.7 -1.5 -5.9 -4.0 -2.5 -2.8 -4.3 -4.0 -6.2 -4.0 -1.7 -0.0 -3.1 -0.8 -3.8 -2.0 -0.7 -1.3 -2.1 -2.0 -4.0 -2.0 -1.9 -0.0 -3.3 -0.7 -4.0 -2.0 -0.8 -1.6 -2.1 -2.0 -6.0 -2.0 -3.9 -0.0 -5.2 -0.4 -6.0 -2.0 -1.0 -1.8 -2.1 -2.0 -12.1 -2.0 -10.2 -0.0 -11.1 -0.2 -11.6 -2.0 -0.5 -1.7 -1.4 -2.0 -6.3 -2.0 -4.9 -0.0 -5.8 0.3 -6.3 2.0 z" />
        <path d="M 128.0 158.0 c -0.9 1.8 -2.1 2.0 -10.0 2.0 -7.9 -0.0 -9.1 0.2 -10.0 2.0 -0.8 1.6 -2.1 2.0 -6.0 2.0 -3.9 -0.0 -5.2 0.4 -6.0 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.7 1.2 -2.1 2.0 -3.8 2.0 -1.9 -0.0 -3.7 1.2 -6.2 4.0 -2.2 2.5 -4.4 4.0 -5.9 4.0 -1.5 -0.0 -4.3 2.0 -8.1 6.0 -3.7 3.8 -6.6 6.0 -8.0 6.0 -1.4 -0.0 -4.3 2.2 -8.0 6.0 -4.1 4.3 -6.5 6.0 -8.4 6.0 -1.5 -0.0 -3.0 0.8 -3.6 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.8 1.6 -2.1 2.0 -6.0 2.0 -3.9 -0.0 -5.2 0.4 -6.0 2.0 -0.9 1.7 -2.1 2.0 -8.5 2.0 l -7.5 -0.0 0.0 40.0 0.0 40.0 7.5 -0.0 c 6.4 -0.0 7.6 -0.3 8.5 -2.0 0.8 -1.6 2.1 -2.0 6.0 -2.0 3.9 -0.0 5.2 -0.4 6.0 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.7 -1.2 2.1 -2.0 3.8 -2.0 1.9 -0.0 3.7 -1.2 6.2 -4.0 2.2 -2.5 4.4 -4.0 5.8 -4.0 1.6 -0.0 5.4 -3.1 12.2 -10.0 7.3 -7.4 10.5 -10.0 12.4 -10.0 1.5 -0.0 3.0 -0.8 3.6 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.9 -1.8 2.1 -2.0 10.0 -2.0 7.9 -0.0 9.1 -0.2 10.0 -2.0 1.0 -1.8 2.1 -2.0 12.0 -2.0 9.9 -0.0 11.0 0.2 12.0 2.0 1.0 1.8 2.1 2.0 12.0 2.0 9.9 -0.0 11.0 0.2 12.0 2.0 0.8 1.6 2.1 2.0 6.0 2.0 3.9 -0.0 5.2 0.4 6.0 2.0 0.7 1.2 2.1 2.0 3.8 2.0 1.9 -0.0 3.7 1.2 6.2 4.0 2.2 2.5 4.4 4.0 5.8 4.0 1.6 -0.0 4.9 2.5 10.2 8.0 5.1 5.2 8.6 8.0 10.0 8.0 1.4 -0.0 4.4 2.2 8.0 6.0 4.1 4.3 6.5 6.0 8.4 6.0 1.5 -0.0 3.0 0.8 3.6 2.0 0.7 1.3 2.1 2.0 4.0 2.0 1.9 -0.0 3.3 0.7 4.0 2.0 0.8 1.6 2.1 2.0 6.0 2.0 3.9 -0.0 5.2 0.4 6.0 2.0 1.0 1.9 2.1 2.0 16.0 2.0 13.9 -0.0 15.0 -0.1 16.0 -2.0 0.8 -1.6 2.1 -2.0 6.0 -2.0 3.9 -0.0 5.2 -0.4 6.0 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.7 -1.2 2.1 -2.0 3.8 -2.0 1.9 -0.0 3.7 -1.2 6.2 -4.0 2.2 -2.5 4.4 -4.0 5.8 -4.0 1.6 -0.0 5.4 -3.1 12.2 -10.0 7.3 -7.4 10.5 -10.0 12.4 -10.0 1.5 -0.0 3.0 -0.8 3.6 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.9 -1.8 2.1 -2.0 10.0 -2.0 7.9 -0.0 9.1 -0.2 10.0 -2.0 1.0 -1.8 2.1 -2.0 14.0 -2.0 11.9 -0.0 13.0 0.2 14.0 2.0 0.9 1.8 2.1 2.0 10.0 2.0 7.9 -0.0 9.1 0.2 10.0 2.0 0.7 1.3 2.1 2.0 4.0 2.0 1.9 -0.0 3.3 0.7 4.0 2.0 0.7 1.3 2.1 2.0 4.0 2.0 1.9 -0.0 3.3 0.7 4.0 2.0 0.7 1.3 2.1 2.0 4.0 2.0 1.9 -0.0 3.3 0.7 4.0 2.0 0.6 1.2 2.1 2.0 3.6 2.0 1.9 -0.0 4.3 1.7 8.4 6.0 3.6 3.8 6.6 6.0 8.0 6.0 1.4 -0.0 4.9 2.8 10.0 8.0 5.7 5.9 8.5 8.0 10.4 8.0 1.5 -0.0 3.0 0.8 3.6 2.0 0.8 1.6 2.1 2.0 6.0 2.0 3.9 -0.0 5.2 0.4 6.0 2.0 0.8 1.6 2.1 2.0 6.0 2.0 3.9 -0.0 5.2 0.4 6.0 2.0 0.9 1.6 2.1 2.0 6.5 2.0 5.5 -0.0 5.5 -0.0 5.5 -40.0 l 0.0 -40.0 -7.5 -0.0 c -6.4 -0.0 -7.6 -0.3 -8.5 -2.0 -0.8 -1.6 -2.1 -2.0 -6.0 -2.0 -3.9 -0.0 -5.2 -0.4 -6.0 -2.0 -0.7 -1.3 -2.1 -2.0 -4.0 -2.0 -1.9 -0.0 -3.3 -0.7 -4.0 -2.0 -0.6 -1.2 -2.1 -2.0 -3.6 -2.0 -1.9 -0.0 -4.3 -1.7 -8.4 -6.0 -3.6 -3.8 -6.6 -6.0 -8.0 -6.0 -1.4 -0.0 -4.9 -2.8 -10.0 -8.0 -5.7 -5.9 -8.5 -8.0 -10.4 -8.0 -1.5 -0.0 -3.0 -0.8 -3.6 -2.0 -0.7 -1.3 -2.1 -2.0 -4.0 -2.0 -1.9 -0.0 -3.3 -0.7 -4.0 -2.0 -0.7 -1.3 -2.1 -2.0 -4.0 -2.0 -1.9 -0.0 -3.3 -0.7 -4.0 -2.0 -0.8 -1.6 -2.1 -2.0 -6.0 -2.0 -3.9 -0.0 -5.2 -0.4 -6.0 -2.0 -1.0 -1.9 -2.1 -2.0 -30.0 -2.0 -27.9 -0.0 -29.0 0.1 -30.0 2.0 -0.8 1.6 -2.1 2.0 -6.0 2.0 -3.9 -0.0 -5.2 0.4 -6.0 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.7 1.2 -2.1 2.0 -3.8 2.0 -1.9 -0.0 -3.7 1.2 -6.2 4.0 -2.2 2.5 -4.4 4.0 -5.9 4.0 -1.5 -0.0 -4.3 2.0 -8.1 6.0 -3.7 3.8 -6.6 6.0 -8.0 6.0 -1.4 -0.0 -4.3 2.2 -8.0 6.0 -4.1 4.3 -6.5 6.0 -8.4 6.0 -1.5 -0.0 -3.0 0.8 -3.6 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.9 1.7 -2.1 2.0 -8.0 2.0 -5.9 -0.0 -7.1 0.3 -8.0 2.0 -1.0 1.8 -2.1 2.0 -12.0 2.0 -9.9 -0.0 -11.0 -0.2 -12.0 -2.0 -0.9 -1.7 -2.1 -2.0 -8.0 -2.0 -5.9 -0.0 -7.1 -0.3 -8.0 -2.0 -0.7 -1.3 -2.1 -2.0 -4.0 -2.0 -1.9 -0.0 -3.3 -0.7 -4.0 -2.0 -0.6 -1.2 -2.1 -2.0 -3.6 -2.0 -1.9 -0.0 -4.3 -1.7 -8.4 -6.0 -3.7 -3.8 -6.6 -6.0 -8.0 -6.0 -1.4 -0.0 -4.3 -2.2 -8.0 -6.0 -3.8 -4.0 -6.6 -6.0 -8.1 -6.0 -1.5 -0.0 -3.7 -1.5 -5.9 -4.0 -2.5 -2.8 -4.3 -4.0 -6.2 -4.0 -1.7 -0.0 -3.1 -0.8 -3.8 -2.0 -0.7 -1.3 -2.1 -2.0 -4.0 -2.0 -1.9 -0.0 -3.3 -0.7 -4.0 -2.0 -0.8 -1.6 -2.1 -2.0 -6.0 -2.0 -3.9 -0.0 -5.2 -0.4 -6.0 -2.0 -0.9 -1.8 -2.1 -2.0 -10.0 -2.0 -7.9 -0.0 -9.1 -0.2 -10.0 -2.0 -0.9 -1.8 -2.1 -2.0 -10.0 -2.0 -7.9 -0.0 -9.1 0.2 -10.0 2.0 z" />
        <path d="M 132.0 286.0 c -0.9 1.8 -2.1 2.0 -10.0 2.0 -7.9 -0.0 -9.1 0.2 -10.0 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.6 1.2 -2.1 2.0 -3.6 2.0 -1.9 -0.0 -4.3 1.7 -8.4 6.0 -3.7 3.8 -6.6 6.0 -8.0 6.0 -1.4 -0.0 -4.3 2.2 -8.0 6.0 -3.8 4.0 -6.6 6.0 -8.1 6.0 -1.5 -0.0 -3.7 1.5 -5.9 4.0 -2.5 2.8 -4.3 4.0 -6.2 4.0 -1.7 -0.0 -3.1 0.8 -3.8 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.9 1.7 -2.1 2.0 -8.0 2.0 -5.9 -0.0 -7.1 0.3 -8.0 2.0 -1.0 1.8 -2.1 2.0 -10.5 2.0 l -9.5 -0.0 0.0 42.0 0.0 42.0 13.5 -0.0 c 12.4 -0.0 13.5 -0.2 14.5 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.6 -1.2 2.1 -2.0 3.6 -2.0 1.9 -0.0 4.3 -1.7 8.4 -6.0 3.6 -3.8 6.6 -6.0 8.0 -6.0 1.4 -0.0 4.9 -2.8 10.0 -8.0 5.3 -5.5 8.6 -8.0 10.2 -8.0 1.4 -0.0 3.6 -1.5 5.8 -4.0 2.5 -2.8 4.3 -4.0 6.2 -4.0 1.7 -0.0 3.1 -0.8 3.8 -2.0 0.8 -1.6 2.1 -2.0 6.0 -2.0 3.9 -0.0 5.2 -0.4 6.0 -2.0 0.9 -1.7 2.1 -2.0 8.0 -2.0 5.9 -0.0 7.1 -0.3 8.0 -2.0 1.0 -1.9 2.1 -2.0 18.0 -2.0 15.9 -0.0 17.0 0.1 18.0 2.0 0.8 1.6 2.1 2.0 6.0 2.0 3.9 -0.0 5.2 0.4 6.0 2.0 0.8 1.6 2.1 2.0 6.0 2.0 3.9 -0.0 5.2 0.4 6.0 2.0 0.7 1.3 2.1 2.0 4.0 2.0 1.9 -0.0 3.3 0.7 4.0 2.0 0.7 1.2 2.1 2.0 3.8 2.0 1.9 -0.0 3.7 1.2 6.2 4.0 2.2 2.5 4.4 4.0 5.9 4.0 1.5 -0.0 4.3 2.0 8.1 6.0 3.8 4.0 6.6 6.0 8.1 6.0 1.5 -0.0 3.7 1.5 5.9 4.0 2.5 2.8 4.3 4.0 6.2 4.0 1.7 -0.0 3.1 0.8 3.8 2.0 0.7 1.3 2.1 2.0 4.0 2.0 1.9 -0.0 3.3 0.7 4.0 2.0 0.7 1.3 2.1 2.0 4.0 2.0 1.9 -0.0 3.3 0.7 4.0 2.0 1.0 1.8 2.1 2.0 11.6 2.0 9.7 -0.0 10.7 0.2 12.5 2.2 1.9 2.3 1.9 2.3 3.8 0.1 1.8 -2.1 2.8 -2.3 12.5 -2.3 9.5 -0.0 10.6 -0.2 11.6 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.6 -1.2 2.1 -2.0 3.6 -2.0 1.9 -0.0 4.3 -1.7 8.4 -6.0 3.6 -3.8 6.6 -6.0 8.0 -6.0 1.4 -0.0 4.9 -2.8 10.0 -8.0 5.7 -5.9 8.5 -8.0 10.4 -8.0 1.5 -0.0 3.0 -0.8 3.6 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.9 -1.7 2.1 -2.0 8.0 -2.0 5.9 -0.0 7.1 -0.3 8.0 -2.0 1.0 -1.9 2.1 -2.0 18.0 -2.0 15.9 -0.0 17.0 0.1 18.0 2.0 0.9 1.7 2.1 2.0 8.0 2.0 5.9 -0.0 7.1 0.3 8.0 2.0 0.8 1.6 2.1 2.0 6.0 2.0 3.9 -0.0 5.2 0.4 6.0 2.0 0.7 1.2 2.1 2.0 3.8 2.0 1.9 -0.0 3.7 1.2 6.2 4.0 2.2 2.5 4.4 4.0 5.9 4.0 1.5 -0.0 4.3 2.0 8.1 6.0 3.7 3.8 6.6 6.0 8.0 6.0 1.4 -0.0 4.3 2.2 8.0 6.0 3.8 4.0 6.6 6.0 8.1 6.0 1.5 -0.0 3.7 1.5 5.9 4.0 2.5 2.8 4.3 4.0 6.2 4.0 1.7 -0.0 3.1 0.8 3.8 2.0 1.0 1.8 2.1 2.0 14.5 2.0 l 13.5 -0.0 0.0 -42.0 0.0 -42.0 -7.5 -0.0 c -6.4 -0.0 -7.6 -0.3 -8.5 -2.0 -0.9 -1.8 -2.1 -2.0 -10.0 -2.0 -7.9 -0.0 -9.1 -0.2 -10.0 -2.0 -0.7 -1.3 -2.1 -2.0 -4.0 -2.0 -1.9 -0.0 -3.3 -0.7 -4.0 -2.0 -0.7 -1.3 -2.1 -2.0 -4.0 -2.0 -1.9 -0.0 -3.3 -0.7 -4.0 -2.0 -0.7 -1.2 -2.1 -2.0 -3.8 -2.0 -1.9 -0.0 -3.7 -1.2 -6.2 -4.0 -2.2 -2.5 -4.4 -4.0 -5.8 -4.0 -1.6 -0.0 -4.9 -2.5 -10.2 -8.0 -5.3 -5.5 -8.6 -8.0 -10.2 -8.0 -1.4 -0.0 -3.6 -1.5 -5.8 -4.0 -2.5 -2.8 -4.3 -4.0 -6.2 -4.0 -1.7 -0.0 -3.1 -0.8 -3.8 -2.0 -0.7 -1.3 -2.1 -2.0 -4.0 -2.0 -1.9 -0.0 -3.3 -0.7 -4.0 -2.0 -1.0 -1.9 -2.1 -2.0 -26.0 -2.0 -23.9 -0.0 -25.0 0.1 -26.0 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.7 1.2 -2.1 2.0 -3.8 2.0 -1.9 -0.0 -3.7 1.2 -6.2 4.0 -2.2 2.5 -4.4 4.0 -5.8 4.0 -1.6 -0.0 -5.4 3.1 -12.2 10.0 -7.3 7.4 -10.5 10.0 -12.4 10.0 -1.5 -0.0 -3.0 0.8 -3.6 2.0 -0.7 1.2 -2.1 2.0 -3.8 2.0 -1.9 -0.0 -3.7 1.2 -6.2 4.0 -3.1 3.6 -4.0 4.0 -8.2 4.0 -3.7 -0.0 -5.0 0.4 -5.8 2.0 -0.9 1.7 -2.1 2.0 -8.0 2.0 -5.9 -0.0 -7.1 0.3 -8.0 2.0 -1.0 1.9 -2.1 2.0 -18.0 2.0 -15.9 -0.0 -17.0 -0.1 -18.0 -2.0 -0.9 -1.8 -2.1 -2.0 -10.0 -2.0 -7.9 -0.0 -9.1 -0.2 -10.0 -2.0 -0.7 -1.3 -2.1 -2.0 -4.0 -2.0 -1.9 -0.0 -3.3 -0.7 -4.0 -2.0 -0.7 -1.3 -2.1 -2.0 -4.0 -2.0 -1.9 -0.0 -3.3 -0.7 -4.0 -2.0 -0.7 -1.2 -2.1 -2.0 -3.8 -2.0 -1.9 -0.0 -3.7 -1.2 -6.2 -4.0 -2.2 -2.5 -4.4 -4.0 -5.8 -4.0 -1.6 -0.0 -4.9 -2.5 -10.2 -8.0 -5.3 -5.5 -8.6 -8.0 -10.2 -8.0 -1.4 -0.0 -3.6 -1.5 -5.8 -4.0 -2.5 -2.8 -4.3 -4.0 -6.2 -4.0 -1.7 -0.0 -3.1 -0.8 -3.8 -2.0 -0.7 -1.3 -2.1 -2.0 -4.0 -2.0 -1.9 -0.0 -3.3 -0.7 -4.0 -2.0 -0.9 -1.8 -2.1 -2.0 -10.0 -2.0 -7.9 -0.0 -9.1 -0.2 -10.0 -2.0 -0.8 -1.6 -2.1 -2.0 -6.0 -2.0 -3.9 -0.0 -5.2 0.4 -6.0 2.0 z" />
        <path d="M 116.0 406.0 c -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.6 1.2 -2.1 2.0 -3.6 2.0 -1.9 -0.0 -4.7 2.1 -10.4 8.0 -5.1 5.2 -8.6 8.0 -10.0 8.0 -1.4 -0.0 -4.4 2.2 -8.0 6.0 -4.1 4.3 -6.5 6.0 -8.4 6.0 -1.5 -0.0 -3.0 0.8 -3.6 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.8 1.6 -2.1 2.0 -6.0 2.0 -3.9 -0.0 -5.2 0.4 -6.0 2.0 -1.0 1.8 -2.1 2.0 -12.0 2.0 -9.9 -0.0 -11.0 0.2 -12.0 2.0 -0.6 1.1 -1.7 2.0 -2.5 2.0 -1.3 -0.0 -1.5 5.3 -1.5 38.0 l 0.0 38.0 13.9 -0.0 c 13.0 -0.0 13.9 -0.1 14.4 -2.0 0.4 -1.7 1.4 -2.0 5.6 -2.0 4.0 -0.0 5.2 -0.4 6.1 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.6 -1.2 2.1 -2.0 3.6 -2.0 1.9 -0.0 4.3 -1.7 8.4 -6.0 3.7 -3.8 6.6 -6.0 8.0 -6.0 1.4 -0.0 4.3 -2.2 8.0 -6.0 3.8 -4.0 6.6 -6.0 8.1 -6.0 1.5 -0.0 3.7 -1.5 5.9 -4.0 2.5 -2.8 4.3 -4.0 6.2 -4.0 1.7 -0.0 3.1 -0.8 3.8 -2.0 0.8 -1.6 2.1 -2.0 6.0 -2.0 3.9 -0.0 5.2 -0.4 6.0 -2.0 1.0 -1.9 2.1 -2.0 18.0 -2.0 15.9 -0.0 17.0 0.1 18.0 2.0 0.8 1.6 2.1 2.0 6.0 2.0 3.9 -0.0 5.2 0.4 6.0 2.0 0.7 1.3 2.1 2.0 4.0 2.0 1.9 -0.0 3.3 0.7 4.0 2.0 0.7 1.2 2.1 2.0 3.8 2.0 1.9 -0.0 3.7 1.2 6.2 4.0 2.2 2.5 4.4 4.0 5.8 4.0 1.6 -0.0 4.9 2.5 10.2 8.0 5.3 5.5 8.6 8.0 10.2 8.0 1.4 -0.0 3.6 1.5 5.8 4.0 2.5 2.8 4.3 4.0 6.2 4.0 1.7 -0.0 3.1 0.8 3.8 2.0 0.8 1.6 2.1 2.0 6.0 2.0 3.9 -0.0 5.2 0.4 6.0 2.0 0.9 1.6 2.1 2.0 6.1 2.0 4.2 -0.0 5.2 0.3 5.6 2.0 0.5 1.9 1.4 2.0 26.3 2.0 24.9 -0.0 25.8 -0.1 26.3 -2.0 0.4 -1.7 1.4 -2.0 5.6 -2.0 4.0 -0.0 5.2 -0.4 6.1 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.7 -1.3 2.1 -2.0 4.0 -2.0 1.9 -0.0 3.3 -0.7 4.0 -2.0 0.6 -1.2 2.1 -2.0 3.6 -2.0 1.9 -0.0 4.3 -1.7 8.4 -6.0 3.7 -3.8 6.6 -6.0 8.0 -6.0 1.4 -0.0 4.3 -2.2 8.0 -6.0 3.8 -4.0 6.6 -6.0 8.1 -6.0 1.5 -0.0 3.7 -1.5 5.9 -4.0 2.5 -2.8 4.3 -4.0 6.2 -4.0 1.7 -0.0 3.1 -0.8 3.8 -2.0 0.8 -1.6 2.1 -2.0 6.0 -2.0 3.9 -0.0 5.2 -0.4 6.0 -2.0 1.0 -1.9 2.1 -2.0 20.0 -2.0 17.9 -0.0 19.0 0.1 20.0 2.0 0.7 1.3 2.1 2.0 4.0 2.0 1.9 -0.0 3.3 0.7 4.0 2.0 0.7 1.3 2.1 2.0 4.0 2.0 1.9 -0.0 3.3 0.7 4.0 2.0 0.7 1.2 2.1 2.0 3.8 2.0 1.9 -0.0 3.7 1.2 6.2 4.0 2.2 2.5 4.4 4.0 5.8 4.0 1.6 -0.0 4.9 2.5 10.2 8.0 5.3 5.5 8.6 8.0 10.2 8.0 1.4 -0.0 3.6 1.5 5.8 4.0 2.5 2.8 4.3 4.0 6.2 4.0 1.7 -0.0 3.1 0.8 3.8 2.0 0.7 1.3 2.1 2.0 4.0 2.0 1.9 -0.0 3.3 0.7 4.0 2.0 0.9 1.7 2.1 2.0 8.1 2.0 6.2 -0.0 7.1 0.2 7.6 2.0 0.5 1.9 1.4 2.0 12.4 2.0 l 11.9 -0.0 0.0 -38.0 c 0.0 -32.7 -0.2 -38.0 -1.5 -38.0 -0.8 -0.0 -1.9 -0.9 -2.5 -2.0 -1.0 -1.8 -2.1 -2.0 -12.0 -2.0 -9.9 -0.0 -11.0 -0.2 -12.0 -2.0 -0.8 -1.6 -2.1 -2.0 -6.0 -2.0 -3.9 -0.0 -5.2 -0.4 -6.0 -2.0 -0.7 -1.3 -2.1 -2.0 -4.0 -2.0 -1.9 -0.0 -3.3 -0.7 -4.0 -2.0 -0.7 -1.2 -2.1 -2.0 -3.8 -2.0 -1.9 -0.0 -3.7 -1.2 -6.2 -4.0 -2.2 -2.5 -4.4 -4.0 -5.8 -4.0 -1.6 -0.0 -4.9 -2.5 -10.2 -8.0 -5.1 -5.2 -8.6 -8.0 -10.0 -8.0 -1.4 -0.0 -4.4 -2.2 -8.0 -6.0 -4.1 -4.3 -6.5 -6.0 -8.4 -6.0 -1.5 -0.0 -3.0 -0.8 -3.6 -2.0 -0.8 -1.6 -2.1 -2.0 -6.0 -2.0 -3.9 -0.0 -5.2 -0.4 -6.0 -2.0 -1.0 -1.9 -2.1 -2.0 -22.0 -2.0 -19.9 -0.0 -21.0 0.1 -22.0 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.6 1.2 -2.1 2.0 -3.6 2.0 -1.9 -0.0 -4.7 2.1 -10.4 8.0 -5.1 5.2 -8.6 8.0 -10.0 8.0 -1.4 -0.0 -4.4 2.2 -8.0 6.0 -4.1 4.3 -6.5 6.0 -8.4 6.0 -1.5 -0.0 -3.0 0.8 -3.6 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.7 1.3 -2.1 2.0 -4.0 2.0 -1.9 -0.0 -3.3 0.7 -4.0 2.0 -0.8 1.6 -2.1 2.0 -6.0 2.0 -3.9 -0.0 -5.2 0.4 -6.0 2.0 -1.0 1.9 -2.1 2.0 -26.0 2.0 -23.9 -0.0 -25.0 -0.1 -26.0 -2.0 -0.9 -1.7 -2.1 -2.0 -8.0 -2.0 -5.9 -0.0 -7.1 -0.3 -8.0 -2.0 -0.7 -1.3 -2.1 -2.0 -4.0 -2.0 -1.9 -0.0 -3.3 -0.7 -4.0 -2.0 -0.7 -1.2 -2.1 -2.0 -3.8 -2.0 -1.9 -0.0 -3.7 -1.2 -6.2 -4.0 -2.2 -2.5 -4.4 -4.0 -5.8 -4.0 -1.6 -0.0 -4.9 -2.5 -10.2 -8.0 -5.1 -5.2 -8.6 -8.0 -10.0 -8.0 -1.4 -0.0 -4.4 -2.2 -8.0 -6.0 -4.1 -4.3 -6.5 -6.0 -8.4 -6.0 -1.5 -0.0 -3.0 -0.8 -3.6 -2.0 -0.8 -1.6 -2.1 -2.0 -6.0 -2.0 -3.9 -0.0 -5.2 -0.4 -6.0 -2.0 -1.0 -1.9 -2.1 -2.0 -22.0 -2.0 -19.9 -0.0 -21.0 0.1 -22.0 2.0 z" />
      </g>
    </svg>
  );
  if (!placa) return dibujo;
  return (
    <div style={{ width: tam, height: tam, borderRadius: radio, background: "var(--f-ffffff)",
                  display: "flex", alignItems: "center", justifyContent: "center",
                  boxShadow: "0 1px 3px rgba(15,41,66,.25)", flexShrink: 0 }}>
      {dibujo}
    </div>
  );
}

/* ============================== TOP BAR ============================== */
/* ====== quién más está usando el portal ahora ======
 * Aparece SÓLO cuando hay otra persona adentro: si estás solo no ocupa lugar ni
 * distrae. "Adentro" es haber hecho algo en los últimos minutos, no tener la
 * sesión abierta (la sesión dura 30 días y la tiene abierta casi todo el mundo).
 *
 * Esto NO impide que dos personas trabajen a la vez —eso sería peor el remedio
 * que la enfermedad—, sólo avisa. Lo que evita que se pisen es el aviso al
 * guardar (ver PISADAS en server.js). */
function Conectados({ compacto }) {
  const [gente, setGente] = useState([]);
  const [abierto, setAbierto] = useState(false);

  useEffect(() => {
    let vivo = true;
    const traer = () => pedir("/api/conectados")
      .then((d) => { if (vivo) setGente(d.conectados || []); })
      .catch(() => {});     // si falla, simplemente no se muestra nada
    traer();
    const t = setInterval(traer, 45000);
    return () => { vivo = false; clearInterval(t); };
  }, []);

  const otros = gente.filter((g) => !g.esVos);
  if (!otros.length) return null;

  const texto = otros.length === 1 ? "1 más adentro" : otros.length + " más adentro";
  return (
    <div style={{ position: "relative" }}>
      <button
        onClick={() => setAbierto(!abierto)}
        title={"También están usando el portal ahora: " + otros.map((g) => g.email).join(", ")}
        style={{ ...S.btnGhost, display: "flex", alignItems: "center", gap: 6,
                 background: "var(--f-e8f8ee)", borderColor: "var(--b-bbe7cb)", color: "var(--t-15803d)", fontWeight: 700 }}>
        <span style={{ width: 8, height: 8, borderRadius: 8, background: "var(--f-22c55e)", display: "inline-block" }} />
        {compacto ? otros.length : texto}
      </button>
      {abierto && (
        <div style={{ position: "absolute", right: 0, top: "calc(100% + 8px)", zIndex: 60,
                      background: "var(--f-ffffff)", color: "var(--t-0f172a)", borderRadius: 10, minWidth: 260,
                      boxShadow: "0 12px 30px rgba(0,0,0,.22)", border: "1px solid var(--b-e2e8f0)", padding: "10px 12px" }}>
          <div style={{ fontWeight: 800, fontSize: 13, marginBottom: 6 }}>Usando el portal ahora</div>
          {otros.map((g) => (
            <div key={g.email} style={{ fontSize: 12.5, padding: "4px 0", borderBottom: "1px solid var(--b-f1f5f9)" }}>
              <div style={{ fontWeight: 700 }}>{g.email}</div>
              <div style={{ color: "var(--t-64748b)" }}>
                {g.isEnapro ? "ENAPRO" : g.agencia} · última señal {haceRato(g.ultima)}
              </div>
            </div>
          ))}
          <div style={{ fontSize: 11.5, color: "var(--t-64748b)", marginTop: 8 }}>
            Pueden trabajar los dos a la vez. Si llegan a tocar el mismo movimiento,
            el portal avisa antes de guardar.
          </div>
        </div>
      )}
    </div>
  );
}

function TopBar({ session, sync, compacto, setCompacto, onConfig, onLogout }) {
  /* En compacto la barra se achica a una sola línea: el subtítulo (panel y hora
     de sincronización) pasa a ser el title del encabezado, así el dato no se
     pierde —se ve al pasar el mouse— y no gasta alto. */
  return (
    <div style={compacto ? { ...S.topbar, padding: "5px 22px" } : S.topbar}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        {/* Sin placa blanca: la barra ya es blanca, la placa no se veía. */}
        <LogoEnapro tam={compacto ? 26 : 36} placa={false} />
        <div style={compacto ? { display: "flex", alignItems: "baseline", gap: 10 } : null}
             title={compacto
               ? (session.isEnapro ? "Panel ENAPRO (Rentas)" : "Agencia: " + session.agencia)
                 + (session.isEnapro ? " · sincronizado con el Excel: " + fmtHora(sync.ultima) : "")
               : undefined}>
          <div style={{ fontWeight: 800, fontSize: compacto ? 13.5 : 15.5, letterSpacing: "-.2px" }}>ENAPRO · Gestión de Buques</div>
          {compacto ? (
            <div style={{ fontSize: 11, color: "var(--t-8a93a3)" }}>
              {session.isEnapro ? "Rentas" : session.agencia}
            </div>
          ) : (
            <div style={{ fontSize: 11.5, color: "var(--t-8a93a3)", marginTop: 2 }}>
              {session.isEnapro ? "Panel ENAPRO (Rentas)" : "Agencia: " + session.agencia}
              {session.isEnapro && <span> · sincronizado con el Excel: {fmtHora(sync.ultima)}</span>}
            </div>
          )}
        </div>
      </div>
      <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
        <Conectados compacto={compacto} />
        {session.isEnapro && (
          <button
            style={{ ...S.btnGhost,
                     background: compacto ? "var(--f-eef6ff)" : "var(--f-f1f3f8)",
                     borderColor: compacto ? "var(--b-bfdcfa)" : undefined,
                     color: compacto ? "var(--t-0b5ea8)" : "var(--t-4b5666)",
                     fontWeight: compacto ? 800 : undefined }}
            title={compacto
              ? "Volver a la vista normal: se ven de nuevo los contadores y las referencias."
              : "Esconde los contadores y las referencias para que la tabla arranque más arriba. Sirve para trabajar con otra ventana abajo. Se recuerda para la próxima vez."}
            onClick={() => setCompacto(!compacto)}>
            {compacto ? "⤢ Vista normal" : "⤡ Vista compacta"}
          </button>
        )}
        {!compacto && <span style={{ ...S.btnGhost, cursor: "default" }}>{session.email}</span>}
        {session.isEnapro && (
          <button style={{ ...S.btnGhost, fontSize: 15, lineHeight: 1, padding: "6px 11px" }}
                  title="Configuración: contraseña y cuentas de ENAPRO"
                  onClick={onConfig}>⚙</button>
        )}
        <button style={S.btnGhost} onClick={onLogout}>Salir</button>
      </div>
    </div>
  );
}

/* ====== controles reutilizables ====== */
/* OJO (arreglado el 27/08/2026): este select mostraba el campo VACÍO cuando el
 * valor cargado no estaba tal cual en la lista. Y no está casi nunca: el Excel
 * escribe "SINGAPUR" y la lista dice "Singapur"; hay 84 escrituras distintas
 * para 48 países. La opción "(actual)" ya se agregaba, pero el `value` del
 * select se forzaba a "" y por eso quedaba seleccionado el placeholder.
 *
 * El dato nunca se perdía —el formulario arranca con lo que tiene el
 * movimiento—, pero en pantalla parecía vacío, que es peor: invita a cargar de
 * nuevo algo que ya estaba, o a creer que falta.
 *
 * Ahora el select se para SIEMPRE sobre el valor cargado. Y si sólo difiere en
 * mayúsculas o acentos ("SINGAPUR" vs "Singapur") se usa la opción de la lista,
 * así no aparece un duplicado al lado del país que ya está. */
function mismoTexto(a, b) {
  const n = (v) => String(v == null ? "" : v).normalize("NFD").replace(/[̀-ͯ]/g, "").trim().toUpperCase();
  return n(a) === n(b);
}

/* Las listas de arriba son las DE FÁBRICA: lo que se ve mientras el portal
 * todavía no contestó. Apenas llega el estado, se reemplazan por las que ENAPRO
 * editó en la ruedita (viven en la base). Se hace así —variables que se
 * reemplazan, y no pasando las listas por parámetro— porque LugarSelect y el
 * selector de muelle se usan en seis lugares distintos, varios de ellos sin
 * acceso a `datos`. Es el mismo mecanismo que ya usaba la tabla de banderas. */
function aplicarListas(l) {
  if (!l) return;
  if (Array.isArray(l.paises) && l.paises.length) PAISES = l.paises;
  if (Array.isArray(l.lugares) && l.lugares.length) DESTINOS_EXTRA = l.lugares;
  if (l.muelles && typeof l.muelles === "object" && typeof MUELLES_POR_ZONA !== "undefined") {
    Object.keys(MUELLES_POR_ZONA).forEach((z) => { delete MUELLES_POR_ZONA[z]; });
    Object.keys(l.muelles).forEach((z) => { MUELLES_POR_ZONA[z] = (l.muelles[z] || []).slice(); });
    if (typeof ZONA_DE_MUELLE !== "undefined") {
      Object.keys(ZONA_DE_MUELLE).forEach((k) => { delete ZONA_DE_MUELLE[k]; });
      Object.keys(MUELLES_POR_ZONA).forEach((z) => {
        MUELLES_POR_ZONA[z].forEach((m) => { ZONA_DE_MUELLE[m] = z; });
      });
    }
  }
}

function LugarSelect({ value, onChange, placeholder, conPuertos }) {
  const opciones = conPuertos ? DESTINOS_EXTRA.concat(PAISES) : PAISES;
  const equivalente = value ? opciones.find((o) => mismoTexto(o, value)) : null;
  const actual = equivalente || value || "";
  const known = !!equivalente;
  return (
    <select style={S.input} value={actual} onChange={(e) => onChange(e.target.value)}>
      <option value="">{placeholder || "Seleccionar…"}</option>
      {!known && value ? <option value={value}>{value} (cargado)</option> : null}
      {conPuertos && <optgroup label="Puertos / plazas de Argentina">{DESTINOS_EXTRA.map((p) => <option key={p} value={p}>{p}</option>)}</optgroup>}
      {conPuertos ? <optgroup label="Países">{PAISES.map((p) => <option key={p} value={p}>{p}</option>)}</optgroup> : PAISES.map((p) => <option key={p} value={p}>{p}</option>)}
    </select>
  );
}
function ZonaMuelleSelect({ zona, muelle, onZona, onMuelle }) {
  const muelles = zona && MUELLES_POR_ZONA[zona] ? MUELLES_POR_ZONA[zona] : [...MUELLES_POR_ZONA.ROSARIO, ...MUELLES_POR_ZONA["SAN LORENZO"]];
  function pickMuelle(m) {
    onMuelle(m);
    // Al elegir muelle se autocompleta la zona, con la misma regla que usa el
    // servidor para completar las que vienen vacías del Excel.
    const z = zonaDeMuelle(m);
    if (z && z !== zona) onZona(z);
  }
  return (
    <>
      <Field label="Zona">
        <select style={S.input} value={zona || ""} onChange={(e) => { onZona(e.target.value); onMuelle(""); }}>
          <option value="">Seleccionar zona…</option>
          {ZONAS.map((z) => <option key={z} value={z}>{z}</option>)}
        </select>
      </Field>
      <Field label="Muelle">
        {/* Mismo arreglo que en LugarSelect: el muelle cargado tiene que quedar
            seleccionado aunque no esté igual escrito en la lista. */}
        <select style={S.input} value={muelle || ""} onChange={(e) => pickMuelle(e.target.value)}>
          <option value="">{zona ? "Muelles de " + zona + "…" : "Seleccionar muelle…"}</option>
          {muelle && !muelles.includes(muelle) ? <option value={muelle}>{muelle} (cargado)</option> : null}
          {muelles.map((m) => <option key={m} value={m}>{m}</option>)}
        </select>
      </Field>
    </>
  );
}
/* Fila de filtros del panel de agencias.
 * `estados` (opcional) es la lista de estados que puede tener esta pestaña: si
 * viene, se dibuja el filtro por estado. Sólo tiene sentido en Confirmados,
 * donde conviven Confirmado y Facturado; en las otras pestañas todas las filas
 * tienen el mismo estado y el filtro no filtraría nada. */
function SortSearchBar({ q, setQ, sort, setSort, estados, estado, setEstado, cols, ocultas, colsAbierto, setColsAbierto }) {
  return (
    <div style={S.sortBar}>
      <div style={S.filterCol}>
        <label style={S.filterLab}>Buscar buque o pasavante</label>
        <input style={{ ...S.input, maxWidth: 300, margin: 0 }} placeholder="Nombre del buque, pasavante, TRN o IMO…" value={q} onChange={(e) => setQ(e.target.value)} />
      </div>
      {estados && estados.length > 1 && (
        <div style={S.filterCol}>
          <label style={S.filterLab}>Filtrar por estado</label>
          <select style={S.select} value={estado} onChange={(e) => setEstado(e.target.value)}>
            <option value="Todos">Todos los estados</option>
            {estados.map((e) => <option key={e} value={e}>{e}</option>)}
          </select>
        </div>
      )}
      <div style={S.filterCol}>
        <label style={S.filterLab}>Ordenar por</label>
        <select style={S.select} value={sort} onChange={(e) => setSort(e.target.value)}>
          <option value="salida_desc">Fecha de salida (más nueva)</option>
          <option value="salida_asc">Fecha de salida (más antigua)</option>
          <option value="pasavante_desc">Pasavante (más alto primero)</option>
          <option value="pasavante_asc">Pasavante (más bajo primero)</option>
          <option value="reciente">Cargados recientemente</option>
        </select>
      </div>
      {cols && <BotonColumnas cols={cols} ocultas={ocultas} abierto={colsAbierto} setAbierto={setColsAbierto} />}
    </div>
  );
}

/* ============================== PANEL AGENCIA ============================== */
function AgencyPanel({ datos, accion, notify }) {
  const ses = datos.sesion;
  const [tab, setTab] = useState("pendientes");
  const [editando, setEditando] = useState(null);
  const [cabotaje, setCabotaje] = useState(null);
  const [pwdOpen, setPwdOpen] = useState(false);
  const [q, setQ] = useState("");
  const [sort, setSort] = useState("salida_desc");
  const [confirmar, setConfirmar] = useState(null);
  const [fEstado, setFEstado] = useState("Todos");
  const [ocultas, setOcultasEstado] = useState(() => leerColumnas(CLAVE_COLUMNAS_AG));
  const [colsAbierto, setColsAbierto] = useState(false);
  const setOcultas = (v) => { setOcultasEstado(v); guardarColumnas(CLAVE_COLUMNAS_AG, v); };

  const forzarPwd = !!ses.mustChange;
  // Se busca también por PASAVANTE (pedido del 08/09/2026): con cientos de
  // facturados, el número del pasavante es la forma más directa de encontrar uno.
  const matchQ = (s) => !q || `${s.buque} ${s.trn || ""} ${s.imo || ""} ${s.pasavante || ""}`.toLowerCase().includes(q.toLowerCase());
  const mios = datos.buques.filter((s) => s.agencia === ses.agencia);
  // Altas que ENAPRO reprobó: van a su propia pestaña con el motivo escrito por
  // ENAPRO, y NO a "Por confirmar" — no hay nada que confirmar, el movimiento
  // ya lo tiene otra agencia o ya se facturó.
  const observados = sortShips(mios.filter((s) => s.reprobada).filter(matchQ), sort);
  /* MIS RECLAMOS: los buques que cargó la propia agencia con "+ Cargar buque" y
   * que ENAPRO todavía no aprobó.
   *
   * Antes caían en "Por confirmar", mezclados con los que vienen a su nombre
   * desde Prefectura, y
   * eso tenía dos problemas: la agencia no distinguía cuál había cargado ella, y
   * podía darle "Completar y guardar" a su propio alta y mandarla sola a
   * Confirmados — aprobándose el alta a sí misma y salteando la revisión.
   * Acá esperan, sin botón de confirmar. Cuando ENAPRO la aprueba el movimiento
   * pasa solo a Confirmados; si la reprueba, va a Observados con el motivo. */
  const misReclamos = sortShips(mios.filter((s) => s.altaPendiente).filter(matchQ), sort);
  // "Consultado" es un Pendiente al que ENAPRO ya le mando el correo: para la
  // agencia es lo mismo, lo tiene que completar igual.
  const pendientes = sortShips(
    mios.filter((s) => ESTADOS_PENDIENTES.includes(s.estado) && !s.reprobada && !s.altaPendiente).filter(matchQ),
    sort
  );
  // Confirmados: los que la agencia ya confirmó (Confirmado) y los que ENAPRO
  // ya facturó (Facturado). El estado lo cambia ENAPRO y la agencia lo ve acá:
  // es el mismo dato, no una copia.
  const confirmados = sortShips(
    mios.filter((s) => ["Confirmado", "Facturado"].includes(s.estado) && !s.reprobada && !s.altaPendiente)
        .filter((s) => fEstado === "Todos" || s.estado === fEstado)
        .filter(matchQ),
    sort
  );
  /* Lista "A reclamar": los que devolvio otra agencia MAS los que todavia no
   * tienen duena (decision del 14/09/2026). Para la agencia el gesto es el
   * mismo en los dos casos — "si es mio, lo reclamo" — asi que tenerlos en dos
   * pestanas separadas era un lugar mas donde mirar. La fila dice de cual se
   * trata. El servidor ya filtra: a la agencia que rechazo un buque ese no le
   * llega. */
  const aReclamar = sortShips(
    /* El estado tiene que aceptar Consultado además de Pendiente (15/09/2026).
       Con la regla vieja, mandar el correo a todas las agencias —que pasa los
       movimientos a Consultado— los borraba de esta misma lista: se les pedía
       que reclamaran buques que el correo acababa de esconderles. */
    datos.buques.filter((s) => s.estado === "Rechazado"
      || (String(s.agencia || "").trim().toUpperCase() === "SIN ASIGNAR" && ESTADOS_PENDIENTES.includes(s.estado))).filter(matchQ),
    sort
  );
  const rechazadosGlobal = aReclamar;
  // Cuántos hay de cada estado, para escribirlo en el subtítulo de la pestaña.
  const cuantosPorEstado = (e) => mios.filter((s) => s.estado === e).length;
  // Las columnas del menú dependen de la pestaña: "Estado" sólo existe en
  // Confirmados (ver COLS_AGENCIA.soloEstado).
  const colsDeTab = colsAgencia(tab === "confirmados", tab === "rechazados");
  const panelCols = colsAbierto ? <PanelColumnas cols={colsDeTab} ocultas={ocultas} setOcultas={setOcultas} cerrar={() => setColsAbierto(false)} /> : null;

  return (
    <div style={S.body}>
      <div style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 8, marginBottom: 8 }}>
        {/* Modo claro / noche, también para las agencias (24/09/2026). Es un
            solo botón que alterna, no dos tarjetas: acá no hay ruedita de
            configuración donde meterlas y no vale la pena abrir una por esto. */}
        <button style={S.btnMini}
          title={datos.sesion.tema === "noche"
            ? "Volver al modo claro. Se guarda en tu usuario."
            : "Pasar al modo noche: fondo oscuro, más cómodo de noche o con poca luz. Se guarda en tu usuario."}
          onClick={() => accion("/api/tema", { json: { tema: datos.sesion.tema === "noche" ? "claro" : "noche" } },
            datos.sesion.tema === "noche" ? "Modo claro puesto." : "Modo noche puesto.")}>
          {datos.sesion.tema === "noche" ? "☀ Modo claro" : "☾ Modo noche"}
        </button>
        <button style={S.btnMini} onClick={() => setPwdOpen(true)}>🔑 Cambiar mi contraseña</button>
      </div>
      <Tabs
        tabs={[
          ["pendientes", `Por confirmar (${pendientes.length})`],
          ["confirmados", `Confirmados (${confirmados.length})`],
          /* Los "No corresponde" no van: una vez que la agencia mandó el
             certificado de cabotaje (o marcó rada), el movimiento es asunto de
             ENAPRO. El servidor tampoco se los manda (ver estadoCompleto). */
          ["rechazados", `A reclamar (${aReclamar.length})`],
          /* Los buques que cargó la propia agencia y ENAPRO todavía no aprobó.
             Como Observados, la pestaña sólo aparece si hay alguno. */
          ...(misReclamos.length || tab === "misreclamos" ? [["misreclamos", `Mis reclamos (${misReclamos.length})`]] : []),
          /* Altas reprobadas por ENAPRO. La pestaña sólo aparece si hay alguna:
             no tiene sentido mostrarle una pestaña vacía a una agencia que
             nunca cargó un alta de más. */
          ...(observados.length || tab === "observados" ? [["observados", `Observados (${observados.length})`]] : []),
          ["nuevo", "+ Cargar buque"],
        ]}
        tab={tab} setTab={setTab}
      />

      {tab === "pendientes" && (
        <Section title="Buques pendientes de confirmación" sub="Prefectura los registró como salidos y a nombre de tu agencia. Confirmalos completando los datos, rechazalos si no son tuyos, o marcalos como No corresponde si es cabotaje o si el buque sólo estuvo en rada.">
          <SortSearchBar q={q} setQ={setQ} sort={sort} setSort={setSort} cols={colsDeTab} ocultas={ocultas} colsAbierto={colsAbierto} setColsAbierto={setColsAbierto} />
          {panelCols}
          {pendientes.length === 0 ? <Empty msg="No tenés buques pendientes. ¡Todo al día!" /> : (
            <TablaAgencia
              lista={pendientes} ocultas={ocultas}
              acciones={(s) => (
                <>
                  <button style={S.btnTablaPrim} onClick={() => setEditando(s)}>Completar</button>
                  <button style={S.btnTablaSec} onClick={() => setConfirmar({
                    msg: `¿Está seguro de que "${nombreLimpio(s.buque)}" (salida ${fmt(s.salida)}) NO es de su agencia? Pasa a la lista de rechazados que ven todas las agencias, para que la que corresponda lo reclame.`,
                    etiqueta: "Sí, no es de mi agencia",
                    onYes: () => accion(`/api/buques/${s.id}/rechazar`, { json: {} }, `${s.buque} enviado a Rechazados.`),
                  })}>No es mío</button>
                  <button style={S.btnTablaSec} onClick={() => setCabotaje(s)}>No corresponde</button>
                </>
              )}
            />
          )}
        </Section>
      )}

      {tab === "confirmados" && (
        <Section
          title="Buques confirmados y facturados"
          sub={`Histórico de lo que ya confirmaste. "Confirmado" es lo que mandaste y ENAPRO todavía no facturó (${cuantosPorEstado("Confirmado")}); "Facturado" es lo que ENAPRO ya facturó (${cuantosPorEstado("Facturado")}).`}
        >
          <SortSearchBar
            q={q} setQ={setQ} sort={sort} setSort={setSort}
            estados={["Confirmado", "Facturado"]} estado={fEstado} setEstado={setFEstado}
            cols={colsDeTab} ocultas={ocultas} colsAbierto={colsAbierto} setColsAbierto={setColsAbierto}
          />
          {panelCols}
          {confirmados.length === 0
            ? <Empty msg={fEstado === "Todos" ? "Todavía no confirmaste ningún buque." : `No hay buques en estado ${fEstado}.`} />
            : <TablaAgencia lista={confirmados} mostrarEstado ocultas={ocultas} />}
        </Section>
      )}

      {tab === "rechazados" && (
        <Section
          title="Buques para reclamar"
          sub="Buques sin dueño: unos porque otra agencia los devolvió y otros porque Prefectura no informó de quién eran. Si alguno es tuyo, reclamalo y pasa a tus pendientes. Los que rechazaste vos no aparecen acá."
        >
          <SortSearchBar q={q} setQ={setQ} sort={sort} setSort={setSort} cols={colsDeTab} ocultas={ocultas} colsAbierto={colsAbierto} setColsAbierto={setColsAbierto} />
          {panelCols}
          {rechazadosGlobal.length === 0 ? <Empty msg="No hay buques para reclamar." /> : (
            <TablaAgencia
              lista={rechazadosGlobal} ocultas={ocultas} listaAjena
              acciones={(s) => (
                <button style={S.btnTablaPrim} onClick={() => setConfirmar({
                  msg: (s.estado === "Rechazado"
                    ? `¿Reclamás "${nombreLimpio(s.buque)}" (salida ${fmt(s.salida)}) para ${ses.agencia}? Otra agencia lo devolvió porque no era suyo.`
                    : `¿Reclamás "${nombreLimpio(s.buque)}" (salida ${fmt(s.salida)}) para ${ses.agencia}? Prefectura no informó de quién era.`)
                    + " Va a pasar a tus buques por confirmar.",
                  etiqueta: "Sí, es mío",
                  onYes: () => accion(`/api/buques/${s.id}/reclamar`, { json: {} }, `Reclamaste ${s.buque}. Ahora está en tus pendientes.`),
                })}>Este es mío — reclamar</button>
              )}
            />
          )}
        </Section>
      )}

      {/* Altas que la agencia cargó y ENAPRO reprobó. No es una tabla: lo que
          importa acá es LEER el motivo que escribió ENAPRO, así que va como
          ficha, con el texto completo bien visible. */}
      {tab === "misreclamos" && (
        <Section
          title="Buques que cargaste vos"
          sub="Los cargaste con “+ Cargar buque” porque no estaban en la lista. ENAPRO los tiene que revisar antes de darlos por buenos: chequea que el movimiento no lo tenga ya otra agencia ni esté facturado. Mientras tanto no hay nada que completar. Si lo aprueban pasa solo a Confirmados; si no, va a Observados con el motivo escrito."
        >
          <SortSearchBar q={q} setQ={setQ} sort={sort} setSort={setSort} cols={colsDeTab} ocultas={ocultas} colsAbierto={colsAbierto} setColsAbierto={setColsAbierto} />
          {panelCols}
          {misReclamos.length === 0 ? <Empty msg="No tenés buques esperando revisión." /> : (
            <TablaAgencia
              lista={misReclamos} ocultas={ocultas}
              /* Sin botones: el que tiene que actuar acá es ENAPRO, no la
                 agencia. Confirmar es justamente lo que no puede hacer —sería
                 aprobarse el alta a sí misma— y editar un alta sin revisar no
                 está permitido del lado del servidor. Si hay un dato mal, ENAPRO
                 lo corrige o reprueba el alta explicando por qué. */
              acciones={(s) => (
                <span style={{ ...S.pillSync, whiteSpace: "nowrap" }}
                      title={"Lo cargaste el " + fmtHora(s.detectadoEn || s.publicadoEn) + ". ENAPRO todavía no lo revisó."}>
                  ⏳ esperando a ENAPRO
                </span>
              )}
            />
          )}
        </Section>
      )}

      {tab === "observados" && (
        <Section
          title="Buques observados por ENAPRO"
          sub="Estos movimientos los cargaste vos y ENAPRO los revisó: no corresponde facturarlos por tu agencia. Abajo está el motivo en cada caso. No hay nada que completar."
        >
          {observados.length === 0 ? <Empty msg="No tenés movimientos observados." /> : (
            <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
              {observados.map((s) => (
                <div key={s.id} style={{ ...S.card, borderLeft: "4px solid var(--b-b45309)" }}>
                  <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
                    <span style={{ fontWeight: 800, fontSize: 16 }} title={s.buque}>{nombreLimpio(s.buque)}</span>
                    <span style={S.pillAvisoAlta}>⚠ Observado por ENAPRO</span>
                  </div>
                  <div style={S.metaGrid}>
                    <span>🚢 Salida: {fmt(s.salida)}</span>
                    {s.entrada && <span>⚓ Entrada: {fmt(s.entrada)}</span>}
                    <span>🏳️ Bandera: {s.band || "—"}</span>
                    <span>← Proc.: {s.procedencia || "—"}</span>
                    <span>→ Destino: {s.destino || "—"}</span>
                    <span>📍 Zona: {s.zona || "—"}</span>
                    <span>Muelle: {s.muelle || "—"}</span>
                    <span>TRN: {s.trn || "—"}</span>
                    <span>IMO: {s.imo || "—"}</span>
                  </div>
                  <div style={{ marginTop: 12, background: "var(--f-fffbeb)", border: "1px solid var(--b-fde68a)", borderRadius: 10, padding: "10px 12px" }}>
                    <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: ".06em", textTransform: "uppercase", color: "var(--t-92400e)", marginBottom: 4 }}>
                      Comentario de ENAPRO {s.reprobada && s.reprobada.fecha ? "· " + fmtHora(s.reprobada.fecha) : ""}
                    </div>
                    <div style={{ fontSize: 13.5, color: "var(--t-78350f)", whiteSpace: "pre-wrap" }}>
                      {(s.reprobada && s.reprobada.motivo) || "Sin motivo escrito."}
                    </div>
                  </div>
                </div>
              ))}
            </div>
          )}
        </Section>
      )}

      {/* Después de cargarlo, la agencia va a "Mis reclamos", que es donde queda
          el buque esperando la revisión de ENAPRO — no a "Por confirmar", donde
          antes aparecía mezclado con los que vienen a su nombre desde
              Prefectura. */}
      {tab === "nuevo" && <NuevoBuque accion={accion} notify={notify} onListo={() => setTab("misreclamos")} />}

      {editando && <ConfirmarModal s={editando} accion={accion} onClose={() => setEditando(null)} />}
      {cabotaje && <NoCorrespondeModal s={cabotaje} accion={accion} onClose={() => setCabotaje(null)} />}
      {(pwdOpen || forzarPwd) && <PasswordModal forced={forzarPwd} accion={accion} onClose={() => setPwdOpen(false)} />}
      {confirmar && <ConfirmDialog {...confirmar} onCancel={() => setConfirmar(null)} onConfirm={() => { confirmar.onYes(); setConfirmar(null); }} />}
    </div>
  );
}

/* ====== modal de confirmación de buque (ITC obligatorio) ====== */
function ConfirmarModal({ s, accion, onClose }) {
  const [entrada, setEntrada] = useState(s.entrada || "");
  const [procedencia, setProcedencia] = useState(s.procedencia || "");
  const [destino, setDestino] = useState(s.destino || "");
  const [trn, setTrn] = useState(s.trn || "");
  const [imo, setImo] = useState(s.imo || "");
  const [comentario, setComentario] = useState(s.comentario || "");
  const [archivo, setArchivo] = useState(null);
  const [enviando, setEnviando] = useState(false);
  /* Dos personas de la misma agencia completando el mismo buque: ver PISADAS.
     `visto` es cómo estaba el movimiento al abrir esta ficha. */
  const [visto] = useState(s.modificadoEn || "");
  const [pisada, setPisada] = useState(null);
  const avisoRef = useRef(null);
  useEffect(() => { if (pisada && avisoRef.current) avisoRef.current.scrollIntoView({ block: "end", behavior: "smooth" }); }, [pisada]);
  // El ITC es obligatorio en cada estadía (decisión del usuario del 21/08/2026:
  // se probó dejar que aprobaran el ya cargado y se prefirió la exigencia). A la
  // agencia no se le muestra qué certificado había antes: que suba el que
  // corresponde a esta estadía y ENAPRO decide después cuál queda.

  async function enviar(forzar) {
    if (enviando) return;
    setEnviando(true);
    try {
      const fd = new FormData();
      fd.append("entrada", entrada); fd.append("procedencia", procedencia); fd.append("destino", destino);
      fd.append("trn", trn); fd.append("imo", imo); fd.append("comentario", comentario);
      fd.append("visto", visto);
      if (forzar) fd.append("forzar", "true");
      if (archivo) fd.append("itc", archivo);
      await accion(`/api/buques/${s.id}/confirmar`, { method: "POST", body: fd, calla409: true }, `${s.buque} confirmado. ENAPRO lo verá para facturar.`);
      onClose();
    } catch (e) {
      if (e.status === 409 && e.pisada) setPisada(e.pisada);
      /* para el resto, el toast ya avisó */
    } finally {
      setEnviando(false);
    }
  }

  return (
    <div style={S.modalWrap} onClick={onClose}>
      <div style={S.modal} onClick={(e) => e.stopPropagation()}>
        <div style={S.modalHead}>
          <div>
            <div style={{ fontWeight: 800, fontSize: 17 }} title={s.buque}>{nombreLimpio(s.buque)}</div>
            <div style={{ fontSize: 12, color: "var(--t-64748b)" }}>Lo registró Prefectura como salido. Completá lo que falta para que ENAPRO pueda facturar.</div>
          </div>
          <button style={S.btnGhostDark} onClick={onClose}>✕</button>
        </div>
        <div style={S.modalBody}>
          {/* VARIANTE 01, elegida el 04/09/2026. Antes acá había un cuadro
              "Datos que ya tiene el buque" que repetía lo mismo que el
              encabezado: eran 6 datos mostrados 13 veces, y el destino
              aparecía dos veces (como dato y como desplegable).
              Ahora los datos del buque van una sola vez, en esta tira, y el
              destino queda SÓLO como campo editable. */}
          <div style={S.tiraDatos}>
            <div style={S.tiraPar}><span style={S.tiraEt}>Bandera</span><span>{s.band || "—"}</span></div>
            <div style={S.tiraPar}><span style={S.tiraEt}>Salida</span><span style={S.numAg}>{fmt(s.salida)}</span></div>
            <div style={S.tiraPar}><span style={S.tiraEt}>Zona</span><span>{s.zona || "—"}</span></div>
            <div style={S.tiraPar}><span style={S.tiraEt}>Muelle</span><span style={S.numAg}>{s.muelle || "—"}</span></div>
          </div>

          <div style={S.formGrid}>
            <Field label="Fecha de entrada *"><input style={S.input} type="date" value={entrada} onChange={(e) => setEntrada(e.target.value)} /></Field>
            <Field label="Procedencia *"><LugarSelect value={procedencia} onChange={setProcedencia} placeholder="Seleccionar procedencia…" conPuertos /></Field>
            <Field label="TRN *"><input style={S.input} value={trn} placeholder="Ej: 27969" onChange={(e) => setTrn(e.target.value)} /></Field>
            <Field label="IMO (N° de identificación)"><input style={S.input} value={imo} placeholder="Ej: 9375953" onChange={(e) => setImo(e.target.value)} /></Field>
          </div>
          <Field label="Destino">
            <LugarSelect value={destino} onChange={setDestino} placeholder="Seleccionar destino…" conPuertos />
            <div style={{ fontSize: 12, color: "var(--t-64748b)", marginTop: -8, marginBottom: 10 }}>Si cambió, corregilo acá. Para el resto usá Comentarios.</div>
          </Field>

          <div style={S.itcBox}>
            <div style={{ fontWeight: 800, color: "var(--t-92400e)", fontSize: 13.5, marginBottom: 4 }}>ITC / Certificado de arqueo — obligatorio</div>
            <div style={{ fontSize: 12.5, color: "var(--t-78350f)", marginBottom: 8 }}>PDF, Word, Excel o imagen (JPG/PNG). Sin esto no se puede confirmar.</div>
            <input type="file" accept={TIPOS_ARCHIVO} onChange={(e) => setArchivo(e.target.files && e.target.files[0])} style={{ fontSize: 13 }} />
            {archivo && <div style={{ marginTop: 6, fontSize: 13, color: "var(--t-166534)", fontWeight: 600 }}>✓ Listo para subir: {archivo.name}</div>}
            {!archivo && s.itc && <div style={{ marginTop: 6, fontSize: 13, color: "var(--t-166534)", fontWeight: 600 }}>✓ Ya tiene adjunto: {s.itc.nombre}</div>}
          </div>

          <div style={{ marginTop: 15 }}>
            <Field label="Comentario (para corregir bandera, zona, muelle, etc.)">
              <textarea
                style={{ ...S.input, height: 60, resize: "vertical", marginBottom: 4 }}
                value={comentario} maxLength={MAX_COMENTARIO}
                placeholder="Ej: la bandera correcta es Panamá; el muelle es 442 VICENTIN…"
                onChange={(e) => setComentario(e.target.value)}
              />
              <div style={{ fontSize: 11.5, color: comentario.length > MAX_COMENTARIO - 60 ? "var(--t-b45309)" : "var(--t-94a3b8)", textAlign: "right", marginBottom: 10 }}>
                {comentario.length} / {MAX_COMENTARIO}
              </div>
            </Field>
          </div>
        </div>
        {/* Otro de la misma agencia (o ENAPRO) tocó este buque mientras esta
            ficha estaba abierta. No se guardó nada: decide la persona. */}
        {pisada && (
          <div ref={avisoRef} style={{ background: "var(--f-fff7ed)", borderTop: "1px solid var(--b-fdba74)", padding: "12px 20px" }}>
            <div style={{ fontWeight: 800, color: "var(--t-9a3412)", fontSize: 13.5, marginBottom: 4 }}>
              No se guardó: alguien más cargó datos de este buque
            </div>
            <div style={{ fontSize: 12.5, color: "var(--t-7c2d12)" }}>
              Fue <b>{pisada.usuario}</b> {haceRato(pisada.fecha)}, mientras vos tenías esta ficha abierta.
              Lo que ves en pantalla es de antes de ese cambio: si guardás así, lo pisás.
            </div>
            <div style={{ display: "flex", gap: 8, marginTop: 10, flexWrap: "wrap" }}>
              <button style={S.btnPrimary} onClick={onClose}>Cerrar y ver cómo quedó</button>
              <button style={S.btnMini} onClick={() => enviar(true)} disabled={enviando}>Guardar igual (pisar lo del otro)</button>
            </div>
          </div>
        )}
        <div style={S.modalFoot}>
          <button style={S.btnGhostDark} onClick={onClose}>Cancelar</button>
          <button style={S.btnPrimary} onClick={() => enviar(false)} disabled={enviando}>{enviando ? "Guardando…" : "Confirmar buque"}</button>
        </div>
      </div>
    </div>
  );
}

/* ====== modal cabotaje ====== */
/* "No corresponde" con sus dos motivos (pedido del 07/09/2026).
 * Antes esto era sólo "Cabotaje" y el certificado era obligatorio siempre. Se
 * agregó Rada — buque que no operó, sólo estuvo fondeado — que por definición no
 * tiene certificado que adjuntar, así que el pedido del adjunto ahora depende
 * del motivo elegido. */
function NoCorrespondeModal({ s, accion, onClose }) {
  const [motivo, setMotivo] = useState("cabotaje");
  const [cert, setCert] = useState(null);
  const [comentario, setComentario] = useState("");
  const [enviando, setEnviando] = useState(false);
  // Confirmación antes de marcarlo: es una acción que la agencia no puede
  // deshacer sola (el movimiento pasa a manos de ENAPRO y deja de verlo).
  const [confirmar, setConfirmar] = useState(null);

  const esCabotaje = motivo === "cabotaje";
  const faltaCert = esCabotaje && !cert && !s.cert_cabotaje;

  async function enviar() {
    if (enviando) return;
    setEnviando(true);
    try {
      const fd = new FormData();
      fd.append("motivo", motivo);
      fd.append("comentario", comentario);
      if (cert) fd.append("cert", cert);
      await accion(`/api/buques/${s.id}/no-corresponde`, { method: "POST", body: fd },
        `${s.buque} marcado como No corresponde (${esCabotaje ? "cabotaje" : "rada"}).`);
      onClose();
    } catch (e) {} finally { setEnviando(false); }
  }

  const opcion = (valor, titulo, detalle) => (
    <label
      key={valor}
      style={{
        display: "block", border: "1.5px solid " + (motivo === valor ? "var(--b-0f2942)" : "var(--b-e2e8f0)"),
        background: motivo === valor ? "var(--f-f8fafc)" : "var(--f-ffffff)",
        borderRadius: 10, padding: "10px 12px", marginBottom: 8, cursor: "pointer",
      }}
    >
      <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <input type="radio" name="motivoNoCorresponde" checked={motivo === valor} onChange={() => setMotivo(valor)} />
        <span style={{ fontWeight: 800, fontSize: 13.5 }}>{titulo}</span>
      </div>
      <div style={{ fontSize: 12.5, color: "var(--t-64748b)", marginLeft: 26 }}>{detalle}</div>
    </label>
  );

  return (
    <div style={S.modalWrap} onClick={onClose}>
      <div style={S.modal} onClick={(e) => e.stopPropagation()}>
        <div style={S.modalHead}>
          <div>
            <div style={{ fontWeight: 800, fontSize: 17 }}>No corresponde facturar</div>
            <div style={{ fontSize: 12, color: "var(--t-64748b)" }}>{nombreLimpio(s.buque)} · Salida {fmt(s.salida)} · {s.band || "—"}</div>
          </div>
          <button style={S.btnGhostDark} onClick={onClose}>✕</button>
        </div>
        <div style={S.modalBody}>
          <div style={{ fontSize: 13, color: "var(--t-475569)", marginBottom: 12 }}>
            Elegí por qué no corresponde facturar este movimiento. Una vez marcado pasa a ENAPRO para su revisión y deja de figurar en tus listas.
          </div>
          {opcion("cabotaje", "Cabotaje (tratamiento de bandera)", "Hace falta adjuntar el certificado correspondiente.")}
          {opcion("rada", "Rada", "El buque no operó: sólo estuvo fondeado en rada.")}

          {esCabotaje && (
            <div style={S.itcBox}>
              <div style={{ fontWeight: 800, color: "var(--t-92400e)", fontSize: 13.5, marginBottom: 4 }}>⚠️ Certificado por tratamiento de bandera (cabotaje) — OBLIGATORIO</div>
              <div style={{ fontSize: 12.5, color: "var(--t-78350f)", marginBottom: 8 }}>Sin el certificado adjunto no se puede marcar. PDF, Word, Excel o imagen.</div>
              <input type="file" accept={TIPOS_ARCHIVO} onChange={(e) => setCert(e.target.files && e.target.files[0])} style={{ fontSize: 13 }} />
              {cert && <div style={{ marginTop: 6, fontSize: 13, color: "var(--t-166534)", fontWeight: 600 }}>✓ Listo para subir: {cert.name}</div>}
              {!cert && s.cert_cabotaje && <div style={{ marginTop: 6, fontSize: 13, color: "var(--t-166534)", fontWeight: 600 }}>✓ Ya tiene adjunto: {s.cert_cabotaje.nombre}</div>}
            </div>
          )}

          <div style={{ marginTop: 12 }}>
            <Field label={esCabotaje ? "Comentario (opcional)" : "Comentario (opcional) — por ejemplo, fechas en que estuvo fondeado"}>
              <textarea
                style={{ ...S.input, height: 60, resize: "vertical", marginBottom: 4 }}
                value={comentario} maxLength={MAX_COMENTARIO}
                onChange={(e) => setComentario(e.target.value)}
              />
              <div style={{ fontSize: 11.5, color: comentario.length > MAX_COMENTARIO - 60 ? "var(--t-b45309)" : "var(--t-94a3b8)", textAlign: "right", marginBottom: 10 }}>
                {comentario.length} / {MAX_COMENTARIO}
              </div>
            </Field>
          </div>
        </div>
        <div style={S.modalFoot}>
          <button style={S.btnGhostDark} onClick={onClose}>Cancelar</button>
          <button
            style={S.btnWarn} disabled={enviando || faltaCert}
            title={faltaCert ? "Adjuntá el certificado de cabotaje para poder marcarlo" : ""}
            onClick={() => setConfirmar({
              msg: esCabotaje
                ? `¿Está seguro de que "${nombreLimpio(s.buque)}" (salida ${fmt(s.salida)}) NO corresponde facturar por tratamiento de bandera (cabotaje)? Pasa a ENAPRO para su revisión y va a dejar de figurar en tus listas.`
                : `¿Está seguro de que "${nombreLimpio(s.buque)}" (salida ${fmt(s.salida)}) no operó y sólo estuvo en rada? Pasa a ENAPRO para su revisión y va a dejar de figurar en tus listas.`,
              etiqueta: esCabotaje ? "Sí, es cabotaje" : "Sí, sólo estuvo en rada",
              onYes: enviar,
            })}
          >{enviando ? "Guardando…" : "Marcar No corresponde"}</button>
        </div>
        {confirmar && <ConfirmDialog {...confirmar} onCancel={() => setConfirmar(null)} onConfirm={() => { confirmar.onYes(); setConfirmar(null); }} />}
      </div>
    </div>
  );
}

/* Una lista editable: agregar, corregir y borrar valores, y volver a la lista
 * original. Se guarda la lista ENTERA de una vez (el servidor la reemplaza), no
 * valor por valor: más simple de seguir y el archivo nunca queda a medias. */
function EditorLista({ titulo, ayuda, valores, onGuardar, onRestaurar, placeholder }) {
  const [items, setItems] = useState(valores || []);
  const [nuevo, setNuevo] = useState("");
  const [tocado, setTocado] = useState(false);
  const [q, setQ] = useState("");
  useEffect(() => { setItems(valores || []); setTocado(false); }, [valores]);

  const cambiar = (i, v) => { const c = items.slice(); c[i] = v; setItems(c); setTocado(true); };
  const borrar = (i) => { const c = items.slice(); c.splice(i, 1); setItems(c); setTocado(true); };
  const agregar = () => {
    const v = nuevo.trim();
    if (!v) return;
    if (items.some((x) => String(x).trim().toUpperCase() === v.toUpperCase())) { setNuevo(""); return; }
    setItems(items.concat(v)); setNuevo(""); setTocado(true);
  };
  const visibles = q ? items.filter((x) => String(x).toLowerCase().includes(q.toLowerCase())) : items;

  return (
    <div style={{ border: "1px solid var(--b-e2e8f0)", borderRadius: 10, padding: "14px 16px", marginBottom: 12 }}>
      <div style={{ fontWeight: 800, marginBottom: 4 }}>{titulo} <span style={{ fontWeight: 600, color: "var(--t-94a3b8)", fontSize: 12 }}>({items.length})</span></div>
      {ayuda && <div style={{ fontSize: 12, color: "var(--t-64748b)", marginBottom: 10 }}>{ayuda}</div>}

      <div style={{ display: "flex", gap: 8, marginBottom: 10, flexWrap: "wrap" }}>
        <input style={{ ...S.input, margin: 0, flex: 1, minWidth: 200 }} value={nuevo} placeholder={placeholder || "Agregar…"}
               onChange={(e) => setNuevo(e.target.value)} onKeyDown={(e) => e.key === "Enter" && agregar()} />
        <button style={S.btnPrimary} onClick={agregar}>Agregar</button>
      </div>

      {items.length > 12 && (
        <input style={{ ...S.input, marginBottom: 8 }} value={q} placeholder="Buscar en la lista…" onChange={(e) => setQ(e.target.value)} />
      )}

      <div style={{ maxHeight: 260, overflow: "auto", border: "1px solid var(--b-f1f5f9)", borderRadius: 8 }}>
        {visibles.length === 0 ? (
          <div style={{ padding: 10, fontSize: 13, color: "var(--t-94a3b8)" }}>
            {q ? "Nada con ese texto." : "La lista está vacía."}
          </div>
        ) : visibles.map((v) => {
          const i = items.indexOf(v);
          return (
            <div key={i + "-" + v} style={{ display: "flex", gap: 8, alignItems: "center", padding: "4px 8px", borderBottom: "1px solid var(--b-f8fafc)" }}>
              <input style={{ ...S.input, margin: 0, flex: 1 }} value={v} onChange={(e) => cambiar(i, e.target.value)} />
              <button style={S.btnMini} title="Sacar de la lista" onClick={() => borrar(i)}>✕</button>
            </div>
          );
        })}
      </div>

      <div style={{ display: "flex", gap: 8, marginTop: 10, flexWrap: "wrap", alignItems: "center" }}>
        <button style={{ ...S.btnPrimary, opacity: tocado ? 1 : .5 }} disabled={!tocado}
                onClick={() => onGuardar(items)}>Guardar cambios</button>
        <button style={S.btnMini} onClick={onRestaurar}>Volver a la lista original</button>
        {tocado && <span style={{ fontSize: 12, color: "var(--t-b45309)", fontWeight: 700 }}>hay cambios sin guardar</span>}
      </div>
      <div style={{ fontSize: 12, color: "var(--t-64748b)", marginTop: 8 }}>
        Sacar un valor de la lista no cambia los movimientos que ya lo tienen cargado: sólo deja de ofrecerse de acá en adelante.
      </div>
    </div>
  );
}

/* ====== configuración (la ruedita) ======
 * Por ahora dos cosas —la contraseña propia y las cuentas de ENAPRO— armadas
 * para que sumar más (colores, plazos, textos de los correos) sea agregar una
 * pestaña, sin tocar el resto.
 *
 * Las cuentas de ENAPRO viven en la base, no en config.json: antes cambiar la
 * clave del administrador era abrir un archivo de texto con un editor, y sumar
 * a otra persona era imposible sin hacer lo mismo. */
function ConfigModal({ ses, datos, accion, notify, onClose }) {
  const [tab, setTab] = useState("clave");
  const [p1, setP1] = useState("");
  const [p2, setP2] = useState("");
  const [err, setErr] = useState("");
  const [lista, setLista] = useState(null);
  const [nuevoMail, setNuevoMail] = useState("");
  const [nuevaClave, setNuevaClave] = useState("");
  const [confirmar, setConfirmar] = useState(null);

  const cargar = () => pedir("/api/admins").then((d) => setLista(d.admins)).catch((e) => notify(e.message));
  useEffect(() => { if (tab === "cuentas" && !lista) cargar(); }, [tab]);

  async function guardarClave() {
    if (p1.length < 6) return setErr("La contraseña debe tener al menos 6 caracteres.");
    if (p1 !== p2) return setErr("Las contraseñas no coinciden.");
    try {
      await accion("/api/password", { json: { nueva: p1 } }, "Contraseña actualizada.");
      setP1(""); setP2(""); setErr("");
    } catch (e) {}
  }
  async function agregar() {
    try {
      await pedir("/api/admins", { json: { email: nuevoMail, password: nuevaClave } });
      notify("Cuenta creada. Pasale esa contraseña: al entrar el portal le va a pedir una nueva.");
      setNuevoMail(""); setNuevaClave(""); setLista(null); cargar();
    } catch (e) { notify(e.message); }
  }

  const secc = { border: "1px solid var(--b-e2e8f0)", borderRadius: 10, padding: "14px 16px", marginBottom: 12 };
  const pestana = (k, txt) => (
    <button key={k} onClick={() => setTab(k)}
      style={{ ...S.btnMini, fontWeight: tab === k ? 800 : 600,
               background: tab === k ? "var(--f-0f2942)" : "var(--f-ffffff)",
               color: tab === k ? "var(--t-ffffff)" : "var(--t-334155)",
               borderColor: tab === k ? "var(--b-0f2942)" : "var(--b-cbd5e1)" }}>{txt}</button>
  );

  return (
    <div style={S.modalWrap} onClick={onClose}>
      <div style={{ ...S.modal, width: 620 }} onClick={(e) => e.stopPropagation()}>
        <div style={S.modalHead}>
          <div>
            <div style={{ fontWeight: 800, fontSize: 17 }}>⚙ Configuración</div>
            <div style={{ fontSize: 12, color: "var(--t-64748b)" }}>Entraste como {ses.email}</div>
          </div>
          <button style={S.btnGhostDark} onClick={onClose}>✕</button>
        </div>
        <div style={S.modalBody}>
          <div style={{ display: "flex", gap: 8, marginBottom: 14, flexWrap: "wrap" }}>
            {pestana("clave", "Mi contraseña")}
            {pestana("apariencia", "Apariencia")}
            {pestana("cuentas", "Cuentas de ENAPRO")}
            {pestana("listas", "Listas de los desplegables")}
          </div>

          {tab === "apariencia" && (
            <div style={secc}>
              <div style={{ fontWeight: 800, marginBottom: 4 }}>Cómo se ve el portal</div>
              <div style={{ fontSize: 12.5, color: "var(--t-64748b)", marginBottom: 12 }}>
                Se guarda en tu usuario, así lo tenés igual desde cualquier computadora.
                No cambia lo que ven las agencias ni el resto de ENAPRO.
              </div>
              <ElegirTema tema={ses.tema} onElegir={(t) => accion("/api/tema", { json: { tema: t } },
                t === "noche" ? "Modo noche puesto." : "Modo claro puesto.")} />
            </div>
          )}

          {tab === "clave" && (
            <div style={secc}>
              <div style={{ fontWeight: 800, marginBottom: 10 }}>Cambiar mi contraseña</div>
              <Field label="Nueva contraseña">
                <input style={S.input} type="password" value={p1} onChange={(e) => { setP1(e.target.value); setErr(""); }} />
              </Field>
              <Field label="Repetir contraseña">
                <input style={S.input} type="password" value={p2} onChange={(e) => { setP2(e.target.value); setErr(""); }}
                       onKeyDown={(e) => e.key === "Enter" && guardarClave()} />
              </Field>
              {err && <div style={{ color: "var(--t-dc2626)", fontSize: 13, marginBottom: 8 }}>{err}</div>}
              <button style={S.btnPrimary} onClick={guardarClave}>Guardar contraseña</button>
            </div>
          )}

          {tab === "cuentas" && (
            <>
              <div style={secc}>
                <div style={{ fontWeight: 800, marginBottom: 10 }}>Quiénes entran como ENAPRO</div>
                {!lista ? <div style={{ fontSize: 13, color: "var(--t-64748b)" }}>Cargando…</div> : (
                  <div>
                    {lista.map((a) => (
                      <div key={a.email} style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap",
                                                  padding: "7px 0", borderBottom: "1px solid var(--b-f1f5f9)" }}>
                        <span style={{ fontWeight: 700, fontSize: 13.5 }}>{a.email}</span>
                        {a.esVos && <span style={S.pillOk}>vos</span>}
                        {a.principal && <span style={S.pillAgencia} title="La cuenta del portal. No se puede dar de baja.">cuenta principal</span>}
                        {a.mustChange && <span style={S.pillSync} title="Todavía no eligió su contraseña: la cambia al entrar">clave sin estrenar</span>}
                        <span style={{ flex: 1 }} />
                        {/* Dar de baja es exclusivo de la cuenta principal: si no,
                            dos personas de ENAPRO podrían sacarse entre ellas. El
                            candado de verdad está en el servidor. */}
                        {ses.principal && !a.esVos && !a.principal && (
                          <button style={S.btnMini} onClick={() => setConfirmar({
                            msg: `¿Sacar a ${a.email} de las cuentas de ENAPRO? No va a poder entrar más, y si está adentro se le cierra la sesión.`,
                            etiqueta: "Sacar la cuenta",
                            onYes: async () => {
                              try {
                                await pedir("/api/admins/" + encodeURIComponent(a.email), { method: "DELETE", json: {} });
                                notify("Cuenta dada de baja."); setLista(null); cargar();
                              } catch (e) { notify(e.message); }
                            },
                          })}>Sacar</button>
                        )}
                      </div>
                    ))}
                    <div style={{ fontSize: 12, color: "var(--t-64748b)", marginTop: 8 }}>
                      {ses.principal
                        ? "La cuenta principal no se puede dar de baja: es la del portal. Sumar y sacar cuentas lo hacés sólo vos."
                        : "Sumar cuentas, darlas de baja y reponer contraseñas lo hace únicamente la cuenta principal. Tu contraseña la cambiás en “Mi contraseña”."}
                    </div>
                  </div>
                )}
              </div>
              {ses.principal && (
              <div style={secc}>
                <div style={{ fontWeight: 800, marginBottom: 10 }}>Sumar a alguien de ENAPRO</div>
                <Field label="Correo"><input style={S.input} value={nuevoMail} onChange={(e) => setNuevoMail(e.target.value)} placeholder="nombre@enapro.com.ar" /></Field>
                <Field label="Contraseña para la primera vez"><input style={S.input} value={nuevaClave} onChange={(e) => setNuevaClave(e.target.value)} placeholder="Al menos 6 caracteres" /></Field>
                <div style={{ fontSize: 12, color: "var(--t-64748b)", marginBottom: 10 }}>
                  Es de un solo uso: al entrar, el portal le pide que elija la suya.
                </div>
                <button style={S.btnPrimary} onClick={agregar}>Crear cuenta</button>
              </div>
              )}
            </>
          )}

          {tab === "listas" && (() => {
            const l = (datos && datos.listas) || {};
            const guardar = (clave, valores, extra) =>
              accion("/api/listas", { json: Object.assign({ clave, valores }, extra || {}) }, "Lista guardada.");
            const restaurar = (cual) =>
              accion("/api/listas", { json: { clave: "restaurar", cual } }, "Lista restaurada a la original.");
            const zonas = Object.keys(l.muelles || {});
            return (
              <>
                <EditorLista
                  titulo="Puertos y plazas de Argentina"
                  ayuda="Lo que aparece arriba de todo al elegir Procedencia o Destino. Acá va RAMALLO, ZARATE, SAN NICOLAS y demás."
                  placeholder="Ej: RAMALLO"
                  valores={l.lugares}
                  onGuardar={(v) => guardar("lugares", v)}
                  onRestaurar={() => restaurar("lugares")} />
                {zonas.map((z) => (
                  <EditorLista key={z}
                    titulo={"Muelles de " + z}
                    ayuda={"Terminales de la zona " + z + ". Conviene que el nombre empiece con el kilómetro: de ahí el portal deduce solo la zona cuando el muelle viene escrito distinto desde el Excel."}
                    placeholder="Ej: 455,0 TERMINAL NUEVA"
                    valores={(l.muelles || {})[z]}
                    onGuardar={(v) => guardar("muelles", v, { zona: z })}
                    onRestaurar={() => restaurar("muelles")} />
                ))}
                <EditorLista
                  titulo="Países"
                  ayuda="Se usan para la Bandera del buque y, debajo de los puertos argentinos, para Procedencia y Destino."
                  placeholder="Ej: Islas Feroe"
                  valores={l.paises}
                  onGuardar={(v) => guardar("paises", v)}
                  onRestaurar={() => restaurar("paises")} />
              </>
            );
          })()}

          {/* Qué versión está andando. Sirve para saber si un cambio pedido ya
              llegó al portal de internet: cada cambio tiene su título. */}
          <div style={{ fontSize: 12, color: "var(--t-94a3b8)", lineHeight: 1.6 }}>
            {!datos.version || datos.version.commit === "local"
              ? "Versión: la de esta computadora (no es la de internet)."
              : <span>Versión del portal: <b style={{ color: "var(--t-64748b)" }}>{datos.version.commit.slice(0, 7)}</b> · {fmtHora(datos.version.fecha)}
                  {datos.version.titulo && <span> · “{datos.version.titulo}”</span>}</span>}
          </div>
        </div>
        {confirmar && <ConfirmDialog {...confirmar} onCancel={() => setConfirmar(null)}
                        onConfirm={() => { confirmar.onYes(); setConfirmar(null); }} />}
      </div>
    </div>
  );
}

/* ====== modal contraseña ====== */
function PasswordModal({ forced, accion, onClose }) {
  const [p1, setP1] = useState("");
  const [p2, setP2] = useState("");
  const [err, setErr] = useState("");
  async function go() {
    if (p1.length < 6) return setErr("La contraseña debe tener al menos 6 caracteres.");
    if (p1 !== p2) return setErr("Las contraseñas no coinciden.");
    try {
      await accion("/api/password", { json: { nueva: p1 } }, "Contraseña actualizada.");
      onClose();
    } catch (e) {}
  }
  return (
    <div style={S.modalWrap} onClick={forced ? undefined : onClose}>
      <div style={{ ...S.modal, width: 420 }} onClick={(e) => e.stopPropagation()}>
        <div style={S.modalHead}>
          <div>
            <div style={{ fontWeight: 800, fontSize: 17 }}>{forced ? "Definí tu nueva contraseña" : "Cambiar contraseña"}</div>
            {forced && <div style={{ fontSize: 12, color: "var(--t-64748b)" }}>Es tu primer ingreso. Por seguridad, cambiá la contraseña inicial.</div>}
          </div>
          {!forced && <button style={S.btnGhostDark} onClick={onClose}>✕</button>}
        </div>
        <div style={S.modalBody}>
          <Field label="Nueva contraseña"><input style={S.input} type="password" value={p1} onChange={(e) => { setP1(e.target.value); setErr(""); }} /></Field>
          <Field label="Repetir contraseña"><input style={S.input} type="password" value={p2} onChange={(e) => { setP2(e.target.value); setErr(""); }} onKeyDown={(e) => e.key === "Enter" && go()} /></Field>
          {err && <div style={{ color: "var(--t-dc2626)", fontSize: 13, marginTop: -6 }}>{err}</div>}
        </div>
        <div style={S.modalFoot}>
          {!forced && <button style={S.btnGhostDark} onClick={onClose}>Cancelar</button>}
          <button style={S.btnPrimary} onClick={go}>Guardar contraseña</button>
        </div>
      </div>
    </div>
  );
}

/* ====== alta de buque ====== */
function NuevoBuque({ accion, notify, onListo, agencias, esAdmin }) {
  const [f, setF] = useState({ buque: "", salida: "", entrada: "", band: "", procedencia: "", destino: "", trn: "", imo: "", zona: "", muelle: "", agencia: "", comentario: "" });
  const [itc, setItc] = useState(null);
  const [enviando, setEnviando] = useState(false);
  const set = (k) => (e) => setF({ ...f, [k]: e.target.value });
  const setVal = (k) => (v) => setF((prev) => ({ ...prev, [k]: v }));

  async function go() {
    if (!f.buque || !f.salida) return notify("⚠️ Nombre del buque y fecha de salida son obligatorios.");
    if (!f.procedencia) return notify("⚠️ Falta la procedencia.");
    if (!f.destino) return notify("⚠️ Falta el destino.");
    if (!itc && !esAdmin) return notify("⚠️ El ITC / certificado de arqueo es obligatorio para la carga.");
    setEnviando(true);
    try {
      const fd = new FormData();
      Object.entries(f).forEach(([k, v]) => fd.append(k, v || ""));
      if (itc) fd.append("itc", itc);
      await accion("/api/buques", { method: "POST", body: fd }, esAdmin ? `${f.buque} cargado.` : `${f.buque} cargado. Queda pendiente de revisión de ENAPRO.`);
      setF({ buque: "", salida: "", entrada: "", band: "", procedencia: "", destino: "", trn: "", imo: "", zona: "", muelle: "", agencia: "", comentario: "" });
      setItc(null);
      if (onListo) onListo();
    } catch (e) {} finally { setEnviando(false); }
  }
  return (
    <Section
      title="Cargar un buque no registrado"
      sub={esAdmin
        ? "Alta manual de un movimiento que no está en el Excel. Queda en la lista de cambios para pasar al Excel."
        : "Si ENAPRO todavía no cargó un buque que necesitás facturar, registralo acá. Puede que la estadía figure para otra agencia y quedara pendiente de revisión."}
    >
      <div style={{ maxWidth: 1250 }}>
      <div style={S.formGrid}>
        <Field label="Nombre del buque *"><input style={S.input} value={f.buque} onChange={set("buque")} /></Field>
        <Field label="Fecha de salida *"><input style={S.input} type="date" value={f.salida} onChange={set("salida")} /></Field>
        <Field label="Fecha de entrada"><input style={S.input} type="date" value={f.entrada} onChange={set("entrada")} /></Field>
        <Field label="Bandera"><LugarSelect value={f.band} onChange={setVal("band")} placeholder="Seleccionar bandera…" /></Field>
        <Field label="Procedencia *"><LugarSelect value={f.procedencia} onChange={setVal("procedencia")} placeholder="Seleccionar procedencia…" conPuertos /></Field>
        <Field label="Destino *"><LugarSelect value={f.destino} onChange={setVal("destino")} placeholder="Seleccionar destino…" conPuertos /></Field>
        <Field label="TRN"><input style={S.input} value={f.trn} onChange={set("trn")} /></Field>
        <Field label="IMO (N° de identificación)"><input style={S.input} value={f.imo} onChange={set("imo")} placeholder="Ej: 9375953" /></Field>
        <ZonaMuelleSelect zona={f.zona} muelle={f.muelle} onZona={setVal("zona")} onMuelle={setVal("muelle")} />
        {esAdmin && (
          <Field label="Agencia">
            <select style={S.input} value={f.agencia} onChange={set("agencia")}>
              <option value="">SIN ASIGNAR (sin agencia)</option>
              {(agencias || []).map((a) => <option key={a} value={a}>{a}</option>)}
            </select>
          </Field>
        )}
      </div>
      <div style={{ ...S.itcBox, marginTop: 14 }}>
        <div style={{ fontWeight: 800, color: "var(--t-92400e)", fontSize: 13.5, marginBottom: 4 }}>⚠️ ITC / Certificado de arqueo {esAdmin ? "(opcional para ENAPRO)" : "— OBLIGATORIO"}</div>
        <div style={{ fontSize: 12.5, color: "var(--t-78350f)", marginBottom: 8 }}>PDF, Word, Excel o imagen (JPG/PNG).</div>
        <input type="file" accept={TIPOS_ARCHIVO} onChange={(e) => setItc(e.target.files && e.target.files[0])} style={{ fontSize: 13 }} />
        {itc && <div style={{ marginTop: 6, fontSize: 13, color: "var(--t-166534)", fontWeight: 600 }}>✓ Listo para subir: {itc.name}</div>}
      </div>
      <div style={{ marginTop: 14 }}>
        <button style={S.btnPrimary} onClick={go} disabled={enviando}>{enviando ? "Cargando…" : "Cargar buque"}</button>
      </div>
      </div>
    </Section>
  );
}

/* ============================== PANEL ENAPRO ============================== */
function EnaproPanel({ datos, accion, notify, compacto }) {
  const [view, setView] = useState("buques");
  const [q, setQ] = useState("");
  const [sort, setSort] = useState("salida_desc");
  /* Casillas escondidas de la sábana. La clave NO es el estado solo, es el
   * estado CRUZADO con si el movimiento tiene agencia o no:
   *     "Rechazado|sin"   "Rechazado|con"   "No corresponde|sin"   …
   *
   * POR QUÉ CRUZADO (pedido del usuario, 15/09/2026). Lo que hay que poder
   * pedir es "los rechazados y los que no tienen dueño", y eso NO es una fila
   * ni una columna de la tabla: es una diagonal. Medido sobre los datos de hoy:
   *
   *                        con agencia   sin asignar
   *     Pendiente               27            0        ← sí los de la derecha
   *     Rechazado                1           16        ← sí los dos
   *     No corresponde          77           18        ← ninguno
   *     Facturado             1790            0
   *
   * Quiere "Rechazado con agencia" SÍ pero "Pendiente con agencia" NO, y
   * "Pendiente sin asignar" SÍ pero "No corresponde sin asignar" NO. Con una
   * lista de estados sola es imposible: al destildar "No corresponde" se van
   * también los sin asignar que sí quiere, y al tildar "sin asignar" vuelven
   * los "No corresponde" que no quiere. Cruzando las dos cosas, cada casilla se
   * prende y se apaga por separado y la diagonal se puede pedir.
   *
   * A PROPÓSITO no se recuerda para la próxima vez, al revés que las columnas.
   * Esconder una columna se nota (falta la columna); esconder filas no: mañana
   * abrirías el portal con 1790 movimientos invisibles y sin nada que lo
   * explique. Cada vez que se abre el portal se ve todo. */
  const [estadosOcultos, setEstadosOcultos] = useState({});
  const [panelEstados, setPanelEstados] = useState(false);
  const [fAg, setFAg] = useState("Todas");
  /* Un solo filtro donde antes habia dos ("cambios pendientes" y "origen del
   * buque"): ver el comentario largo en el JSX del desplegable. */
  const [fMirar, setFMirar] = useState("Todos");
  const [editando, setEditando] = useState(null);
  const [coincidencias, setCoincidencias] = useState(null);
  const [confirmar, setConfirmar] = useState(null);
  const [verAvisos, setVerAvisos] = useState(null);
  const [sincronizando, setSincronizando] = useState(false);
  // Movimientos tildados para consultarle a la agencia por correo.
  const [sel, setSel] = useState({});
  const [enviar, setEnviar] = useState(false);
  const [ocultas, setOcultasEstado] = useState(() => leerColumnas(CLAVE_COLUMNAS));
  const [panelColumnas, setPanelColumnas] = useState(false);
  const [cuadrosOcultos, setCuadrosOcultosEstado] = useState(() => leerCuadrosOcultos());
  const [panelCuadros, setPanelCuadros] = useState(false);
  const yaSincronice = useRef(false);

  const setOcultas = (v) => { setOcultasEstado(v); guardarColumnas(CLAVE_COLUMNAS, v); };
  const setCuadrosOcultos = (v) => { setCuadrosOcultosEstado(v); guardarCuadrosOcultos(v); };

  // Al abrir el portal, ENAPRO sincroniza solo con el Excel: la idea es cargar
  // los buques del día en la planilla, entrar acá y encontrarlos ya leídos.
  // El servidor avisa con sync.convieneSincronizar para no releer el Excel en
  // cada F5 (por defecto, si la última sincronización tiene más de 2 minutos).
  useEffect(() => {
    if (yaSincronice.current || !datos.sync.convieneSincronizar) return;
    yaSincronice.current = true;
    setSincronizando(true);
    accion("/api/sync", { method: "POST" })
      .catch(() => {})
      .then(() => setSincronizando(false));
  }, []);

  // Borrar un movimiento. Se usa cuando hay dos registros del mismo movimiento
  // (por ejemplo el alta de una agencia y la fila que ya tenía otra) y uno no
  // sirve. Queda en la papelera de la pestaña Sincronización, por si acaso.
  const pedirEliminar = (s) => setConfirmar({
    msg: `¿Eliminar el movimiento "${s.buque}" (salida ${fmt(s.salida)}, agencia ${s.agencia})?`
      + (s.origen === "excel" && !s.ausenteEnExcel ? ` Está en el Excel (fila ${s.filaExcel || "?"}): la sincronización no lo va a volver a traer y queda anotado en el CSV como BAJA para que borres esa fila.` : "")
      + " Podés restaurarlo desde la papelera, en la pestaña Excel y respaldo.",
    etiqueta: "Eliminar movimiento",
    onYes: () => accion(`/api/buques/${s.id}`, { method: "DELETE", json: {} }, `${s.buque} eliminado.`),
  });

  const agencias = datos.agencias || [];
  const filtrados = sortShips(
    datos.buques.filter((s) => {
      /* REGLA (aclarada por el usuario el 07/09/2026): un alta cargada por una
         agencia NO entra en esta sábana hasta que se apruebe. Vive en la
         pestaña "Altas de agencias" mientras su estado no está resuelto.
         Aprobada = altaResuelta.resultado "distinto", que es lo que deja el
         botón "Aprobar — es un buque nuevo".
         Las otras dos salidas (es el mismo movimiento / reprobada) no llegan
         nunca acá: en la primera el que sigue es el registro original, y en la
         segunda el alta no va.
         El filtro de origen es la única forma de verlas desde acá. */
      const altaSinAprobar = s.cargadaPor === "agencia" && (!s.altaResuelta || s.reprobada);
      const verAltas = fMirar === "Altas de agencias sin aprobar";
      if (altaSinAprobar && !verAltas) return false;
      if (verAltas && !altaSinAprobar) return false;
      /* MISMA REGLA para las novedades del Excel (pedido del 24/09/2026): un
         movimiento que todavía no se publicó NO entra en esta sábana. Vive en
         "Novedades para revisar" hasta que se publique.
         Antes aparecía en los dos lados a la vez, pintado de amarillo, y no se
         entendía si ya estaba o si faltaba hacerle algo.
         El cartel de arriba con el botón "Revisar las novedades" se queda: es
         justamente lo que avisa que hay algo esperando. Y para verlas desde acá
         está el filtro "Sin publicar (novedades)". */
      const sinPublicar = s.publicado === false;
      const verNovedades = fMirar === "Sin publicar (novedades)";
      if (sinPublicar && !verNovedades) return false;
      if (verNovedades && !sinPublicar) return false;
      if (estadosOcultos[casillaDe(s)]) return false;
      if (fAg !== "Todas" && s.agencia !== fAg) return false;
      const pendSinAprobar = s.pendienteExcel && !s.pendienteExcel.aprobado;
      if (fMirar === "Con cambios sin pasar al Excel" && !pendSinAprobar) return false;
      if (fMirar === "Sin cambios pendientes" && pendSinAprobar) return false;
      if (fMirar === "Cargados por las agencias" && s.cargadaPor !== "agencia") return false;
      if (fMirar === "Con posible duplicado" && !(s.cargadaPor === "agencia" && (s.coincidencias || []).length)) return false;
      if (fMirar === "Del Excel" && s.cargadaPor === "agencia") return false;
      if (fMirar === "Con datos para revisar" && !avisosVigentes(s).length) return false;
      if (q && !`${s.buque} ${s.agencia} ${s.trn || ""} ${s.imo || ""} ${s.pasavante || ""}`.toLowerCase().includes(q.toLowerCase())) return false;
      return true;
    }),
    sort
  );
  const counts = useMemo(() => {
    const c = {};
    // Los números de arriba son de lo facturable, así que no cuentan las altas
    // de agencia que todavía no se aprobaron (misma regla que la sábana).
    /* Y tampoco las novedades sin publicar (24/09/2026): si el contador dijera
       44 y en la tabla se vieran 40, el número no serviría para nada. Las que
       faltan revisar las cuenta el cartel de arriba, que dice cuántas son. */
    const enLaSabana = (s) => !(s.cargadaPor === "agencia" && (!s.altaResuelta || s.reprobada)) && s.publicado !== false;
    ESTADOS.forEach((e) => (c[e] = datos.buques.filter((s) => s.estado === e && enLaSabana(s)).length));
    // Y el cruce estado × con/sin agencia, que es lo que muestra el panel de
    // filtros casilla por casilla.
    datos.buques.forEach((s) => {
      if (!enLaSabana(s)) return;
      const k = casillaDe(s);
      c[k] = (c[k] || 0) + 1;
    });
    return c;
  }, [datos.buques]);
  const CASILLAS = useMemo(() => {
    const l = [];
    ESTADOS.forEach((e) => { l.push(e + "|con"); l.push(e + "|sin"); });
    return l;
  }, []);
  const nOcultos = CASILLAS.filter((k) => estadosOcultos[k]).length;
  // Qué quedó visible, en palabras, para el aviso de arriba de la tabla.
  const resumenVisible = ESTADOS.map((e) => {
    const con = !estadosOcultos[e + "|con"], sin = !estadosOcultos[e + "|sin"];
    if (con && sin) return e;
    if (sin) return e + " (sin asignar)";
    if (con) return e + " (con agencia)";
    return null;
  }).filter(Boolean);
  // Los "sin dueño": la suma de las casillas del correo a todas.
  const nSinAsignar = CASILLAS_SIN_ASIGNAR.reduce((a, k) => a + (counts[k] || 0), 0);
  const soloSinAsignar = () => setEstadosOcultos(soloEstasCasillas(CASILLAS_SIN_ASIGNAR));
  // ¿El filtro de ahora es exactamente el de "sin dueño"? Se compara casilla por
  // casilla y no con una bandera aparte: así, si tocás una sola casilla a mano,
  // la tarjeta deja de figurar como activa sin que haya nada que sincronizar.
  const filtroSinAsignar = CASILLAS.every((k) =>
    !!estadosOcultos[k] === !CASILLAS_SIN_ASIGNAR.includes(k));
  const altasAgencia = useMemo(() => datos.buques.filter((s) => s.cargadaPor === "agencia"), [datos.buques]);
  const altasPorRevisar = altasAgencia.filter((s) => !s.altaResuelta).length;
  const novedades = useMemo(() => sortShips(datos.buques.filter((s) => s.publicado === false), "salida_desc"), [datos.buques]);
  const novedadesConAvisos = novedades.filter((s) => avisosVigentes(s).length).length;

  // Cuadros de arriba: cuáles se muestran y de qué color va la franja de cada
  // uno (el degradé se reparte entre los que quedan, no entre los nueve).
  const cuadrosVisibles = CUADROS.filter((c) => !cuadrosOcultos[c.clave]);
  const franjas = franjaCuadros(cuadrosVisibles.length);
  // Para la línea de "Ocultos:" que va debajo de la fila. Se arma con el número
  // de cada uno a propósito: esconder el cuadro no tiene que esconder el dato.
  const numeroDeCuadro = (clave) =>
    clave === "sinAsignar" ? nSinAsignar
      : clave === "total" ? counts.Facturado + counts.Cabotaje + nSinAsignar
        : counts[clave];
  const cuadrosEscondidos = CUADROS.filter((c) => cuadrosOcultos[c.clave]);

  return (
    /* En compacta se recorta el aire de arriba y el de abajo de las pestañas:
       son otros ~35px que gana la tabla sin sacar ningún control. */
    <div style={compacto ? { ...S.body, padding: "6px 18px 60px" } : S.body}>
      <Tabs
        compacto={compacto}
        tabs={[
          ["buques", "Buques"],
          ["novedades", `Novedades para revisar${novedades.length ? " (" + novedades.length + ")" : ""}`],
          // El número es de las que FALTA revisar. Antes, cuando no quedaba
          // ninguna, mostraba el total y la pestaña seguía marcada para siempre
          // aunque ya estuvieran todas revisadas.
          ["altas", `Altas de agencias${altasPorRevisar ? " (" + altasPorRevisar + ")" : ""}`],
          ["sync", "Los dos Excel"],
          ["usuarios", "Administrar usuarios (agencias)"],
          ["nuevo", "+ Cargar buque"],
          ["registro", "Registro de buques"],
        ]}
        tab={view} setTab={setView}
      />

      {view === "novedades" && (
        <Novedades
          novedades={novedades} sync={datos.sync} accion={accion} sincronizando={sincronizando} ocultas={ocultas}
          onEditar={setEditando} onComparar={setCoincidencias} onEliminar={pedirEliminar} onVerAvisos={setVerAvisos}
          onSincronizar={async () => { setSincronizando(true); try { await accion("/api/sync", { method: "POST" }, "Sincronización terminada."); } catch (e) {} setSincronizando(false); }}
        />
      )}
      {view === "altas" && <AltasAgencias altas={altasAgencia} accion={accion} onEditar={setEditando} />}
      {view === "sync" && <SyncPanel datos={datos} accion={accion} notify={notify} />}
      {view === "usuarios" && <UserAdmin datos={datos} accion={accion} notify={notify} />}
      {view === "nuevo" && <NuevoBuque accion={accion} notify={notify} agencias={agencias} esAdmin onListo={() => setView("buques")} />}
      {view === "registro" && <RegistroBuques buques={datos.buques} fichas={datos.registroBuques} clavesBarco={datos.clavesBarco} sinUbicar={datos.sinUbicar} sync={datos.sync} accion={accion} notify={notify} paises={datos.paises} />}

      {view === "buques" && (
        <>
          {sincronizando && (
            <div style={S.bannerInfo}>
              <span style={{ fontSize: 16 }}>↻</span>
              <span>Leyendo el Excel para traer los buques nuevos… (no se toca el archivo original)</span>
            </div>
          )}
          {novedades.length > 0 && !sincronizando && (
            <div style={S.banner}>
              <div style={{ fontSize: 13.5, color: "var(--t-78350f)" }}>
                <b style={{ fontSize: 15 }}>Hay {novedades.length} movimiento{novedades.length > 1 ? "s" : ""} nuevo{novedades.length > 1 ? "s" : ""} del Excel sin publicar.</b>
                <div style={{ marginTop: 3 }}>
                  Las agencias todavía no los ven. Revisalos y publicalos cuando estén bien.
                  {novedadesConAvisos > 0 && <b> {novedadesConAvisos} tiene{novedadesConAvisos > 1 ? "n" : ""} datos raros para mirar.</b>}
                </div>
              </div>
              <button style={S.btnPrimary} onClick={() => setView("novedades")}>Revisar las novedades →</button>
            </div>
          )}
          {/* Los contadores se esconden en vista compacta: son 120px de alto y
              lo que hace falta con la otra ventana encimada es que la tabla
              arranque arriba. No cambian qué se ve en la lista. */}
          {!compacto && (
          <div style={S.statRow}>
            {/* El orden y los nombres salen de CUADROS (arriba del todo). Acá se
                filtra por los que el usuario dejó tildados en el botón
                "Cuadros", y la franja de color se recalcula sobre los que
                quedan: con ocho o con seis la fila sigue yendo de amarillo a
                violeta en vez de cortarse a la mitad del degradé. */}
            {cuadrosVisibles.map((c, i) => {
              const clave = c.clave;
              const franja = { borderTop: "3px solid " + franjas[i] };

              /* SIN ASIGNAR. No es un estado más: es la suma de dos casillas del
                 cruce —los rechazados (los devolvió alguna agencia) y los que
                 esperan datos sin agencia— que en el trabajo diario son una sola
                 cosa: los que van en el correo a todas.
                 Se muestra junto y se puede pedir de un clic SIN unificar los dos
                 estados por abajo, que es lo que haría perder el dato de cuántas
                 agencias ya dijeron que no es suyo. */
              if (clave === "sinAsignar") {
                return (
                  <div key={clave}
                    style={{ ...S.stat, ...franja, cursor: "pointer",
                             outline: filtroSinAsignar ? "2px solid var(--b-7c3aed)" : "none", outlineOffset: -1 }}
                    onClick={() => (filtroSinAsignar ? setEstadosOcultos({}) : soloSinAsignar())}
                    title={filtroSinAsignar
                      ? "Ya estás viendo solo estos. Click para volver a ver todo."
                      : "Los rechazados y los que no tienen agencia. Click para dejar solo estos en la lista: son los que van en el correo a todas."}>
                    <div style={{ fontSize: 26, fontWeight: 800 }}>{nSinAsignar}</div>
                    <div style={{ fontSize: 12, color: "var(--t-64748b)" }}>
                      Sin asignar {filtroSinAsignar
                        ? <span style={{ color: "var(--t-7c3aed)", fontWeight: 700 }}>· viendo solo estos</span>
                        : <span style={{ color: "var(--t-94a3b8)" }}>· ver solo estos</span>}
                    </div>
                  </div>
                );
              }

              /* TOTAL = Facturados + Cabotaje + Sin asignar (pedido del usuario,
                 28/09/2026). OJO: no es el total de movimientos. "Sin asignar"
                 no es un estado sino un cruce, así que esos mismos movimientos
                 ya están contados adentro de Rechazado y de Consultado. El
                 número contesta "cuántos movimientos me ocupan": los que cobré,
                 los que no se cobran por bandera y los que todavía no tienen
                 dueño. El title lo explica al pasar el mouse. */
              if (clave === "total") {
                return (
                  <div key={clave} style={{ ...S.stat, ...franja, background: "var(--f-f8fafc)" }}
                    title={"Facturados (" + counts.Facturado + ") + Cabotaje (" + counts.Cabotaje
                           + ") + Sin asignar (" + nSinAsignar + "). "
                           + "No es el total de movimientos: los \u201csin asignar\u201d ya están contados en Rechazado y Consultado."}>
                    <div style={{ fontSize: 26, fontWeight: 800 }}>{counts.Facturado + counts.Cabotaje + nSinAsignar}</div>
                    <div style={{ fontSize: 12, color: "var(--t-64748b)" }}>Total</div>
                  </div>
                );
              }

              return (
                <div key={clave} style={{ ...S.stat, ...franja }}>
                  <div style={{ fontSize: 26, fontWeight: 800 }}>{counts[clave]}</div>
                  <div style={{ fontSize: 12, color: "var(--t-64748b)" }}>{c.label}</div>
                </div>
              );
            })}
          </div>
          )}

          {/* Línea de los escondidos. El cuadro no está, pero el número sí: la
              idea de esconder es ganar espacio, no perder de vista un dato.
              También es el recordatorio de que la fila está recortada — sin
              esto, dentro de tres meses uno mira la pantalla y no se acuerda. */}
          {!compacto && cuadrosEscondidos.length > 0 && (
            <div style={{ fontSize: 12, color: "var(--t-94a3b8)", margin: "-8px 0 12px",
                          display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
              <span>Ocultos:</span>
              {cuadrosEscondidos.map((c) => (
                <span key={c.clave} style={{ color: "var(--t-64748b)" }}>
                  {c.label} <b style={{ fontWeight: 700 }}>{numeroDeCuadro(c.clave)}</b>
                </span>
              ))}
              <button style={{ ...S.btnMini, padding: "2px 9px", fontSize: 11.5 }}
                      onClick={() => setCuadrosOcultos({})}>Mostrar todos</button>
            </div>
          )}

          <div style={S.filterRow}>
            <div style={S.filterCol}>
              <label style={S.filterLab}>Buscar buque o pasavante</label>
              <input style={{ ...S.input, maxWidth: 240, margin: 0 }} placeholder="Buque, pasavante, agencia, TRN o IMO…" value={q} onChange={(e) => setQ(e.target.value)} />
            </div>
            <div style={S.filterCol}>
              <label style={S.filterLab}>Ordenar por</label>
              <select style={S.select} value={sort} onChange={(e) => setSort(e.target.value)}>
                <option value="salida_desc">Salida (más nueva)</option>
                <option value="salida_asc">Salida (más antigua)</option>
                <option value="pasavante_desc">Pasavante (más alto primero)</option>
                <option value="pasavante_asc">Pasavante (más bajo primero)</option>
                <option value="agencia_asc">Agencia (A → Z)</option>
                <option value="agencia_desc">Agencia (Z → A)</option>
                <option value="reciente">Cargados recientemente</option>
              </select>
            </div>
            <div style={S.filterCol}>
              <label style={S.filterLab}>Filtrar por estado y agencia</label>
              <button
                /* Resaltado de "hay filtros puestos": ver --f-filtro en index.html. */
                style={{ ...S.select, cursor: "pointer", textAlign: "left", minWidth: 190,
                         borderColor: nOcultos ? "var(--b-filtro)" : undefined,
                         background: nOcultos ? "var(--f-filtro)" : "var(--f-ffffff)",
                         color: nOcultos ? "var(--t-filtro)" : "var(--t-1c2430)",
                         fontWeight: nOcultos ? 700 : undefined }}
                onClick={() => setPanelEstados(!panelEstados)}>
                {panelEstados ? "▲" : "▼"} {nOcultos ? "Filtrado (" + filtrados.length + ")" : "Todo visible"}
              </button>
            </div>
            <div style={S.filterCol}>
              <label style={S.filterLab}>Filtrar por agencia</label>
              <select style={S.select} value={fAg} onChange={(e) => setFAg(e.target.value)}>
                <option value="Todas">Todas las agencias</option>
                {/* SIN ASIGNAR no es una agencia (no está en la lista): va como
                    opción aparte, para poder ver sólo los que no tienen. */}
                <option value="SIN ASIGNAR">SIN ASIGNAR (sin agencia)</option>
                {agencias.map((a) => <option key={a} value={a}>{a}</option>)}
              </select>
            </div>
            {/* UN SOLO desplegable en lugar de "cambios pendientes" + "origen del
                buque" (pedido del usuario, 17/09/2026: "quiero hacer algo de
                espacio, que no haya tanto filtro").

                Eran dos listas que en la práctica contestaban la misma pregunta
                —"¿qué subconjunto miro ahora?"— y ninguna de las dos era un
                filtro que se cruzara con la otra en el trabajo real. Juntarlas
                saca una columna de la fila de filtros y no pierde NINGUNA
                opción: están las siete, agrupadas por para qué sirven.

                Lo único que ya no se puede es pedir dos a la vez (por ejemplo
                "cargados por las agencias" Y "esperando aprobación"). Eso ya se
                mira mejor desde la pestaña "Altas de agencias". */}
            <div style={S.filterCol}>
              <label style={S.filterLab}>Mostrar solo</label>
              <select
                /* maxWidth a propósito (28/09/2026): el <select> se agranda solo
                   hasta la opción más larga ("Con cambios de agencia esperando
                   aprobación" lo llevaba a 382px) y eso empujaba el botón de
                   exportar a una segunda línea. Con el tope, la opción elegida
                   se recorta pero la lista desplegada sigue mostrando el texto
                   entero, que es donde se lee. */
                style={{ ...S.select, minWidth: 210, maxWidth: 240,
                         borderColor: fMirar !== "Todos" ? "var(--b-filtro)" : undefined,
                         background: fMirar !== "Todos" ? "var(--f-filtro)" : "var(--f-ffffff)",
                         color: fMirar !== "Todos" ? "var(--t-filtro)" : "var(--t-1c2430)",
                         fontWeight: fMirar !== "Todos" ? 700 : undefined }}
                value={fMirar} onChange={(e) => setFMirar(e.target.value)}>
                <option value="Todos">Todos los movimientos</option>
                <optgroup label="Lo que necesita que mires">
                  <option value="Con datos para revisar">Con datos para revisar</option>
                  <option value="Con cambios sin pasar al Excel">Con cambios de agencia esperando aprobación</option>
                  <option value="Sin publicar (novedades)">Novedades sin publicar (fuera de la sábana)</option>
                  <option value="Con posible duplicado">Con posible duplicado</option>
                </optgroup>
                <optgroup label="Según quién lo cargó">
                  <option value="Cargados por las agencias">Cargados por las agencias</option>
                  <option value="Del Excel">Del Excel / ENAPRO</option>
                  <option value="Altas de agencias sin aprobar">Altas de agencias sin aprobar (fuera de la sábana)</option>
                </optgroup>
                <optgroup label="Lo ya resuelto">
                  <option value="Sin cambios pendientes">Sin cambios pendientes</option>
                </optgroup>
              </select>
            </div>
            {/* En vista compacta no va: ahí no hay ningún cuadro en pantalla, así
                que un botón para elegir cuáles ver no tendría a qué referirse
                (y encima quedaría en ámbar avisando de algo invisible). */}
            {!compacto && <BotonCuadros ocultos={cuadrosOcultos} abierto={panelCuadros} setAbierto={setPanelCuadros} />}
            <BotonColumnas cols={COLS_ADMIN} ocultas={ocultas} abierto={panelColumnas} setAbierto={setPanelColumnas} />
            <span style={{ fontSize: 13, color: "var(--t-64748b)", alignSelf: "flex-end", paddingBottom: 10 }}>{filtrados.length} resultados</span>
            {/* Exportar vive acá, al final de la fila de filtros (28/09/2026).
                Antes ocupaba un cuadro entero arriba, al lado de los contadores:
                eran 120px de alto para un botón, y con nueve cuadros ya no
                entraba. Acá aprovecha el espacio que sobra a la derecha y no
                gasta ni una línea de alto. */}
            <a style={{ ...S.btnPrimary, background: "var(--f-494963)", textDecoration: "none",
                        alignSelf: "flex-end", marginLeft: "auto", whiteSpace: "nowrap",
                        padding: "9px 14px", fontSize: 12.5 }}
               href="/descargas/confirmados.csv"
               title="Baja un archivo con los movimientos confirmados, para tu sistema de facturación.">
              ↓ Exportar
            </a>
          </div>
          {panelEstados && (
            <PanelEstados
              ocultos={estadosOcultos} setOcultos={setEstadosOcultos}
              counts={counts} cerrar={() => setPanelEstados(false)} />
          )}
          {panelCuadros && !compacto && <PanelCuadros ocultos={cuadrosOcultos} setOcultos={setCuadrosOcultos} cerrar={() => setPanelCuadros(false)} />}
          {panelColumnas && <PanelColumnas cols={COLS_ADMIN} ocultas={ocultas} setOcultas={setOcultas} cerrar={() => setPanelColumnas(false)} />}
          {/* Aviso mientras haya filas escondidas: la cuenta de "N resultados"
             baja sin explicación si no.
             En vista compacta NO se muestra (pedido del usuario, 17/09/2026):
             el botón del filtro ya queda en ámbar y con la cuenta al lado, así
             que el cartel repite algo que se ve igual y gasta 45px, que es
             justo lo que la vista compacta viene a ganar. En vista normal sigue
             estando: ahí el espacio sobra y el aviso es el que evita pensar que
             faltan movimientos. "Mostrar todo" no se pierde — está dentro del
             panel del filtro. */}
          {nOcultos > 0 && !compacto && (
            <div style={{ background: "var(--f-fffbeb)", border: "1px solid var(--b-fcd34d)", borderRadius: 8,
                          padding: "8px 12px", marginBottom: 12, fontSize: 13, color: "var(--t-78350f)",
                          display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
              <span>
                Estás viendo solo <b>{resumenVisible.length ? resumenVisible.join(" · ") : "nada: escondiste todo"}</b>.
                {" "}El resto no está en la lista y tampoco se puede tildar para consultar.
              </span>
              <button style={S.btnMini} onClick={() => setEstadosOcultos({})}>Mostrar todo</button>
            </div>
          )}

          <BarraConsulta
            datos={datos} filtrados={filtrados} sel={sel} setSel={setSel} onEnviar={() => setEnviar(true)}
          />

          {/* La fila de referencias también se va en compacta: es una leyenda
              que se lee una vez y después ocupa alto para siempre. */}
          {!compacto && (
          <div style={{ display: "flex", gap: 14, flexWrap: "wrap", alignItems: "center", marginBottom: 10, fontSize: 12, color: "var(--t-64748b)" }}>
            <span style={{ fontWeight: 700 }}>Referencias:</span>
            <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
              <span style={{ background: "var(--f-marca-violeta)", border: "1px solid var(--b-marca-violeta)", borderLeftWidth: 4, borderLeftColor: "var(--marca-violeta)", padding: "1px 8px", borderRadius: 3, fontWeight: 700, color: "var(--t-marca-violeta)" }}>fila celeste</span>
              = buque cargado por una agencia
            </span>
            <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}><span style={S.pillDup}>⚠ parecidos</span> ya hay un movimiento igual o parecido cargado — click para comparar</span>
            <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}><span style={{ background: "var(--f-marca-amarillo)", color: "var(--t-marca-amarillo)", fontWeight: 700, padding: "1px 6px", borderRadius: 4 }}>✎</span> destino modificado por la agencia</span>
            <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}><span style={S.pillAvisoAlta}>⚠ datos para revisar</span> algo vino raro del Excel — click para ver qué</span>
          </div>
          )}

          <TablaBuques
            lista={filtrados} accion={accion} ocultas={ocultas}
            sel={sel} onSel={(id, v) => setSel((p) => { const n = { ...p }; if (v) n[id] = true; else delete n[id]; return n; })}
            onEditar={setEditando} onComparar={setCoincidencias} onEliminar={pedirEliminar} onVerAvisos={setVerAvisos}
          />
        </>
      )}

      {editando && <EditarBuqueModal s={editando} agencias={agencias} accion={accion} onClose={() => setEditando(null)} />}
      {coincidencias && <CoincidenciasModal alta={coincidencias} accion={accion} onClose={() => setCoincidencias(null)} />}
      {verAvisos && <AvisosModal s={verAvisos} onClose={() => setVerAvisos(null)} onEditar={setEditando} />}
      {enviar && <EnviarConsultaModal datos={datos} sel={sel} accion={accion} notify={notify}
                   onClose={() => setEnviar(false)} onEnviado={() => setSel({})} />}
      {confirmar && <ConfirmDialog {...confirmar} onCancel={() => setConfirmar(null)} onConfirm={() => { confirmar.onYes(); setConfirmar(null); }} />}
    </div>
  );
}

/* ============================================================================
 * TABLA DE MOVIMIENTOS (panel ENAPRO)
 *
 * Una sola tabla para la pestaña Buques y para la de Novedades. El orden de las
 * columnas es fijo (COLS_ADMIN) y los anchos van en porcentaje: entra completa
 * en pantalla, sin deslizar para los costados.
 * ==========================================================================*/
function TablaBuques({ lista, accion, onEditar, onComparar, onEliminar, onVerAvisos, limite, modoNovedades, ocultas, sel, onSel }) {
  const tope = limite || 600;
  const visibles = COLS_ADMIN.filter((c) => c.fijo || !(ocultas || {})[c.clave]);
  const suma = visibles.reduce((a, c) => a + c.w, 0);
  const ancho = (c) => (100 * c.w / suma).toFixed(3) + "%";

  // Cómo se dibuja cada columna. Está acá y no en el JSX de la fila para que
  // ocultar una columna sea sólo no incluirla en `visibles`.
  const CELDAS = {
    /* Solo los Pendientes se pueden consultar: son los que tienen datos para
       pedirle a la agencia. En los demas la casilla no se dibuja. */
    /* La casilla lleva la marca de "ya se consultó": un aro azul suave alrededor.
     * Va acá y no como etiqueta al lado del estado porque esta columna ya existe
     * y es angosta —la marca no gasta ni un píxel de ancho— y porque es justo
     * donde se mira al elegir qué mandar. El estado NO cambia al consultar: un
     * rechazado sigue rechazado, esto es lo único que lo delata. */
    sel: (s) => (!sePuedeConsultar(s) ? null : (
      <span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center",
                     width: 24, height: 24, borderRadius: 12,
                     background: s.consultaEnviada ? "var(--f-eff6ff)" : "transparent",
                     boxShadow: s.consultaEnviada ? "0 0 0 2px var(--b-93c5fd)" : "none" }}>
        <input
          type="checkbox"
          checked={!!(sel && sel[s.id])}
          title={s.consultaEnviada
            ? "✉ Ya se consultó el " + fmtHora(s.consultaEnviada.fecha) + " (" + haceCuanto(s.consultaEnviada.fecha) + ")."
              + "\nA: " + (s.consultaEnviada.a || []).join(", ")
              + "\nTildalo igual si querés volver a preguntar."
            : "Elegir para consultarle a la agencia"}
          onChange={(e) => onSel && onSel(s.id, e.target.checked)}
          style={{ cursor: "pointer", width: 16, height: 16 }}
        />
      </span>
    )),
    estado: (s, x) => (
      <div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
        <Badge estado={s.estado} compacto />
        {x.sinPublicar && !modoNovedades && <span title="Novedad del Excel: la agencia todavía no lo ve" style={S.pillNueva}>◍ sin publicar</span>}
        {x.nivelAviso && !modoNovedades && (
          <span
            title={x.avisos.map((a) => "• " + a.texto).join("\n")}
            style={x.nivelAviso === "alta" ? S.pillAvisoAlta : S.pillAvisoMedia}
            onClick={() => onVerAvisos && onVerAvisos(s)}
          >
            ⚠ {x.avisos.length} dato{x.avisos.length > 1 ? "s" : ""} para revisar
          </span>
        )}
        {x.deAgencia && <span title={"Buque cargado por la agencia " + (s.agenciaCarga || s.agencia) + ", no vino del Excel"} style={S.pillAgencia}>▲ alta de agencia</span>}
        {x.deAgencia && x.coinc.length > 0 && !s.altaResuelta && (
          <span title="Hay movimientos parecidos ya cargados. Click para ver el detalle." style={S.pillDup} onClick={() => onComparar(s)}>⚠ {x.coinc.length} parecido{x.coinc.length > 1 ? "s" : ""}</span>
        )}
        {x.deAgencia && x.coinc.length === 0 && !s.altaResuelta && <span title="No se encontró ningún movimiento parecido ya cargado" style={S.pillOk}>sin duplicados</span>}
        {x.deAgencia && s.altaResuelta && <span title={s.altaResuelta.resultado === "mismo" ? "Se resolvió como duplicado" : "Revisada: es un buque distinto"} style={S.pillOk}>{s.altaResuelta.resultado === "mismo" ? "duplicada" : "revisada"}</span>}
        {/* Marca de consultado al lado del estado. SOLO mientras la consulta
            está fresca (menos de DIAS_CONSULTA_FRESCA días): es cuando volver a
            mandarla sería repetir. Pasado el plazo se apaga sola y la pantalla
            queda limpia; el dato sigue estando en el aro de la casilla y en el
            historial. Vale para cualquier estado, no solo Pendiente: un
            rechazado no cambia de estado al consultarlo, así que sin esto no
            había forma de saber que ya se había mandado. */}
        {s.consultaEnviada && diasDesde(s.consultaEnviada.fecha) !== null
          && diasDesde(s.consultaEnviada.fecha) < DIAS_CONSULTA_FRESCA && (
          <span
            style={{ fontSize: 10.5, color: "var(--t-64748b)", alignSelf: "center", whiteSpace: "nowrap" }}
            title={"Consulta enviada el " + fmtHora(s.consultaEnviada.fecha) + " (" + haceCuanto(s.consultaEnviada.fecha) + ")"
                   + "\nA: " + (s.consultaEnviada.a || []).join(", ")}>
            ✉ {ddmmCorto(s.consultaEnviada.fecha)}
          </span>
        )}
        {/* Sólo lo que mandó una agencia y todavía no aprobó ENAPRO. Lo aprobado
            (o lo que ENAPRO hizo él mismo) no tiene por qué seguir avisando. */}
        {s.pendienteExcel && !s.pendienteExcel.aprobado && (
          <span title={"Cambio de la agencia esperando tu aprobación: " + s.pendienteExcel.tipo} style={{ ...S.pillSync, marginLeft: 0 }}>↔ sin aprobar</span>
        )}
        {/* En un alta de agencia "no está en Excel" es obvio: no se repite.
            Y ENAPRO puede bajar el aviso de un clic (s.ausenteVisto): el dato de
            que no está en la planilla sigue siendo cierto y lo usan la papelera y
            el CSV de bajas — lo único que se guarda es que alguien ya lo miró.
            Queda anotado en el historial con fecha y usuario. */}
        {s.ausenteEnExcel && !s.ausenteVisto && !x.deAgencia && s.cargadaPor !== "agencia" && (
          <span
            title={"No aparece en el Excel. Click para sacar este aviso: queda anotado en el historial del movimiento."}
            style={{ ...S.pillAusente, marginLeft: 0, cursor: "pointer" }}
            onClick={() => accion("/api/buques/" + s.id + "/ausente-visto", { json: {} },
                                  "Aviso sacado. Queda anotado en el historial.")}>
            no está en Excel ✕
          </span>
        )}
      </div>
    ),
    entrada: (s) => <span title={fmt(s.entrada)}>{fmtCorto(s.entrada)}</span>,
    salida: (s) => <span title={fmt(s.salida)}>{fmtCorto(s.salida)}</span>,
    buque: (s) => <span style={{ fontWeight: 700 }} title={s.buque}>{nombreLimpio(s.buque)}</span>,
    band: (s) => <span title={s.band || ""}>{s.band || "—"}</span>,
    procedencia: (s) => <span title={s.procedencia || ""}>{s.procedencia || "—"}</span>,
    destino: (s) => (
      <span title={s.destino_cambiado ? `Destino modificado por la agencia (antes: ${s.destino_anterior || "—"})` : s.destino || ""}>
        {s.destino || "—"}{s.destino_cambiado ? " ✎" : ""}
      </span>
    ),
    agencia: (s) => (
      <span title={s.agenciaExcel ? 'El Excel dice "' + s.agenciaExcel + '", que no es una agencia del portal' : s.agencia}>
        {s.agencia}
        {/* Sólo cuando la planilla nombra una agencia que el portal no conoce
            (RAMOS, GLS…): ahí dice qué decía el Excel. "SIN ASIGNAR" o la misma
            agencia de la fila no agregan nada y se ocultan. */}
        {s.agenciaExcel && String(s.agenciaExcel).trim().toUpperCase() !== "SIN ASIGNAR"
          && String(s.agenciaExcel).trim().toUpperCase() !== String(s.agencia || "").trim().toUpperCase()
          && <div style={{ fontSize: 11, color: "var(--t-b45309)", fontWeight: 700 }} title="La planilla dice esta agencia y el portal no la conoce">Excel: {s.agenciaExcel}</div>}
      </span>
    ),
    trn: (s) => s.trn || "—",
    zona: (s) => s.zona || "—",
    muelle: (s) => <span title={s.muelle || ""}>{s.muelle || "—"}</span>,
    pasavante: (s) => <span title={s.pasavante || ""}>{s.pasavante || "—"}</span>,
    imo: (s) => s.imo || "—",
    // Los dos adjuntos posibles: el ITC de la estadía y el certificado de
    // cabotaje. El de cabotaje no se mostraba en ninguna parte de la tabla, así
    // que un movimiento marcado por bandera parecía venir sin respaldo.
    itc: (s) => (
      <span style={{ whiteSpace: "nowrap" }}>
        {s.itc && <a href={`/descargas/adjunto/${s.id}/itc`} title={"ITC: " + s.itc.nombre} style={{ color: "var(--t-166534)", textDecoration: "none", fontSize: 15 }}>📎</a>}
        {s.itc && s.cert_cabotaje ? " " : null}
        {s.cert_cabotaje && <a href={`/descargas/adjunto/${s.id}/cabotaje`} title={"Certificado de cabotaje: " + s.cert_cabotaje.nombre} style={{ color: "var(--t-b45309)", textDecoration: "none", fontSize: 15 }}>🏳</a>}
        {!s.itc && !s.cert_cabotaje && "—"}
      </span>
    ),
    comentario: (s) => {
      // La nota que traia el nombre entre parentesis: se saco de la columna
      // Buque, pero si no estaba tambien en los comentarios aparece aca. Nada se
      // pierde por limpiar el nombre.
      const nota = notaDelNombre(s);
      return (
        <span style={{ color: "var(--t-475569)" }} title={s.comentario || ""}>
          {s.comentario || (s.comentarioExcel || nota ? "" : "—")}
          {s.comentarioExcel && <div style={{ fontSize: 11, color: "var(--t-94a3b8)" }}>Excel: {s.comentarioExcel}</div>}
          {nota && <div style={{ fontSize: 11, color: "var(--t-94a3b8)" }} title="Venía entre paréntesis en el nombre del buque">Nombre: {nota}</div>}
          {/* Qué agencia lo devolvió o lo marcó como cabotaje. Sólo lo ve ENAPRO:
              el servidor no manda estos campos a las agencias. */}
          {/* Todas las agencias que ya dijeron que no es suyo (el servidor las
              junta del rechazo del portal y del texto del Excel). A cada una de
              ellas el buque ya no le figura en su lista de rechazados. */}
          {(s.rechazaron || []).length > 0 && (
            <div style={{ fontSize: 11, color: "var(--t-b91c1c)", fontWeight: 600 }} title={"Ya dijeron que no es suyo: " + s.rechazaron.join(", ") + ". A ellas no les figura en Rechazados."}>
              Rechazó: {s.rechazaron.join(", ")}
            </div>
          )}
          {!(s.rechazaron || []).length && s.rechazadoPor && s.rechazadoPor.agencia && (
            <div style={{ fontSize: 11, color: "var(--t-b91c1c)", fontWeight: 600 }} title={"Rechazado por " + s.rechazadoPor.usuario + " el " + fmtHora(s.rechazadoPor.fecha)}>
              Rechazó: {s.rechazadoPor.agencia}
            </div>
          )}
          {s.noCorrespondePor && s.noCorrespondePor.agencia && (
            <div style={{ fontSize: 11, color: "var(--t-b45309)", fontWeight: 600 }} title={"Marcado No corresponde por " + s.noCorrespondePor.usuario + " el " + fmtHora(s.noCorrespondePor.fecha)}>
              No corresp.{s.noCorrespondeMotivo ? " (" + (s.noCorrespondeMotivo === "rada" ? "rada" : "cabotaje") + ")" : ""}: {s.noCorrespondePor.agencia}
            </div>
          )}
        </span>
      );
    },
    acciones: (s, x) => (
      <div style={{ display: "flex", flexWrap: "wrap", gap: 4, alignItems: "center" }}>
        <button style={{ ...S.btnMini, padding: "4px 6px" }} onClick={() => onEditar(s)}>Editar</button>
        <select
          style={{ ...S.selectSm, flex: "1 1 72px", minWidth: 0, maxWidth: "100%", padding: "3px 2px", fontSize: 11 }}
          title={"Cambiar estado (actual: " + s.estado + ")"}
          value={s.estado}
          onChange={(e) => accion(`/api/buques/${s.id}/estado`, { json: { estado: e.target.value } }, `${s.buque} → ${e.target.value}`)}
        >
          {ESTADOS.map((e) => <option key={e}>{e}</option>)}
        </select>
        {modoNovedades && <button style={{ ...S.btnMini, padding: "4px 8px", background: "var(--f-dcfce7)", borderColor: "var(--b-86efac)", color: "var(--t-166534)" }} title="Publicar sólo este movimiento" onClick={() => accion(`/api/buques/${s.id}/publicar`, { method: "POST" }, `${s.buque} publicado.`)}>Publicar</button>}
        {!modoNovedades && x.sinPublicar && <button style={{ ...S.btnMini, padding: "4px 8px" }} title="Publicar este movimiento para la agencia" onClick={() => accion(`/api/buques/${s.id}/publicar`, { method: "POST" }, `${s.buque} publicado.`)}>Publicar</button>}
        {x.deAgencia && x.coinc.length > 0 && <button style={{ ...S.btnMini, padding: "4px 8px" }} onClick={() => onComparar(s)}>Comparar</button>}
        {/* Cuando la agencia ya dejó el movimiento en Confirmado, elegir
            "Confirmado" en la lista no cambia nada y los avisos quedarían
            pegados. Este botón es la forma de decir "ya lo miré". */}
        {(x.avisos.length || (s.pendienteExcel && !s.pendienteExcel.aprobado) || x.deAgencia) && (
          <button
            style={{ ...S.btnMini, padding: "4px 8px", background: "var(--f-dcfce7)", borderColor: "var(--b-86efac)", color: "var(--t-166534)" }}
            title="Marcar como revisado: se guardan los avisos que ya viste y dejan de aparecer en la fila"
            onClick={() => accion(`/api/buques/${s.id}/revisado`, { method: "POST", json: {} }, `${s.buque} marcado como revisado.`)}
          >✓ Revisado</button>
        )}
        <button style={{ ...S.btnMiniDanger, padding: "3px 5px", fontSize: 13 }} title="Eliminar este movimiento" onClick={() => onEliminar(s)}>🗑</button>
      </div>
    ),
  };

  return (
    <div style={S.tableWrapFijo}>
      <table style={S.tableFija}>
        <colgroup>{visibles.map((c) => <col key={c.clave} style={{ width: ancho(c) }} />)}</colgroup>
        <thead>
          <tr>{visibles.map((c) => <th key={c.clave} style={S.thFija}>{c.label}</th>)}</tr>
        </thead>
        <tbody>
          {lista.slice(0, tope).map((s) => {
            const avisos = avisosVigentes(s);
            const x = {
              // Un alta ya trabajada por ENAPRO deja de anunciarse como alta: el
              // dato queda en el historial y en la pestaña de Altas.
              deAgencia: s.cargadaPor === "agencia" && !s.revisado,
              revisado: s.revisado || null,
              sinPublicar: s.publicado === false,
              coinc: s.coincidencias || [],
              avisos,
              nivelAviso: avisos.some((a) => a.nivel === "alta") ? "alta" : avisos.length ? "media" : null,
            };
            return (
              <React.Fragment key={s.id}>
                <tr style={{ borderBottom: avisos.length && modoNovedades ? "none" : "1px solid var(--b-f1f5f9)", ...(x.sinPublicar ? S.filaNueva : x.deAgencia ? S.filaAgencia : {}) }}>
                  {visibles.map((c) => {
                    const base = c.nowrap ? S.tdFijaNw : S.tdFija;
                    const extra = c.clave === "destino" && s.destino_cambiado
                      ? { background: "var(--f-fef3c7)", color: "var(--t-92400e)", fontWeight: 700 }
                      : c.clave === "itc" ? { textAlign: "center" } : null;
                    return <td key={c.clave} style={extra ? { ...base, ...extra } : base}>{CELDAS[c.clave](s, x)}</td>;
                  })}
                </tr>
                {modoNovedades && avisos.length > 0 && (
                  <tr style={{ borderBottom: "1px solid var(--b-f1f5f9)" }}>
                    <td colSpan={visibles.length} style={S.avisoFila}>
                      <ListaAvisos avisos={avisos} compacta />
                    </td>
                  </tr>
                )}
              </React.Fragment>
            );
          })}
        </tbody>
      </table>
      {lista.length > tope && <div style={{ padding: 12, fontSize: 12.5, color: "var(--t-64748b)" }}>Mostrando los primeros {tope} de {lista.length}. Usá los filtros para acotar.</div>}
    </div>
  );
}

/* ============================================================================
 * CONSULTA POR CORREO A LAS AGENCIAS
 *
 * Reemplaza la macro del Excel mientras el portal no esta en la web: se tildan
 * movimientos pendientes y el portal manda un correo por agencia.
 * ==========================================================================*/
function BarraConsulta({ datos, filtrados, sel, setSel, onEnviar }) {
  const ids = Object.keys(sel).map(Number).filter((id) => sel[id]);
  // Solo los pendientes se pueden consultar (es lo que se tilda en la tabla).
  const consultables = filtrados.filter(sePuedeConsultar);
  const elegidos = datos.buques.filter((b) => ids.includes(b.id));
  const reclamar = elegidos.filter(esAReclamar);
  const conAgencia = elegidos.filter((b) => !esAReclamar(b));
  const agenciasConCorreo = [...new Set(conAgencia.map((b) => b.agencia))].sort();
  const agencias = agenciasConCorreo;
  const sinCorreo = agenciasConCorreo.filter((a) => !((datos.cuentas || {})[a] || []).length);
  const todosTildados = consultables.length > 0 && consultables.every((s) => sel[s.id]);
  const correoListo = datos.correo && datos.correo.listo;

  function tildarTodos(v) {
    setSel((p) => {
      const n = { ...p };
      consultables.forEach((s) => { if (v) n[s.id] = true; else delete n[s.id]; });
      return n;
    });
  }

  if (!consultables.length && !ids.length) return null;

  return (
    <div style={{
      display: "flex", gap: 14, flexWrap: "wrap", alignItems: "center", marginBottom: 10,
      background: ids.length ? "var(--f-f0f7ff)" : "var(--f-f8fafc)",
      border: "1px solid " + (ids.length ? "var(--b-bfdbfe)" : "var(--b-e2e8f0)"),
      borderRadius: 10, padding: "9px 12px", fontSize: 13,
    }}>
      <label style={{ display: "flex", alignItems: "center", gap: 7, cursor: "pointer", fontWeight: 700, color: "var(--t-334155)" }}>
        <input type="checkbox" checked={todosTildados} onChange={(e) => tildarTodos(e.target.checked)} style={{ width: 16, height: 16, cursor: "pointer" }} />
        Consultar a las agencias
      </label>
      <span style={{ color: "var(--t-64748b)" }}>
        {ids.length ? (
          <>
            Tildaste <b>{ids.length}</b> movimiento(s)
            {conAgencia.length > 0 && <> · <b>{agenciasConCorreo.length}</b> correo(s) por agencia</>}
            {reclamar.length > 0 && <> · <b>{reclamar.length}</b> para el correo a todas</>}
          </>
        ) : <>Tildá los que quieras consultar ({consultables.length} a la vista)</>}
      </span>
      {sinCorreo.length > 0 && (
        <span style={{ ...S.pillAusente, marginLeft: 0 }}
              title={sinCorreo.includes("SIN ASIGNAR")
                ? "Un movimiento sin agencia no tiene a quien escribirle: primero hay que asignarle una"
                : "Esas agencias no tienen usuarios cargados"}>
          {sinCorreo.includes("SIN ASIGNAR") && sinCorreo.length === 1
            ? "SIN ASIGNAR no tiene a quién escribirle"
            : "sin correo: " + sinCorreo.join(", ")}
        </span>
      )}
      <div style={{ flex: 1 }} />
      {ids.length > 0 && <button style={S.btnMini} onClick={() => setSel({})}>Destildar todo</button>}
      <button
        style={{ ...S.btnPrimary, opacity: ids.length ? 1 : 0.45, cursor: ids.length ? "pointer" : "not-allowed" }}
        disabled={!ids.length}
        title={correoListo ? "" : "Falta configurar el correo en config.json"}
        onClick={onEnviar}
      >
        ✉ Enviar consulta ({ids.length})
      </button>
    </div>
  );
}

/* Cuando el envio falla por el certificado, casi siempre es un antivirus que
 * "revisa el correo": se mete en el medio de la conexion y presenta un
 * certificado propio. Esto va y mira quien es, en vez de dejar al usuario con
 * un mensaje en ingles que no dice nada. */
function DiagnosticoCertificado({ accion, notify }) {
  const [cadena, setCadena] = useState(null);
  const [corriendo, setCorriendo] = useState(false);
  const [listo, setListo] = useState(false);

  async function mirar(confiar) {
    if (corriendo) return;
    setCorriendo(true);
    try {
      const d = await accion("/api/correo/certificado", { json: { confiar } });
      if (d && d.certificado) {
        setCadena(d.certificado.cadena);
        if (d.certificado.guardado) {
          setListo(true);
          notify("Listo: ahora el portal confia en el certificado de " + d.certificado.guardado + ". Proba de nuevo.");
        }
      }
    } catch (e) {} finally { setCorriendo(false); }
  }

  const raiz = cadena && cadena.length ? cadena[cadena.length - 1] : null;
  const esGoogle = raiz && /google|gts|gtsca/i.test((raiz.sujeto || "") + (raiz.emisor || ""));

  return (
    <div style={{ marginTop: 14, background: "var(--f-fffbeb)", border: "1px solid var(--b-fde68a)", borderRadius: 10, padding: "12px 14px", fontSize: 13, color: "var(--t-78350f)" }}>
      <div style={{ fontWeight: 800, marginBottom: 6 }}>Falló por el certificado. Casi siempre es el antivirus.</div>
      <div style={{ lineHeight: 1.7, marginBottom: 10 }}>
        Los antivirus que “revisan el correo” (Avast, ESET, Kaspersky) se meten en el medio de la conexión y presentan
        un certificado propio. Windows lo conoce, pero el portal no. Apretá el botón y el portal va a mirar quién está
        firmando la conexión.
      </div>
      {!cadena && (
        <button style={S.btnPrimary} disabled={corriendo} onClick={() => mirar(false)}>
          {corriendo ? "Mirando…" : "Ver quién está firmando"}
        </button>
      )}
      {cadena && (
        <div>
          <div style={{ fontWeight: 700, marginBottom: 4 }}>La conexión con el servidor de correo la firma:</div>
          <div style={{ background: "var(--f-ffffff)", border: "1px solid var(--b-fde68a)", borderRadius: 8, padding: "8px 10px", marginBottom: 10 }}>
            {cadena.map((c, i) => (
              <div key={i} style={{ fontSize: 12.5 }}>
                {i === 0 ? "El servidor dice ser: " : "firmado por: "}<b>{c.sujeto || c.emisor || "(sin nombre)"}</b>
              </div>
            ))}
          </div>
          {esGoogle ? (
            <div>
              El que firma es Google, así que el problema no es un antivirus. Mandame esta pantalla y lo miramos.
            </div>
          ) : listo ? (
            <div style={{ color: "var(--t-166534)", fontWeight: 700 }}>
              ✓ Guardado. Cerrá esta ventana y volvé a probar el envío.
            </div>
          ) : (
            <div>
              <div style={{ marginBottom: 8 }}>
                Eso <b>no es Google</b>: es el programa de tu PC que revisa el correo. Si lo reconocés como tu antivirus,
                podés decirle al portal que confíe en ese certificado — es lo mismo que ya hace Windows. Si no lo
                reconocés, no lo aceptes y avisame.
              </div>
              <button style={S.btnPrimary} disabled={corriendo} onClick={() => mirar(true)}>
                {corriendo ? "Guardando…" : "Es mi antivirus: confiar en este certificado"}
              </button>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

/* Los datos del correo, en pantalla. Existe para no tener que editar config.json
 * a mano: ahi una coma de mas deja el portal sin arrancar. */
function ConfigCorreoModal({ datos, accion, notify, onClose }) {
  const c = datos.correo || {};
  const [f, setF] = useState({
    usuario: c.usuario || "", desde: c.desde || "", nombre: c.nombre || "ENAPRO - Operaciones",
    password: "", asunto: c.asunto || "Consulta de buques gestionados",
    copiaA: c.copiaA || "", pruebaA: c.pruebaA || "",
  });
  const [guardando, setGuardando] = useState(false);
  const set = (k) => (e) => setF({ ...f, [k]: e.target.value });

  async function guardar() {
    if (guardando) return;
    setGuardando(true);
    try {
      await accion("/api/config/correo", { json: f }, "Datos de correo guardados.");
      onClose();
    } catch (e) {} finally { setGuardando(false); }
  }

  return (
    <div style={S.modalWrap} onClick={onClose}>
      <div style={{ ...S.modal, width: 640 }} onClick={(e) => e.stopPropagation()}>
        <div style={S.modalHead}>
          <div>
            <div style={{ fontWeight: 800, fontSize: 17 }}>Datos del correo</div>
            <div style={{ fontSize: 12, color: "var(--t-64748b)" }}>Desde qué casilla salen las consultas a las agencias.</div>
          </div>
          <button style={S.btnGhostDark} onClick={onClose}>✕</button>
        </div>
        <div style={S.modalBody}>
          <div style={{ background: "var(--f-f0f7ff)", border: "1px solid var(--b-bfdbfe)", borderRadius: 10, padding: "10px 12px", marginBottom: 16, fontSize: 12.5, color: "var(--t-334155)", lineHeight: 1.8 }}>
            <b>La contraseña no es la de la cuenta.</b> Google no deja que un programa entre con la contraseña
            normal. Hay que pedirle una <b>contraseña de aplicación</b>: son 16 letras que sirven sólo para esto y
            se pueden anular cuando quieras, sin tocar la cuenta.
            <div style={{ marginTop: 6 }}>
              <b>1.</b> Entrá a la cuenta de Google de esa casilla → Seguridad.<br />
              <b>2.</b> Activá la <b>verificación en dos pasos</b> si no está activada (sin eso Google no ofrece la opción).<br />
              <b>3.</b> Buscá <b>Contraseñas de aplicaciones</b>, creá una con el nombre que quieras (por ejemplo "Portal ENAPRO").<br />
              <b>4.</b> Google te muestra 16 letras. Copialas y pegalas acá abajo: no las vas a poder volver a ver.
            </div>
          </div>

          <div style={S.formGrid}>
            <Field label="Casilla desde la que se manda *">
              <input style={S.input} value={f.usuario} placeholder="operaciones@enapro.com.ar" onChange={set("usuario")} />
            </Field>
            <Field label="Contraseña de aplicación (16 letras)">
              <input style={S.input} type="password" value={f.password} autoComplete="new-password"
                     placeholder={c.tienePassword ? "ya guardada — dejala vacía para no cambiarla" : "las 16 letras que te dio Google"}
                     onChange={set("password")} />
            </Field>
            <Field label="Nombre que ven las agencias">
              <input style={S.input} value={f.nombre} placeholder="ENAPRO - Operaciones" onChange={set("nombre")} />
            </Field>
            <Field label="Asunto del correo">
              <input style={S.input} value={f.asunto} onChange={set("asunto")} />
            </Field>
            <Field label="Copia a (opcional, separadas por coma)">
              <input style={S.input} value={f.copiaA} placeholder="rentas@enapro.gob.ar" onChange={set("copiaA")} />
            </Field>
            <Field label="Casilla donde querés recibir las pruebas">
              <input style={S.input} value={f.pruebaA} placeholder="tu propio correo" onChange={set("pruebaA")} />
              <div style={{ fontSize: 11.5, color: "var(--t-94a3b8)", marginTop: -8, marginBottom: 10, lineHeight: 1.6 }}>
                El botón <b>"Probar: mandármelos a mí"</b> manda los correos acá en vez de a las agencias, para que
                veas cómo quedan antes de mandarlos de verdad. Poné tu propio correo — puede ser el mismo de arriba.
              </div>
            </Field>
          </div>
          <div style={{ fontSize: 12, color: "var(--t-94a3b8)", marginTop: 4, lineHeight: 1.7 }}>
            Se guarda en <code>portal\config.json</code>, en esta misma PC. La contraseña queda ahí en texto plano —es la
            única forma de que el portal pueda mandar solo—, así que si algún día no la querés más, anulá esa
            contraseña de aplicación desde la cuenta de Google y listo.
          </div>
        </div>
        <div style={S.modalFoot}>
          <button style={S.btnGhostDark} onClick={onClose}>Cancelar</button>
          <button style={S.btnPrimary} disabled={guardando || !f.usuario.trim()} onClick={guardar}>
            {guardando ? "Guardando…" : "Guardar"}
          </button>
        </div>
      </div>
    </div>
  );
}

/* Antes de mandar nada se muestra QUE se va a mandar y A QUIEN, agencia por
 * agencia. Un correo que sale mal a 30 agencias no se puede deshacer. */
function EnviarConsultaModal({ datos, sel, accion, notify, onClose, onEnviado }) {
  const [corriendo, setCorriendo] = useState(false);
  const [resultado, setResultado] = useState(null);
  const [config, setConfig] = useState(false);
  // Correos destildados para ESTE envio nada mas (alguien de vacaciones, una
  // casilla que rebota). No se toca la agencia: la proxima vez vuelven todos.
  const [excluidos, setExcluidos] = useState({});
  const ids = Object.keys(sel).map(Number).filter((id) => sel[id]);
  const elegidos = datos.buques.filter((b) => ids.includes(b.id));
  // Los rechazados y los sin agencia van en un correo aparte, a todas.
  const reclamar = elegidos.filter(esAReclamar);
  const porAgencia = {};
  elegidos.filter((b) => !esAReclamar(b)).forEach((b) => { (porAgencia[b.agencia] = porAgencia[b.agencia] || []).push(b); });
  const agencias = Object.keys(porAgencia).sort();
  /* Las casillas de prueba no entran en ningún envío (ver esCorreoEnviable en
     el servidor): sus dominios no existen y una sola dirección rechazada tira
     abajo el correo entero de una agencia. Acá se aplica la misma regla para
     que esta ventana muestre exactamente lo que se va a mandar. */
  const enviable = (u) => {
    const e = String((u && u.email) || "").trim();
    if (!e || e.indexOf("@") === -1) return false;
    if (u.dePrueba) return false;
    return !/\.(local|localhost|test|invalid|example)$/i.test(e.split("@").pop());
  };
  const todosLosCorreos = [...new Set(Object.keys(datos.cuentas || {})
    .filter((a) => a.trim().toUpperCase() !== "SIN ASIGNAR")
    .flatMap((a) => (datos.cuentas[a] || []).filter(enviable).map((u) => u.email)))];
  const ocultosFinales = todosLosCorreos.filter((e) => !excluidos[e]);
  const cfg = datos.correo || {};
  const correos = (ag) => ((datos.cuentas || {})[ag] || []).filter(enviable).map((u) => u.email);
  const yaConsultados = elegidos.filter((b) => b.consultaEnviada).length;

  async function mandar(prueba) {
    if (corriendo) return;
    setCorriendo(true);
    try {
      const d = await accion("/api/consultas/enviar", {
        json: { ids, prueba, excluidos: Object.keys(excluidos).filter((e) => excluidos[e]) },
      });
      if (d && d.envio) {
        setResultado(d.envio);
        notify(d.envio.enviados + " de " + d.envio.total + " correo(s) enviados" + (prueba ? " (prueba)" : "") + ".");
        if (!prueba && d.envio.enviados) onEnviado();
      }
    } catch (e) {} finally { setCorriendo(false); }
  }

  return (
    <div style={S.modalWrap} onClick={corriendo ? undefined : onClose}>
      <div style={{ ...S.modal, width: 720 }} onClick={(e) => e.stopPropagation()}>
        <div style={S.modalHead}>
          <div>
            <div style={{ fontWeight: 800, fontSize: 17 }}>Consultar a las agencias</div>
            <div style={{ fontSize: 12, color: "var(--t-64748b)" }}>
              {elegidos.length} movimiento(s) ·{" "}
              {[agencias.length ? agencias.length + " correo(s) por agencia" : null,
                reclamar.length ? "1 correo a todas" : null].filter(Boolean).join(" + ")}
            </div>
          </div>
          {!corriendo && <button style={S.btnGhostDark} onClick={onClose}>✕</button>}
        </div>
        <div style={S.modalBody}>
          {resultado ? (
            <div style={{ fontSize: 13.5 }}>
              <div style={{ ...(resultado.enviados === resultado.total ? S.pillOk : S.pillAusente), display: "inline-block", padding: "6px 10px", marginBottom: 12, marginLeft: 0 }}>
                {resultado.enviados} de {resultado.total} enviados{resultado.prueba ? " (prueba)" : ""}
              </div>
              {resultado.resultados.map((r) => (
                <div key={r.agencia} style={{ padding: "6px 0", borderBottom: "1px solid var(--b-f1f5f9)" }}>
                  <b>{r.agencia}</b> · {r.movimientos} movimiento(s) ·{" "}
                  {r.ok ? <span style={{ color: "var(--t-166534)" }}>enviado a {r.para.join(", ")}</span>
                        : <span style={{ color: "var(--t-b91c1c)" }}>no se pudo: {r.error}</span>}
                  <div style={{ fontSize: 11.5, color: "var(--t-94a3b8)" }}>copia guardada en salida\correos\{r.archivo}</div>
                </div>
              ))}
              {resultado.resultados.some((r) => !r.ok && /certificate|certificado/i.test(r.error || "")) && (
                <DiagnosticoCertificado accion={accion} notify={notify} />
              )}
              <div style={{ marginTop: 12, fontSize: 12.5, color: "var(--t-64748b)" }}>
                De cada correo queda el archivo .eml en <code>portal\salida\correos\</code>, se haya enviado o no.
                Si alguno falló, ese archivo se puede abrir y mandar a mano.
              </div>
            </div>
          ) : (
            <>
              {!cfg.listo && (
                <div style={{ background: "var(--f-fef2f2)", border: "1px solid var(--b-fecaca)", borderRadius: 10, padding: "10px 12px", marginBottom: 14, fontSize: 13, color: "var(--t-7f1d1d)" }}>
                  <b>Falta decirle al portal desde qué casilla mandar.</b> Se carga una sola vez.
                  <div style={{ marginTop: 8 }}>
                    <button style={S.btnPrimary} onClick={() => setConfig(true)}>Cargar los datos del correo</button>
                  </div>
                </div>
              )}
              {cfg.noEnviar && (
                <div style={S.aviso}>
                  <code>noEnviar</code> está en true: se van a generar los archivos .eml pero no se va a mandar ningún correo.
                </div>
              )}
              {yaConsultados > 0 && (
                <div style={S.aviso}>
                  {yaConsultados} de los tildados ya tienen una consulta enviada. Se les va a mandar de nuevo.
                </div>
              )}
              <div style={{ fontSize: 13, color: "var(--t-475569)", marginBottom: 12 }}>
                {agencias.length > 0 && <>Sale <b>un correo por agencia</b> desde <b>{cfg.desde || "(sin configurar)"}</b>, con la tabla de sus movimientos
                en el orden del portal: Entrada · Salida · Buque · Bandera · Procedencia · Destino · Agencia · TRN · Zona · Muelle.</>}
                <div style={{ color: "var(--t-64748b)", marginTop: 4 }}>
                  Destildá un correo para dejarlo afuera <b>solo de este envío</b> — por ejemplo si alguien está de vacaciones.
                  La agencia no se modifica: la próxima vez vuelven todos.
                </div>
              </div>
              {reclamar.length > 0 && (
                <div style={{ border: "1px solid var(--b-bfdbfe)", background: "var(--f-f0f7ff)", borderRadius: 10, padding: "12px 14px", marginBottom: 12 }}>
                  <div style={{ fontWeight: 800, fontSize: 14 }}>Un correo a TODAS las agencias, en copia oculta</div>
                  <div style={{ fontSize: 12.5, color: "var(--t-334155)", marginTop: 4, lineHeight: 1.7 }}>
                    {reclamar.length} movimiento(s) rechazados y/o sin agencia. Va a <b>{ocultosFinales.length} correos</b> en
                    copia oculta (CCO): ninguna agencia ve los correos de las demás. En el destinatario visible va tu propia
                    casilla.
                  </div>
                  <div style={{ fontSize: 12.5, color: "var(--t-334155)", marginTop: 4, lineHeight: 1.7 }}>
                    En este correo la tabla <b>no lleva la columna Agencia</b>: nadie ve a quién pertenecía el movimiento
                    ni qué agencia lo rechazó.
                  </div>
                  <div style={{ fontSize: 12, color: "var(--t-64748b)", marginTop: 6 }}>
                    {reclamar.slice(0, 12).map((b) => nombreLimpio(b.buque) + " (" + fmt(b.salida) + ")").join(" · ")}
                    {reclamar.length > 12 ? " … y " + (reclamar.length - 12) + " más" : ""}
                  </div>
                </div>
              )}

              {agencias.length > 0 && (
              <div style={{ maxHeight: 280, overflow: "auto", border: "1px solid var(--b-e2e8f0)", borderRadius: 10 }}>
                {agencias.map((ag) => (
                  <div key={ag} style={{ padding: "10px 12px", borderBottom: "1px solid var(--b-f1f5f9)" }}>
                    <div style={{ fontWeight: 800, fontSize: 13.5 }}>
                      {ag} <span style={{ fontWeight: 500, color: "var(--t-94a3b8)" }}>· {porAgencia[ag].length} movimiento(s)</span>
                    </div>
                    {correos(ag).length === 0 ? (
                      <div style={{ fontSize: 12.5, color: "var(--t-b91c1c)", marginTop: 2 }}>
                        sin correos cargados — no se le va a poder mandar
                      </div>
                    ) : (
                      <div style={{ display: "flex", flexWrap: "wrap", gap: "2px 16px", marginTop: 4 }}>
                        {correos(ag).map((e) => (
                          <label key={e} style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 12.5, cursor: "pointer",
                                                  color: excluidos[e] ? "var(--t-94a3b8)" : "var(--t-166534)",
                                                  textDecoration: excluidos[e] ? "line-through" : "none" }}>
                            <input type="checkbox" checked={!excluidos[e]}
                                   onChange={(ev) => setExcluidos((p) => ({ ...p, [e]: !ev.target.checked }))} />
                            {e}
                          </label>
                        ))}
                      </div>
                    )}
                    {correos(ag).length > 0 && correos(ag).every((e) => excluidos[e]) && (
                      <div style={{ fontSize: 12, color: "var(--t-b91c1c)", fontWeight: 700, marginTop: 4 }}>
                        Destildaste todos: a esta agencia no se le va a mandar nada.
                      </div>
                    )}
                    <div style={{ fontSize: 12, color: "var(--t-64748b)", marginTop: 4 }}>
                      {porAgencia[ag].map((b) => nombreLimpio(b.buque) + " (" + fmt(b.salida) + ")").join(" · ")}
                    </div>
                  </div>
                ))}
              </div>
              )}
            </>
          )}
        </div>
        <div style={S.modalFoot}>
          {resultado
            ? <button style={S.btnPrimary} onClick={onClose}>Cerrar</button>
            : (
              <>
                <button style={S.btnGhostDark} onClick={onClose} disabled={corriendo}>Cancelar</button>
                <button style={S.btnMini} disabled={corriendo || !cfg.listo} title="Manda todos los correos a tu propia casilla para que veas como llegan"
                        onClick={() => mandar(true)}>
                  Probar: mandármelos a mí
                </button>
                <button style={S.btnPrimary} disabled={corriendo || !cfg.listo} onClick={() => mandar(false)}>
                  {corriendo ? "Enviando…" : "Enviar a las agencias"}
                </button>
              </>
            )}
        </div>
        {config && <ConfigCorreoModal datos={datos} accion={accion} notify={notify} onClose={() => setConfig(false)} />}
      </div>
    </div>
  );
}

/* Mostrar u ocultar columnas. El botón vive en la fila de filtros y el panel se
 * dibuja debajo, a lo ancho: si el panel fuera hijo del botón, estiraba la fila
 * de filtros entera. */
/* Botón "Cuadros" (28/09/2026). Es el hermano de "Columnas": mismo lugar, mismo
 * gesto y mismo panel, para que no haya un mecanismo nuevo que aprender.
 *
 * Cuando hay alguno escondido el botón se pone en ámbar, igual que el filtro de
 * estados. Ese resaltado es lo que evita el problema de fondo de esconder
 * cosas: que después uno no se acuerde de que la pantalla está recortada. */
function BotonCuadros({ ocultos, abierto, setAbierto }) {
  const cuantos = CUADROS.filter((c) => ocultos[c.clave]).length;
  return (
    <div style={S.filterCol}>
      <label style={S.filterLab}>Cuadros</label>
      <button
        style={{ ...S.select, cursor: "pointer", textAlign: "left", minWidth: 130,
                 borderColor: cuantos ? "var(--b-filtro)" : undefined,
                 background: cuantos ? "var(--f-filtro)" : "var(--f-ffffff)",
                 color: cuantos ? "var(--t-filtro)" : "var(--t-1c2430)",
                 fontWeight: cuantos ? 700 : undefined }}
        onClick={() => setAbierto(!abierto)}
        title="Elegí qué cuadros de arriba querés ver. Esconderlos no cambia ningún número.">
        {abierto ? "▲" : "▼"} {cuantos ? cuantos + " oculto" + (cuantos > 1 ? "s" : "") : "Todos visibles"}
      </button>
    </div>
  );
}

function PanelCuadros({ ocultos, setOcultos, cerrar }) {
  return (
    <div style={S.panelColumnas}>
      <div style={{ fontSize: 12.5, color: "var(--t-64748b)", marginBottom: 8 }}>
        Destildá los cuadros que no quieras ver: los demás se ensanchan. Los números <b>no</b> cambian
        — el Total sigue contando todo y la lista de abajo queda igual. Se recuerda para la próxima vez.
      </div>
      <div style={{ display: "flex", flexWrap: "wrap", gap: "6px 18px" }}>
        {CUADROS.map((c) => (
          <label key={c.clave} style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13, cursor: "pointer", color: "var(--t-334155)" }}>
            <input
              type="checkbox"
              checked={!ocultos[c.clave]}
              onChange={(e) => setOcultos(Object.assign({}, ocultos, { [c.clave]: !e.target.checked }))}
            />
            {c.label}
          </label>
        ))}
      </div>
      <div style={{ display: "flex", gap: 8, marginTop: 10 }}>
        <button style={S.btnMini} onClick={() => setOcultos({})}>Mostrar todos</button>
        <button style={S.btnMini} onClick={cerrar}>Listo</button>
      </div>
    </div>
  );
}

function BotonColumnas({ cols, ocultas, abierto, setAbierto }) {
  const cuantas = (cols || COLS_ADMIN).filter((c) => !c.fijo && ocultas[c.clave]).length;
  return (
    <div style={S.filterCol}>
      <label style={S.filterLab}>Columnas</label>
      <button style={{ ...S.select, cursor: "pointer", textAlign: "left", minWidth: 150 }} onClick={() => setAbierto(!abierto)}>
        {abierto ? "▲" : "▼"} {cuantas ? cuantas + " oculta" + (cuantas > 1 ? "s" : "") : "Todas visibles"}
      </button>
    </div>
  );
}

/* Filtro de la sábana: estado CRUZADO con "tiene agencia / no tiene".
 *
 * Es una tabla de doble entrada y no una lista de estados porque lo que hay que
 * poder pedir —los rechazados y los que no tienen dueño, sin los "No
 * corresponde"— es una diagonal del cruce, no una fila ni una columna. Ver el
 * comentario largo en EnaproPanel, donde está la medición.
 *
 * Tocando el nombre del estado se prende o apaga la fila entera; tocando el
 * título de una columna, la columna entera.
 */
function PanelEstados({ ocultos, setOcultos, counts, cerrar }) {
  const n = (k) => (counts && counts[k]) || 0;
  const visible = (k) => !ocultos[k];
  const poner = (claves, mostrar) => {
    const o = Object.assign({}, ocultos);
    claves.forEach((k) => { if (mostrar) delete o[k]; else o[k] = true; });
    setOcultos(o);
  };
  // Deja visibles SOLO las casillas de la lista.
  // Deja visibles SOLO las casillas de la lista (misma cuenta que usa la
  // tarjeta "Sin asignar": una sola definicion para los dos).
  const solo = (claves) => setOcultos(soloEstasCasillas(claves));
  const cel = { padding: "5px 10px", fontSize: 13, color: "var(--t-334155)", whiteSpace: "nowrap" };
  const th = { ...cel, fontSize: 11.5, letterSpacing: ".04em", textTransform: "uppercase",
               color: "var(--t-64748b)", fontWeight: 700, cursor: "pointer", textAlign: "center" };
  const colTodas = (lado) => {
    const claves = ESTADOS.map((e) => e + "|" + lado);
    const todasVisibles = claves.every(visible);
    poner(claves, !todasVisibles);
  };
  return (
    <div style={S.panelColumnas}>
      <div style={{ fontSize: 12.5, color: "var(--t-64748b)", marginBottom: 8 }}>
        Destildá lo que no quieras ver. Cada estado está partido en dos según tenga agencia o no,
        así podés quedarte, por ejemplo, con los rechazados y los sin asignar sin que se te cuelen
        los “No corresponde”. Se vuelve a ver todo al abrir el portal de nuevo.
      </div>
      <div style={{ overflowX: "auto" }}>
        <table style={{ borderCollapse: "collapse" }}>
          <tbody>
            <tr>
              <td style={{ ...cel, fontSize: 11.5, color: "var(--t-94a3b8)", textTransform: "uppercase", letterSpacing: ".04em" }}>Estado</td>
              <td style={th} onClick={() => colTodas("con")} title="Prender o apagar toda la columna">Con agencia</td>
              <td style={th} onClick={() => colTodas("sin")} title="Prender o apagar toda la columna">Sin asignar</td>
              <td style={{ ...cel, fontSize: 11.5, color: "var(--t-94a3b8)" }}></td>
            </tr>
            {ESTADOS.map((e) => {
              const kCon = e + "|con", kSin = e + "|sin";
              const fila = [kCon, kSin];
              const todaVisible = fila.every(visible);
              return (
                <tr key={e} style={{ borderTop: "1px solid var(--b-f1f5f9)" }}>
                  <td
                    style={{ ...cel, cursor: "pointer", fontWeight: 600 }}
                    onClick={() => poner(fila, !todaVisible)}
                    title="Prender o apagar toda la fila">
                    <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
                      <span style={{ width: 8, height: 8, borderRadius: 4, background: ESTADO_COLOR[e].dot }} />
                      {e}
                    </span>
                  </td>
                  {[kCon, kSin].map((k) => (
                    <td key={k} style={{ ...cel, textAlign: "center" }}>
                      {n(k) === 0 ? (
                        <span style={{ color: "var(--t-cbd5e1)" }} title="No hay ninguno así">—</span>
                      ) : (
                        <label style={{ display: "inline-flex", alignItems: "center", gap: 5, cursor: "pointer" }}>
                          <input type="checkbox" checked={visible(k)} onChange={(ev) => poner([k], ev.target.checked)} />
                          <span style={{ color: visible(k) ? "var(--t-334155)" : "var(--t-94a3b8)" }}>{n(k)}</span>
                        </label>
                      )}
                    </td>
                  ))}
                  <td style={{ ...cel, color: "var(--t-94a3b8)", fontSize: 12 }}>
                    {n(kCon) + n(kSin)} en total
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
      <div style={{ display: "flex", gap: 8, marginTop: 12, flexWrap: "wrap", alignItems: "center" }}>
        <button style={S.btnMini} onClick={() => setOcultos({})}>Mostrar todo</button>
        <button
          style={{ ...S.btnMini, background: "var(--f-1e3a8a)", color: "var(--t-ffffff)", borderColor: "var(--b-1e3a8a)", fontWeight: 700 }}
          onClick={() => solo(CASILLAS_SIN_ASIGNAR)}>
          Los del correo a todas
        </button>
        <button style={S.btnMini} onClick={() => solo(ESTADOS_PENDIENTES.concat("Rechazado").flatMap((e) => [e + "|con", e + "|sin"]))}>
          Los que espero respuesta
        </button>
        <button style={S.btnMini} onClick={cerrar}>Listo</button>
        <span style={{ fontSize: 12, color: "var(--t-64748b)" }}>
          “Los del correo a todas” deja los rechazados y los sin dueño que esperan datos: es lo que va en ese correo.
        </span>
      </div>
    </div>
  );
}

function PanelColumnas({ cols, ocultas, setOcultas, cerrar }) {
  const ocultables = (cols || COLS_ADMIN).filter((c) => !c.fijo);
  return (
    <div style={S.panelColumnas}>
      <div style={{ fontSize: 12.5, color: "var(--t-64748b)", marginBottom: 8 }}>
        Destildá las columnas que no quieras ver: las demás se ensanchan. Se recuerda para la próxima vez.
      </div>
      <div style={{ display: "flex", flexWrap: "wrap", gap: "6px 18px" }}>
        {ocultables.map((c) => (
          <label key={c.clave} style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13, cursor: "pointer", color: "var(--t-334155)" }}>
            <input
              type="checkbox"
              checked={!ocultas[c.clave]}
              onChange={(e) => setOcultas(Object.assign({}, ocultas, { [c.clave]: !e.target.checked }))}
            />
            {c.label}
          </label>
        ))}
      </div>
      <div style={{ display: "flex", gap: 8, marginTop: 10 }}>
        <button style={S.btnMini} onClick={() => setOcultas({})}>Mostrar todas</button>
        <button style={S.btnMini} onClick={cerrar}>Listo</button>
      </div>
    </div>
  );
}

/* ============================================================================
 * REGISTRO DE BUQUES
 *
 * Las otras pestañas listan MOVIMIENTOS (una fila por estadía). Esta lista
 * BUQUES: junta todas las estadías del mismo barco en una sola ficha con su
 * IMO, pasavante, bandera, ITC y el historial de agencias que lo operaron.
 *
 * QUÉ ES UN BUQUE (revisado el 21/08/2026). La agrupación NO se calcula acá: el
 * servidor manda `datos.clavesBarco` (id del movimiento → clave del barco), que
 * sale de `lib/registro.js`. Es la misma regla que usan el CSV del registro y el
 * respaldo; antes estaba escrita dos veces y las dos versiones se separaron.
 *
 * La regla: se agrupa por nombre normalizado, salvo que un nombre tenga DOS O
 * MÁS IMO distintos — ahí son barcos distintos y van separados. Caso real: los
 * dos AVRA, uno de BAHAMAS (IMO 1024211) y uno de ISLAS MARSHALL (IMO 9859791),
 * cada uno con su certificado. Antes aparecían como uno solo con la etiqueta
 * "IMO distintos" y las medidas del primero.
 * ==========================================================================*/
function normBuque(v) {
  return String(v == null ? "" : v)
    .normalize("NFD").replace(/[\u0300-\u036f]/g, "")
    .replace(/\s*\([^)]*\)/g, "")   // "ANGIE (reclama MARTIN)" → "ANGIE"
    .replace(/[^A-Za-z0-9 ]/g, " ").replace(/\s+/g, " ").trim().toUpperCase();
}

function armarRegistro(buques, clavesBarco) {
  const porNombre = {};
  buques.forEach((b) => {
    const k = (clavesBarco || {})[b.id] || normBuque(b.buque);
    if (!k) return;
    if (!porNombre[k]) {
      porNombre[k] = {
        clave: k, nombre: b.buque, movimientos: [],
        imos: new Set(), pasavantes: new Set(), banderas: new Set(),
        agencias: new Set(), zonas: new Set(), trns: new Set(),
      };
    }
    const r = porNombre[k];
    r.movimientos.push(b);
    if (b.imo) r.imos.add(String(b.imo).trim());
    if (b.pasavante) r.pasavantes.add(String(b.pasavante).trim());
    if (b.band) r.banderas.add(b.band);
    if (b.agencia) r.agencias.add(b.agencia);
    if (b.zona) r.zonas.add(b.zona);
    if (b.trn) r.trns.add(String(b.trn).trim());
  });

  return Object.values(porNombre).map((r) => {
    const movs = r.movimientos.slice().sort((a, b) => String(b.salida || "").localeCompare(String(a.salida || "")));
    // El nombre que se muestra es el de la estadía más reciente, y el ITC más
    // nuevo es el que sirve como certificado de arqueo vigente del buque.
    const conItc = movs.filter((m) => m.itc);
    const conCert = movs.filter((m) => m.cert_cabotaje);
    const porEstado = {};
    movs.forEach((m) => (porEstado[m.estado] = (porEstado[m.estado] || 0) + 1));
    return {
      clave: r.clave,
      nombre: nombreLimpio(movs[0].buque || r.nombre),
      movimientos: movs,
      cantidad: movs.length,
      ultima: movs[0].salida,
      primera: movs[movs.length - 1].salida,
      imos: [...r.imos],
      pasavantes: [...r.pasavantes],
      banderas: [...r.banderas],
      agencias: [...r.agencias].sort(),
      zonas: [...r.zonas],
      trns: [...r.trns],
      movItc: conItc.length ? conItc[0] : null,
      itcCantidad: conItc.length,
      certCantidad: conCert.length,
      porEstado,
      // Consultado tambien esta sin facturar: es un Pendiente al que ya se le
      // pregunto. Faltaba desde que se agrego ese estado.
      sinFacturar: movs.filter((m) => ESTADOS_PENDIENTES.includes(m.estado) || m.estado === "Confirmado").length,
    };
  }).sort((a, b) => a.nombre.localeCompare(b.nombre, "es"));
}

/* ---------------------------------------------------------------- banderas
 * En la columna Bandera va SÓLO el nombre del país, en castellano.
 * La tabla vive en `lib/paises.js` (del lado del servidor) y llega en el estado
 * como `datos.paises`: así el CSV y esta pantalla no pueden desincronizarse.
 * Los patrones vienen como texto; acá se compilan una sola vez. */
let PAISES_BANDERA = [];
let PAISES_RE = null;
function cargarPaises(tabla) {
  if (!Array.isArray(tabla) || tabla === PAISES_BANDERA) return;
  PAISES_BANDERA = tabla;
  PAISES_RE = tabla.map(([patron, nombre]) => [new RegExp(patron), nombre]);
}

function pais(v) {
  if (!PAISES_RE) return "";
  const s = String(v == null ? "" : v)
    .normalize("NFD").replace(/[\u0300-\u036f]/g, "")
    .replace(/[^A-Za-z ]/g, " ").replace(/\s+/g, " ").trim().toUpperCase();
  if (!s) return "";
  for (const [re, nombre] of PAISES_RE) if (re.test(s)) return nombre;
  return "";
}

/* La bandera del Excel manda (es la que usa Rentas); si no hay, se usa la del
 * certificado. Si el valor del Excel no se pudo identificar se muestra tal cual
 * — alguien lo escribió y quiere decir algo. Si el que no se pudo identificar es
 * el del certificado, se deja vacío: los ITC vienen con la cola destrozada por
 * el OCR y no vale la pena mostrar eso. */
function banderaPais(x) {
  for (const v of (x.banderas || [])) {
    const p = pais(v);
    if (p) return p;
  }
  const p = pais(x.ficha && x.ficha.bandera);
  if (p) return p;
  const crudo = (x.banderas || []).filter(Boolean)[0];
  return crudo ? String(crudo).trim().toUpperCase() : "";
}

/* Los nombres del Excel traen a veces una nota entre paréntesis
 * ("ANGIE (reclama MARTIN Ship.)"): es una anotación de Rentas, no parte del
 * nombre del buque. */
// nombreLimpio() y notaDelNombre() estan arriba, con las utilidades.

// Eslora, manga, puntal y calado: SÓLO el número, con la coma decimal de acá.
// La unidad va en el título de la columna ("Eslora (m)") para que el dato quede
// limpio y sirva tal cual si alguna vez hay que exportarlo.
function medida(v) {
  if (v === undefined || v === null || v === "") return React.createElement("span", { style: { color: "var(--t-94a3b8)" } }, "—");
  return String(v).replace(".", ",");
}

/* ------------------------------------------------- un buque que cambió de nombre
 * El IMO se le asigna al casco y no cambia nunca; el nombre sí. Entonces dos
 * nombres distintos con el MISMO IMO son el mismo barco, y va una sola fila:
 * la del nombre más reciente (el de la última salida). El nombre viejo no se
 * pierde — se muestra abajo del actual y el buscador lo sigue encontrando,
 * porque una factura de enero puede estar a nombre del anterior.
 *
 * Caso real en los datos de ENAPRO: IMO 9641821 = AGIA IRINI y LADY VENEZIA. */
// Resguardo: el IMO podría venir mal leído de un certificado y coincidir por
// casualidad (pasa el dígito verificador 1 de cada 10 veces). Antes de unificar
// se comprueba que los datos físicos NO se contradigan: el casco es el mismo, así
// que la eslora y el arqueo bruto tienen que dar igual. Si no, son dos barcos
// distintos y quedan separados.
function contradiceFicha(a, b) {
  if (!a || !b) return false;
  const diff = (x, y) => x && y && Math.abs(+x - +y) / Math.max(+x, +y) > 0.02;
  return diff(a.eslora, b.eslora) || diff(a.arqueoBruto, b.arqueoBruto);
}

function unificarPorImo(filas) {
  const porImo = {}, sinImo = [];
  filas.forEach((x) => {
    if (!x.imoFinal) { sinImo.push(x); return; }
    (porImo[x.imoFinal] = porImo[x.imoFinal] || []).push(x);
  });
  const salida = sinImo.slice();
  Object.keys(porImo).forEach((imo) => {
    const grupo = porImo[imo];
    if (grupo.length === 1) { salida.push(grupo[0]); return; }
    // El nombre actual es el de la salida más reciente.
    const orden = grupo.slice().sort((a, b) => String(b.ultima || "").localeCompare(String(a.ultima || "")));
    // Si los datos físicos se contradicen no es un cambio de nombre: se dejan
    // las filas separadas y se marca el IMO para que ENAPRO lo mire.
    if (orden.some((y) => contradiceFicha(orden[0].ficha, y.ficha))) {
      orden.forEach((y) => salida.push(Object.assign({}, y, { imoDudoso: true })));
      return;
    }
    const actual = orden[0], anteriores = orden.slice(1);
    const movs = orden.reduce((a, y) => a.concat(y.movimientos), [])
      .sort((a, b) => String(b.salida || "").localeCompare(String(a.salida || "")));
    const juntar = (campo) => [...new Set(orden.reduce((a, y) => a.concat(y[campo] || []), []))];
    const conItc = movs.filter((m) => m.itc);
    salida.push(Object.assign({}, actual, {
      // Si el nombre actual no tiene ficha, sirve la del nombre anterior: son
      // las medidas del mismo casco.
      ficha: actual.ficha || (anteriores.filter((y) => y.ficha)[0] || {}).ficha || null,
      nombresAnteriores: anteriores.map((y) => y.nombre),
      clavesUnificadas: orden.map((y) => y.clave),
      movimientos: movs,
      cantidad: movs.length,
      ultima: movs.length ? movs[0].salida : actual.ultima,
      primera: movs.length ? movs[movs.length - 1].salida : actual.primera,
      imos: juntar("imos"), trns: juntar("trns"), banderas: juntar("banderas"),
      pasavantes: juntar("pasavantes"), agencias: juntar("agencias").sort(), zonas: juntar("zonas"),
      movItc: conItc.length ? conItc[0] : null,
      itcCantidad: conItc.length,
      // Consultado tambien esta sin facturar: es un Pendiente al que ya se le
      // pregunto. Faltaba desde que se agrego ese estado.
      sinFacturar: movs.filter((m) => ESTADOS_PENDIENTES.includes(m.estado) || m.estado === "Confirmado").length,
    }));
  });
  return salida.sort((a, b) => a.nombre.localeCompare(b.nombre, "es"));
}

function RegistroBuques({ buques, fichas, clavesBarco, sinUbicar, sync, accion, notify, paises }) {
  cargarPaises(paises);
  const [q, setQ] = useState("");
  const [conImo, setConImo] = useState("Todos");
  const [editando, setEditando] = useState(null);
  const [importando, setImportando] = useState(false);
  const [pagina, setPagina] = useState(0);

  // Se cruza lo que sale de los movimientos con la ficha del buque (los datos
  // del certificado de arqueo). El IMO de la ficha vale cuando los movimientos
  // no lo traen, que es el caso del 93% de las filas del Excel.
  //
  // Son tres pasos y el orden importa: primero se resuelve el IMO de cada
  // nombre, después se unifican los nombres que comparten IMO (un buque que
  // cambió de nombre), y sólo al final se calculan TRB y TRN, que dependen de la
  // ficha que quedó después de unificar.
  const registro = useMemo(() => {
    const perdidas = new Set(sinUbicar || []);
    const filas = armarRegistro(buques, clavesBarco).map((x) =>
      Object.assign(x, { sinUbicar: x.movimientos.filter((m) => perdidas.has(m.id)).length }));
    // Fichas de certificados que todavía no tienen ninguna estadía asignada.
    // Pasa con el SEGUNDO barco de un nombre repetido: tenemos su certificado
    // (ENAPRO lo guardó como "AENEAS (CHIPRE).pdf") pero las estadías del Excel
    // no traen IMO, así que no se puede saber cuál es de cuál. Se muestran igual
    // —marcadas— porque el dato existe: si quedaran afuera, el certificado que
    // se leyó no se vería en ninguna parte.
    const conEstadias = new Set(filas.map((x) => x.clave));
    Object.keys(fichas || {}).forEach((k) => {
      if (conEstadias.has(k)) return;
      const f = fichas[k];
      filas.push({
        clave: k, nombre: nombreLimpio(f.nombre || k), movimientos: [], cantidad: 0,
        ultima: null, primera: null, imos: [], pasavantes: [], banderas: [],
        agencias: [], zonas: [], trns: [], movItc: null, itcCantidad: 0,
        certCantidad: 0, porEstado: {}, sinFacturar: 0, sinEstadias: true,
      });
    });
    filas.sort((a, b) => a.nombre.localeCompare(b.nombre, "es"));
    const conImoResuelto = filas.map((x) => {
      const f = (fichas || {})[x.clave] || null;
      const imoFicha = f && f.imo ? String(f.imo) : null;
      return Object.assign({}, x, {
        ficha: f,
        imoFinal: x.imos[0] || imoFicha || null,
        imoDeItc: !x.imos.length && !!imoFicha,
      });
    });
    return unificarPorImo(conImoResuelto).map((x) => {
      const f = x.ficha;
      // TRN = tonelaje de registro neto. Es el mismo dato que la columna TRN del
      // Excel y que el arqueo neto del certificado, así que manda el del Excel
      // (es el que usa Rentas para facturar) y el del ITC sólo completa cuando
      // el Excel no lo tiene. Si los dos están y no coinciden se avisa.
      const trnExcel = x.trns[0] || null;
      const trnItc = f && f.arqueoNeto ? String(f.arqueoNeto) : null;
      return Object.assign({}, x, {
        trb: f && f.arqueoBruto ? String(f.arqueoBruto) : null,
        trn: trnExcel || trnItc || null,
        trnDeItc: !trnExcel && !!trnItc,
        trnDiscrepa: trnExcel && trnItc && !x.trns.includes(trnItc) ? trnItc : null,
      });
    });
  }, [buques, fichas, clavesBarco, sinUbicar]);

  const filtrado = useMemo(() => {
    const t = q.trim().toLowerCase();
    let r = registro.filter((x) => {
      if (conImo === "Con IMO" && !x.imoFinal) return false;
      if (conImo === "Sin IMO" && x.imoFinal) return false;
      if (conImo === "Con ITC" && !x.movItc) return false;
      if (conImo === "Con ficha del certificado" && !x.ficha) return false;
      if (conImo === "Sin ficha del certificado" && x.ficha) return false;
      if (!t) return true;
      return (
        x.nombre.toLowerCase().includes(t) ||
        (x.nombresAnteriores || []).some((n) => n.toLowerCase().includes(t)) ||
        (x.imoFinal || "").toLowerCase().includes(t) ||
        x.imos.some((i) => i.toLowerCase().includes(t)) ||
        x.trns.some((i) => i.toLowerCase().includes(t)) ||
        x.banderas.some((i) => i.toLowerCase().includes(t)) ||
        String((x.ficha && x.ficha.bandera) || "").toLowerCase().includes(t) ||
        String((x.ficha && x.ficha.certificado) || "").toLowerCase().includes(t)
      );
    });
    return r;
  }, [registro, q, conImo]);

  // MEDIDO: pintar las 1445 filas de una vez tarda 2,3 s y deja el buscador en
  // 300 ms por tecla, que se siente pegajoso. De a 200 va instantáneo.
  const POR_PAGINA = 200;
  const paginas = Math.max(1, Math.ceil(filtrado.length / POR_PAGINA));
  const pag = Math.min(pagina, paginas - 1);
  const desde = pag * POR_PAGINA;
  const enPantalla = filtrado.slice(desde, desde + POR_PAGINA);
  // Al cambiar el filtro se vuelve a la primera página: si no, con menos
  // resultados que páginas quedaba en blanco.
  useEffect(() => { setPagina(0); }, [q, conImo]);

  const conFicha = registro.filter((x) => x.ficha).length;
  const ultimaImp = (sync || {}).ultimaImportacionItc;

  return (
    <Section
      title="Registro de buques"
      sub="Un buque por fila con sus datos: IMO, eslora, manga, puntal, calado, TRB, TRN, bandera y el certificado de arqueo. Manda el IMO: dos nombres distintos con el mismo IMO son un solo barco que cambió de nombre (queda el nombre nuevo y el anterior sigue siendo buscable), y un mismo nombre con dos IMO son dos barcos distintos, cada uno con su fila y su certificado. Los que no traen IMO se agrupan por nombre, que es lo único que hay en la mayoría de las filas del Excel."
    >
      <div style={{ ...S.card, display: "flex", gap: 14, flexWrap: "wrap", alignItems: "flex-end" }}>
        <div style={S.filterCol}>
          <label style={S.filterLab}>Buscar</label>
          <input
            style={{ ...S.input, margin: 0, width: 320 }}
            placeholder="Nombre del buque, IMO, TRN o bandera…"
            value={q} onChange={(e) => setQ(e.target.value)}
          />
        </div>
        <div style={S.filterCol}>
          <label style={S.filterLab}>Filtrar</label>
          <select style={S.select} value={conImo} onChange={(e) => setConImo(e.target.value)}>
            <option value="Todos">Todos los buques</option>
            <option value="Con IMO">Con IMO cargado</option>
            <option value="Sin IMO">Sin IMO</option>
            <option value="Con ITC">Con certificado adjunto</option>
            <option value="Con ficha del certificado">Con datos del certificado</option>
            <option value="Sin ficha del certificado">Sin datos del certificado</option>
          </select>
        </div>
        <div style={{ flex: 1 }} />
        <div style={{ fontSize: 13, color: "var(--t-64748b)" }}>
          <b>{filtrado.length}</b> buque{filtrado.length === 1 ? "" : "s"}
          <div style={{ fontSize: 11.5, color: "var(--t-94a3b8)" }}>
            {filtrado.length > POR_PAGINA
              ? "viendo " + (desde + 1) + " a " + Math.min(desde + POR_PAGINA, filtrado.length) + " · " + registro.length + " en total"
              : "de " + registro.length + " buques en total"}
          </div>
        </div>
        <a style={{ ...S.btnGhostDark, textDecoration: "none" }} href="/descargas/registro.csv">↓ Exportar registro (CSV)</a>
      </div>

      <div style={{ ...S.card, display: "flex", justifyContent: "space-between", gap: 16, flexWrap: "wrap", alignItems: "center" }}>
        <div style={{ fontSize: 13, color: "var(--t-475569)" }}>
          <b>Datos de los certificados de arqueo (ITC)</b>
          <div style={{ marginTop: 3, color: "var(--t-64748b)" }}>
            {conFicha} de {registro.length} buques tienen la ficha cargada (IMO, medidas y arqueos leídos del certificado).
            {ultimaImp
              ? <span> Última importación: <b>{fmtHora(ultimaImp.fecha)}</b> — {ultimaImp.resumen.altas} nuevas, {ultimaImp.resumen.actualizadas} actualizadas.</span>
              : <span> Todavía no importaste ninguna.</span>}
          </div>
          <div style={{ marginTop: 3, fontSize: 12, color: "var(--t-94a3b8)" }}>
            Se lee de <code>entrada\registro_itc.json</code>. Sólo entran los certificados que se pudieron leer con seguridad; el resto queda sin completar.
          </div>
          <CarpetaCertificados info={(sync || {}).certificados} />
        </div>
        <button
          style={S.btnPrimary} disabled={importando}
          onClick={async () => {
            setImportando(true);
            try {
              const d = await accion("/api/registro/importar", { json: {} });
              if (d && d.resumen) {
                const r = d.resumen;
                notify("Importación: " + r.altas + " fichas nuevas, " + r.actualizadas + " actualizadas, " +
                       r.salteadas + " sin cambios" + (r.sinConfianza ? ", " + r.sinConfianza + " salteadas por no ser confiables" : "") + ".");
              }
            } catch (e) {} finally { setImportando(false); }
          }}
        >
          {importando ? "Importando…" : "↥ Importar datos de certificados"}
        </button>
      </div>

      {filtrado.length === 0 ? <Empty msg="Ningún buque coincide con la búsqueda." /> : (
        <div style={S.tableWrapFijo}>
          <table style={S.tableRegistro}>
            <colgroup>{["19%", "10.5%", "6.5%", "6.5%", "6.5%", "6.5%", "7.5%", "8.5%", "16%", "6%", "6%"].map((w, i) => <col key={i} style={{ width: w }} />)}</colgroup>
            <thead>
              <tr>
                <th style={S.thFija}>Buque</th>
                <th style={S.thFija}>IMO</th>
                <th style={S.thFija}>Eslora (m)</th>
                <th style={S.thFija}>Manga (m)</th>
                <th style={S.thFija}>Puntal (m)</th>
                <th style={S.thFija} title="Calado de trazado (moulded draught), en metros">Calado (m)</th>
                <th style={S.thFija} title="Tonelaje de registro bruto (arqueo bruto)">TRB</th>
                <th style={S.thFija} title="Tonelaje de registro neto (arqueo neto)">TRN</th>
                <th style={S.thFija}>Bandera</th>
                <th style={S.thFija}>ITC</th>
                <th style={S.thFija}></th>
              </tr>
            </thead>
            <tbody>
              {enPantalla.map((x) => (
                <tr key={x.clave} style={{ borderBottom: "1px solid var(--b-f1f5f9)" }}>
                  <td style={{ ...S.tdFija, fontWeight: 700 }}>
                    {x.nombre}
                    {x.nombresAnteriores && x.nombresAnteriores.length > 0 && (
                      <div style={{ fontSize: 11, color: "var(--t-94a3b8)", fontWeight: 400 }}
                           title="Mismo IMO: es el mismo barco, con el nombre anterior">
                        antes: {x.nombresAnteriores.join(", ")}
                      </div>
                    )}
                    {x.sinUbicar > 0 && (
                      <div style={{ ...S.pillAvisoMedia, display: "inline-block", cursor: "default", marginTop: 3, fontWeight: 400 }}
                           title={"Hay otro barco con este mismo nombre y " + (x.sinUbicar > 1 ? x.sinUbicar + " estadías" : "una estadía") + " que no se pudo asignar con seguridad: no traen IMO y el TRN y la bandera no alcanzaron para decidir. Quedaron acá. Cargale el IMO al movimiento y se ubica solo."}>
                        {x.sinUbicar > 1 ? x.sinUbicar + " estadías" : "1 estadía"} sin identificar
                      </div>
                    )}
                    {x.sinEstadias && (
                      <div style={{ ...S.pillAvisoMedia, display: "inline-block", cursor: "default", marginTop: 3, fontWeight: 400 }}
                           title={"Tenemos el certificado de este barco (" + ((x.ficha && x.ficha.archivo) || "") + ") pero ninguna estadía del Excel se le pudo asignar. Suele pasar cuando hay dos barcos con el mismo nombre y las estadías no traen IMO: cargale el IMO al movimiento y se junta solo."}>
                        sin estadías
                      </div>
                    )}
                  </td>
                  <td style={S.tdFijaNw}>
                    {x.imoFinal
                      ? <span><b>{x.imoFinal}</b>{x.imoDeItc && <div style={{ ...S.pillOk, display: "inline-block", marginTop: 3 }}>del ITC</div>}</span>
                      : <span style={{ color: "var(--t-94a3b8)" }}>—</span>}
                    {x.imos.length > 1 && <div style={{ ...S.pillAvisoMedia, display: "inline-block", cursor: "default", marginTop: 3 }}>IMO distintos</div>}
                    {x.imoDudoso && <div style={{ ...S.pillAvisoMedia, display: "inline-block", cursor: "default", marginTop: 3 }} title="Otro buque tiene este mismo IMO pero con medidas distintas: alguno de los dos está mal.">IMO repetido</div>}
                  </td>
                  <td style={S.tdFijaNw}>{medida(x.ficha && x.ficha.eslora)}</td>
                  <td style={S.tdFijaNw}>{medida(x.ficha && x.ficha.manga)}</td>
                  <td style={S.tdFijaNw}>{medida(x.ficha && x.ficha.puntal)}</td>
                  <td style={S.tdFijaNw}>{medida(x.ficha && x.ficha.calado)}</td>
                  <td style={S.tdFijaNw}>{x.trb || <span style={{ color: "var(--t-94a3b8)" }}>—</span>}</td>
                  <td style={S.tdFijaNw} title={x.trns.length > 1 ? "En el Excel figura con más de un TRN: " + x.trns.join(" / ") : undefined}>
                    {x.trn || <span style={{ color: "var(--t-94a3b8)" }}>—</span>}
                    {x.trns.length > 1 && <span style={{ color: "var(--t-b45309)", fontSize: 11 }}> +{x.trns.length - 1}</span>}
                    {x.trnDeItc && x.trn && <div style={{ ...S.pillOk, display: "inline-block", marginTop: 3 }}>del ITC</div>}
                    {x.trnDiscrepa && <div style={{ ...S.pillAvisoMedia, display: "inline-block", marginTop: 3, cursor: "default" }} title={"El certificado dice " + x.trnDiscrepa}>≠ ITC</div>}
                  </td>
                  <td style={S.tdFija}>{banderaPais(x) || <span style={{ color: "var(--t-94a3b8)" }}>—</span>}</td>
                  <td style={S.tdFija}>
                    {x.movItc
                      ? <a href={`/descargas/adjunto/${x.movItc.id}/itc`} title={x.movItc.itc.nombre} style={{ color: "var(--t-166534)" }}>📎 ver</a>
                      : <span style={{ color: "var(--t-94a3b8)" }}>—</span>}
                  </td>
                  <td style={S.tdFija}>
                    {/* Los dos botones van uno al lado del otro: apilarlos duplica
                        el alto de las 1446 filas. */}
                    <div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
                      <button style={{ ...S.btnMini, padding: "4px 6px", flex: "0 1 auto" }} onClick={() => setEditando(x)}>Ficha</button>
                    </div>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
          {paginas > 1 && (
            <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 8, padding: "12px 10px", flexWrap: "wrap" }}>
              <button style={{ ...S.btnMini, opacity: pag === 0 ? 0.45 : 1 }} disabled={pag === 0} onClick={() => setPagina(0)}>« Primera</button>
              <button style={{ ...S.btnMini, opacity: pag === 0 ? 0.45 : 1 }} disabled={pag === 0} onClick={() => setPagina(pag - 1)}>‹ Anterior</button>
              <span style={{ fontSize: 13, color: "var(--t-475569)", padding: "0 6px" }}>
                Página <b>{pag + 1}</b> de <b>{paginas}</b>
              </span>
              <button style={{ ...S.btnMini, opacity: pag >= paginas - 1 ? 0.45 : 1 }} disabled={pag >= paginas - 1} onClick={() => setPagina(pag + 1)}>Siguiente ›</button>
              <button style={{ ...S.btnMini, opacity: pag >= paginas - 1 ? 0.45 : 1 }} disabled={pag >= paginas - 1} onClick={() => setPagina(paginas - 1)}>Última »</button>
              <span style={{ fontSize: 12, color: "var(--t-94a3b8)", marginLeft: 8 }}>
                {filtrado.length} buques · {POR_PAGINA} por página
              </span>
            </div>
          )}
        </div>
      )}
      {editando && <FichaBuqueModal x={editando} accion={accion} onClose={() => setEditando(null)} />}
    </Section>
  );
}

/* Ficha del buque: los datos que salen del certificado de arqueo. Lo que se
 * edita a mano queda protegido de que una importación posterior lo pise. */
/* El portal no lee los certificados (hace falta OCR), pero SÍ puede mirar la
 * carpeta y contar. Sirve para que se note cuando entran certificados nuevos y
 * el registro se queda atrás: antes eso era invisible. */
function CarpetaCertificados({ info }) {
  if (!info) return null;
  if (info.error) {
    return (
      <div style={{ marginTop: 6, fontSize: 12, color: "var(--t-b45309)" }}>
        No se pudo mirar la carpeta de certificados (<code>{info.carpeta}</code>): {info.error}
      </div>
    );
  }
  const atrasado = info.pendientes > 0;
  return (
    <div style={{ marginTop: 6, fontSize: 12, color: atrasado ? "var(--t-92400e)" : "var(--t-64748b)" }}>
      En la carpeta hay <b>{info.archivos}</b> certificados y el registro tiene leídos <b>{info.leidos}</b>.
      {atrasado ? (
        <>
          {" "}Quedan <b>{info.pendientes}</b> sin leer
          {info.noPdf ? <> ({info.noPdf} no son PDF: .doc, .xlsx, .jpg o sin extensión)</> : null}.
          {" "}<a href="/descargas/certificados_sin_leer.txt" style={{ color: "var(--t-92400e)", fontWeight: 700 }}>Descargar la lista</a>{" "}
          — el portal no puede leerlos solo, hay que pasarlos por la lectura de ITC.
        </>
      ) : " Están todos leídos."}
    </div>
  );
}

function FichaBuqueModal({ x, accion, onClose }) {
  const f = x.ficha || {};
  const [d, setD] = useState({
    imo: f.imo || "", eslora: f.eslora || "", manga: f.manga || "", puntal: f.puntal || "", calado: f.calado || "",
    arqueoBruto: f.arqueoBruto || "", arqueoNeto: f.arqueoNeto || "", bandera: f.bandera || "",
    certificado: f.certificado || "", fechaCertificado: f.fechaCertificado || "",
  });
  const set = (k) => (e) => setD(Object.assign({}, d, { [k]: e.target.value }));
  const manuales = f.camposManuales || [];
  return (
    <div style={S.modalWrap} onClick={onClose}>
      <div style={{ ...S.modal, width: 620 }} onClick={(e) => e.stopPropagation()}>
        <div style={S.modalHead}>
          <div>
            <div style={{ fontWeight: 800, fontSize: 17 }}>Ficha del buque — {x.nombre}</div>
            <div style={{ fontSize: 12.5, color: "var(--t-64748b)" }}>
              {f.archivo ? "certificado: " + f.archivo : "sin certificado leído"}
            </div>
          </div>
          <button style={S.btnGhostDark} onClick={onClose}>✕</button>
        </div>
        <div style={S.modalBody}>
          {/* OJO: no usar S.dataBox acá. Es display:flex con gap, pensado para
              pares de <span>; con un párrafo el <b> se vuelve un ítem flex y
              quedan huecos alrededor ("son  del barco  , no de la estadía"). */}
          <div style={{ background: "var(--f-f8fafc)", border: "1px solid var(--b-e2e8f0)", borderRadius: 10,
                        padding: "12px 14px", fontSize: 13, color: "var(--t-334155)", lineHeight: 1.6, marginBottom: 14 }}>
            Estos datos son <b>del barco</b>, no de la estadía: no cambian entre viajes. Los completa
            la lectura del certificado de arqueo, y lo que corrijas a mano queda protegido de las
            importaciones siguientes.
            {manuales.length > 0 && <div style={{ marginTop: 6 }}>Editados a mano: <b>{manuales.join(", ")}</b></div>}
          </div>
          <div style={S.formGrid}>
            <Field label="IMO"><input style={S.input} value={d.imo} onChange={set("imo")} placeholder="7 dígitos" /></Field>
            <Field label="Eslora (m)"><input style={S.input} value={d.eslora} onChange={set("eslora")} /></Field>
            <Field label="Manga (m)"><input style={S.input} value={d.manga} onChange={set("manga")} /></Field>
            <Field label="Puntal (m)"><input style={S.input} value={d.puntal} onChange={set("puntal")} /></Field>
            <Field label="Calado (m)"><input style={S.input} value={d.calado} onChange={set("calado")} /></Field>
            <Field label="TRB — tonelaje de registro bruto"><input style={S.input} value={d.arqueoBruto} onChange={set("arqueoBruto")} /></Field>
            <Field label="TRN — tonelaje de registro neto"><input style={S.input} value={d.arqueoNeto} onChange={set("arqueoNeto")} /></Field>
            <Field label="Bandera"><input style={S.input} value={d.bandera} onChange={set("bandera")} /></Field>
            <Field label="N° de certificado"><input style={S.input} value={d.certificado} onChange={set("certificado")} /></Field>
            <Field label="Fecha del certificado"><input style={S.input} value={d.fechaCertificado} onChange={set("fechaCertificado")} /></Field>
          </div>
          {x.trns.length > 0 && (
            <div style={{ fontSize: 12.5, color: "var(--t-64748b)" }}>
              TRN que figura en el Excel para este buque: <b>{x.trns.join(" / ")}</b>
              {d.arqueoNeto && !x.trns.includes(String(d.arqueoNeto)) && <span style={{ color: "var(--t-b45309)" }}> — no coincide con el arqueo neto</span>}
            </div>
          )}

          {/* Los certificados que subieron las agencias para este buque. Uno queda
              vigente y los demás como historial: nunca se borran, porque son el
              respaldo de lo que se facturó con el arqueo anterior. */}
          {(f.certificados || []).length > 0 && (
            <div style={{ marginTop: 16 }}>
              <div style={{ fontWeight: 800, fontSize: 14, marginBottom: 2 }}>
                Certificados de arqueo cargados ({f.certificados.length})
              </div>
              <div style={{ fontSize: 12.5, color: "var(--t-64748b)", marginBottom: 8 }}>
                {f.certificados.length > 1
                  ? "Hay más de uno. Elegí cuál es el vigente: el otro queda como historial, no se borra."
                  : "El que usan las agencias al confirmar una estadía de este buque."}
              </div>
              {f.certificados.slice().sort((a, b) => String(b.salida || "").localeCompare(String(a.salida || ""))).map((c) => (
                <div key={c.id} style={{
                  display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap",
                  padding: "8px 10px", marginBottom: 6, borderRadius: 8,
                  background: c.vigente ? "var(--f-f0fdf4)" : "var(--f-f8fafc)",
                  border: "1px solid " + (c.vigente ? "var(--b-86efac)" : "var(--b-e2e8f0)"),
                }}>
                  {c.vigente
                    ? <span style={{ ...S.pillOk, whiteSpace: "nowrap" }}>vigente</span>
                    : <span style={{ fontSize: 11.5, color: "var(--t-94a3b8)", whiteSpace: "nowrap" }}>historial</span>}
                  <a href={`/descargas/adjunto/${c.desdeBuque}/itc`} style={{ color: "var(--t-166534)", fontWeight: 600, fontSize: 13 }}>📎 {c.nombre}</a>
                  <span style={{ fontSize: 12, color: "var(--t-64748b)" }}>
                    {c.salida ? "estadía del " + fmtCorto(c.salida) : ""}
                    {c.agencia ? " · " + c.agencia : ""}
                    {c.trnDeclarado ? " · TRN declarado " + c.trnDeclarado : ""}
                  </span>
                  <div style={{ flex: 1 }} />
                  {!c.vigente && (
                    <button style={S.btnMini}
                      onClick={() => accion(`/api/registro/${encodeURIComponent(x.clave)}/certificado/${c.id}`, { json: {} },
                                           "Certificado vigente: " + c.nombre)}>
                      Marcar como vigente
                    </button>
                  )}
                </div>
              ))}
            </div>
          )}
        </div>
        <div style={S.modalFoot}>
          <button style={S.btnGhostDark} onClick={onClose}>Cancelar</button>
          <button style={S.btnPrimary} onClick={async () => {
            await accion(`/api/registro/${encodeURIComponent(x.clave)}`, { method: "PUT", json: Object.assign({ nombre: x.nombre }, d) }, "Ficha de " + x.nombre + " guardada.");
            onClose();
          }}>Guardar ficha</button>
        </div>
      </div>
    </div>
  );
}

/* ============================================================================
 * NOVEDADES PARA REVISAR
 *
 * Circuito que pidió el usuario: se cargan los buques del día en el Excel; al
 * abrir el portal, ENAPRO sincroniza; los movimientos nuevos quedan acá, SIN
 * que las agencias los vean; ENAPRO los revisa (edita lo que vino mal, borra lo
 * que no corresponde) y recién entonces aprieta "Publicar para las agencias".
 * ==========================================================================*/
function Novedades({ novedades, sync, accion, sincronizando, ocultas, onEditar, onComparar, onEliminar, onVerAvisos, onSincronizar }) {
  const [confirmar, setConfirmar] = useState(null);
  const [soloAvisos, setSoloAvisos] = useState(false);
  const ultima = sync.ultimaPublicacion;
  const conAvisos = novedades.filter((s) => avisosVigentes(s).length);
  const graves = conAvisos.filter((s) => (s.avisos || []).some((a) => a.nivel === "alta"));
  const lista = soloAvisos ? conAvisos : novedades;

  return (
    <Section
      title="Novedades del Excel para revisar"
      sub="Los movimientos que entraron en la última sincronización quedan acá y las agencias todavía no los ven. Revisalos —editá lo que vino mal cargado, eliminá lo que no corresponde— y cuando estén bien, publicalos: en ese momento aparecen en el panel de cada agencia."
    >
      <div style={S.card}>
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16, flexWrap: "wrap" }}>
          <div>
            <div style={{ fontSize: 15, fontWeight: 800 }}>
              {sincronizando ? "Leyendo el Excel…" : novedades.length === 0 ? "No hay novedades sin publicar" : novedades.length + " movimiento(s) esperando revisión"}
            </div>
            <div style={{ fontSize: 12.5, color: "var(--t-64748b)", marginTop: 3 }}>
              Última sincronización: <b>{fmtHora(sync.ultima)}</b>
              {ultima ? <span> · última publicación: <b>{fmtHora(ultima.fecha)}</b> ({ultima.cantidad} movimiento{ultima.cantidad === 1 ? "" : "s"}, {ultima.usuario})</span> : null}
            </div>
            {conAvisos.length > 0 && (
              <div style={{ marginTop: 8, display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap", fontSize: 13 }}>
                <span style={graves.length ? S.pillAvisoAlta : S.pillAvisoMedia}>⚠ {conAvisos.length} con datos raros</span>
                <span style={{ color: "var(--t-78350f)" }}>
                  {graves.length > 0
                    ? graves.length + " con algo que conviene corregir antes de publicar (fechas imposibles, sin agencia, duplicados…)."
                    : "Nada grave, pero vale una mirada."}
                </span>
                <label style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer", color: "var(--t-475569)" }}>
                  <input type="checkbox" checked={soloAvisos} onChange={(e) => setSoloAvisos(e.target.checked)} />
                  ver sólo esos
                </label>
              </div>
            )}
          </div>
          <div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
            <button style={S.btnGhostDark} onClick={onSincronizar} disabled={sincronizando}>{sincronizando ? "Sincronizando…" : "↻ Buscar novedades en el Excel"}</button>
            <button
              style={{ ...S.btnPrimary, opacity: novedades.length ? 1 : 0.45, cursor: novedades.length ? "pointer" : "default" }}
              disabled={!novedades.length || sincronizando}
              onClick={() => setConfirmar({
                msg: `¿Publicar ${novedades.length} movimiento(s) para las agencias? A partir de ese momento cada agencia ve los suyos en "Por confirmar" y puede empezar a completarlos.`
                  + (conAvisos.length ? ` Ojo: ${conAvisos.length} tiene${conAvisos.length > 1 ? "n" : ""} datos raros sin corregir${graves.length && graves.length < conAvisos.length ? `, ${graves.length} de ellos importantes` : ""}.` : ""),
                etiqueta: "Publicar para las agencias",
                tono: "primario",
                onYes: () => accion("/api/publicar", { json: {} }, "Novedades publicadas: las agencias ya las ven."),
              })}
            >
              ✓ Publicar para las agencias{novedades.length ? " (" + novedades.length + ")" : ""}
            </button>
          </div>
        </div>
        {sync.error && <div style={S.aviso}>⚠️ {sync.error}</div>}
      </div>

      {novedades.length === 0 ? (
        <Empty msg="Todo lo que trajo el Excel ya está publicado. Cargá los buques del día en la planilla y tocá “Buscar novedades en el Excel”." />
      ) : (
        <>
          <div style={{ display: "flex", gap: 14, flexWrap: "wrap", alignItems: "center", fontSize: 12, color: "var(--t-64748b)" }}>
            <span style={{ fontWeight: 700 }}>Referencias:</span>
            <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
              <span style={{ background: "var(--f-fffbeb)", border: "1px solid var(--b-cbd5e1)", borderLeftWidth: 4, borderLeftColor: "var(--b-d97706)", padding: "1px 8px", borderRadius: 3, fontWeight: 700, color: "var(--t-92400e)" }}>fila ámbar</span>
              = todavía no lo ve la agencia
            </span>
            <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}><span style={S.pillAvisoAlta}>revisar</span> / <span style={S.pillAvisoMedia}>ojo</span> = avisos de datos raros, debajo de cada fila</span>
            <span>Podés publicar de a uno con el botón <b>Publicar</b> de cada fila, o todos juntos con el botón de arriba.</span>
          </div>
          <TablaBuques
            lista={lista} accion={accion} modoNovedades ocultas={ocultas}
            onEditar={onEditar} onComparar={onComparar} onEliminar={onEliminar} onVerAvisos={onVerAvisos}
          />
        </>
      )}
      {confirmar && <ConfirmDialog {...confirmar} onCancel={() => setConfirmar(null)} onConfirm={() => { confirmar.onYes(); setConfirmar(null); }} />}
    </Section>
  );
}

/* ============================================================================
 * ALTAS CARGADAS POR LAS AGENCIAS
 *
 * Pestaña propia del panel ENAPRO. Cada alta viene con la comparación contra lo
 * que ya está cargado: si el movimiento existe pero figura para otra agencia,
 * acá se ve y se resuelve en un click.
 * ==========================================================================*/
function AltasAgencias({ altas, accion, onEditar }) {
  const [ver, setVer] = useState("porRevisar");
  const [confirmar, setConfirmar] = useState(null);
  /* Dos listas y nada más (pedido del 07/09/2026):
   *  · Por revisar → altas esperando decisión.
   *  · Reprobadas  → las que no van, hasta que ENAPRO las borre a mano.
   * Las aprobadas desaparecen de acá: su lugar es la pestaña Buques. Las que se
   * resolvieron como "es el mismo movimiento" también, porque el que queda vivo
   * es el registro original.
   * Antes había además "Ya revisadas" y "Quitadas de la lista": eso obligaba a
   * vaciar la bandeja a mano y dejaba altas ya trabajadas dando vueltas. */
  const porRevisar = altas.filter((a) => !a.altaResuelta);
  const reprobadas = altas.filter((a) => a.reprobada);
  const lista = ver === "reprobadas" ? reprobadas : porRevisar;

  return (
    <Section
      title="Buques cargados por las agencias"
      sub="Bandeja de entrada: acá revisás las altas que cargaron las agencias porque el buque no figuraba. El portal las compara con todo lo cargado (nombre, IMO, fecha de salida y zona) y te avisa si el movimiento ya existe a nombre de otra agencia. Al aprobar un alta, el movimiento pasa a la pestaña Buques ya revisado y confirmado, y desaparece de acá."
    >
      <div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
        <button style={ver === "porRevisar" ? S.btnPrimary : S.btnGhostDark} onClick={() => setVer("porRevisar")}>Por revisar ({porRevisar.length})</button>
        <button style={ver === "reprobadas" ? S.btnPrimary : S.btnGhostDark} onClick={() => setVer("reprobadas")}>Reprobadas ({reprobadas.length})</button>
      </div>
      {ver === "reprobadas" && (
        <div style={{ fontSize: 12.5, color: "var(--t-64748b)", marginTop: 10 }}>
          Altas que reprobaste. La agencia ya vio tu comentario en su pestaña <b>Observados</b>. No están en la sábana de <b>Buques</b> y
          se quedan acá hasta que las borres con <b>🗑 Descartar</b>.
        </div>
      )}

      {lista.length === 0 ? (
        <Empty msg={
          ver === "porRevisar" ? "No hay altas de agencias esperando revisión."
          : "No reprobaste ningún alta."
        } />
      ) : lista.map((a) => <AltaCard key={a.id} a={a} accion={accion} onEditar={onEditar} />)}
      {confirmar && <ConfirmDialog {...confirmar} onCancel={() => setConfirmar(null)} onConfirm={() => { confirmar.onYes(); setConfirmar(null); }} />}
    </Section>
  );
}

function AltaCard({ a, accion, onEditar }) {
  const coinc = a.coincidencias || [];
  const top = coinc[0];
  const [confirmar, setConfirmar] = useState(null);
  const [reprobar, setReprobar] = useState(false);
  const agenciaPide = a.agenciaCarga || a.agencia;

  return (
    <div style={{ ...S.card, borderLeft: "4px solid var(--b-2563eb)" }}>
      <div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap", alignItems: "flex-start" }}>
        <div>
          <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
            <span style={{ fontWeight: 800, fontSize: 16 }} title={a.buque}>{nombreLimpio(a.buque)}</span>
            <Badge estado={a.estado} />
            <span style={S.pillAgencia}>▲ lo cargó {agenciaPide}</span>
            {a.reprobada
              ? <span style={S.pillAvisoAlta} title={"Reprobada el " + fmtHora(a.reprobada.fecha) + (a.reprobada.usuario ? " por " + a.reprobada.usuario : "")}>⚠ reprobada — la agencia ya fue avisada</span>
              : a.altaResuelta && <span style={S.pillOk}>{a.altaResuelta.resultado === "mismo" ? "resuelta como duplicada" : "revisada: buque distinto"}</span>}
          </div>
          <div style={S.metaGrid}>
            <span>🚢 Salida: {fmt(a.salida)}</span>
            {a.entrada && <span>⚓ Entrada: {fmt(a.entrada)}</span>}
            <span>IMO: <b>{a.imo || "—"}</b></span>
            <span>TRN: {a.trn || "—"}</span>
            <span>📍 Zona: {a.zona || "—"}</span>
            <span>Muelle: {a.muelle || "—"}</span>
            <span>🏳️ Bandera: {a.band || "—"}</span>
            <span>← Proc.: {a.procedencia || "—"}</span>
            <span>→ Destino: {a.destino || "—"}</span>
            {a.itc && <span style={{ color: "var(--t-166534)" }}>📎 ITC: <a href={`/descargas/adjunto/${a.id}/itc`} style={{ color: "var(--t-166534)" }}>{a.itc.nombre}</a></span>}
          </div>
        </div>
        <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
          <button style={S.btnMini} onClick={() => onEditar(a)}>Editar</button>
          {a.altaResuelta
            ? <button style={S.btnMini} onClick={() => accion(`/api/buques/${a.id}/revisada`, { json: { reabrir: true } }, "Alta reabierta.")}>Reabrir</button>
            : <button style={S.btnPrimary} title="Es un movimiento nuevo: se aprueba y pasa a la sábana de la pestaña Buques" onClick={() => accion(`/api/buques/${a.id}/revisada`, { json: {} }, "Alta aprobada: ya está en la pestaña Buques.")}>✓ Aprobar — es un buque nuevo</button>}
          {/* REPROBAR: el movimiento ya lo tiene otra agencia o ya se facturó.
              Distinto de "es el mismo", que le pasa el movimiento a la agencia
              que lo pidió. Acá el alta no va, y hay que explicarle por qué:
              el texto que escribo lo lee la agencia. */}
          {!a.altaResuelta && (
            <button
              style={{ ...S.btnMini, background: "var(--f-fffbeb)", borderColor: "var(--b-fde68a)", color: "var(--t-92400e)" }}
              title="El movimiento ya existe o ya se facturó: se reprueba el alta y la agencia recibe tu comentario"
              onClick={() => setReprobar(true)}
            >
              ⚠ Reprobar y avisar
            </button>
          )}
          {/* Descartar el alta. Hace falta para el caso en que la agencia cargó
              algo que no va: si es duplicado de otro movimiento se usa "es el
              mismo" (que reasigna), pero si simplemente está de más, esto lo
              saca. No borra de verdad: va a la papelera y se puede restaurar. */}
          {/* Descartar: disponible mientras el alta está sin resolver y también
              cuando quedó reprobada, porque ésas se borran a mano. */}
          {(!a.altaResuelta || a.reprobada) && (
            <button
              style={{ ...S.btnMini, color: "var(--t-b91c1c)", borderColor: "var(--b-fecaca)" }}
              onClick={() => setConfirmar({
                msg: `¿Descartar el alta de "${a.buque}" (salida ${fmt(a.salida)}) que cargó ${agenciaPide}? Va a la papelera, en la pestaña "Excel y respaldo", y desde ahí podés restaurarla. Usá esto cuando el alta no corresponde; si es el mismo movimiento que otro ya cargado, mejor usá "Sí, es el mismo".`,
                etiqueta: "Sí, descartar",
                onYes: () => accion(`/api/buques/${a.id}`, { method: "DELETE" }, `Alta de ${a.buque} descartada. Está en la papelera.`),
              })}
            >
              🗑 Descartar
            </button>
          )}
        </div>
      </div>

      {coinc.length === 0 ? (
        <div style={{ marginTop: 12, ...S.pillOk, display: "inline-block", padding: "6px 10px", fontSize: 12 }}>
          Sin coincidencias: no hay ningún movimiento parecido cargado. Es un buque nuevo de verdad.
        </div>
      ) : (
        <div style={{ marginTop: 14 }}>
          <div style={{ ...S.nivelCaja[top.nivel], borderRadius: 10, padding: "9px 12px", fontSize: 13, fontWeight: 700, marginBottom: 10 }}>
            {top.resumen}: <b>{top.buque}</b> — salida {fmt(top.salida)} — figura para <b>{top.agencia}</b> ({top.estado}
            {top.filaExcel ? ", fila " + top.filaExcel + " del Excel" : ""}).
          </div>
          {coinc.map((c) => (
            <Coincidencia
              key={c.id} alta={a} c={c}
              onFusionar={() => setConfirmar({
                msg: `¿Confirmás que "${a.buque}" (salida ${fmt(a.salida)}) es el mismo movimiento que el que hoy figura para ${c.agencia}? El registro original pasa a ${agenciaPide} y esta alta queda archivada como duplicada. Se genera el cambio de AGENCIA para pasar al Excel.`,
                etiqueta: "Sí, es el mismo",
                onYes: () => accion(`/api/buques/${a.id}/fusionar`, { json: { destinoId: c.id } }, `Movimiento reasignado a ${agenciaPide}.`),
              })}
              permitirFusion={!a.altaResuelta}
            />
          ))}
        </div>
      )}
      {a.reprobada && (
        <div style={{ marginTop: 12, background: "var(--f-fffbeb)", border: "1px solid var(--b-fde68a)", borderRadius: 10, padding: "10px 12px" }}>
          <div style={{ fontSize: 11, fontWeight: 800, letterSpacing: ".06em", textTransform: "uppercase", color: "var(--t-92400e)", marginBottom: 4 }}>
            Lo que ve {agenciaPide} · {fmtHora(a.reprobada.fecha)}
          </div>
          <div style={{ fontSize: 13.5, color: "var(--t-78350f)", whiteSpace: "pre-wrap" }}>{a.reprobada.motivo}</div>
          <button
            style={{ ...S.btnMini, marginTop: 8 }}
            title="Le saca la observación a la agencia y el alta vuelve a estar por revisar"
            onClick={() => accion(`/api/buques/${a.id}/reprobar`, { json: { reabrir: true } }, "Se le sacó la observación a la agencia.")}
          >
            Dar de vuelta
          </button>
        </div>
      )}
      {reprobar && <ReprobarModal a={a} accion={accion} onClose={() => setReprobar(false)} />}
      {confirmar && <ConfirmDialog {...confirmar} onCancel={() => setConfirmar(null)} onConfirm={() => { confirmar.onYes(); setConfirmar(null); }} />}
    </div>
  );
}

/* ====== reprobar un alta: el comentario que va a leer la agencia ======
 * Lo importante de esta pantalla es que el texto NO es una nota interna: es lo
 * único que le explica a la agencia por qué el buque que cargó no va. Por eso
 * el campo es obligatorio y el cartel lo dice. */
function ReprobarModal({ a, accion, onClose }) {
  const agenciaPide = a.agenciaCarga || a.agencia;
  const yaExiste = (a.coincidencias || [])[0];
  const sugerencia = yaExiste
    ? `El movimiento de ${nombreLimpio(a.buque)} con salida ${fmt(a.salida)} ya está registrado en el portal, así que no corresponde darlo de alta de nuevo. Cualquier duda, escribinos.`
    : "";
  const [motivo, setMotivo] = useState(sugerencia);
  const [enviando, setEnviando] = useState(false);

  async function enviar() {
    if (enviando || !motivo.trim()) return;
    setEnviando(true);
    try {
      await accion(`/api/buques/${a.id}/reprobar`, { json: { motivo: motivo.trim() } },
        `Alta de ${nombreLimpio(a.buque)} reprobada. ${agenciaPide} ya ve el comentario.`);
      onClose();
    } catch (e) {} finally { setEnviando(false); }
  }

  return (
    <div style={S.modalWrap} onClick={onClose}>
      <div style={S.modal} onClick={(e) => e.stopPropagation()}>
        <div style={S.modalHead}>
          <div>
            <div style={{ fontWeight: 800, fontSize: 17 }}>Reprobar el alta y avisar a {agenciaPide}</div>
            <div style={{ fontSize: 12, color: "var(--t-64748b)" }}>{nombreLimpio(a.buque)} · Salida {fmt(a.salida)} · {a.band || "—"}</div>
          </div>
          <button style={S.btnGhostDark} onClick={onClose}>✕</button>
        </div>
        <div style={S.modalBody}>
          <div style={{ fontSize: 13, color: "var(--t-475569)", marginBottom: 12 }}>
            El alta sale de la sábana de Buques —no es un movimiento a facturar— pero queda acá en Altas de agencias hasta que la borres a mano.
            A <b>{agenciaPide}</b> le va a aparecer en su pestaña <b>Observados</b>, con una alerta y con este comentario.
          </div>
          {yaExiste && (
            <div style={{ ...S.nivelCaja[yaExiste.nivel] || {}, borderRadius: 10, padding: "9px 12px", fontSize: 12.5, marginBottom: 12 }}>
              El portal encontró que ya figura para <b>{yaExiste.agencia}</b> ({yaExiste.estado}
              {yaExiste.filaExcel ? ", fila " + yaExiste.filaExcel + " del Excel" : ""}).
              <div style={{ marginTop: 4, color: "var(--t-475569)" }}>Ojo: si escribís el nombre de la otra agencia acá, {agenciaPide} lo va a leer.</div>
            </div>
          )}
          <Field label="Comentario para la agencia — obligatorio">
            <textarea
              style={{ ...S.input, height: 110, resize: "vertical", marginBottom: 4 }}
              value={motivo} maxLength={MAX_COMENTARIO} autoFocus
              placeholder="Ej: el movimiento ya está registrado en el portal, no corresponde darlo de alta de nuevo."
              onChange={(e) => setMotivo(e.target.value)}
            />
            <div style={{ fontSize: 11.5, color: motivo.length > MAX_COMENTARIO - 60 ? "var(--t-b45309)" : "var(--t-94a3b8)", textAlign: "right" }}>
              {motivo.length} / {MAX_COMENTARIO}
            </div>
          </Field>
        </div>
        <div style={S.modalFoot}>
          <button style={S.btnGhostDark} onClick={onClose}>Cancelar</button>
          <button style={S.btnWarn} disabled={enviando || !motivo.trim()} onClick={enviar}>
            {enviando ? "Guardando…" : "Reprobar y avisar"}
          </button>
        </div>
      </div>
    </div>
  );
}

/* Comparación campo por campo entre el alta de la agencia y un candidato. */
function Coincidencia({ alta, c, onFusionar, permitirFusion }) {
  const agenciaPide = alta.agenciaCarga || alta.agencia;
  return (
    <div style={{ border: "1px solid var(--b-e2e8f0)", borderRadius: 10, padding: "10px 12px", marginBottom: 10, background: "var(--f-fcfdff)" }}>
      <div style={{ display: "flex", justifyContent: "space-between", gap: 10, flexWrap: "wrap", alignItems: "center" }}>
        <div style={{ fontSize: 13 }}>
          <b title={c.buque}>{nombreLimpio(c.buque)}</b> · salida {fmt(c.salida)} · agencia <b>{c.agencia}</b> · <Badge estado={c.estado} compacto />
          {c.filaExcel ? <span style={{ color: "var(--t-64748b)" }}> · fila {c.filaExcel} del Excel</span> : null}
          {c.mismaAgencia ? <span style={{ ...S.pillOk, marginLeft: 6 }}>ya es de {agenciaPide}</span> : null}
        </div>
        <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
          <span style={{ ...S.nivelCaja[c.nivel], borderRadius: 20, padding: "3px 10px", fontSize: 11.5, fontWeight: 800 }}>
            {c.nivel === "muy_alta" ? "coincidencia muy alta" : c.nivel === "alta" ? "coincidencia alta" : "coincidencia media"} · {c.puntaje} pts
          </span>
          {permitirFusion && !c.mismaAgencia && <button style={S.btnMini} onClick={onFusionar}>Es el mismo — reasignar a {agenciaPide}</button>}
        </div>
      </div>
      <table style={S.cmpTabla}>
        <thead>
          <tr>
            <th style={{ ...S.cmpTh, width: "14%" }}>Dato</th>
            <th style={{ ...S.cmpTh, width: "26%" }}>Lo que cargó {agenciaPide}</th>
            <th style={{ ...S.cmpTh, width: "26%" }}>Lo que ya está cargado</th>
            <th style={S.cmpTh}>Lectura</th>
          </tr>
        </thead>
        <tbody>
          {c.motivos.map((m, i) => (
            <tr key={i}>
              <td style={{ ...S.cmpTd, fontWeight: 700, color: "var(--t-475569)" }}>{m.campo}</td>
              <td style={S.cmpTd}>{m.campo === "Salida" ? fmt(m.alta) : m.alta || "—"}</td>
              <td style={S.cmpTd}>{m.campo === "Salida" ? fmt(m.otro) : m.otro || "—"}</td>
              <td style={S.cmpTd}>
                <span style={{ ...S.nivelChip[m.nivel], padding: "2px 8px", borderRadius: 12, fontSize: 11.5, fontWeight: 700 }}>{m.texto}</span>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

/* Mismo detalle, en modal, para poder abrirlo desde la tabla de buques. */
function CoincidenciasModal({ alta, accion, onClose }) {
  const coinc = alta.coincidencias || [];
  const agenciaPide = alta.agenciaCarga || alta.agencia;
  const [confirmar, setConfirmar] = useState(null);
  return (
    <div style={S.modalWrap} onClick={onClose}>
      <div style={S.modalAncho} onClick={(e) => e.stopPropagation()}>
        <div style={S.modalHead}>
          <div>
            <div style={{ fontWeight: 800, fontSize: 17 }}>¿Este buque ya estaba cargado?</div>
            <div style={{ fontSize: 12.5, color: "var(--t-64748b)" }}>
              Alta de <b>{agenciaPide}</b>: {nombreLimpio(alta.buque)} · salida {fmt(alta.salida)} · IMO {alta.imo || "—"} · zona {alta.zona || "—"}
            </div>
          </div>
          <button style={S.btnGhostDark} onClick={onClose}>✕</button>
        </div>
        <div style={S.modalBody}>
          {coinc.length === 0 ? <Empty msg="No se encontraron movimientos parecidos." /> : coinc.map((c) => (
            <Coincidencia
              key={c.id} alta={alta} c={c} permitirFusion={!alta.altaResuelta}
              onFusionar={() => setConfirmar({
                msg: `¿Confirmás que "${alta.buque}" (salida ${fmt(alta.salida)}) es el mismo movimiento que el que hoy figura para ${c.agencia}? El registro original pasa a ${agenciaPide} y esta alta queda archivada como duplicada.`,
                etiqueta: "Sí, es el mismo",
                onYes: async () => { await accion(`/api/buques/${alta.id}/fusionar`, { json: { destinoId: c.id } }, `Movimiento reasignado a ${agenciaPide}.`); onClose(); },
              })}
            />
          ))}
        </div>
        <div style={S.modalFoot}>
          {!alta.altaResuelta && <button style={S.btnPrimary} onClick={async () => { await accion(`/api/buques/${alta.id}/revisada`, { json: {} }, "Alta aprobada: ya está en la pestaña Buques."); onClose(); }}>✓ Aprobar — es un buque nuevo</button>}
          <button style={S.btnPrimary} onClick={onClose}>Cerrar</button>
        </div>
        {confirmar && <ConfirmDialog {...confirmar} onCancel={() => setConfirmar(null)} onConfirm={() => { confirmar.onYes(); setConfirmar(null); }} />}
      </div>
    </div>
  );
}

/* ====== panel de sincronización ====== */
function SyncPanel({ datos, accion, notify }) {
  const [corriendo, setCorriendo] = useState(false);
  const [guardando, setGuardando] = useState(false);
  const [reiniciar, setReiniciar] = useState(false);
  const [archivo, setArchivo] = useState(null);
  const [subiendo, setSubiendo] = useState(false);
  const inputArchivo = useRef(null);
  const s = datos.sync;

  /* Portal en internet: la planilla se sube desde acá (ver /excel/subir en
     server.js). Se lee en el acto y sólo agrega los movimientos nuevos. */
  async function subirPlanilla() {
    if (!archivo) return notify("⚠️ Elegí primero el archivo RENTAS BUQUES.");
    setSubiendo(true);
    try {
      const fd = new FormData();
      fd.append("planilla", archivo, archivo.name);
      const d = await accion("/api/excel/subir", { method: "POST", body: fd });
      if (d && d.resumen) {
        const r = d.resumen;
        if (r.error) notify("⚠️ " + r.error);
        else notify("Planilla leída: " + r.filasLeidas + " filas, " + r.altas + " movimiento(s) nuevo(s)" +
                    (r.altas ? " — están en Novedades para revisar." : "."));
      }
      setArchivo(null);
      if (inputArchivo.current) inputArchivo.current.value = "";
    } catch (e) {} finally { setSubiendo(false); }
  }

  async function sincronizar() {
    setCorriendo(true);
    try {
      const d = await accion("/api/sync", { method: "POST" }, "Listo: se buscaron buques nuevos en el Excel.");
      if (d && d.resumen && d.resumen.error) notify("⚠️ " + d.resumen.error);
    } catch (e) {} finally { setCorriendo(false); }
  }

  async function guardarRespaldo() {
    setGuardando(true);
    try {
      const d = await accion("/api/respaldo", { method: "POST" });
      if (d && d.respaldo) notify("Respaldo guardado: " + d.respaldo.archivo + " (" + d.respaldo.movimientos + " movimientos).");
    } catch (e) {} finally { setGuardando(false); }
  }

  return (
    <Section
      title="Los dos Excel"
      sub="Hay dos planillas y hacen cosas distintas: en una entran los movimientos del día, la otra es la copia de seguridad de todo el portal."
    >
      {/* ---------------------------------------------------------------
          1. EXCEL PRINCIPAL — sólo entrada.
          El portal LEE este archivo y nunca lo escribe.
          --------------------------------------------------------------- */}
      <div style={{ ...S.card, borderLeft: "4px solid var(--b-2563eb)" }}>
        <div style={{ display: "flex", alignItems: "baseline", gap: 10, flexWrap: "wrap" }}>
          <span style={{ ...S.pillTipo, background: "var(--f-dbeafe)", color: "var(--t-1e40af)" }}>1</span>
          <span style={{ fontWeight: 800, fontSize: 15 }}>Excel principal — por acá entran los movimientos</span>
        </div>
        <div style={{ fontSize: 13.5, color: "var(--t-334155)", lineHeight: 1.7, marginTop: 8 }}>
          Pegás ahí los buques salidos del día y el portal los da de alta solo. Quedan en <b>Novedades para revisar</b> hasta
          que los publiques, sin que las agencias los vean. <b>El portal nunca escribe en este archivo</b>: sólo lo lee, y sobre una
          copia. Un movimiento que ya está en el portal no se modifica desde la planilla.
        </div>
        <div style={{ marginTop: 12, fontSize: 12.5, color: "var(--t-64748b)", lineHeight: 1.7 }}>
          {s.modoSubida
            ? <div><b>Última planilla subida:</b> {s.ultimaSubida
                ? <span>{s.ultimaSubida.nombre} · {fmtHora(s.ultimaSubida.fecha)} · por {s.ultimaSubida.por}</span>
                : <span style={{ color: "var(--t-94a3b8)" }}>todavía ninguna</span>}</div>
            : <div><b>Archivo:</b> {s.rutaExcel}</div>}
          <div><b>Hoja:</b> {s.hoja} · <b>Desde:</b> {s.desde ? fmt(s.desde) : "todo"}</div>
        </div>
        <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(180px,1fr))", gap: 14, marginTop: 14 }}>
          <Dato etiqueta="Última lectura" valor={fmtHora(s.ultima)} />
          <Dato etiqueta="Filas leídas" valor={s.filasLeidas || 0} />
          <Dato etiqueta="Movimientos nuevos en la última" valor={s.altas || 0} destacado={!!s.altas} />
          {s.modoSubida
            ? <Dato etiqueta="Se lee" valor="al subirla" />
            : <Dato etiqueta="Lee sola cada" valor={s.minutos + " minutos"} />}
        </div>
        {s.error && <div style={S.aviso}>⚠️ {s.error}</div>}
        {s.modoSubida ? (
          /* En internet: elegir el archivo y subirlo. */
          <div style={{ marginTop: 14, background: "var(--f-f0f7ff)", border: "1px solid var(--b-bfdbfe)", borderRadius: 10, padding: "12px 14px" }}>
            <div style={{ fontSize: 13, color: "var(--t-334155)", marginBottom: 10, lineHeight: 1.6 }}>
              Cargás los buques del día en tu <b>RENTAS BUQUES</b> como siempre, lo guardás, y lo subís acá. El portal lo lee en el
              momento y los nuevos quedan en <b>Novedades para revisar</b>.
            </div>
            <div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "center" }}>
              <input ref={inputArchivo} type="file" accept=".xlsm,.xlsx"
                     onChange={(e) => setArchivo((e.target.files || [])[0] || null)}
                     style={{ fontSize: 13, color: "var(--t-334155)" }} />
              <button style={S.btnPrimary} onClick={subirPlanilla} disabled={subiendo || !archivo}>
                {subiendo ? "Subiendo y leyendo…" : "⬆ Subir planilla"}
              </button>
            </div>
          </div>
        ) : (
        <div style={{ display: "flex", gap: 10, marginTop: 14, flexWrap: "wrap", alignItems: "center" }}>
          <button style={S.btnPrimary} onClick={sincronizar} disabled={corriendo}>
            {corriendo ? "Buscando…" : "↻ Buscar buques nuevos ahora"}
          </button>
          <span style={{ fontSize: 12.5, color: "var(--t-94a3b8)" }}>No hace falta apretarlo: el portal lee solo cada {s.minutos} minutos y al abrirlo.</span>
        </div>
        )}
        {s.sinAgencia > 0 && (
          <div style={{ marginTop: 12, fontSize: 12.5, color: "var(--t-334155)", background: "var(--f-f0f7ff)", border: "1px solid var(--b-bfdbfe)", borderRadius: 10, padding: "10px 12px" }}>
            Hay <b>{s.sinAgencia}</b> buque(s) sin agencia esperando que alguna se haga cargo. Los ven todas las agencias en su
            lista <b>A reclamar</b>.
            {s.sinAgenciaViejos > 0 && (
              <div style={{ color: "var(--t-92400e)", fontWeight: 700, marginTop: 4 }}>
                De esos, {s.sinAgenciaViejos} llevan más de {s.diasSinReclamar} días sin que nadie los reclame: están marcados en Buques.
              </div>
            )}
          </div>
        )}
        {(s.agenciasDesconocidas || []).length > 0 && (
          <div style={{ marginTop: 12, fontSize: 12.5, color: "var(--t-7f1d1d)", background: "var(--f-fef2f2)", border: "1px solid var(--b-fecaca)", borderRadius: 10, padding: "10px 12px" }}>
            <div style={{ fontWeight: 800, marginBottom: 4 }}>
              El Excel nombra {s.agenciasDesconocidas.length} agencia(s) que el portal no conoce
            </div>
            <div style={{ marginBottom: 6 }}>
              {s.agenciasDesconocidas.map((a) => (
                <span key={a.nombre} style={{ ...S.pillTipo, background: "var(--f-fee2e2)", color: "var(--t-991b1b)", marginRight: 6, display: "inline-block", marginTop: 4 }}>
                  {a.nombre} ({a.cuantos})
                </span>
              ))}
            </div>
            El portal <b>no las da de alta solo</b>: puede ser un error de tipeo en la planilla. Esos movimientos quedan
            en <b>SIN ASIGNAR</b> con la alerta puesta, y los ves en <b>Buques</b> filtrando por "Con datos para revisar".
            Si la agencia es de verdad nueva, dala de alta en <b>Administrar usuarios</b> y después asignásela al movimiento.
          </div>
        )}
        {s.conflictos > 0 && (
          <div style={{ marginTop: 12, fontSize: 12.5, color: "var(--t-92400e)", background: "var(--f-fffbeb)", border: "1px solid var(--b-fde68a)", borderRadius: 10, padding: "8px 12px" }}>
            En <b>{s.conflictos}</b> movimiento(s) el portal y la planilla dicen cosas distintas. Es sólo informativo: manda el portal.
            Los ves en la pestaña <b>Buques</b> con la alerta de datos para revisar.
          </div>
        )}
      </div>

      {/* ---------------------------------------------------------------
          2. EXCEL DE RESPALDO — copia de todo el portal, a pedido.
          --------------------------------------------------------------- */}
      <div style={{ ...S.card, borderLeft: "4px solid var(--b-166534)" }}>
        <div style={{ display: "flex", alignItems: "baseline", gap: 10, flexWrap: "wrap" }}>
          <span style={{ ...S.pillTipo, background: "var(--f-dcfce7)", color: "var(--t-166534)" }}>2</span>
          <span style={{ fontWeight: 800, fontSize: 15 }}>Excel de respaldo — la copia de todo el portal</span>
        </div>
        <div style={{ fontSize: 13.5, color: "var(--t-334155)", lineHeight: 1.7, marginTop: 8 }}>
          Guarda en una planilla <b>todo lo que hay en el portal en este momento</b>: los movimientos con sus estados, pasavantes y
          comentarios, más una hoja con el registro de buques. Es para que si algún día el portal no arranca, los datos sigan estando
          en un Excel que se abre en cualquier PC. <b>Este archivo no se vuelve a leer</b>: es una salida, no una entrada.
        </div>
        <div style={{ display: "flex", gap: 10, marginTop: 14, flexWrap: "wrap", alignItems: "center" }}>
          <button style={S.btnPrimary} onClick={guardarRespaldo} disabled={guardando}>
            {guardando ? "Guardando…" : "💾 Guardar datos del portal"}
          </button>
          <a style={{ ...S.btnGhostDark, textDecoration: "none" }} href="/descargas/respaldo.xlsx">↓ Descargar una copia ahora</a>
        </div>
        {/* El paquete completo sirve para VOLVER A LEVANTAR el portal (base,
            cuentas y certificados adjuntos). La planilla de arriba no alcanza
            para eso. Sólo la cuenta principal: lleva todo de todas las agencias. */}
        {datos.sesion.principal && (
          <div style={{ marginTop: 12, fontSize: 12.5, color: "var(--t-334155)", background: "var(--f-f0fdf4)", border: "1px solid var(--b-86efac)", borderRadius: 10, padding: "10px 12px", lineHeight: 1.6 }}>
            <b>Respaldo completo del portal</b> — la base entera con las cuentas y los certificados adjuntos, en un solo archivo.
            Es el que sirve para restaurar el portal si algo sale mal. Conviene bajarlo cada tanto y guardarlo en tu PC.
            <div style={{ marginTop: 8 }}>
              <a style={{ ...S.btnGhostDark, textDecoration: "none" }} href="/descargas/portal_completo.tar.gz">↓ Descargar respaldo completo</a>
            </div>
          </div>
        )}
        <div style={{ marginTop: 12, fontSize: 13, color: "var(--t-475569)" }}>
          {s.respaldo
            ? <span>Último guardado: <b>{s.respaldo.archivo}</b> — {s.respaldo.movimientos} movimientos y {s.respaldo.buques} buques, a las {fmtHora(s.respaldo.fecha)}.</span>
            : <span style={{ color: "var(--t-94a3b8)" }}>Todavía no se guardó ninguno desde que arrancó el portal.</span>}
        </div>
        <div style={{ marginTop: 6, fontSize: 12, color: "var(--t-94a3b8)", lineHeight: 1.7 }}>
          Cada vez que guardás queda un archivo propio con la fecha y la hora, en <code>portal\salida\</code>.
          Se conservan los <b>{(s.respaldo && s.respaldo.conserva) || 5} últimos</b> y los más viejos se borran solos.
          El portal además guarda uno solo, una vez por día, por si te olvidás.
          <div>En <code>portal\data\respaldos\</code> queda también una copia de la base, que es la que permite volver a levantar el portal tal cual estaba, con adjuntos e historial.</div>
        </div>
      </div>

      {/* ---------------------------------------------------------------
          Puesta en marcha: dejar el portal igual al Excel.
          Va al final y aparte porque borra movimientos. No es parte del uso
          de todos los días: es el arranque.
          --------------------------------------------------------------- */}
      <div style={{ ...S.card, borderLeft: "4px solid var(--b-b91c1c)" }}>
        <div style={{ fontWeight: 800, fontSize: 15 }}>Puesta en marcha — dejar el portal igual al Excel</div>
        <div style={{ fontSize: 13.5, color: "var(--t-334155)", lineHeight: 1.7, marginTop: 8 }}>
          Borra <b>todos</b> los movimientos del portal y los vuelve a leer del Excel, como si fuera la primera vez.
          Sirve para arrancar: cargás de una vez los movimientos que tenés hasta hoy y a partir de ahí el circuito de
          todos los días es el de arriba.
        </div>
        <div style={{ fontSize: 12.5, color: "var(--t-64748b)", lineHeight: 1.7, marginTop: 8 }}>
          <div><b>Se borra:</b> los movimientos con sus estados, comentarios y adjuntos, y la papelera.</div>
          <div><b>No se toca:</b> las agencias y sus usuarios, las contraseñas, las sesiones abiertas, el registro de buques
            con sus fichas y certificados, y la configuración.</div>
          <div>Antes de borrar nada, el portal <b>guarda un respaldo solo</b>, y guarda otro con el resultado.</div>
        </div>
        <div style={{ marginTop: 14 }}>
          <button style={{ ...S.btnMini, background: "var(--f-fef2f2)", borderColor: "var(--b-fecaca)", color: "var(--t-b91c1c)", fontWeight: 700, padding: "8px 14px" }}
                  onClick={() => setReiniciar(true)}>
            ⟳ Reiniciar los movimientos desde el Excel
          </button>
        </div>
      </div>

      {reiniciar && <ReiniciarModal datos={datos} accion={accion} notify={notify} onClose={() => setReiniciar(false)} />}

      <Papelera datos={datos} accion={accion} />

      <div style={S.card}>
        <div style={{ fontWeight: 800, fontSize: 15, marginBottom: 8 }}>Historial de lecturas del Excel</div>
        {(s.log || []).length === 0 ? <div style={{ fontSize: 13, color: "var(--t-94a3b8)" }}>Sin registros todavía.</div> : (
          <div style={{ fontSize: 12.5, color: "var(--t-475569)", display: "flex", flexDirection: "column", gap: 6 }}>
            {s.log.map((l, i) => (
              <div key={i}><b>{fmtHora(l.fecha)}</b> {l.manual ? "(manual)" : "(automática)"} — {l.detalle || l.texto || ""}</div>
            ))}
          </div>
        )}
      </div>
    </Section>
  );
}
/* ====== papelera: movimientos que ENAPRO eliminó ====== */
/* ====== reiniciar los movimientos desde el Excel ======
 * Pantalla aparte y con confirmación escrita: borra movimientos, y el número
 * que se muestra es el que se va a perder. */
function ReiniciarModal({ datos, accion, notify, onClose }) {
  const [texto, setTexto] = useState("");
  const [publicar, setPublicar] = useState(true);
  const [corriendo, setCorriendo] = useState(false);
  const [resultado, setResultado] = useState(null);
  const cuantos = datos.buques.length;
  const listo = texto.trim().toUpperCase() === "REINICIAR";

  async function go() {
    if (!listo || corriendo) return;
    setCorriendo(true);
    try {
      const d = await accion("/api/reiniciar-desde-excel", { json: { confirmar: "REINICIAR", publicar } });
      if (d && d.reinicio) {
        setResultado(d.reinicio);
        notify("Portal reiniciado desde el Excel: " + d.reinicio.despues.movimientos + " movimientos.");
      }
    } catch (e) {} finally { setCorriendo(false); }
  }

  const filaEstado = (obj) => Object.keys(obj || {}).sort().map((k) => k + ": " + obj[k]).join(" · ") || "—";

  return (
    <div style={S.modalWrap} onClick={corriendo ? undefined : onClose}>
      <div style={S.modal} onClick={(e) => e.stopPropagation()}>
        <div style={S.modalHead}>
          <div>
            <div style={{ fontWeight: 800, fontSize: 17 }}>Reiniciar los movimientos desde el Excel</div>
            <div style={{ fontSize: 12, color: "var(--t-64748b)" }}>El portal va a quedar con lo que diga el Excel en este momento.</div>
          </div>
          {!corriendo && <button style={S.btnGhostDark} onClick={onClose}>✕</button>}
        </div>
        <div style={S.modalBody}>
          {resultado ? (
            <div>
              <div style={{ ...S.pillOk, display: "inline-block", padding: "6px 10px", fontSize: 12.5, marginBottom: 12 }}>Listo</div>
              <div style={{ fontSize: 13.5, color: "var(--t-334155)", lineHeight: 1.9 }}>
                <div>Movimientos antes: <b>{resultado.antes.movimientos}</b> — ahora: <b>{resultado.despues.movimientos}</b></div>
                <div>Filas leídas del Excel: <b>{resultado.filasLeidas}</b></div>
                {resultado.publicados > 0 && <div>Publicados para las agencias: <b>{resultado.publicados}</b></div>}
                {resultado.zonas > 0 && <div>Zonas completadas desde el muelle: <b>{resultado.zonas}</b></div>}
                <div style={{ marginTop: 8 }}><b>Antes por estado:</b> {filaEstado(resultado.antes.porEstado)}</div>
                <div><b>Ahora por estado:</b> {filaEstado(resultado.despues.porEstado)}</div>
                {resultado.respaldoPrevio && (
                  <div style={{ marginTop: 10, color: "var(--t-64748b)" }}>
                    Respaldo de cómo estaba antes: <code>{resultado.respaldoPrevio}</code> en <code>portal\salida\</code>.
                  </div>
                )}
                {resultado.error && <div style={S.aviso}>⚠️ {resultado.error}</div>}
              </div>
            </div>
          ) : (
            <>
              <div style={{ background: "var(--f-fef2f2)", border: "1px solid var(--b-fecaca)", borderRadius: 10, padding: "10px 12px", marginBottom: 14 }}>
                <div style={{ fontWeight: 800, color: "var(--t-991b1b)", fontSize: 13.5 }}>
                  Se van a borrar los {cuantos} movimientos que hay ahora
                </div>
                <div style={{ fontSize: 12.5, color: "var(--t-7f1d1d)", marginTop: 4 }}>
                  Con sus estados, comentarios y adjuntos. Se vuelven a crear sólo los que estén hoy en el Excel.
                  Las agencias, el registro de buques y los certificados no se tocan.
                </div>
              </div>
              <div style={{ fontSize: 12.5, color: "var(--t-64748b)", marginBottom: 14, lineHeight: 1.7 }}>
                Antes de borrar nada el portal guarda un respaldo en <code>portal\salida\</code>, así que si algo sale
                mal el estado anterior queda en un Excel.
              </div>
              <label style={{ display: "flex", gap: 8, alignItems: "flex-start", fontSize: 13, color: "var(--t-334155)", marginBottom: 14, cursor: "pointer" }}>
                <input type="checkbox" checked={publicar} onChange={(e) => setPublicar(e.target.checked)} style={{ marginTop: 3 }} />
                <span>
                  <b>Publicar todo enseguida para las agencias.</b>
                  <div style={{ color: "var(--t-64748b)" }}>
                    Recomendado en la carga inicial: son movimientos que ya pasaron. Si lo destildás, quedan todos en
                    "Novedades para revisar" y hay que publicarlos a mano.
                  </div>
                </span>
              </label>
              <Field label={'Para confirmar, escribí REINICIAR'}>
                <input style={S.input} value={texto} autoFocus placeholder="REINICIAR"
                       onChange={(e) => setTexto(e.target.value)} />
              </Field>
            </>
          )}
        </div>
        <div style={S.modalFoot}>
          {resultado
            ? <button style={S.btnPrimary} onClick={onClose}>Cerrar</button>
            : (
              <>
                <button style={S.btnGhostDark} onClick={onClose} disabled={corriendo}>Cancelar</button>
                <button style={{ ...S.btnPrimary, background: listo ? "var(--f-b91c1c)" : "var(--f-cbd5e1)", borderColor: "transparent" }}
                        disabled={!listo || corriendo} onClick={go}>
                  {corriendo ? "Reiniciando…" : "Reiniciar desde el Excel"}
                </button>
              </>
            )}
        </div>
      </div>
    </div>
  );
}

function Papelera({ datos, accion }) {
  const lista = datos.eliminados || [];
  const [confirmar, setConfirmar] = useState(null);

  return (
    <div style={S.card}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 14, flexWrap: "wrap" }}>
        <div>
          <div style={{ fontWeight: 800, fontSize: 15, marginBottom: 4 }}>Movimientos eliminados ({lista.length})</div>
          <div style={{ fontSize: 12.5, color: "var(--t-64748b)", marginBottom: 12 }}>
            Los que eliminaste a mano, y también las altas de agencia que descartaste. La sincronización <b>no</b> los
            vuelve a traer desde el Excel. Desde acá se pueden restaurar.
          </div>
        </div>
        {lista.length > 0 && (
          <button
            style={S.btnMiniDanger}
            title="Vaciar la papelera: los movimientos que sigan estando en el Excel los vuelve a traer la próxima sincronización"
            onClick={() => setConfirmar({
              msg: "¿Olvidar las " + lista.length + " eliminaciones? La lista queda vacía y, para los que la fila siga estando en el Excel, la próxima sincronización los vuelve a cargar. Los que borraste porque estaban repetidos y ya sacaste del Excel no vuelven.",
              etiqueta: "Sí, olvidar las " + lista.length,
              onYes: () => accion("/api/eliminados/purgar-todos", { json: {} }, "Papelera vaciada."),
            })}
          >
            🗑 Olvidar todo ({lista.length})
          </button>
        )}
      </div>
      {lista.length === 0 ? <Empty msg="No eliminaste ningún movimiento." /> : (
        <div style={{ ...S.tableWrap, boxShadow: "none" }}>
          <table style={S.tableFija}>
            <colgroup>{["12%", "7%", "12%", "7%", "9%", "9%", "13%", "31%"].map((w, i) => <col key={i} style={{ width: w }} />)}</colgroup>
            <thead><tr>{["Buque", "Salida", "Agencia", "TRN", "Estado", "En el Excel", "Quién / cuándo", ""].map((h, i) => <th key={i} style={S.thFija}>{h}</th>)}</tr></thead>
            <tbody>
              {lista.map((e) => (
                <tr key={e.id} style={{ borderBottom: "1px solid var(--b-f1f5f9)" }}>
                  <td style={{ ...S.tdFija, fontWeight: 700 }}>{e.buque}</td>
                  <td style={S.tdFijaNw}>{fmtCorto(e.salida)}</td>
                  <td style={S.tdFija}>{e.agencia}</td>
                  <td style={S.tdFijaNw}>{e.trn || "—"}</td>
                  <td style={S.tdFija}><Badge estado={e.estado} compacto /></td>
                  <td style={S.tdFija}>
                    {e.estabaEnExcel
                      ? (e.pasadoAExcel ? <span style={S.pillOk}>ya lo borré</span> : <span style={{ ...S.pillAusente, marginLeft: 0 }}>fila {e.filaExcel || "?"} — borrar</span>)
                      : <span style={{ color: "var(--t-94a3b8)" }}>no estaba</span>}
                  </td>
                  <td style={S.tdFija}>{e.usuario}<div style={{ fontSize: 11.5, color: "var(--t-94a3b8)" }}>{fmtHora(e.fecha)}</div></td>
                  <td style={S.tdFija}>
                    <div style={{ display: "flex", gap: 5, flexWrap: "wrap" }}>
                      <button style={S.btnMini} onClick={() => accion("/api/eliminados/restaurar", { json: { id: e.id } }, e.buque + " restaurado.")}>Restaurar</button>
                      {e.estabaEnExcel && !e.pasadoAExcel && (
                        <button style={S.btnMini} onClick={() => accion("/api/eliminados/marcar", { json: { id: e.id } }, "Marcado como borrado del Excel.")}>Ya lo borré del Excel</button>
                      )}
                      <button style={S.btnMiniDanger} title="Olvidar esta eliminación: si la fila sigue en el Excel, la próxima sincronización vuelve a traer el movimiento" onClick={() => setConfirmar({
                        msg: `¿Olvidar la eliminación de "${e.buque}"? Sale de esta lista y, si la fila sigue en el Excel, la próxima sincronización lo vuelve a cargar.`,
                        etiqueta: "Olvidar",
                        onYes: () => accion("/api/eliminados/purgar", { json: { id: e.id } }, "Eliminación olvidada."),
                      })}>Olvidar</button>
                    </div>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
      {confirmar && <ConfirmDialog {...confirmar} onCancel={() => setConfirmar(null)} onConfirm={() => { confirmar.onYes(); setConfirmar(null); }} />}
    </div>
  );
}

function Dato({ etiqueta, valor, destacado }) {
  return (
    <div>
      <div style={{ fontSize: 11.5, fontWeight: 700, color: "var(--t-64748b)", textTransform: "uppercase", letterSpacing: ".3px" }}>{etiqueta}</div>
      <div style={{ fontSize: 19, fontWeight: 800, color: destacado ? "var(--t-b45309)" : "var(--t-0f172a)", marginTop: 2 }}>{valor}</div>
    </div>
  );
}

/* ====== editar buque (ENAPRO) ====== */
function EditarBuqueModal({ s, agencias, accion, onClose }) {
  const [f, setF] = useState({ ...s });
  const set = (k) => (e) => setF({ ...f, [k]: e.target.value });
  const setVal = (k) => (v) => setF((prev) => ({ ...prev, [k]: v }));
  /* Cómo estaba el movimiento CUANDO SE ABRIÓ esta ficha. Se manda al guardar:
     si en el medio otro lo tocó, el portal no guarda y avisa (ver PISADAS).
     Va en useState y no se recalcula: `s` se actualiza solo cuando llegan datos
     nuevos del servidor, y ahí perderíamos justo la referencia que queremos. */
  const [visto] = useState(s.modificadoEn || "");
  const [pisada, setPisada] = useState(null);
  // El modal entero scrollea: si el aviso aparece abajo de todo puede quedar
  // fuera de la vista justo cuando hace falta leerlo.
  const avisoRef = useRef(null);
  useEffect(() => { if (pisada && avisoRef.current) avisoRef.current.scrollIntoView({ block: "end", behavior: "smooth" }); }, [pisada]);

  async function guardar(forzar) {
    try {
      await accion(`/api/buques/${s.id}`,
        { method: "PUT", calla409: true, json: Object.assign({}, f, { visto, forzar: !!forzar }) },
        `${f.buque} actualizado.`);
      onClose();
    } catch (e) {
      if (e.status === 409 && e.pisada) setPisada(e.pisada);
    }
  }

  return (
    <div style={S.modalWrap} onClick={onClose}>
      <div style={S.modal} onClick={(e) => e.stopPropagation()}>
        <div style={S.modalHead}>
          <div>
            <div style={{ fontWeight: 800, fontSize: 17 }} title={s.buque}>Editar movimiento — {nombreLimpio(s.buque)}</div>
            <div style={{ fontSize: 12, color: "var(--t-64748b)" }}>
              Origen: {s.origen === "excel" ? "Excel (fila " + (s.filaExcel || "?") + ")" : "cargado en el portal"}
              {s.estadoExcel ? " · ESTADO en Excel: " + s.estadoExcel : ""}
            </div>
          </div>
          <button style={S.btnGhostDark} onClick={onClose}>✕</button>
        </div>
        <div style={S.modalBody}>
          <div style={S.aviso}>
            El portal es el registro de verdad: lo que edités acá queda así, y la sincronización del Excel no lo pisa (el Excel sólo agrega los buques nuevos del día). Queda anotado en el historial del movimiento.
          </div>
          {s.ausenteVisto && (
            <div style={{ background: "var(--f-f8fafc)", border: "1px solid var(--b-e2e8f0)", borderRadius: 10, padding: "10px 12px", marginBottom: 14,
                          display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", fontSize: 12.5, color: "var(--t-475569)" }}>
              <span>
                El aviso de <b>“no está en el Excel”</b> lo sacaste el {fmtHora(s.ausenteVisto.fecha)}
                {s.ausenteVisto.usuario ? " (" + s.ausenteVisto.usuario + ")" : ""}.
              </span>
              <span style={{ flex: 1 }} />
              <button style={S.btnMini}
                onClick={() => accion("/api/buques/" + s.id + "/ausente-visto", { json: { deshacer: true } }, "Aviso restaurado.")}>
                Volver a mostrarlo
              </button>
            </div>
          )}
          {(s.avisos || []).length > 0 && (
            <div style={{ background: "var(--f-fff7ed)", border: "1px solid var(--b-fdba74)", borderRadius: 10, padding: "10px 12px", marginBottom: 14 }}>
              <div style={{ fontWeight: 800, fontSize: 13, color: "var(--t-9a3412)", marginBottom: 6 }}>Datos que conviene revisar</div>
              <ListaAvisos avisos={s.avisos} compacta />
            </div>
          )}
          {/* ¿La agencia lo ve? Con "No corresponde" la respuesta es NO y no
              depende de publicar: el servidor no le manda esos movimientos a
              ninguna agencia (ver estadoCompleto). Antes acá decía
              "sí — publicado el …", que era mentira, y ofrecía un botón de
              retener que no cambiaba nada. */}
          {s.estado === "No corresponde" ? (
            <div style={{ ...S.dataBox, marginBottom: 14 }}>
              <span>
                <b>¿La agencia lo ve?</b>{" "}
                <span style={S.pillNueva}>no — los "No corresponde" los ve sólo ENAPRO</span>
              </span>
            </div>
          ) : (
            <div style={{ ...S.dataBox, justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
              <span>
                <b>¿La agencia lo ve?</b>{" "}
                {s.publicado === false
                  ? <span style={S.pillNueva}>◍ no — está sin publicar</span>
                  : <span style={S.pillOk}>sí — publicado {s.publicadoEn ? "el " + fmtHora(s.publicadoEn) : ""}</span>}
                {s.estado === "Rechazado" && <span style={{ color: "var(--t-64748b)", marginLeft: 8 }}>· lo ven todas menos las que ya lo rechazaron</span>}
              </span>
              {s.publicado === false
                ? <button style={S.btnMini} onClick={async () => { await accion(`/api/buques/${s.id}/publicar`, { method: "POST" }, `${s.buque} publicado: la agencia ya lo ve.`); onClose(); }}>Publicar para la agencia</button>
                : <button style={S.btnMini} title="La agencia deja de verlo hasta que lo vuelvas a publicar" onClick={async () => { await accion(`/api/buques/${s.id}/retener`, { method: "POST" }, `${s.buque} retenido: la agencia dejó de verlo.`); onClose(); }}>Retener (que la agencia no lo vea)</button>}
            </div>
          )}
          <div style={S.formGrid}>
            <Field label="Buque"><input style={S.input} value={f.buque || ""} onChange={set("buque")} /></Field>
            <Field label="Agencia">
              {/* SIN ASIGNAR va como opción aparte (no es una agencia). Y si el
                  movimiento tiene una agencia que no está en la lista, se agrega
                  igual: si no, el desplegable mostraría otra y al guardar se la
                  cambiaría sin que nadie lo decidiera. */}
              <select style={S.input} value={f.agencia || "SIN ASIGNAR"} onChange={set("agencia")}>
                <option value="SIN ASIGNAR">SIN ASIGNAR (sin agencia)</option>
                {f.agencia && f.agencia.toUpperCase() !== "SIN ASIGNAR" && !agencias.includes(f.agencia) && <option value={f.agencia}>{f.agencia}</option>}
                {agencias.map((a) => <option key={a} value={a}>{a}</option>)}
              </select>
            </Field>
            <Field label="Estado"><select style={S.input} value={f.estado} onChange={set("estado")}>{ESTADOS.map((e) => <option key={e}>{e}</option>)}</select></Field>
            <Field label="Fecha de entrada"><input style={S.input} type="date" value={f.entrada || ""} onChange={set("entrada")} /></Field>
            <Field label="Fecha de salida"><input style={S.input} type="date" value={f.salida || ""} onChange={set("salida")} /></Field>
            <Field label="Bandera"><LugarSelect value={f.band} onChange={setVal("band")} placeholder="Seleccionar bandera…" /></Field>
            <Field label="Procedencia"><LugarSelect value={f.procedencia} onChange={setVal("procedencia")} placeholder="Seleccionar procedencia…" conPuertos /></Field>
            <Field label="Destino"><LugarSelect value={f.destino} onChange={setVal("destino")} placeholder="Seleccionar destino…" conPuertos /></Field>
            <Field label="TRN"><input style={S.input} value={f.trn || ""} onChange={set("trn")} /></Field>
            <Field label="Pasavante"><input style={S.input} value={f.pasavante || ""} onChange={set("pasavante")} /></Field>
            <Field label="IMO"><input style={S.input} value={f.imo || ""} onChange={set("imo")} /></Field>
            <ZonaMuelleSelect zona={f.zona} muelle={f.muelle} onZona={setVal("zona")} onMuelle={setVal("muelle")} />
          </div>
          <Field label="Comentario"><textarea style={{ ...S.input, height: 60, resize: "vertical" }} value={f.comentario || ""} onChange={set("comentario")} /></Field>
          {/* Lo que el movimiento ya tiene y no es un campo editable. Estaba sólo
              en la tabla, así que al abrir Editar parecía que no existía. */}
          {(s.agenciaExcel || s.comentarioExcel || s.itc || s.cert_cabotaje || s.certificadoBuque || s.rechazadoPor || s.noCorrespondePor || s.reclamadoPor) && (
            <div style={{ fontSize: 12.5, color: "var(--t-475569)", background: "var(--f-f8fafc)", border: "1px solid var(--b-e2e8f0)", borderRadius: 10, padding: "8px 12px", marginBottom: 10 }}>
              {s.agenciaExcel && (
                <div style={{ color: "var(--t-92400e)" }}>
                  <b>Agencia segun el Excel:</b> "{s.agenciaExcel}" — no es ninguna de las del portal.
                  Elegi arriba la que corresponde, o dala de alta en Administrar usuarios.
                </div>
              )}
              {s.comentarioExcel && <div><b>Comentario del Excel:</b> {s.comentarioExcel}</div>}
              {s.rechazadoPor && s.rechazadoPor.agencia && (
                <div><b>Lo rechazó:</b> {s.rechazadoPor.agencia} ({s.rechazadoPor.usuario}) el {fmtHora(s.rechazadoPor.fecha)}</div>
              )}
              {(s.rechazaron || []).length > 0 && (
                <div><b>Ya dijeron que no es suyo:</b> {s.rechazaron.join(", ")} — a esas agencias no les figura en su lista de rechazados</div>
              )}
              {s.noCorrespondePor && s.noCorrespondePor.agencia && (
                <div>
                  <b>Marcó "No corresponde"{s.noCorrespondeMotivo ? " por " + (s.noCorrespondeMotivo === "rada" ? "rada (el buque no operó)" : "cabotaje") : ""}:</b>{" "}
                  {s.noCorrespondePor.agencia} ({s.noCorrespondePor.usuario}) el {fmtHora(s.noCorrespondePor.fecha)}
                </div>
              )}
              {s.reclamadoPor && s.reclamadoPor.agencia && (
                <div><b>Lo reclamó:</b> {s.reclamadoPor.agencia} ({s.reclamadoPor.usuario}) el {fmtHora(s.reclamadoPor.fecha)}{s.reclamadoPor.anterior ? " — antes figuraba en " + s.reclamadoPor.anterior : ""}</div>
              )}
              {s.itc && <div><b>ITC de esta estadía:</b> <a href={`/descargas/adjunto/${s.id}/itc`} style={{ color: "var(--t-166534)" }}>{s.itc.nombre}</a></div>}
              {!s.itc && s.certificadoBuque && <div><b>Certificado del buque:</b> <a href={`/descargas/adjunto/${s.certificadoBuque.desdeBuque}/itc`} style={{ color: "var(--t-166534)" }}>{s.certificadoBuque.nombre}</a> (de otra estadía)</div>}
              {/* Con enlace: el archivo estaba guardado y descargable pero acá
                  sólo se escribía el nombre, así que no había forma de abrirlo. */}
              {s.cert_cabotaje && <div><b>Certificado por tratamiento de bandera (cabotaje):</b> <a href={`/descargas/adjunto/${s.id}/cabotaje`} style={{ color: "var(--t-b45309)" }}>{s.cert_cabotaje.nombre}</a></div>}
            </div>
          )}
          {(s.historial || []).length > 0 && (
            <div style={{ marginTop: 6 }}>
              <div style={{ ...S.filterLab, marginBottom: 6 }}>Historial</div>
              <div style={{ fontSize: 12.5, color: "var(--t-475569)", display: "flex", flexDirection: "column", gap: 4, maxHeight: 130, overflow: "auto" }}>
                {s.historial.slice().reverse().map((h, i) => <div key={i}>{fmtHora(h.fecha)} · {h.usuario} · {h.accion} {h.detalle ? "— " + h.detalle : ""}</div>)}
              </div>
            </div>
          )}
        </div>
        {/* Choque: otro tocó el movimiento mientras esta ficha estaba abierta.
            No se guardó nada todavía. La decisión es de la persona: mirar cómo
            quedó (cerrar y volver a abrir, que trae lo nuevo) o pisar igual. */}
        {pisada && (
          <div ref={avisoRef} style={{ background: "var(--f-fff7ed)", borderTop: "1px solid var(--b-fdba74)", padding: "12px 20px" }}>
            <div style={{ fontWeight: 800, color: "var(--t-9a3412)", fontSize: 13.5, marginBottom: 4 }}>
              No se guardó: alguien más editó este movimiento
            </div>
            <div style={{ fontSize: 12.5, color: "var(--t-7c2d12)" }}>
              Lo tocó <b>{pisada.usuario}</b> {haceRato(pisada.fecha)}, mientras vos tenías esta ficha
              abierta. Lo que ves en pantalla es de antes de ese cambio: si guardás así, lo pisás.
            </div>
            <div style={{ display: "flex", gap: 8, marginTop: 10, flexWrap: "wrap" }}>
              <button style={S.btnPrimary} onClick={onClose}>Cerrar y ver cómo quedó</button>
              <button style={S.btnMini} onClick={() => guardar(true)}>Guardar igual (pisar lo del otro)</button>
            </div>
          </div>
        )}
        <div style={S.modalFoot}>
          <button style={S.btnGhostDark} onClick={onClose}>Cancelar</button>
          <button style={S.btnPrimary} onClick={() => guardar(false)}>Guardar cambios</button>
        </div>
      </div>
    </div>
  );
}

/* ====== administrar usuarios ====== */
function UserAdmin({ datos, accion, notify }) {
  const cuentas = datos.cuentas || {};
  const [open, setOpen] = useState(null);
  const [newAg, setNewAg] = useState("");
  const [newEmail, setNewEmail] = useState({});
  const [confirmAction, setConfirmAction] = useState(null);
  // Contraseñas recién generadas: se muestran UNA vez (después no se pueden ver).
  const [clavesNuevas, setClavesNuevas] = useState(null);
  const mostrarClave = (d) => { if (d && d.claveNueva) setClavesNuevas([d.claveNueva]); };

  async function generarClaves() {
    try {
      const d = await accion("/api/claves-iniciales", { json: {} });
      if (d && d.claves) { descargarClaves(d.claves); setClavesNuevas(d.claves); }
    } catch (e) {}
  }

  /* Desde el 29/09/2026 las contraseñas se guardan como huella (lib/claves.js)
     y ya no se pueden VER: ni el servidor las sabe. Para que alguien vuelva a
     entrar se le pone una nueva o se la resetea a la inicial. */
  return (
    <Section title="Administrar usuarios y agencias" sub={"Crear, editar o eliminar agencias y sus usuarios. Cada usuario nuevo entra con la contraseña inicial (" + (datos.passwordInicial || "—") + ") y la cambia al ingresar por primera vez. Las contraseñas no se pueden ver: si alguien se la olvidó, reseteala o escribile una nueva. El portal no manda los accesos: los das de alta y vos les avisás."}>
      <div style={{ ...S.card, display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
        <input style={{ ...S.input, margin: 0, maxWidth: 320 }} placeholder="Nombre de nueva agencia…" value={newAg} onChange={(e) => setNewAg(e.target.value)}
          onKeyDown={(e) => { if (e.key === "Enter" && newAg.trim()) { accion("/api/agencias", { json: { nombre: newAg.trim() } }, "Agencia creada."); setNewAg(""); } }} />
        <button style={S.btnPrimary} onClick={() => { if (newAg.trim()) { accion("/api/agencias", { json: { nombre: newAg.trim() } }, "Agencia creada."); setNewAg(""); } }}>+ Agregar agencia</button>
      </div>

      {/* Contraseña inicial compartida (29/09/2026). Todas las cuentas de agencia
          arrancaron con la misma, y la conocen todas las agencias: en internet,
          cualquiera podría entrar como otra. Hasta que cada una tenga la suya,
          el portal de internet no las deja entrar. */}
      {datos.conClaveCompartida > 0 && (
        <div style={{ ...S.card, borderLeft: "4px solid var(--b-dc2626)", background: "var(--f-fef2f2)" }}>
          <div style={{ fontWeight: 800, color: "var(--t-991b1b)", marginBottom: 6 }}>
            {datos.conClaveCompartida} usuario(s) de agencia siguen con la contraseña inicial compartida ({datos.passwordInicial})
          </div>
          <div style={{ fontSize: 13, color: "var(--t-7f1d1d)", lineHeight: 1.6 }}>
            Esa contraseña la conocen todas las agencias: en internet, cualquiera podría entrar a la cuenta de otra. Por eso
            <b> el portal de internet no deja entrar a esas cuentas</b> hasta que cada una tenga la suya.
            {" "}El botón le da una contraseña distinta a cada usuario y te baja un Excel con la lista, para que se la mandes a
            cada agencia. Al entrar por primera vez, cada persona elige la propia.
          </div>
          {datos.sesion.principal
            ? <button style={{ ...S.btnPrimary, marginTop: 10 }} onClick={() => setConfirmAction({
                msg: "Se va a generar una contraseña distinta para cada uno de los " + datos.conClaveCompartida + " usuarios y se va a bajar un archivo con la lista. La lista se ve UNA sola vez: guardá el archivo. ¿Seguimos?",
                etiqueta: "Generar y bajar la lista", onYes: generarClaves })}>
                🔑 Darle una contraseña propia a cada uno
              </button>
            : <div style={{ fontSize: 12.5, color: "var(--t-7f1d1d)", marginTop: 8 }}>Lo puede hacer la cuenta principal.</div>}
        </div>
      )}

      {/* Agencias que no estan en la lista acordada. Aparecen sobre todo porque
          antes la sincronizacion creaba una agencia cada vez que el Excel traia
          un nombre que no reconocia. */}
      {(datos.agenciasNoOficiales || []).length > 0 && (
        <div style={{ ...S.card, borderLeft: "4px solid var(--b-b45309)" }}>
          <div style={{ fontWeight: 800, fontSize: 15, marginBottom: 4 }}>
            Agencias fuera de la lista acordada ({datos.agenciasNoOficiales.length})
          </div>
          <div style={{ fontSize: 12.5, color: "var(--t-64748b)", marginBottom: 10, lineHeight: 1.7 }}>
            Estas no están entre las {(datos.agenciasOficiales || []).length} agencias de la lista. Algunas aparecieron solas,
            cuando la sincronización todavía daba de alta una agencia cada vez que el Excel traía un nombre que no reconocía.
            Si alguna es un error de escritura, borrala: sus movimientos pasan a <b>SIN ASIGNAR</b> con la alerta puesta
            para que les asignes la correcta. Si es una agencia nueva de verdad, dejala.
          </div>
          <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
            {datos.agenciasNoOficiales.map((a) => (
              <span key={a.nombre} style={{ background: "var(--f-fffbeb)", border: "1px solid var(--b-fde68a)", borderRadius: 8, padding: "6px 10px", fontSize: 12.5, color: "var(--t-92400e)" }}>
                <b>{a.nombre}</b> · {a.movimientos} movimiento(s) · {a.usuarios} usuario(s)
              </span>
            ))}
          </div>
        </div>
      )}

      {Object.keys(cuentas).sort().map((ag) => (
        <div key={ag} style={S.card}>
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, cursor: "pointer" }} onClick={() => setOpen(open === ag ? null : ag)}>
            <div style={{ fontWeight: 800, fontSize: 15 }}>
              {ag} <span style={{ fontWeight: 500, color: "var(--t-94a3b8)", fontSize: 13 }}>· {cuentas[ag].length} usuario(s) · {datos.buques.filter((b) => b.agencia === ag).length} buques</span>
              {cuentas[ag].length === 0 && <span style={{ ...S.pillAusente, marginLeft: 8 }}>sin usuarios</span>}
              {ag.toUpperCase() === "SIN ASIGNAR" && (cuentas[ag] || []).length > 0 && (
                <span style={{ ...S.pillAvisoAlta, marginLeft: 8 }}
                      title="SIN ASIGNAR no es una agencia: es donde quedan los movimientos que todavia no tienen dueña. Conviene sacarle los usuarios.">
                  ⚠ no debería tener usuarios
                </span>
              )}
              {(datos.agenciasNoOficiales || []).some((x) => x.nombre === ag) && (
                <span style={{ ...S.pillNueva, marginLeft: 8 }} title="No está en la lista acordada de agencias">fuera de la lista</span>
              )}
            </div>
            <span style={{ color: "var(--t-64748b)", fontSize: 13 }}>{open === ag ? "▲ cerrar" : "▼ administrar"}</span>
          </div>
          {open === ag && (
            <div style={{ marginTop: 14, borderTop: "1px solid var(--b-eef2f6)", paddingTop: 14 }}>
              <div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 14 }}>
                <input style={{ ...S.input, margin: 0, maxWidth: 260 }} defaultValue={ag} placeholder="Renombrar agencia"
                  onBlur={(e) => { const v = e.target.value.trim(); if (v && v !== ag) accion(`/api/agencias/${encodeURIComponent(ag)}`, { method: "PUT", json: { nombre: v } }, "Agencia renombrada."); }} />
                {ag.toUpperCase() === "SIN ASIGNAR" ? (
                  <span style={{ fontSize: 12.5, color: "var(--t-64748b)", alignSelf: "center" }}>
                    SIN ASIGNAR no se elimina: es donde quedan los movimientos sin agencia.
                  </span>
                ) : (
                <button style={S.btnDanger} onClick={() => setConfirmAction({
                  msg: `¿Eliminar la agencia "${ag}" y todos sus usuarios?`
                    + (datos.buques.filter((b) => b.agencia === ag).length
                        ? ` Sus ${datos.buques.filter((b) => b.agencia === ag).length} movimiento(s) pasan a SIN ASIGNAR con la alerta de agencia desconocida, para que les asignes la que corresponde. No se borra ninguno.`
                        : " No tiene movimientos."),
                  etiqueta: "Eliminar",
                  onYes: () => accion(`/api/agencias/${encodeURIComponent(ag)}`, { method: "DELETE" }, "Agencia eliminada."),
                })}>Eliminar agencia</button>
                )}
              </div>

              {cuentas[ag].length === 0 && <div style={{ fontSize: 13, color: "var(--t-94a3b8)", marginBottom: 10 }}>Sin usuarios. Agregá uno abajo.</div>}
              {cuentas[ag].map((u, i) => (
                <div key={i} style={{ display: "flex", gap: 8, marginBottom: 8, flexWrap: "wrap", alignItems: "center" }}>
                  <input style={{ ...S.input, margin: 0, flex: "1 1 220px" }} defaultValue={u.email}
                    onBlur={(e) => { if (e.target.value !== u.email) accion(`/api/agencias/${encodeURIComponent(ag)}/usuarios/${i}`, { method: "PUT", json: { email: e.target.value } }, "Correo actualizado."); }} />
                  {/* Campo para PONER una contraseña nueva, no para ver la actual
                      (que ya no se guarda). Vacío no cambia nada. */}
                  <input style={{ ...S.input, margin: 0, width: 170 }} type="text" defaultValue="" placeholder="Nueva contraseña…"
                    autoComplete="off" title="Escribí una contraseña nueva y salí del campo para guardarla. Vacío no cambia nada."
                    onBlur={(e) => { const v = e.target.value; if (v) { accion(`/api/agencias/${encodeURIComponent(ag)}/usuarios/${i}`, { method: "PUT", json: { password: v } }, "Contraseña nueva guardada para " + u.email + "."); e.target.value = ""; } }} />
                  {u.mustChange && <span style={S.pillNew} title="Debe cambiar la contraseña en el próximo ingreso">inicial</span>}
                  {u.claveCompartida && <span style={{ ...S.pillNew, background: "var(--f-fee2e2)", color: "var(--t-991b1b)" }} title="Sigue con la contraseña inicial compartida: en internet no puede entrar">compartida</span>}
                  <button style={S.btnMini} title="Le pone una contraseña nueva, distinta, y te la muestra para que se la pases"
                    onClick={() => accion(`/api/agencias/${encodeURIComponent(ag)}/usuarios/${i}`, { method: "PUT", json: { reset: true } }).then(mostrarClave).catch(() => {})}>Resetear</button>
                  <button style={S.btnMiniDanger} onClick={() => setConfirmAction({
                    msg: `¿Eliminar al usuario "${u.email}"?`, etiqueta: "Eliminar",
                    onYes: () => accion(`/api/agencias/${encodeURIComponent(ag)}/usuarios/${i}`, { method: "DELETE" }, "Usuario eliminado."),
                  })}>Eliminar</button>
                </div>
              ))}
              <div style={{ display: "flex", gap: 8, marginTop: 6, flexWrap: "wrap" }}>
                <input style={{ ...S.input, margin: 0, flex: "1 1 220px" }} placeholder="nuevo.correo@agencia.com" value={newEmail[ag] || ""} onChange={(e) => setNewEmail({ ...newEmail, [ag]: e.target.value })} />
                <button style={S.btnPrimary} onClick={() => {
                  const e = (newEmail[ag] || "").trim();
                  if (!e) return notify("⚠️ Escribí un correo.");
                  accion(`/api/agencias/${encodeURIComponent(ag)}/usuarios`, { json: { email: e } }).then(mostrarClave).catch(() => {});
                  setNewEmail({ ...newEmail, [ag]: "" });
                }}>+ Agregar usuario</button>
              </div>
            </div>
          )}
        </div>
      ))}
      {confirmAction && <ConfirmDialog {...confirmAction} onCancel={() => setConfirmAction(null)} onConfirm={() => { confirmAction.onYes(); setConfirmAction(null); }} />}
      {clavesNuevas && <ClavesNuevasModal filas={clavesNuevas} onClose={() => setClavesNuevas(null)} />}
    </Section>
  );
}

/* Lista de contraseñas recién generadas, como planilla para Excel (con ; y la
   marca BOM, que es lo que el Excel en castellano abre bien de un doble clic). */
function descargarClaves(filas) {
  const q = (v) => '"' + String(v == null ? "" : v).replace(/"/g, '""') + '"';
  const txt = "\ufeff" + ["Agencia;Correo;Contraseña inicial"].concat(filas.map((f) => [f.agencia, f.email, f.clave].map(q).join(";"))).join("\r\n");
  const url = URL.createObjectURL(new Blob([txt], { type: "text/csv;charset=utf-8" }));
  const a = document.createElement("a");
  a.href = url; a.download = "accesos_agencias_" + new Date().toISOString().slice(0, 10) + ".csv";
  document.body.appendChild(a); a.click(); a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 5000);
}

/* Las contraseñas se ven UNA sola vez: el portal guarda sólo la huella y
   después no hay forma de volver a mostrarlas. */
function ClavesNuevasModal({ filas, onClose }) {
  const [copiado, setCopiado] = useState(false);
  const texto = filas.map((f) => f.agencia + " — " + f.email + " — " + f.clave).join("\n");
  const copiar = () => {
    try { navigator.clipboard.writeText(texto).then(() => setCopiado(true)); } catch (e) {}
  };
  return (
    <div style={S.modalWrap} onClick={onClose}>
      <div style={{ ...S.modal, width: 620, maxHeight: "86vh", display: "flex", flexDirection: "column" }} onClick={(e) => e.stopPropagation()}>
        <div style={{ padding: "18px 22px 8px" }}>
          <div style={{ fontWeight: 800, fontSize: 16 }}>
            {filas.length === 1 ? "Contraseña nueva" : filas.length + " contraseñas nuevas"}
          </div>
          <div style={{ fontSize: 13, color: "var(--t-92400e)", background: "var(--f-fffbeb)", border: "1px solid var(--b-fde68a)", borderRadius: 8, padding: "8px 10px", marginTop: 8, lineHeight: 1.5 }}>
            <b>Se ven una sola vez.</b> El portal no las guarda de forma legible: si cerrás esta ventana sin anotarlas, hay que generar otra.
            {filas.length > 1 && " También se bajó un archivo con la lista (accesos_agencias_…csv)."}
            {" "}Al entrar por primera vez, cada persona elige su propia contraseña.
          </div>
        </div>
        <div style={{ overflow: "auto", padding: "4px 22px", flex: 1 }}>
          <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
            <tbody>
              {filas.map((f, i) => (
                <tr key={i} style={{ borderBottom: "1px solid var(--b-eef2f6)" }}>
                  <td style={{ padding: "6px 4px", color: "var(--t-64748b)" }}>{f.agencia}</td>
                  <td style={{ padding: "6px 4px" }}>{f.email}</td>
                  <td style={{ padding: "6px 4px", fontFamily: "ui-monospace,Consolas,monospace", fontWeight: 700, whiteSpace: "nowrap" }}>{f.clave}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
        <div style={{ ...S.modalFoot, display: "flex", gap: 8, justifyContent: "flex-end" }}>
          {filas.length > 1 && <button style={S.btnGhostDark} onClick={() => descargarClaves(filas)}>↓ Bajar la lista otra vez</button>}
          <button style={S.btnGhostDark} onClick={copiar}>{copiado ? "✓ Copiado" : "Copiar"}</button>
          <button style={S.btnPrimary} onClick={onClose}>Ya las anoté</button>
        </div>
      </div>
    </div>
  );
}

/* ============================== UI compartida ============================== */
function ConfirmDialog({ msg, etiqueta, tono, onCancel, onConfirm }) {
  return (
    <div style={S.modalWrap} onClick={onCancel}>
      <div style={{ ...S.modal, width: 440 }} onClick={(e) => e.stopPropagation()}>
        <div style={S.modalHead}>
          <div style={{ fontWeight: 800, fontSize: 16 }}>Confirmar</div>
          <button style={S.btnGhostDark} onClick={onCancel}>✕</button>
        </div>
        <div style={{ ...S.modalBody, fontSize: 14, color: "var(--t-334155)" }}>{msg}</div>
        <div style={S.modalFoot}>
          <button style={S.btnGhostDark} onClick={onCancel}>Cancelar</button>
          <button style={tono === "primario" ? S.btnPrimary : S.btnDanger} onClick={onConfirm}>{etiqueta || "Confirmar"}</button>
        </div>
      </div>
    </div>
  );
}
function Tabs({ tabs, tab, setTab, compacto }) {
  return (
    <div style={compacto ? { ...S.tabs, marginBottom: 8 } : S.tabs}>
      {tabs.map(([k, label]) => (
        <button key={k} onClick={() => setTab(k)} style={{ ...S.tab, ...(tab === k ? S.tabActive : {}) }}>{label}</button>
      ))}
    </div>
  );
}
function Section({ title, sub, children }) {
  return (
    <div>
      <h2 style={S.h2}>{title}</h2>
      {sub && <p style={S.sub}>{sub}</p>}
      <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>{children}</div>
    </div>
  );
}
/* ============================================================================
 * TABLA DE BUQUES DEL PANEL DE AGENCIAS
 *
 * Una fila por buque, con las columnas en el orden del viaje: de dónde vino y
 * cuándo entró, a dónde fue y cuándo salió. `acciones` es una función para que
 * cada pestaña ponga sus botones (confirmar/rechazar en pendientes, reclamar en
 * rechazados, ninguno en confirmados).
 * ==========================================================================*/
function TablaAgencia({ lista, acciones, mostrarEstado, listaAjena, ocultas }) {
  // Igual que en el panel de ENAPRO: ocultar una columna es sólo no incluirla
  // acá, sin repetir la fila entera. Las `fijo` no se pueden destildar.
  const visibles = colsAgencia(mostrarEstado, listaAjena).filter((c) => c.fijo || !(ocultas || {})[c.clave]);

  const CELDAS = {
    /* Debajo del nombre, la marca de "lo reclama otra agencia".
     *
     * Va acá y no en una columna de Observaciones a propósito: los comentarios
     * del movimiento tienen cosas que NO son para la agencia —medido sobre los
     * pendientes de hoy: de 4 con comentario, 2 decían qué agencia lo había
     * rechazado antes, que es justo lo que el portal esconde—. Esto muestra
     * únicamente el reclamo, y sólo cuando existe: la fila sin reclamo queda
     * igual que siempre y no se gasta ancho de tabla.
     *
     * El dato lo calcula el servidor con la misma función que arma la frase del
     * correo, así la pantalla y el mail dicen siempre lo mismo. */
    buque: {
      estilo: S.tdAg,
      dibujo: (s) => (
        <>
          <span style={{ fontWeight: 700, letterSpacing: ".02em" }} title={s.buque}>{nombreLimpio(s.buque)}</span>
          {s.reclamaOtra && (
            <div
              style={{ marginTop: 3, display: "inline-flex", alignItems: "center", gap: 4,
                       background: "var(--f-fef3c7)", color: "var(--t-92400e)", border: "1px solid var(--b-fcd34d)",
                       borderRadius: 4, padding: "1px 6px", fontSize: 11, fontWeight: 700,
                       whiteSpace: "nowrap" }}
              title={"La agencia " + s.reclamaOtra + " dice que este movimiento es suyo. "
                     + "Prefectura lo registró a nombre tuyo, así que ENAPRO te lo consulta igual: "
                     + "si no es tuyo, marcalo con “No es mío”."}>
              ⚠ lo reclama {s.reclamaOtra}
            </div>
          )}
        </>
      ),
    },
    estado: { estilo: S.tdAg, dibujo: (s) => <Badge estado={s.estado} compacto /> },
    band: { estilo: S.tdAg, dibujo: (s) => s.band || "—" },
    entrada: { estilo: { ...S.tdAg, ...S.numAg }, dibujo: (s) => (s.entrada ? fmt(s.entrada) : "—") },
    salida: { estilo: { ...S.tdAg, ...S.numAg }, dibujo: (s) => fmt(s.salida) },
    procedencia: { estilo: { ...S.tdAg, ...S.viaAgFuerte }, dibujo: (s) => s.procedencia || "—" },
    destino: { estilo: { ...S.tdAg, ...S.viaAgFuerte }, dibujo: (s) => s.destino || "—" },
    motivo: {
      estilo: S.tdAg,
      dibujo: (s) => (s.estado === "Rechazado"
        ? <span style={S.pillAusente} title="Otra agencia dijo que no era suyo">lo devolvió otra agencia</span>
        : <span style={S.pillNueva} title="Prefectura no informó la agencia">sin agencia informada</span>),
    },
    zona: { estilo: S.tdAg, dibujo: (s) => s.zona || "—" },
    muelle: { estilo: { ...S.tdAgWrap, ...S.numAg }, dibujo: (s) => s.muelle || "—" },
    pasavante: { estilo: { ...S.tdAg, ...S.numAg }, dibujo: (s) => s.pasavante || "—" },
    adjuntos: {
      estilo: S.tdAg,
      dibujo: (s) => (
        <>
          {s.itc && <a href={`/descargas/adjunto/${s.id}/itc`} style={{ color: "var(--t-166534)", fontSize: 12.5 }} title={s.itc.nombre}>ITC</a>}
          {s.itc && s.cert_cabotaje ? " · " : null}
          {s.cert_cabotaje && <a href={`/descargas/adjunto/${s.id}/cabotaje`} style={{ color: "var(--t-b45309)", fontSize: 12.5 }} title={s.cert_cabotaje.nombre}>Cabotaje</a>}
          {!s.itc && !s.cert_cabotaje && <span style={{ color: "var(--t-94a3b8)" }}>—</span>}
        </>
      ),
    },
  };

  return (
    <div style={S.tableWrapFijo}>
      <table style={S.tablaAg}>
        <thead>
          <tr>
            {visibles.map((c) => <th key={c.clave} style={S.thAg}>{c.label}</th>)}
            {acciones && <th style={S.thAg}></th>}
          </tr>
        </thead>
        <tbody>
          {lista.map((s) => (
            <tr key={s.id}>
              {visibles.map((c) => <td key={c.clave} style={CELDAS[c.clave].estilo}>{CELDAS[c.clave].dibujo(s)}</td>)}
              {acciones && <td style={S.tdAg}><div style={S.accAg}>{acciones(s)}</div></td>}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function ShipCard({ s, children, readOnly, ocultarAgencia }) {
  return (
    <div style={S.card}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
            <span style={{ fontWeight: 800, fontSize: 16 }} title={s.buque}>{nombreLimpio(s.buque)}</span>
            <Badge estado={s.estado} />
          </div>
          <div style={S.meta}>
            <span>🏳️ {s.band || "—"}</span>
            <span>📍 {s.zona || "—"} · {s.muelle || "—"}</span>
            {/* Orden natural del viaje: primero de dónde viene y cuándo entró,
                después a dónde va y cuándo salió. */}
            {s.entrada && <span>⚓ Entrada: {fmt(s.entrada)}</span>}
            <span>🚢 Salida: {fmt(s.salida)}</span>
            {s.procedencia && <span>← Proc.: {s.procedencia}</span>}
            {s.destino && <span>→ Destino: {s.destino}</span>}
            {s.trn && <span>TRN: {s.trn}</span>}
            {s.imo && <span>IMO: {s.imo}</span>}
            {s.itc && <span style={{ color: "var(--t-166534)" }}>📎 ITC: <a href={`/descargas/adjunto/${s.id}/itc`} style={{ color: "var(--t-166534)" }}>{s.itc.nombre}</a></span>}
            {s.cert_cabotaje && <span style={{ color: "var(--t-b45309)" }}>📎 Cert. cabotaje: <a href={`/descargas/adjunto/${s.id}/cabotaje`} style={{ color: "var(--t-b45309)" }}>{s.cert_cabotaje.nombre}</a></span>}
          </div>
          {s.comentario && <div style={S.comment}>💬 {s.comentario}</div>}
        </div>
      </div>
      {!readOnly && children}
    </div>
  );
}
function Badge({ estado, compacto }) {
  const c = ESTADO_COLOR[estado] || ESTADO_COLOR["No corresponde"];
  const p = compacto
    ? { padding: "2px 7px", fontSize: 10.5, gap: 4 }
    : { padding: "3px 10px", fontSize: 12, gap: 6 };
  return (
    <span style={{ background: c.bg, color: c.fg, borderRadius: 20, fontWeight: 700, display: "inline-flex", alignItems: "center", whiteSpace: "nowrap", ...p }}>
      <span style={{ width: 6, height: 6, borderRadius: "50%", background: c.dot, flexShrink: 0 }} />{estado}
    </span>
  );
}
function Field({ label, children }) {
  return (
    <div style={{ marginBottom: 12 }}>
      <label style={S.lab}>{label}</label>
      {children}
    </div>
  );
}
function Empty({ msg }) { return <div style={S.empty}>{msg}</div>; }

/* Lista de avisos de datos raros de un movimiento. */
function ListaAvisos({ avisos, compacta }) {
  if (!avisos || !avisos.length) return null;
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
      {avisos.map((a, i) => (
        <div key={i} style={{ display: "flex", gap: 7, alignItems: "flex-start", fontSize: compacta ? 12.5 : 13 }}>
          <span style={{ flexShrink: 0, ...(a.nivel === "alta" ? S.pillAvisoAlta : S.pillAvisoMedia), cursor: "default" }}>
            {a.nivel === "alta" ? "revisar" : "ojo"}
          </span>
          <span>{a.texto}</span>
        </div>
      ))}
    </div>
  );
}

/* Modal con el detalle de los avisos, para abrir desde la tabla. */
function AvisosModal({ s, onClose, onEditar }) {
  return (
    <div style={S.modalWrap} onClick={onClose}>
      <div style={{ ...S.modal, width: 620 }} onClick={(e) => e.stopPropagation()}>
        <div style={S.modalHead}>
          <div>
            <div style={{ fontWeight: 800, fontSize: 17 }} title={s.buque}>Datos para revisar — {nombreLimpio(s.buque)}</div>
            <div style={{ fontSize: 12.5, color: "var(--t-64748b)" }}>
              Salida {fmt(s.salida)} · entrada {fmt(s.entrada)} · {s.agencia} · {s.zona || "sin zona"}
              {s.filaExcel ? " · fila " + s.filaExcel + " del Excel" : ""}
            </div>
          </div>
          <button style={S.btnGhostDark} onClick={onClose}>✕</button>
        </div>
        <div style={S.modalBody}>
          <ListaAvisos avisos={s.avisos} />
          <div style={{ fontSize: 12.5, color: "var(--t-64748b)", marginTop: 14 }}>
            Son avisos, no bloqueos: podés publicar el movimiento igual. Si corregís el dato el aviso desaparece solo, y en cuanto le pongas un estado desde Acciones dejan de mostrarse en la fila (si más adelante aparece un problema nuevo, ese sí vuelve a avisar).
          </div>
        </div>
        <div style={S.modalFoot}>
          <button style={S.btnGhostDark} onClick={onClose}>Cerrar</button>
          <button style={S.btnPrimary} onClick={() => { onClose(); onEditar(s); }}>Corregir ahora</button>
        </div>
      </div>
    </div>
  );
}

/* ============================== ESTILOS ============================== */
/* ============================ ESTILO ============================
 * Estilo elegido el 23/09/2026 después de mirar cinco maquetas: la mezcla del
 * ingreso "planilla" (tarjeta sobria, sin barra de título) con la pantalla de
 * buques "moderna" (bordes redondeados, sombras suaves, barra de arriba clara).
 *
 * Reglas del estilo, para que lo que se agregue de acá en más no desentone:
 *   · fondo de la pantalla #f6f7fb, las cosas van en tarjetas blancas;
 *   · nada de bordes duros: 1px #e9ecf2 y una sombra muy baja;
 *   · radios: 10 en campos y botones, 13 en tarjetas, 15 en paneles grandes;
 *   · el rojo del ENAPRO es acento, NO fondo: subrayado de la pestaña activa,
 *     el logo y poco más. El color fuerte de los botones es el azul oscuro;
 *   · los estados son pastillas de color suave con el texto del mismo color.
 *
 * LO QUE NO CAMBIÓ: ninguna función. Los filtros, las columnas, la vista
 * compacta y los avisos son los mismos; esto es solamente cómo se ve. */
/* ---- DE DONDE SALEN LOS COLORES ----
 * Desde el 24/09/2026 ningun color esta escrito acá: todos son variables CSS
 * que se definen en index.html, con DOS juegos de valores — uno para el modo
 * claro y otro para el modo noche. Acá se las nombra así:
 *     var(--f-xxxxxx)   el color cuando hace de FONDO
 *     var(--b-xxxxxx)   cuando hace de BORDE
 *     var(--t-xxxxxx)   cuando hace de LETRA
 * y "xxxxxx" es el color de siempre, el del modo claro. Es a propósito: leyendo
 * var(--f-15263c) se sabe que en modo claro eso es #15263c, igual que antes.
 *
 * El mismo color puede tener tres valores de noche distintos segun el papel que
 * cumpla: #fff de fondo se oscurece, pero #fff de letra sigue siendo blanco.
 *
 * PARA AGREGAR UN COLOR NUEVO: definí la variable en index.html en los dos
 * juegos. Si escribís el color directo acá, va a quedar igual en modo noche.
 *
 * Azules que se usan seguido:
 *   --f-15263c  azul oscuro de los botones y el toast
 *   --f-1f4b7a  azul del encabezado de las tablas (las tres)
 *   --b-173a60  la linea debajo de ese encabezado */
const S = {
  app: { minHeight: "100vh", background: "var(--f-f6f7fb)", fontFamily: "'Inter','Segoe UI',system-ui,sans-serif", color: "var(--t-1c2430)" },
  // Antes el contenido estaba encerrado en 1240 px y la tabla del admin no
  // entraba: había que deslizar para los costados. Ahora se usa todo el ancho
  // de la pantalla y la tabla reparte las columnas en porcentaje.
  body: { width: "100%", boxSizing: "border-box", margin: "0 auto", padding: "22px 18px 60px" },
  /* La barra de arriba ahora es BLANCA. Era azul oscura y ocupaba mucho peso
   * visual para lo poco que dice; el color fuerte quedó para el logo y para el
   * subrayado de la pestaña en la que estás. */
  topbar: { background: "var(--f-ffffff)", color: "var(--t-1c2430)", padding: "11px 22px", display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8 },
  loginWrap: { minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--f-e9edf3)", fontFamily: "'Inter','Segoe UI',system-ui,sans-serif", padding: 20 },
  /* padding abajo en 0 y overflow hidden: es lo que deja que la franja de la
     marca toque los dos bordes y se recorte con la curva de las esquinas. */
  loginCard: { background: "var(--f-ffffff)", borderRadius: 16, padding: "32px 34px 0", width: 430, maxWidth: "100%", border: "1px solid var(--b-dfe4ec)", overflow: "hidden", boxShadow: "0 2px 4px rgba(16,24,40,.04),0 24px 48px -24px rgba(16,24,40,.28)" },
  /* Franja con el degradé de la marca (amarillo → violeta de la lámina
     institucional). Los 26px de margen de arriba reemplazan al padding que se
     le sacó a la tarjeta. */
  barraMarca: { height: 5, marginTop: 26, marginLeft: -34, marginRight: -34,
                background: DEGRADE_MARCA },
  // La misma franja, más fina, cerrando la barra de arriba del portal.
  barraTope: { height: 3, background: DEGRADE_MARCA },
  brandRow: { display: "flex", alignItems: "center", gap: 14, marginBottom: 22, paddingBottom: 20, borderBottom: "1px solid var(--b-eef1f6)" },
  anchor: { fontSize: 34, width: 56, height: 56, borderRadius: 14, background: "var(--f-15263c)", color: "var(--t-ffffff)", display: "flex", alignItems: "center", justifyContent: "center" },
  brandTitle: { fontWeight: 800, fontSize: 17, color: "var(--t-15263c)", letterSpacing: "-.2px" },
  brandSub: { fontSize: 12, color: "var(--t-6f7d90)", marginTop: 4, lineHeight: 1.45 },
  lab: { display: "block", fontSize: 12, fontWeight: 700, color: "var(--t-3d4757)", marginBottom: 6 },
  input: { width: "100%", boxSizing: "border-box", padding: "10px 13px", border: "1px solid var(--b-d9dfe8)", borderRadius: 10, fontSize: 13.5, marginBottom: 14, outline: "none", fontFamily: "inherit", background: "var(--f-fbfcfe)", color: "var(--t-1c2430)" },
  select: { padding: "9px 12px", border: "1px solid var(--b-d9dfe8)", borderRadius: 10, fontSize: 13.5, background: "var(--f-ffffff)", fontFamily: "inherit", color: "var(--t-1c2430)" },
  selectSm: { padding: "5px 9px", border: "1px solid var(--b-e0e4ec)", borderRadius: 8, fontSize: 12.5, background: "var(--f-ffffff)", fontFamily: "inherit" },
  btnPrimary: { background: "var(--f-15263c)", color: "var(--t-ffffff)", border: "none", padding: "10px 18px", borderRadius: 10, fontWeight: 700, fontSize: 13.5, cursor: "pointer", fontFamily: "inherit" },
  btnDanger: { background: "var(--f-ffffff)", color: "var(--t-dc2626)", border: "1px solid var(--b-fca5a5)", padding: "10px 18px", borderRadius: 10, fontWeight: 700, fontSize: 13.5, cursor: "pointer", fontFamily: "inherit" },
  btnWarn: { background: "var(--f-ffffff)", color: "var(--t-b45309)", border: "1px solid var(--b-fcd34d)", padding: "10px 18px", borderRadius: 10, fontWeight: 700, fontSize: 13.5, cursor: "pointer", fontFamily: "inherit" },
  btnMini: { background: "var(--f-ffffff)", color: "var(--t-3d4757)", border: "1px solid var(--b-e4e8f0)", padding: "5px 12px", borderRadius: 8, fontWeight: 600, fontSize: 12, cursor: "pointer", fontFamily: "inherit", whiteSpace: "nowrap" },
  btnMiniDanger: { background: "var(--f-fef2f2)", color: "var(--t-dc2626)", border: "1px solid var(--b-fca5a5)", padding: "5px 12px", borderRadius: 8, fontWeight: 700, fontSize: 12, cursor: "pointer", fontFamily: "inherit", whiteSpace: "nowrap" },
  /* btnGhost vive en la barra de arriba, que ahora es blanca: dejó de ser un
   * botón traslúcido sobre azul y pasó a ser una pastilla gris clara. */
  btnGhost: { background: "var(--f-f1f3f8)", color: "var(--t-4b5666)", border: "1px solid var(--b-e9ecf2)", padding: "6px 13px", borderRadius: 22, fontWeight: 600, fontSize: 12.5, cursor: "pointer", fontFamily: "inherit" },
  btnGhostDark: { background: "var(--f-ffffff)", color: "var(--t-4b5666)", border: "1px solid var(--b-e4e8f0)", padding: "9px 16px", borderRadius: 10, fontWeight: 700, fontSize: 13.5, cursor: "pointer", fontFamily: "inherit", display: "inline-block" },
  demoBox: { marginTop: 18, background: "var(--f-f4f8fd)", border: "1px solid var(--b-dbe7f5)", borderRadius: 12, padding: "12px 14px", fontSize: 13 },
  demoRow: { display: "flex", justifyContent: "space-between", gap: 8, marginBottom: 5, color: "var(--t-334155)" },
  code: { background: "var(--f-ffffff)", border: "1px solid var(--b-cbd5e1)", borderRadius: 6, padding: "1px 6px", fontSize: 12, cursor: "pointer", color: "var(--t-15263c)" },
  /* Pestañas: la activa se levanta del fondo (queda del color de la pantalla,
   * como una carpeta abierta) y lleva el subrayado rojo del ENAPRO. */
  tabs: { display: "flex", gap: 5, marginBottom: 20, flexWrap: "wrap", borderBottom: "1px solid var(--b-e9ecf2)" },
  tab: { background: "transparent", border: "none", borderRadius: "9px 9px 0 0", padding: "10px 15px", fontWeight: 600, fontSize: 12.5, color: "var(--t-6b7686)", cursor: "pointer", fontFamily: "inherit" },
  tabActive: { color: "var(--t-111827)", fontWeight: 800, background: "var(--f-f6f7fb)", boxShadow: "inset 0 -3px 0 #E8354A" },
  h2: { fontSize: 19, fontWeight: 800, margin: "0 0 4px" },
  sub: { fontSize: 13.5, color: "var(--t-64748b)", margin: "0 0 18px", maxWidth: 1100, lineHeight: 1.5 },
  card: { background: "var(--f-ffffff)", borderRadius: 13, padding: "16px 18px", boxShadow: "0 1px 2px rgba(16,24,40,.05),0 8px 24px -14px rgba(16,24,40,.18)", border: "1px solid var(--b-eef1f6)" },
  meta: { display: "flex", flexWrap: "wrap", gap: "6px 22px", marginTop: 8, fontSize: 13, color: "var(--t-475569)" },
  // Grilla de datos para las fichas de altas de agencia: columnas alineadas.
  metaGrid: { display: "grid", gridTemplateColumns: "repeat(auto-fill,minmax(175px,235px))", gap: "6px 18px", marginTop: 8, fontSize: 13, color: "var(--t-475569)", maxWidth: 1200 },
  comment: { marginTop: 10, fontSize: 13, color: "var(--t-475569)", background: "var(--f-f8fafc)", borderRadius: 8, padding: "7px 11px" },
  cardActions: { display: "flex", gap: 10, marginTop: 14, flexWrap: "wrap" },
  empty: { background: "var(--f-ffffff)", borderRadius: 12, padding: "40px 20px", textAlign: "center", color: "var(--t-94a3b8)", fontSize: 14, border: "1px dashed var(--b-cbd5e1)" },
  formGrid: { display: "grid", gridTemplateColumns: "repeat(auto-fill,minmax(220px,1fr))", gap: "0 16px" },
  itcBox: { background: "var(--f-fffbeb)", border: "1px solid var(--b-fcd34d)", borderRadius: 10, padding: "12px 14px" },
  aviso: { background: "var(--f-fffbeb)", border: "1px solid var(--b-fcd34d)", borderRadius: 10, padding: "10px 12px", fontSize: 12.5, color: "var(--t-78350f)", marginBottom: 12 },
  dataBox: { display: "flex", flexWrap: "wrap", gap: "6px 18px", background: "var(--f-f8fafc)", border: "1px solid var(--b-e2e8f0)", borderRadius: 10, padding: "12px 14px", fontSize: 13, color: "var(--t-334155)" },
  sortBar: { display: "flex", gap: 10, flexWrap: "wrap" },
  filterCol: { display: "flex", flexDirection: "column", gap: 4 },
  filterLab: { fontSize: 11.5, fontWeight: 700, color: "var(--t-64748b)", textTransform: "uppercase", letterSpacing: ".3px" },
  filterRow: { display: "flex", gap: 10, marginBottom: 14, flexWrap: "wrap" },
  modalWrap: { position: "fixed", inset: 0, background: "rgba(21,38,60,.42)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 50, padding: 20 },
  modal: { background: "var(--f-ffffff)", borderRadius: 16, width: 580, maxWidth: "100%", maxHeight: "90vh", overflow: "auto", boxShadow: "0 30px 70px rgba(16,24,40,.35)" },
  modalHead: { display: "flex", justifyContent: "space-between", alignItems: "flex-start", padding: "18px 20px", borderBottom: "1px solid var(--b-eef1f6)", gap: 10 },
  modalBody: { padding: "16px 20px" },
  modalFoot: { display: "flex", justifyContent: "flex-end", gap: 10, padding: "14px 20px", borderTop: "1px solid var(--b-eef1f6)" },
  statRow: { display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(140px,1fr))", gap: 11, marginBottom: 16 },
  stat: { background: "var(--f-ffffff)", borderRadius: 13, padding: "13px 15px", border: "1px solid var(--b-eef1f6)", boxShadow: "0 1px 2px rgba(16,24,40,.05),0 8px 24px -14px rgba(16,24,40,.18)" },
  /* ---- Tabla del panel de agencias (variante elegida el 04/09/2026) ----
   * Reemplaza las fichas de una por una. Los cinco o veinte buques pendientes
   * entran en pantalla sin scrollear y las columnas quedan alineadas.
   * Decisiones que vinieron con el diseño:
   *   · sin emojis como separadores de dato — eran lo que más ruido hacía;
   *   · encabezados en mayúscula chica, que es la etiqueta del dato;
   *   · números en monoespaciada con `tabular-nums`, así 448,2 y las fechas se
   *     leen en columna;
   *   · los tres botones NO pesan igual: confirmar es lo que se hace siempre y
   *     va sólido, las excepciones quedan calladas al costado.
   * La tipografía (Inter, la de la marca del ENAPRO) viaja DENTRO de la carpeta, en
   * public/vendor/fuente: no se baja de internet, así que el portal sigue
   * abriendo igual en una máquina sin conexión. Si esos archivos faltaran, la
   * lista de fontFamily cae en 'Segoe UI' y se ve como antes. */
  tiraDatos: { display: "flex", flexWrap: "wrap", gap: "8px 26px", background: "var(--f-f8fafc)", border: "1px solid var(--b-e2e8f0)", borderRadius: 9, padding: "11px 14px", marginBottom: 16 },
  tiraPar: { display: "flex", flexDirection: "column", gap: 1 },
  tiraEt: { fontSize: 10.5, fontWeight: 700, letterSpacing: ".085em", textTransform: "uppercase", color: "var(--t-94a3b8)" },

  tablaAg: { width: "100%", borderCollapse: "collapse", fontSize: 13.5, background: "var(--f-ffffff)" },
  thAg: {
    textAlign: "left", padding: "12px", background: "var(--f-494963)",
    fontSize: 10.5, fontWeight: 700, letterSpacing: ".07em", textTransform: "uppercase",
    color: "var(--t-ffffff)", whiteSpace: "nowrap",
    // Mismo criterio que thFija: pegado arriba, fondo opaco y la línea por
    // boxShadow porque el borde se pierde al quedar sticky.
    position: "sticky", top: 0, zIndex: 3, boxShadow: "inset 0 -1px 0 var(--b-3a3a50), 0 3px 6px -3px rgba(16,24,40,.28)",
  },
  tdAg: { padding: "12px", borderBottom: "1px solid var(--b-f3f5f9)", verticalAlign: "middle", whiteSpace: "nowrap" },
  tdAgWrap: { padding: "12px", borderBottom: "1px solid var(--b-f3f5f9)", verticalAlign: "middle" },
  numAg: { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", fontVariantNumeric: "tabular-nums" },
  accAg: { display: "flex", gap: 6, justifyContent: "flex-end" },
  btnTablaPrim: { background: "var(--f-15263c)", color: "var(--t-ffffff)", border: "1px solid var(--b-15263c)", padding: "7px 14px", borderRadius: 9, fontWeight: 700, fontSize: 12.5, cursor: "pointer", fontFamily: "inherit", whiteSpace: "nowrap" },
  btnTablaSec: { background: "var(--f-ffffff)", color: "var(--t-6b7686)", border: "1px solid var(--b-e4e8f0)", padding: "7px 12px", borderRadius: 9, fontWeight: 600, fontSize: 12, cursor: "pointer", fontFamily: "inherit", whiteSpace: "nowrap" },
  viaAg: { color: "var(--t-64748b)" },
  viaAgFuerte: { color: "var(--t-0f172a)", fontWeight: 500 },

  tableWrap: { background: "var(--f-ffffff)", borderRadius: 15, overflowX: "auto", border: "1px solid var(--b-eef1f6)", boxShadow: "0 1px 2px rgba(16,24,40,.05),0 8px 24px -14px rgba(16,24,40,.18)" },
  /* Igual que tableWrap pero SIN overflow, y esto no es un detalle: `overflowX`
   * convierte al div en su propio contenedor de scroll, y dentro de un
   * contenedor de scroll un `position: sticky` se pega al borde de ESE div, no
   * al de la pantalla. Como el div crece con la tabla y nunca hace scroll por
   * dentro, el encabezado nunca se pegaría a nada. Sin overflow, el que
   * scrollea es la página y el encabezado sí queda fijo arriba.
   * Las tablas anchas (minWidth 1180/1200) desbordan y en ese caso el scroll
   * horizontal pasa a ser el de la página, que es un buen intercambio: en una
   * tabla de 500 filas importa mucho más no perder el encabezado. */
  tableWrapFijo: { background: "var(--f-ffffff)", borderRadius: 15, border: "1px solid var(--b-eef1f6)", boxShadow: "0 1px 2px rgba(16,24,40,.05),0 8px 24px -14px rgba(16,24,40,.18)" },
  table: { width: "100%", borderCollapse: "collapse", fontSize: 13 },
  // La tabla del admin usa ancho fijo por porcentaje (tableLayout: "fixed") para
  // que las 15 columnas entren siempre en pantalla sin scroll horizontal.
  tableFija: { width: "100%", minWidth: 1180, tableLayout: "fixed", borderCollapse: "collapse", fontSize: 12.5 },
  /* Encabezado azul con letra blanca (elegido el 23/09/2026). En blanco con
   * letra gris clara costaba leerlo y no se separaba de las filas. El azul es
   * AZUL_ENC, un tono solo para esto: no compite con el rojo del ENAPRO de la
   * pestaña activa ni con los colores de los estados, que son los que tienen
   * que saltar en la tabla. */
  th: { textAlign: "left", padding: "12px", background: "var(--f-494963)", color: "var(--t-ffffff)", fontWeight: 700, fontSize: 10.5, textTransform: "uppercase", letterSpacing: ".7px", borderBottom: "1px solid var(--b-3a3a50)", whiteSpace: "nowrap" },
  /* Encabezado pegado arriba: al scrollear una tabla de cientos de filas se
   * sigue viendo a qué columna pertenece cada dato.
   * · va en el `th`, no en el `thead`: es lo que funciona en todos los casos.
   * · el fondo TIENE que ser opaco, si no las filas se ven por detrás.
   * · con borderCollapse el borde de abajo se pierde al quedar pegado, así que
   *   la línea se dibuja con un boxShadow interior. */
  thFija: {
    /* Sin letter-spacing y a 10px: con 17 columnas de ancho fijo, "PROCEDENCIA"
     * y "COMENTARIOS" se partían en dos renglones. */
    textAlign: "left", padding: "9px 6px", background: "var(--f-494963)", color: "var(--t-ffffff)", fontWeight: 700,
    fontSize: 10, letterSpacing: 0, textTransform: "uppercase", lineHeight: 1.3,
    whiteSpace: "normal", overflowWrap: "break-word",
    position: "sticky", top: 0, zIndex: 3, boxShadow: "inset 0 -1px 0 var(--b-3a3a50), 0 3px 6px -3px rgba(16,24,40,.28)",
  },
  td: { padding: "10px 12px", whiteSpace: "nowrap", verticalAlign: "top" },
  // Celdas que envuelven el texto en lugar de estirar la tabla.
  tdFija: { padding: "6px 7px", verticalAlign: "top", whiteSpace: "normal", overflowWrap: "break-word", lineHeight: 1.3 },
  tdFijaNw: { padding: "6px 7px", verticalAlign: "top", whiteSpace: "nowrap", lineHeight: 1.3 },
  pillNew: { background: "var(--f-fef3c7)", color: "var(--t-92400e)", fontSize: 11, fontWeight: 700, padding: "3px 8px", borderRadius: 12 },
  pillSync: { background: "var(--f-ede9fe)", color: "var(--t-5b21b6)", fontSize: 10.5, fontWeight: 700, padding: "2px 7px", borderRadius: 12, marginLeft: 6, whiteSpace: "nowrap" },
  pillAusente: { background: "var(--f-fee2e2)", color: "var(--t-991b1b)", fontSize: 10.5, fontWeight: 700, padding: "2px 7px", borderRadius: 12, marginLeft: 6, whiteSpace: "nowrap" },
  pillTipo: { background: "var(--f-e0f2fe)", color: "var(--t-075985)", fontSize: 11, fontWeight: 800, padding: "3px 8px", borderRadius: 6, whiteSpace: "nowrap" },
  // Altas cargadas por las agencias: distinción visual en el panel ENAPRO.
  pillAgencia: { background: "var(--f-marca-violeta)", color: "var(--t-marca-violeta)", fontSize: 10, fontWeight: 800, padding: "2px 6px", borderRadius: 12, whiteSpace: "nowrap", border: "1px solid var(--b-marca-violeta)" },
  pillDup: { background: "var(--f-marca-naranja)", color: "var(--t-marca-naranja)", fontSize: 10, fontWeight: 800, padding: "2px 6px", borderRadius: 12, whiteSpace: "nowrap", border: "1px solid var(--b-marca-naranja)", cursor: "pointer" },
  pillOk: { background: "var(--f-dcfce7)", color: "var(--t-166534)", fontSize: 10, fontWeight: 800, padding: "2px 6px", borderRadius: 12, whiteSpace: "nowrap", border: "1px solid var(--b-86efac)" },
  filaAgencia: { background: "var(--f-marca-violeta)", boxShadow: "inset 3px 0 0 var(--marca-violeta)" },
  // Novedades que entraron del Excel y todavía no ve la agencia.
  filaNueva: { background: "var(--f-fffbeb)", boxShadow: "inset 3px 0 0 #d97706" },
  pillNueva: { background: "var(--f-fef3c7)", color: "var(--t-92400e)", fontSize: 10, fontWeight: 800, padding: "2px 6px", borderRadius: 12, whiteSpace: "nowrap", border: "1px solid var(--b-fcd34d)" },
  pillAvisoAlta: { background: "var(--f-marca-carmin)", color: "var(--t-marca-carmin)", fontSize: 10, fontWeight: 800, padding: "2px 6px", borderRadius: 12, whiteSpace: "nowrap", border: "1px solid var(--b-marca-carmin)", cursor: "pointer" },
  pillAvisoMedia: { background: "var(--f-marca-amarillo)", color: "var(--t-marca-amarillo)", fontSize: 10, fontWeight: 800, padding: "2px 6px", borderRadius: 12, whiteSpace: "nowrap", border: "1px solid var(--b-marca-amarillo)", cursor: "pointer" },
  avisoFila: { background: "var(--f-fff7ed)", fontSize: 12.5, color: "var(--t-7c2d12)", padding: "8px 12px 10px" },
  tableRegistro: { width: "100%", minWidth: 1200, tableLayout: "fixed", borderCollapse: "collapse", fontSize: 12.5 },
  panelColumnas: { background: "var(--f-ffffff)", border: "1px solid var(--b-e2e8f0)", borderRadius: 10, padding: "12px 14px", marginBottom: 12, boxShadow: "0 2px 8px rgba(15,41,66,.08)" },
  banner: { background: "var(--f-fffbeb)", border: "1px solid var(--b-fcd34d)", borderRadius: 12, padding: "14px 16px", marginBottom: 16, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 14, flexWrap: "wrap" },
  bannerInfo: { background: "var(--f-eff6ff)", border: "1px solid var(--b-bfdbfe)", borderRadius: 12, padding: "12px 16px", marginBottom: 16, fontSize: 13.5, color: "var(--t-1e40af)", display: "flex", alignItems: "center", gap: 10 },
  modalAncho: { background: "var(--f-ffffff)", borderRadius: 14, width: 1000, maxWidth: "100%", maxHeight: "92vh", overflow: "auto", boxShadow: "0 20px 60px rgba(0,0,0,.3)" },
  cmpTabla: { width: "100%", borderCollapse: "collapse", fontSize: 12.5, marginTop: 8 },
  cmpTh: { textAlign: "left", padding: "6px 9px", background: "var(--f-f8fafc)", color: "var(--t-64748b)", fontWeight: 700, fontSize: 11, textTransform: "uppercase", borderBottom: "1px solid var(--b-e2e8f0)" },
  cmpTd: { padding: "6px 9px", borderBottom: "1px solid var(--b-f1f5f9)", verticalAlign: "top" },
  nivelChip: {
    igual: { background: "var(--f-dcfce7)", color: "var(--t-166534)" },
    parecido: { background: "var(--f-fef3c7)", color: "var(--t-92400e)" },
    distinto: { background: "var(--f-fee2e2)", color: "var(--t-991b1b)" },
    sin_dato: { background: "var(--f-f1f5f9)", color: "var(--t-64748b)" },
  },
  nivelCaja: {
    muy_alta: { background: "var(--f-fee2e2)", border: "1px solid var(--b-fca5a5)", color: "var(--t-991b1b)" },
    alta: { background: "var(--f-ffedd5)", border: "1px solid var(--b-fdba74)", color: "var(--t-9a3412)" },
    media: { background: "var(--f-fef9c3)", border: "1px solid var(--b-fde047)", color: "var(--t-854d0e)" },
  },
  toast: { position: "fixed", bottom: 24, left: "50%", transform: "translateX(-50%)", background: "var(--f-15263c)", color: "var(--t-ffffff)", padding: "12px 22px", borderRadius: 12, fontSize: 13.5, fontWeight: 600, boxShadow: "0 12px 34px rgba(16,24,40,.35)", zIndex: 99, maxWidth: "90vw" },
};

ReactDOM.createRoot(document.getElementById("raiz")).render(<App />);
