/* ============================================================
   Nodos — Estado, salud y configuración de la red ANPR.

   Incluye (propuestas de valor — el operador decide cuáles quedan):
     #1 Cobertura de corredores / puntos ciegos
     #2 Alta de nodo (formulario)
     #3 Estado de mantenimiento (afecta cómo Inferencia lee el silencio)
     #5 Calidad de lectura OCR por nodo
     #6 Detalle de nodo + tendencia de disponibilidad 7 días

   Pensado como GET /api/nodes/health + POST /api/nodes.
   ============================================================ */

// ── Métricas operativas derivadas de forma determinista por nodo ──
const NODO_STATUS = {
  active:      { label: "Operativo",      color: "var(--g-signal-green)",    dot: "var(--g-signal-green)" },
  alert:       { label: "Con alerta",     color: "var(--g-signal-red-soft)", dot: "var(--g-signal-red)" },
  inactive:    { label: "Inactivo",       color: "var(--g-text-mute-dark)",  dot: "var(--g-text-mute-dark)" },
  maintenance: { label: "Mantenimiento",  color: "var(--g-signal-yellow)",   dot: "var(--g-signal-yellow)" },
};

// ── 12 municipios (orden alfabético) + prefijo de código ──
const NODO_MUNICIPIOS = [
  { id: "armenia",    nombre: "Armenia",    pref: "ARM" },
  { id: "buenavista", nombre: "Buenavista", pref: "BUE" },
  { id: "calarca",    nombre: "Calarcá",    pref: "CAL" },
  { id: "circasia",   nombre: "Circasia",   pref: "CIR" },
  { id: "cordoba",    nombre: "Córdoba",    pref: "COR" },
  { id: "filandia",   nombre: "Filandia",   pref: "FIL" },
  { id: "genova",     nombre: "Génova",     pref: "GEN" },
  { id: "tebaida",    nombre: "La Tebaida", pref: "TEB" },
  { id: "montenegro", nombre: "Montenegro", pref: "MON" },
  { id: "pijao",      nombre: "Pijao",      pref: "PIJ" },
  { id: "quimbaya",   nombre: "Quimbaya",   pref: "QUI" },
  { id: "salento",    nombre: "Salento",    pref: "SAL" },
];
// Entidades territoriales para el directorio = los 12 municipios + el nivel DEPARTAMENTAL.
// (Los puestos de control siguen siendo municipales; esto es solo para contactos/grupos.)
const NODO_ENTIDADES = [
  { id: "quindio", nombre: "Departamento del Quindío", pref: "DEP" },
  ...NODO_MUNICIPIOS,
];

/* ────────────────────────────────────────────────────────────
   Piezas reutilizables
   ──────────────────────────────────────────────────────────── */

const NodoKpi = ({ icon, label, value, sub, accent }) => (
  <div className="surface" style={{ padding: "16px 18px", background: "var(--g-surface-on-dark)" }}>
    <div style={{ display: "flex", alignItems: "center", gap: 8, color: "var(--g-text-mute-dark)" }}>
      <window.Icon name={icon} size={15} color={accent || "var(--accent, var(--g-celadon))"}/>
      <span style={{ fontSize: 11, letterSpacing: 0.4, textTransform: "uppercase", fontWeight: 600 }}>{label}</span>
    </div>
    <div style={{ fontSize: 28, fontWeight: 600, color: "var(--g-text-cream)", marginTop: 8, letterSpacing: -0.5, fontVariantNumeric: "tabular-nums" }}>
      {value}
    </div>
    {sub && <div style={{ fontSize: 11.5, color: "var(--g-text-mute-dark)", marginTop: 2 }}>{sub}</div>}
  </div>
);

/* ────────────────────────────────────────────────────────────
   #2 — Alta de nodo (modal con formulario)
   ──────────────────────────────────────────────────────────── */
const NAltaSelect = ({ value, onChange, children }) => (
  <div style={{ position: "relative" }}>
    <select className="input reg-select" value={value} onChange={e => onChange(e.target.value)}
      style={{ width: "100%", appearance: "none", paddingRight: 38, cursor: "pointer" }}>
      {children}
    </select>
    <window.Icon name="chevron-down" size={16} style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", color: "var(--g-text-mute-dark)", pointerEvents: "none" }}/>
  </div>
);

const NODO_HORARIOS = [
  { value: "24 horas",                 desc: "Siempre activo" },
  { value: "Diurno · 06:00–18:00",     desc: "Activo de día" },
  { value: "Nocturno · 18:00–06:00",   desc: "Activo de noche" },
];

// Determina si un puesto está activo ahora según su horario.
const horarioActivoAhora = (horario) => {
  const h = new Date().getHours();
  if (horario.startsWith("Diurno")) return h >= 6 && h < 18;
  if (horario.startsWith("Nocturno")) return h >= 18 || h < 6;
  return true; // 24 horas
};

const NodoAlta = ({ onClose, onCreate }) => {
  const [form, setForm] = React.useState({
    municipio: "armenia",
    nombre: "", lat: "", lon: "",
    horario: NODO_HORARIOS[0].value,
  });
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
  const mun = NODO_MUNICIPIOS.find(x => x.id === form.municipio);
  // Código consecutivo de puestos (PC-01, PC-02…).
  const nextPc = window.SONAR_NODES.filter(n => n.kind === "puesto").length + 1;
  const codigo = `PC-${String(nextPc).padStart(2, "0")}`;

  const latN = parseFloat(form.lat), lonN = parseFloat(form.lon);
  const nombreOk = form.nombre.trim().length >= 2;
  const latOk = !isNaN(latN) && latN >= 4.0 && latN <= 5.0;     // rango Quindío
  const lonOk = !isNaN(lonN) && lonN >= -76.2 && lonN <= -75.2;
  const valid = nombreOk && latOk && lonOk;

  const submit = (e) => {
    e.preventDefault();
    if (!valid) return;
    const maxN = window.SONAR_NODES.reduce((mx, n) => Math.max(mx, parseInt((n.id.match(/\d+/) || [0])[0], 10)), 0);
    const id = "N" + String(maxN + 1).padStart(2, "0");
    const { x, y } = window.SONAR_geoToXY(latN, lonN);
    const activo = horarioActivoAhora(form.horario);
    const node = {
      id, codigo, kind: "puesto",
      nombre: form.nombre.trim(), alias: "Puesto de control fijo",
      municipio: form.municipio, via: null,
      lat: latN, lon: lonN, horario: form.horario,
      x, y, status: activo ? "active" : "inactive", activity: activo ? 28 : 0,
    };
    window.SONAR_NODES.push(node);
    onCreate(node);
  };

  return (
    <div onClick={onClose} style={{
      position: "absolute", inset: 0, zIndex: 70,
      background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
      display: "grid", placeItems: "center", padding: 24,
    }}>
      <form onClick={e => e.stopPropagation()} onSubmit={submit} className="surface fade-in" style={{
        width: "min(500px, 100%)", background: "var(--s-panel)", padding: 24,
        boxShadow: "0 30px 70px rgba(0,0,0,0.5)",
      }}>
        <div style={{ display: "flex", alignItems: "center", gap: 9, marginBottom: 4 }}>
          <window.Icon name="shield-plus" size={18} color="var(--g-signal-yellow)"/>
          <h2 style={{ fontSize: 19, fontWeight: 600, color: "var(--g-text-cream)" }}>Puesto de control fijo</h2>
          <button type="button" className="btn btn--ghost" style={{ marginLeft: "auto", padding: "5px 8px" }} onClick={onClose}>
            <window.Icon name="x" size={16}/>
          </button>
        </div>
        <p style={{ fontSize: 12.5, color: "var(--g-text-mute-dark)", marginBottom: 18, lineHeight: 1.5 }}>
          Registra un puesto fijo de policía como nodo de detección. Ubícalo con coordenadas y define su horario; aparecerá en el mapa (atenuado si está fuera de servicio).
        </p>

        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
          <label style={{ display: "flex", flexDirection: "column", gap: 7 }}>
            <span style={{ fontSize: 11.5, fontWeight: 600, color: "var(--g-text-cream)" }}>Municipio</span>
            <NAltaSelect value={form.municipio} onChange={v => set("municipio", v)}>
              {NODO_MUNICIPIOS.map(mn => <option key={mn.id} value={mn.id}>{mn.nombre}</option>)}
            </NAltaSelect>
          </label>
          <label style={{ display: "flex", flexDirection: "column", gap: 7 }}>
            <span style={{ fontSize: 11.5, fontWeight: 600, color: "var(--g-text-cream)" }}>Código (auto)</span>
            <div className="input" style={{ display: "flex", alignItems: "center", color: "var(--g-signal-yellow)", fontWeight: 600, letterSpacing: 1.2 }}>{codigo}</div>
          </label>
          <label style={{ display: "flex", flexDirection: "column", gap: 7, gridColumn: "1 / -1" }}>
            <span style={{ fontSize: 11.5, fontWeight: 600, color: "var(--g-text-cream)" }}>Nombre del puesto <span style={{ color: "var(--accent)" }}>*</span></span>
            <input className="input" value={form.nombre} onChange={e => set("nombre", e.target.value)} placeholder="Ej. Peaje Corozal · Salida sur"/>
          </label>
          <label style={{ display: "flex", flexDirection: "column", gap: 7 }}>
            <span style={{ fontSize: 11.5, fontWeight: 600, color: "var(--g-text-cream)" }}>Latitud <span style={{ color: "var(--accent)" }}>*</span></span>
            <input className="input" value={form.lat} onChange={e => set("lat", e.target.value)} placeholder="4.5089" inputMode="decimal"
              style={{ borderColor: form.lat && !latOk ? "var(--g-signal-red)" : undefined }}/>
          </label>
          <label style={{ display: "flex", flexDirection: "column", gap: 7 }}>
            <span style={{ fontSize: 11.5, fontWeight: 600, color: "var(--g-text-cream)" }}>Longitud <span style={{ color: "var(--accent)" }}>*</span></span>
            <input className="input" value={form.lon} onChange={e => set("lon", e.target.value)} placeholder="-75.6431" inputMode="decimal"
              style={{ borderColor: form.lon && !lonOk ? "var(--g-signal-red)" : undefined }}/>
          </label>
          <label style={{ display: "flex", flexDirection: "column", gap: 7, gridColumn: "1 / -1" }}>
            <span style={{ fontSize: 11.5, fontWeight: 600, color: "var(--g-text-cream)" }}>Horario de servicio</span>
            <NAltaSelect value={form.horario} onChange={v => set("horario", v)}>
              {NODO_HORARIOS.map(h => <option key={h.value} value={h.value}>{h.value} — {h.desc}</option>)}
            </NAltaSelect>
          </label>
        </div>

        <button type="submit" className="btn btn--primary" disabled={!valid} style={{
          width: "100%", justifyContent: "center", padding: "12px", marginTop: 20,
          opacity: valid ? 1 : 0.5, cursor: valid ? "pointer" : "not-allowed",
        }}>
          <window.Icon name="shield-plus" size={16}/> Crear puesto {codigo}
        </button>
      </form>
    </div>
  );
};

/* ════════════════════════════════════════════════════════════
   Pantalla Nodos
   ════════════════════════════════════════════════════════════ */
const nodTabStyle = (active) => ({
  display: "inline-flex", alignItems: "center", gap: 8,
  padding: "9px 15px",
  borderRadius: "var(--g-radius-pill)",
  border: `1px solid ${active ? "var(--accent, var(--g-celadon))" : "var(--g-border-on-dark)"}`,
  background: active ? "rgba(79, 212, 255, 0.1)" : "transparent",
  color: active ? "var(--g-text-cream)" : "var(--g-text-mute-dark)",
  fontFamily: "var(--font-sans)", fontSize: 13, fontWeight: 600, letterSpacing: 0.2,
  cursor: "pointer", transition: "all 150ms var(--ease-emphasised)",
});
// Segmento conectado (on/off) — tres variantes vía tweak.
const nodSegStyle = (active, variant = "solido") => {
  const base = {
    display: "inline-flex", alignItems: "center", gap: 9,
    fontFamily: "var(--font-sans)", fontSize: 16, fontWeight: 700, letterSpacing: 0.2,
    cursor: "pointer", transition: "all 160ms var(--ease-emphasised)",
  };
  if (variant === "contorno") {
    return {
      ...base, padding: "11px 24px", borderRadius: "var(--g-radius-pill)",
      border: `1.5px solid ${active ? "var(--accent, var(--g-celadon))" : "transparent"}`,
      background: "transparent",
      color: active ? "var(--accent, var(--g-celadon))" : "var(--g-text-mute-dark)",
    };
  }
  if (variant === "subrayado") {
    return {
      ...base, padding: "10px 22px 12px", borderRadius: 0,
      border: 0, borderBottom: `3px solid ${active ? "var(--accent, var(--g-celadon))" : "transparent"}`,
      background: "transparent",
      color: active ? "var(--g-text-cream)" : "var(--g-text-mute-dark)",
    };
  }
  // solido (default)
  return {
    ...base, padding: "12px 24px", borderRadius: "var(--g-radius-pill)",
    border: `1px solid ${active ? "var(--accent, var(--g-celadon))" : "transparent"}`,
    background: active ? "var(--accent, var(--g-celadon))" : "transparent",
    color: active ? "#04141b" : "var(--g-text-mute-dark)",
  };
};
const nodTabBadge = (active) => ({
  fontSize: 11, fontWeight: 600, fontVariantNumeric: "tabular-nums",
  padding: "1px 7px", borderRadius: 999,
  background: active ? "rgba(4, 20, 27, 0.22)" : "rgba(127,134,142,0.18)",
  color: active ? "#04141b" : "var(--g-text-mute-dark)",
});

/* ─── Directorio de contactos operativos ────────────────────── */
const NODO_CANAL_META = {
  whatsapp: { label: "WhatsApp", color: "var(--g-signal-whatsapp)", icon: "message-circle" },
  sms:      { label: "SMS · PDA", color: "var(--accent, var(--g-celadon))", icon: "smartphone" },
};
const NodoDirectorio = ({ nuevoContacto = false, onCloseNuevo, solicitarGrupo = false, onCloseSolicitar, onCountChange }) => {
  const [contactos, setContactos] = React.useState(() => window.SONAR_CONTACTOS || []);
  const [groups, setGroups] = React.useState(() => window.SONAR_CONTACT_GROUPS || []);
  const [editing, setEditing] = React.useState(null);     // contacto en edición
  const [grupoEdit, setGrupoEdit] = React.useState(null); // categoría {id?, nombre, icono} o null
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState("");
  const [msg, setMsg] = React.useState("");               // aviso de éxito (solicitud enviada, etc.)
  const [q, setQ] = React.useState("");                   // búsqueda de contactos
  const [canalFiltro, setCanalFiltro] = React.useState("todos"); // todos | whatsapp | sms

  // Refresca contactos + categorías desde la nube y sincroniza los globales que
  // el resto del proyecto lee de forma síncrona (candado, switch, etc.).
  const reload = React.useCallback(async () => {
    try {
      const [cs, gs] = await Promise.all([
        window.SonarAPI.getContacts(),
        window.SonarAPI.getContactGroups(),
      ]);
      window.SONAR_CONTACTOS = cs || [];
      window.SONAR_CONTACT_GROUPS = gs || [];
      setContactos(window.SONAR_CONTACTOS);
      setGroups(window.SONAR_CONTACT_GROUPS);
      if (onCountChange) onCountChange(window.SONAR_CONTACTOS.length);
    } catch (e) { /* conserva lo cacheado */ }
  }, [onCountChange]);
  React.useEffect(() => { reload(); }, [reload]);

  // Guardar contacto (alta o edición) → backend → recarga.
  const saveContact = async (form) => {
    setBusy(true); setErr("");
    const payload = {
      grupo: form.grupo, nombre: (form.nombre || "").trim(), rol: form.rol,
      municipio: form.municipio, tel: form.tel, canal: form.canal,
    };
    try {
      if (form.id != null && !form.isNew) await window.SonarAPI.updateContact(form.id, payload);
      else await window.SonarAPI.addContact(payload);
      await reload();
      setEditing(null);
      if (onCloseNuevo) onCloseNuevo();
    } catch (e) { setErr("No se pudo guardar el contacto."); }
    setBusy(false);
  };
  const deleteContact = async (c) => {
    setBusy(true); setErr("");
    try { await window.SonarAPI.removeContact(c.id); await reload(); setEditing(null); }
    catch (e) { setErr("No se pudo eliminar el contacto."); }
    setBusy(false);
  };

  // Guardar categoría (alta o rename) → backend → recarga. Renombrar propaga a sus contactos.
  const saveGrupo = async (g) => {
    setBusy(true); setErr("");
    try {
      if (g.id != null) await window.SonarAPI.updateContactGroup(g.id, { nombre: (g.nombre || "").trim(), icono: g.icono });
      else await window.SonarAPI.addContactGroup({ nombre: (g.nombre || "").trim(), icono: g.icono });
      await reload();
      setGrupoEdit(null);
    } catch (e) {
      setErr(/409/.test(String(e && e.message)) ? "Ya existe una categoría con ese nombre." : "No se pudo guardar la categoría.");
    }
    setBusy(false);
  };
  const deleteGrupo = async (g) => {
    const usados = contactos.filter(c => c.grupo === g.nombre).length;
    if (usados) { setErr(`"${g.nombre}" tiene ${usados} contacto(s); muévelos o elimínalos primero.`); return; }
    setBusy(true); setErr("");
    try { await window.SonarAPI.removeContactGroup(g.id); await reload(); }
    catch (e) { setErr("No se pudo eliminar la categoría."); }
    setBusy(false);
  };

  // Solicitar ingreso a un GRUPO de WhatsApp → lo crea pendiente + avisa al admin.
  const requestGrupoWA = async (form) => {
    setBusy(true); setErr(""); setMsg("");
    try {
      const r = await window.SonarAPI.requestWhatsappGroup({
        grupo: form.grupo, nombre: (form.nombre || "").trim(), municipio: form.municipio,
      });
      await reload();
      if (onCloseSolicitar) onCloseSolicitar();
      const tel = r && r.admin && r.admin.telefono;
      const sim = r && r.whatsapp && r.whatsapp.simulated;
      setMsg(`Solicitud registrada. ${sim ? "Aviso al administrador SIMULADO (falta API de WhatsApp)" : "Se avisó al administrador"}${tel ? ` (${tel})` : ""}. El grupo quedó pendiente hasta que confirmes que Sonar ya fue agregado.`);
    } catch (e) { setErr("No se pudo registrar la solicitud del grupo."); }
    setBusy(false);
  };
  // Confirmación manual: "sí, Sonar ya fue agregado al grupo" → queda activo (guardado).
  const confirmGrupo = async (c) => {
    setBusy(true); setErr(""); setMsg("");
    try { await window.SonarAPI.confirmContact(c.id); await reload(); setMsg(`«${c.nombre}» confirmado: ya recibe notificaciones de candado.`); }
    catch (e) { setErr("No se pudo confirmar el grupo."); }
    setBusy(false);
  };

  const blankContact = () => ({
    grupo: (groups[0] && groups[0].nombre) || "Comando Policía",
    nombre: "", rol: "", municipio: "quindio", tel: "", canal: "whatsapp",
    isNew: true,
  });
  const blankGrupoWA = () => ({
    grupo: (groups[0] && groups[0].nombre) || "Comando Policía",
    nombre: "", municipio: "quindio",
  });

  // Icono / nombre por id de categoría o municipio.
  const grupoIcono = (nombre) => (groups.find(x => x.nombre === nombre) || {}).icono || "users";
  const muniNombre = (id) => (NODO_ENTIDADES.find(x => x.id === id) || {}).nombre || "Sin municipio";
  // Filtro de contactos: por canal + búsqueda (nombre, rol, categoría, municipio, teléfono).
  const term = q.trim().toLowerCase();
  const filtered = contactos.filter(c => {
    if (canalFiltro !== "todos" && c.canal !== canalFiltro) return false;
    if (term && !`${c.nombre} ${c.rol || ""} ${c.grupo || ""} ${muniNombre(c.municipio)} ${c.tel || ""}`.toLowerCase().includes(term)) return false;
    return true;
  }).sort((a, b) => muniNombre(a.municipio).localeCompare(muniNombre(b.municipio)) || (a.nombre || "").localeCompare(b.nombre || ""));
  const nMunicipios = new Set(contactos.map(c => c.municipio || "_sin")).size;

  return (
    <div className="fade-in">
      <div style={{ display: "flex", gap: 22, alignItems: "flex-start" }}>
        {/* COLUMNA PRINCIPAL: KPIs + búsqueda/filtros + contactos por municipio */}
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(168px, 1fr))", gap: 14, marginTop: 24 }}>
            <NodoKpi icon="contact" label="Contactos" value={contactos.length} sub={`${nMunicipios} municipios`}/>
            <NodoKpi icon="message-circle" label="Por WhatsApp" value={contactos.filter(c => c.canal === "whatsapp").length} sub="canal preferente" accent="var(--g-signal-whatsapp)"/>
            <NodoKpi icon="smartphone" label="Por SMS / PDA" value={contactos.filter(c => c.canal === "sms").length} sub="patrullas en ruta"/>
          </div>

          {err && (
            <div className="fade-in" style={{ marginTop: 16, padding: "10px 13px", borderRadius: 9,
              background: "rgba(229,51,63,0.10)", border: "1px solid rgba(229,51,63,0.35)",
              color: "var(--g-signal-red-soft)", fontSize: 12.5, display: "flex", alignItems: "center", gap: 8 }}>
              <window.Icon name="triangle-alert" size={14}/> <span style={{ flex: 1 }}>{err}</span>
              <button className="btn btn--ghost" style={{ padding: "2px 6px" }} onClick={() => setErr("")}><window.Icon name="x" size={13}/></button>
            </div>
          )}
          {msg && (
            <div className="fade-in" style={{ marginTop: 16, padding: "10px 13px", borderRadius: 9,
              background: "rgba(37,211,102,0.10)", border: "1px solid rgba(37,211,102,0.35)",
              color: "var(--g-text-cream)", fontSize: 12.5, display: "flex", alignItems: "center", gap: 8 }}>
              <window.Icon name="check-circle-2" size={14} color="var(--g-signal-whatsapp)"/> <span style={{ flex: 1 }}>{msg}</span>
              <button className="btn btn--ghost" style={{ padding: "2px 6px" }} onClick={() => setMsg("")}><window.Icon name="x" size={13}/></button>
            </div>
          )}

          {/* Búsqueda + filtros (mismo estilo que Nodos ANPR) */}
          <div style={{ display: "flex", alignItems: "center", gap: 9, flexWrap: "wrap", marginTop: 22 }}>
            {[["todos", "Todos", contactos.length], ["whatsapp", "WhatsApp", contactos.filter(c => c.canal === "whatsapp").length], ["sms", "SMS", contactos.filter(c => c.canal === "sms").length]].map(opt => {
              const on = canalFiltro === opt[0];
              return (
                <button key={opt[0]} onClick={() => setCanalFiltro(opt[0])} style={{
                  display: "inline-flex", alignItems: "center", gap: 7, padding: "7px 13px", borderRadius: "var(--g-radius-pill)",
                  border: `1px solid ${on ? "var(--accent, var(--g-celadon))" : "var(--g-border-on-dark)"}`,
                  background: on ? "rgba(79,212,255,0.1)" : "transparent",
                  color: on ? "var(--g-text-cream)" : "var(--g-text-mute-dark)",
                  fontFamily: "var(--font-sans)", fontSize: 12.5, fontWeight: 600, cursor: "pointer" }}>
                  {opt[1]}<span style={{ fontVariantNumeric: "tabular-nums", opacity: 0.85 }}>{opt[2]}</span>
                </button>
              );
            })}
            <div style={{ marginLeft: "auto", position: "relative" }}>
              <window.Icon name="search" size={14} style={{ position: "absolute", left: 11, top: "50%", transform: "translateY(-50%)", color: "var(--g-text-mute-dark)", pointerEvents: "none" }}/>
              <input className="input" value={q} onChange={e => setQ(e.target.value)} placeholder="Buscar contacto, rol o municipio"
                style={{ width: 270, paddingLeft: 34 }}/>
            </div>
          </div>

          {/* Lista compacta de contactos (tabla · ordenada por municipio) */}
          <div className="surface" style={{ padding: 0, overflow: "hidden", marginTop: 20 }}>
            <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
              <thead>
                <tr>
                  {["Contacto", "Categoría", "Municipio", "Teléfono", "Canal", ""].map((h, k) => (
                    <th key={k} style={{ textAlign: "left", padding: "10px 14px", fontSize: 11, fontWeight: 600,
                      letterSpacing: 0.6, textTransform: "uppercase", color: "var(--g-text-mute-dark)",
                      borderBottom: "1px solid var(--g-border-on-dark)", whiteSpace: "nowrap" }}>{h}</th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {filtered.length === 0 ? (
                  <tr><td colSpan={6} style={{ padding: "16px 14px", color: "var(--g-text-mute-dark)" }}>
                    {contactos.length === 0 ? "Sin contactos todavía." : "Ningún contacto coincide con el filtro."}
                  </td></tr>
                ) : filtered.map(c => {
                  const cm = NODO_CANAL_META[c.canal] || NODO_CANAL_META.whatsapp;
                  const esGrupo = c.tipo === "grupo";
                  const pend = c.estado === "pendiente";
                  return (
                    <tr key={c.id} style={{ borderBottom: "1px solid var(--g-border-on-dark)" }}>
                      <td style={{ padding: "9px 14px" }}>
                        <div style={{ display: "flex", alignItems: "center", gap: 7 }}>
                          <span style={{ color: "var(--g-text-cream)", fontWeight: 500, whiteSpace: "nowrap",
                            overflow: "hidden", textOverflow: "ellipsis", maxWidth: 170, display: "inline-block" }}>{c.nombre}</span>
                          {esGrupo && <span style={{ flexShrink: 0, fontSize: 9, fontWeight: 700, letterSpacing: 0.5, textTransform: "uppercase",
                            padding: "1px 5px", borderRadius: 4, color: "var(--g-signal-whatsapp)", background: "rgba(37,211,102,0.12)" }}>Grupo</span>}
                        </div>
                        {c.rol && <div style={{ fontSize: 11, color: "var(--g-text-mute-dark)", marginTop: 1,
                          whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", maxWidth: 200 }}>{c.rol}</div>}
                      </td>
                      <td style={{ padding: "9px 14px", color: "var(--g-text-mute-dark)", whiteSpace: "nowrap" }}>
                        <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
                          <window.Icon name={grupoIcono(c.grupo)} size={13}/> {c.grupo}
                        </span>
                      </td>
                      <td style={{ padding: "9px 14px", color: "var(--g-text-mute-dark)", whiteSpace: "nowrap" }}>{muniNombre(c.municipio)}</td>
                      <td style={{ padding: "9px 14px", color: "var(--g-text-cream)", fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap" }}>{c.tel}</td>
                      <td style={{ padding: "9px 14px", whiteSpace: "nowrap" }}>
                        {pend ? (
                          <span style={{ display: "inline-flex", alignItems: "center", gap: 5, fontSize: 11.5, fontWeight: 600, color: "var(--g-signal-yellow)" }}>
                            <window.Icon name="clock" size={13} color="var(--g-signal-yellow)"/> Pendiente
                          </span>
                        ) : (
                          <span style={{ display: "inline-flex", alignItems: "center", gap: 5, fontSize: 11.5, fontWeight: 600, color: cm.color }}>
                            <window.Icon name={cm.icon} size={13} color={cm.color}/> {cm.label}
                          </span>
                        )}
                      </td>
                      <td style={{ padding: "6px 12px", textAlign: "right", whiteSpace: "nowrap" }}>
                        {pend && (
                          <button className="btn btn--ghost" title="Confirmar acceso al grupo" disabled={busy}
                            style={{ padding: "5px 7px", color: "var(--g-signal-whatsapp)" }} onClick={() => confirmGrupo(c)}>
                            <window.Icon name="check" size={14}/>
                          </button>
                        )}
                        <button className="btn btn--ghost" title="Editar contacto" style={{ padding: "5px 7px" }} onClick={() => setEditing(c)}>
                          <window.Icon name="pencil" size={14}/>
                        </button>
                      </td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </div>

          <div style={{ fontSize: 11, color: "var(--g-text-mute-dark)", marginTop: 10, display: "flex", alignItems: "center", gap: 6 }}>
            <window.Icon name="info" size={12}/>
            Lista ordenada por municipio. Cada contacto recibe el candado por UN solo canal (WhatsApp o SMS).
          </div>
        </div>

        {/* COLUMNA LATERAL: gestión de categorías (fuera del flujo de contactos) */}
        <aside className="surface" style={{ width: 244, flexShrink: 0, padding: 14, marginTop: 24,
          background: "var(--g-surface-on-dark)", position: "sticky", top: 8 }}>
          <div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 3 }}>
            <window.Icon name="tags" size={14} color="var(--accent, var(--g-celadon))"/>
            <span style={{ fontSize: 13, fontWeight: 600, color: "var(--g-text-cream)" }}>Categorías</span>
            <span className="meta" style={{ marginLeft: "auto" }}>{groups.length}</span>
          </div>
          <div style={{ fontSize: 11, color: "var(--g-text-mute-dark)", marginBottom: 12, lineHeight: 1.4 }}>
            Etiqueta de cada contacto. Renombrar actualiza todo el sistema.
          </div>
          <div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
            {groups.map(g => {
              const used = contactos.filter(c => c.grupo === g.nombre).length;
              return (
                <div key={g.id != null ? g.id : g.nombre} style={{ display: "flex", alignItems: "center", gap: 7,
                  padding: "8px 10px", borderRadius: 9, background: "var(--s-panel)", border: "1px solid var(--g-border-on-dark)" }}>
                  <window.Icon name={g.icono || "users"} size={14} color="var(--g-text-mute-dark)"/>
                  <span style={{ fontSize: 12.5, color: "var(--g-text-cream)", flex: 1, minWidth: 0,
                    whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{g.nombre}</span>
                  <span style={{ fontSize: 11, color: "var(--g-text-mute-dark)", fontVariantNumeric: "tabular-nums" }}>{used}</span>
                  <button className="btn btn--ghost" title="Renombrar" style={{ padding: "3px 5px" }}
                    onClick={() => setGrupoEdit({ id: g.id, nombre: g.nombre, icono: g.icono || "users-round" })}>
                    <window.Icon name="pencil" size={12}/>
                  </button>
                  {used === 0 && (
                    <button className="btn btn--ghost" title="Eliminar categoría vacía" style={{ padding: "3px 5px" }} onClick={() => deleteGrupo(g)}>
                      <window.Icon name="trash-2" size={12}/>
                    </button>
                  )}
                </div>
              );
            })}
          </div>
          <button onClick={() => setGrupoEdit({ nombre: "", icono: "users-round" })} style={{
            marginTop: 11, width: "100%", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 7,
            padding: "9px", borderRadius: 9, cursor: "pointer",
            border: "1px dashed var(--g-border-on-dark)", background: "transparent",
            color: "var(--g-text-mute-dark)", fontFamily: "var(--font-sans)", fontSize: 12.5, fontWeight: 600 }}>
            <window.Icon name="plus" size={14}/> Agregar categoría
          </button>
        </aside>
      </div>

      {(editing || nuevoContacto) && (
        <NodoContactoEdit
          contacto={editing || blankContact()}
          groups={groups}
          busy={busy}
          onDelete={editing ? deleteContact : null}
          onClose={() => { setEditing(null); if (onCloseNuevo) onCloseNuevo(); }}
          onSave={saveContact}
        />
      )}
      {grupoEdit && (
        <NodoGrupoEdit grupo={grupoEdit} busy={busy} onClose={() => setGrupoEdit(null)} onSave={saveGrupo}/>
      )}
      {solicitarGrupo && (
        <NodoGrupoWA grupo={blankGrupoWA()} groups={groups} busy={busy}
          onClose={() => { if (onCloseSolicitar) onCloseSolicitar(); }}
          onRequest={requestGrupoWA}/>
      )}
    </div>
  );
};

/* ─── Editar contacto ───────────────────────────────────────── */
const NodoContactoEdit = ({ contacto, onClose, onSave, onDelete, groups = [], busy = false }) => {
  const [form, setForm] = React.useState({ ...contacto });
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
  const muni = NODO_ENTIDADES.find(m => m.id === form.municipio);
  const isNew = !!contacto.isNew;
  const valid = form.nombre.trim() && !!(form.tel && form.tel.trim());
  return (
    <div onClick={onClose} style={{
      position: "fixed", inset: 0, zIndex: 80,
      background: "rgba(0,0,0,0.6)", backdropFilter: "blur(3px)",
      display: "grid", placeItems: "center", padding: 20,
    }}>
      <div onClick={e => e.stopPropagation()} className="surface" style={{
        width: 460, maxWidth: "94%", padding: 24,
        background: "var(--s-panel)", border: "1px solid var(--g-border-on-dark)",
      }}>
        <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 4 }}>
          <window.Icon name={isNew ? "user-plus" : "pencil"} size={16} color="var(--accent, var(--g-celadon))"/>
          <h3 style={{ fontSize: 18, fontWeight: 600, color: "var(--g-text-cream)", letterSpacing: -0.2, whiteSpace: "nowrap" }}>
            {isNew ? "Nuevo contacto" : "Editar contacto"}
          </h3>
          <button className="btn btn--ghost" style={{ marginLeft: "auto", padding: "5px 8px" }} onClick={onClose}>
            <window.Icon name="x" size={15}/>
          </button>
        </div>
        <div style={{ fontSize: 12, color: "var(--g-text-mute-dark)", marginBottom: 18 }}>
          {isNew ? "Contacto individual (un teléfono). Para vincular un grupo de WhatsApp usa «Solicitar acceso a grupo»." : `${form.grupo}${muni ? ` · ${muni.nombre}` : ""}`}
        </div>

        <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
          <NodoEditField label="Categoría">
            <NAltaSelect value={form.grupo} onChange={v => set("grupo", v)}>
              {groups.map(gr => (
                <option key={gr.id != null ? gr.id : gr.nombre} value={gr.nombre}>{gr.nombre}</option>
              ))}
            </NAltaSelect>
          </NodoEditField>
          <NodoEditField label="Nombre">
            <input className="input" value={form.nombre} onChange={e => set("nombre", e.target.value)} placeholder="Ej. Estación de Policía Filandia"/>
          </NodoEditField>
          <NodoEditField label="Rol / dependencia">
            <input className="input" value={form.rol} onChange={e => set("rol", e.target.value)} placeholder="Ej. Comandante de turno"/>
          </NodoEditField>
          <NodoEditField label="Entidad territorial">
            <NAltaSelect value={form.municipio} onChange={v => set("municipio", v)}>
              {NODO_ENTIDADES.map(m => <option key={m.id} value={m.id}>{m.nombre}</option>)}
            </NAltaSelect>
          </NodoEditField>
          <NodoEditField label="Canal">
            <div style={{ display: "flex", gap: 6 }}>
              {["whatsapp", "sms"].map(opt => {
                const cm = NODO_CANAL_META[opt];
                const on = form.canal === opt;
                return (
                  <button key={opt} onClick={() => set("canal", opt)} style={{
                    flex: 1, display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6,
                    padding: "10px 6px", borderRadius: 9,
                    border: `1px solid ${on ? cm.color : "var(--g-border-on-dark)"}`,
                    background: on ? "rgba(127,134,142,0.10)" : "transparent",
                    color: on ? "var(--g-text-cream)" : "var(--g-text-mute-dark)",
                    fontFamily: "var(--font-sans)", fontSize: 12, fontWeight: 600, cursor: "pointer",
                  }}>
                    <window.Icon name={cm.icon} size={14} color={on ? cm.color : "currentColor"}/>
                    {cm.label}
                  </button>
                );
              })}
            </div>
            <div style={{ fontSize: 11, color: "var(--g-text-mute-dark)", marginTop: 7, lineHeight: 1.45 }}>
              Se notifica el candado <strong style={{ color: "var(--g-text-cream)" }}>solo por {form.canal === "whatsapp" ? "WhatsApp" : "SMS"}</strong>. Un contacto usa un único canal para no saturar el operativo.
            </div>
          </NodoEditField>
          <NodoEditField label="Teléfono">
            <input className="input" value={form.tel} onChange={e => set("tel", e.target.value)}
              placeholder="XXX XXX XXXX"
              style={{ fontVariantNumeric: "tabular-nums", letterSpacing: 1 }}/>
          </NodoEditField>
        </div>

        <div style={{ display: "flex", gap: 10, marginTop: 22 }}>
          {!isNew && onDelete && (
            <button className="btn" title="Eliminar contacto" disabled={busy}
              style={{ padding: "11px 13px", flexShrink: 0, color: "var(--g-signal-red-soft)", borderColor: "rgba(229,51,63,0.4)" }}
              onClick={() => onDelete(form)}>
              <window.Icon name="trash-2" size={15}/>
            </button>
          )}
          <button className="btn" style={{ flex: 1, justifyContent: "center", padding: "11px" }} onClick={onClose}>Cancelar</button>
          <button className="btn btn--primary" style={{ flex: 1, justifyContent: "center", padding: "11px", opacity: valid && !busy ? 1 : 0.5 }}
            disabled={!valid || busy} onClick={() => onSave(form)}>
            <window.Icon name="check" size={15}/> {busy ? "Guardando…" : (isNew ? "Crear contacto" : "Guardar cambios")}
          </button>
        </div>
      </div>
    </div>
  );
};
const NodoEditField = ({ label, children }) => (
  <div>
    <div style={{ fontSize: 11, letterSpacing: 1, textTransform: "uppercase", color: "var(--g-text-mute-dark)", marginBottom: 6 }}>{label}</div>
    {children}
  </div>
);

/* ─── Solicitar acceso a un GRUPO de WhatsApp (flujo separado) ── */
const NodoGrupoWA = ({ grupo, groups = [], onClose, onRequest, busy = false }) => {
  const [form, setForm] = React.useState({ ...grupo });
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
  const valid = (form.nombre || "").trim().length >= 2;
  return (
    <div onClick={onClose} style={{
      position: "fixed", inset: 0, zIndex: 82,
      background: "rgba(0,0,0,0.6)", backdropFilter: "blur(3px)",
      display: "grid", placeItems: "center", padding: 20,
    }}>
      <div onClick={e => e.stopPropagation()} className="surface" style={{
        width: 460, maxWidth: "94%", padding: 24,
        background: "var(--s-panel)", border: "1px solid var(--g-border-on-dark)",
      }}>
        <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 4 }}>
          <window.Icon name="users-round" size={16} color="var(--g-signal-whatsapp)"/>
          <h3 style={{ fontSize: 18, fontWeight: 600, color: "var(--g-text-cream)", letterSpacing: -0.2 }}>
            Solicitar acceso a grupo
          </h3>
          <button className="btn btn--ghost" style={{ marginLeft: "auto", padding: "5px 8px" }} onClick={onClose}>
            <window.Icon name="x" size={15}/>
          </button>
        </div>
        <div style={{ fontSize: 12, color: "var(--g-text-mute-dark)", marginBottom: 18, lineHeight: 1.5 }}>
          Se enviará una solicitud por WhatsApp al administrador para que agregue a Sonar al grupo. Queda <strong style={{ color: "var(--g-signal-yellow)" }}>pendiente</strong> hasta que confirmes que ya fue agregado.
        </div>
        <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
          <NodoEditField label="Nombre del grupo">
            <input className="input" value={form.nombre} autoFocus onChange={e => set("nombre", e.target.value)} placeholder="Ej. Cuadrantes Armenia · Candado"/>
          </NodoEditField>
          <NodoEditField label="Categoría">
            <NAltaSelect value={form.grupo} onChange={v => set("grupo", v)}>
              {groups.map(gr => <option key={gr.id != null ? gr.id : gr.nombre} value={gr.nombre}>{gr.nombre}</option>)}
            </NAltaSelect>
          </NodoEditField>
          <NodoEditField label="Entidad territorial">
            <NAltaSelect value={form.municipio} onChange={v => set("municipio", v)}>
              {NODO_ENTIDADES.map(m => <option key={m.id} value={m.id}>{m.nombre}</option>)}
            </NAltaSelect>
          </NodoEditField>
          <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 11.5, color: "var(--g-text-mute-dark)",
            padding: "10px 12px", borderRadius: 9, background: "rgba(37,211,102,0.07)", border: "1px solid rgba(37,211,102,0.25)" }}>
            <window.Icon name="message-circle" size={14} color="var(--g-signal-whatsapp)"/>
            <span>El canal es <strong style={{ color: "var(--g-text-cream)" }}>WhatsApp</strong> (grupo). No requiere número.</span>
          </div>
        </div>
        <div style={{ display: "flex", gap: 10, marginTop: 22 }}>
          <button className="btn" style={{ flex: 1, justifyContent: "center", padding: "11px" }} onClick={onClose}>Cancelar</button>
          <button className="btn btn--primary" style={{ flex: 1, justifyContent: "center", padding: "11px", opacity: valid && !busy ? 1 : 0.5 }}
            disabled={!valid || busy} onClick={() => onRequest(form)}>
            <window.Icon name="send" size={15}/> {busy ? "Enviando…" : "Solicitar y agregar"}
          </button>
        </div>
      </div>
    </div>
  );
};

/* ─── Crear / renombrar categoría del directorio ────────────── */
const GRUPO_ICONOS = ["building-2", "car", "search", "users-round", "shield", "radio", "phone", "siren"];
const NodoGrupoEdit = ({ grupo, onClose, onSave, busy = false }) => {
  const [form, setForm] = React.useState({ ...grupo });
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
  const isNew = grupo.id == null;
  const valid = (form.nombre || "").trim().length >= 2;
  return (
    <div onClick={onClose} style={{
      position: "fixed", inset: 0, zIndex: 85,
      background: "rgba(0,0,0,0.6)", backdropFilter: "blur(3px)",
      display: "grid", placeItems: "center", padding: 20,
    }}>
      <div onClick={e => e.stopPropagation()} className="surface" style={{
        width: 420, maxWidth: "94%", padding: 24,
        background: "var(--s-panel)", border: "1px solid var(--g-border-on-dark)",
      }}>
        <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 4 }}>
          <window.Icon name={isNew ? "folder-plus" : "pencil"} size={16} color="var(--accent, var(--g-celadon))"/>
          <h3 style={{ fontSize: 18, fontWeight: 600, color: "var(--g-text-cream)", letterSpacing: -0.2 }}>
            {isNew ? "Nueva categoría" : "Renombrar categoría"}
          </h3>
          <button className="btn btn--ghost" style={{ marginLeft: "auto", padding: "5px 8px" }} onClick={onClose}>
            <window.Icon name="x" size={15}/>
          </button>
        </div>
        <div style={{ fontSize: 12, color: "var(--g-text-mute-dark)", marginBottom: 18 }}>
          Se guarda en la nube.{!isNew && " Renombrarla actualiza también todos sus contactos."}
        </div>
        <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
          <NodoEditField label="Nombre">
            <input className="input" value={form.nombre} autoFocus onChange={e => set("nombre", e.target.value)} placeholder="Ej. Bomberos · Tránsito · Ejército"/>
          </NodoEditField>
          <NodoEditField label="Icono">
            <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
              {GRUPO_ICONOS.map(ic => {
                const on = form.icono === ic;
                return (
                  <button key={ic} onClick={() => set("icono", ic)} style={{
                    width: 40, height: 40, borderRadius: 10, display: "grid", placeItems: "center", cursor: "pointer",
                    border: `1px solid ${on ? "var(--accent, var(--g-celadon))" : "var(--g-border-on-dark)"}`,
                    background: on ? "rgba(79,212,255,0.12)" : "transparent",
                    color: on ? "var(--g-text-cream)" : "var(--g-text-mute-dark)",
                  }}>
                    <window.Icon name={ic} size={17}/>
                  </button>
                );
              })}
            </div>
          </NodoEditField>
        </div>
        <div style={{ display: "flex", gap: 10, marginTop: 22 }}>
          <button className="btn" style={{ flex: 1, justifyContent: "center", padding: "11px" }} onClick={onClose}>Cancelar</button>
          <button className="btn btn--primary" style={{ flex: 1, justifyContent: "center", padding: "11px", opacity: valid && !busy ? 1 : 0.5 }}
            disabled={!valid || busy} onClick={() => onSave(form)}>
            <window.Icon name="check" size={15}/> {busy ? "Guardando…" : (isNew ? "Crear categoría" : "Guardar")}
          </button>
        </div>
      </div>
    </div>
  );
};

/* ─── Tabla de nodos ANPR (densa · filtrable · SOLO datos reales) ──────────────
   Muestra únicamente lo que el backend sabe de verdad: estado (heartbeat),
   capturas de hoy (conteo real) y última captura (timestamp real). Sin métricas
   inventadas (disponibilidad/OCR/sparkline del prototipo). */
const fmtAgo = (iso) => {
  if (!iso) return "—";
  const ms = Date.now() - new Date(iso).getTime();
  if (ms < 45000) return "hace segundos";
  const min = Math.round(ms / 60000);
  if (min < 60) return `hace ${min} min`;
  const h = Math.round(min / 60);
  if (h < 24) return `hace ${h} h`;
  return `hace ${Math.round(h / 24)} d`;
};
const NodoTabla = ({ nodes, onMap, initialQuery = "" }) => {
  const [filtro, setFiltro] = React.useState("todos");   // todos | operativos | sin_conexion
  const [q, setQ] = React.useState(initialQuery || "");
  // Búsqueda automática (p. ej. desde el "ver más" del mapa, por código).
  React.useEffect(() => { if (initialQuery) { setQ(initialQuery); setFiltro("todos"); } }, [initialQuery]);
  const muniName = (id) => (NODO_MUNICIPIOS.find(m => m.id === id) || {}).nombre || id;
  const nOperativos = nodes.filter(n => n.status === "active").length;
  const nSinConexion = nodes.length - nOperativos;
  const term = q.trim().toLowerCase();
  const rows = nodes.filter(n => {
    if (filtro === "operativos" && n.status !== "active") return false;
    if (filtro === "sin_conexion" && n.status === "active") return false;
    if (term && !`${n.codigo} ${n.nombre} ${muniName(n.municipio)}`.toLowerCase().includes(term)) return false;
    return true;
  });
  const chip = (id, label, count) => {
    const on = filtro === id;
    return (
      <button onClick={() => setFiltro(id)} style={{
        display: "inline-flex", alignItems: "center", gap: 7, padding: "7px 13px", borderRadius: "var(--g-radius-pill)",
        border: `1px solid ${on ? "var(--accent, var(--g-celadon))" : "var(--g-border-on-dark)"}`,
        background: on ? "rgba(79,212,255,0.1)" : "transparent",
        color: on ? "var(--g-text-cream)" : "var(--g-text-mute-dark)",
        fontFamily: "var(--font-sans)", fontSize: 12.5, fontWeight: 600, cursor: "pointer",
      }}>{label}<span style={{ fontVariantNumeric: "tabular-nums", opacity: 0.85 }}>{count}</span></button>
    );
  };
  const th = (label, align) => (
    <th style={{ textAlign: align || "left", padding: "11px 14px", fontSize: 11, fontWeight: 600,
      letterSpacing: 0.6, textTransform: "uppercase", color: "var(--g-text-mute-dark)",
      borderBottom: "1px solid var(--g-border-on-dark)", whiteSpace: "nowrap" }}>{label}</th>
  );
  return (
    <div>
      <div style={{ display: "flex", alignItems: "center", gap: 9, flexWrap: "wrap", marginBottom: 14 }}>
        {chip("todos", "Todos", nodes.length)}
        {chip("operativos", "Operativos", nOperativos)}
        {chip("sin_conexion", "Sin conexión", nSinConexion)}
        <div style={{ marginLeft: "auto", position: "relative" }}>
          <window.Icon name="search" size={14} style={{ position: "absolute", left: 11, top: "50%", transform: "translateY(-50%)", color: "var(--g-text-mute-dark)", pointerEvents: "none" }}/>
          <input className="input" value={q} onChange={e => setQ(e.target.value)} placeholder="Buscar código, nodo o municipio"
            style={{ width: 280, paddingLeft: 34 }}/>
        </div>
      </div>
      <div className="surface" style={{ padding: 0, overflow: "hidden" }}>
        <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
          <thead>
            <tr>{th("Estado")}{th("Código")}{th("Nodo")}{th("Municipio")}{th("Capturas hoy", "right")}{th("Última captura")}{th("")}</tr>
          </thead>
          <tbody>
            {rows.length === 0 ? (
              <tr><td colSpan={7} style={{ padding: "18px 14px", color: "var(--g-text-mute-dark)" }}>Sin nodos para este filtro.</td></tr>
            ) : rows.map(n => {
              const esFrontera = n.kind === "frontera";
              const st = NODO_STATUS[n.status] || NODO_STATUS.inactive;
              const activo = n.status === "active";
              const dotColor = esFrontera ? "#FF8A3D" : st.dot;
              return (
                <tr key={n.id} style={{ borderBottom: "1px solid var(--g-border-on-dark)" }}>
                  <td style={{ padding: "11px 14px", whiteSpace: "nowrap" }}>
                    <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
                      <span style={{ width: 8, height: 8, borderRadius: "50%", background: dotColor, flexShrink: 0,
                        boxShadow: activo && !esFrontera ? "0 0 0 3px rgba(46,204,113,0.18)" : "none" }}/>
                      <span style={{ color: esFrontera ? "#FF8A3D" : st.color, fontWeight: 600 }}>{esFrontera ? "Frontera" : (activo ? "Operativo" : "Sin conexión")}</span>
                    </span>
                  </td>
                  <td style={{ padding: "11px 14px", color: "var(--accent, var(--g-celadon))", fontWeight: 600, letterSpacing: 0.4, whiteSpace: "nowrap" }}>{n.codigo}</td>
                  <td style={{ padding: "11px 14px", color: "var(--g-text-cream)" }}>
                    {n.nombre}{n.kind === "puesto" && <span style={{ marginLeft: 6, fontSize: 10, fontWeight: 600, color: "var(--g-signal-yellow)" }}>· puesto</span>}
                    {esFrontera && <span style={{ marginLeft: 6, fontSize: 10, fontWeight: 600, color: "#FF8A3D" }}>· frontera</span>}
                  </td>
                  <td style={{ padding: "11px 14px", color: "var(--g-text-mute-dark)" }}>{muniName(n.municipio) || "—"}</td>
                  <td style={{ padding: "11px 14px", textAlign: "right", fontVariantNumeric: "tabular-nums",
                    color: activo ? "var(--g-text-cream)" : "var(--g-text-mute-dark)" }}>
                    {activo && n.capturasHoy ? n.capturasHoy.toLocaleString("es-CO") : "—"}
                  </td>
                  <td style={{ padding: "11px 14px", color: "var(--g-text-mute-dark)", whiteSpace: "nowrap" }}>
                    {activo ? fmtAgo(n.lastSeen) : "sin conexión"}
                  </td>
                  <td style={{ padding: "8px 14px", textAlign: "right", whiteSpace: "nowrap" }}>
                    <button className="btn btn--ghost" style={{ padding: "6px 10px" }} onClick={() => onMap(n)} title="Ver en el mapa">
                      <window.Icon name="map-pin" size={14}/>
                    </button>
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
    </div>
  );
};

/* ─── Gestión de categorías de marcación (tipo / motivo / autoridad) ──────────
   CRUD en DB (sonar.marcacion_categoria). Al renombrar, avisa cuántos registros
   usan la categoría y los propaga (servicio del backend). */
const MARCA_CLASES = [
  { clase: "tipo",      titulo: "Tipos de marcación", icon: "tag",       sub: "Reportado, Robado, Seguimiento…" },
  { clase: "motivo",    titulo: "Motivos",            icon: "file-text", sub: "razón de la marcación" },
  { clase: "autoridad", titulo: "Autoridades",        icon: "building-2", sub: "entidad de origen del reporte" },
];

const NodoMarcaEdit = ({ entry, busy, onClose, onSave }) => {
  const [nombre, setNombre] = React.useState(entry.nombre || "");
  const [usos, setUsos] = React.useState(null);   // null = consultando / no aplica
  const isNew = entry.id == null;
  React.useEffect(() => {
    if (!isNew) window.SonarAPI.getMarcacionUso(entry.id).then(r => setUsos(r ? r.usos : 0)).catch(() => setUsos(0));
  }, []);
  const valid = nombre.trim().length >= 2;
  const renombra = !isNew && nombre.trim() && nombre.trim() !== entry.nombre;
  return (
    <div onClick={onClose} style={{
      position: "fixed", inset: 0, zIndex: 85,
      background: "rgba(0,0,0,0.6)", backdropFilter: "blur(3px)",
      display: "grid", placeItems: "center", padding: 20,
    }}>
      <div onClick={e => e.stopPropagation()} className="surface" style={{
        width: 440, maxWidth: "94%", padding: 24,
        background: "var(--s-panel)", border: "1px solid var(--g-border-on-dark)",
      }}>
        <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 4 }}>
          <window.Icon name={isNew ? "plus" : "pencil"} size={16} color="var(--accent, var(--g-celadon))"/>
          <h3 style={{ fontSize: 18, fontWeight: 600, color: "var(--g-text-cream)", letterSpacing: -0.2 }}>
            {isNew ? "Nueva categoría" : "Renombrar categoría"}
          </h3>
          <button className="btn btn--ghost" style={{ marginLeft: "auto", padding: "5px 8px" }} onClick={onClose}>
            <window.Icon name="x" size={15}/>
          </button>
        </div>
        <div style={{ fontSize: 12, color: "var(--g-text-mute-dark)", marginBottom: 18 }}>
          Se guarda en la nube y alimenta el detalle de marcación al registrar placas.
        </div>
        <NodoEditField label="Nombre">
          <input className="input" value={nombre} autoFocus onChange={e => setNombre(e.target.value)} placeholder="Ej. Hurto vehículo"/>
        </NodoEditField>
        {renombra && usos != null && usos > 0 && (
          <div className="fade-in" style={{ marginTop: 14, padding: "11px 13px", borderRadius: 9,
            background: "rgba(242,198,29,0.09)", border: "1px solid rgba(242,198,29,0.35)",
            display: "flex", alignItems: "flex-start", gap: 9 }}>
            <window.Icon name="triangle-alert" size={15} color="var(--g-signal-yellow)" style={{ flexShrink: 0, marginTop: 1 }}/>
            <div style={{ fontSize: 12.5, color: "var(--g-text-cream)", lineHeight: 1.45 }}>
              Hay <strong>{usos}</strong> registro(s) con «{entry.nombre}». Al renombrar se <strong>actualizarán automáticamente</strong> al nuevo nombre.
            </div>
          </div>
        )}
        <div style={{ display: "flex", gap: 10, marginTop: 22 }}>
          <button className="btn" style={{ flex: 1, justifyContent: "center", padding: "11px" }} onClick={onClose}>Cancelar</button>
          <button className="btn btn--primary" style={{ flex: 1, justifyContent: "center", padding: "11px", opacity: valid && !busy ? 1 : 0.5 }}
            disabled={!valid || busy} onClick={() => onSave({ clase: entry.clase, id: entry.id, nombre })}>
            <window.Icon name="check" size={15}/> {busy ? "Guardando…" : (isNew ? "Crear" : "Guardar")}
          </button>
        </div>
      </div>
    </div>
  );
};

const NodoMarcaciones = ({ onCountChange }) => {
  const [data, setData] = React.useState(() => window.SONAR_MARCACIONES || { tipo: [], motivo: [], autoridad: [] });
  const [edit, setEdit] = React.useState(null);
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState("");
  const [msg, setMsg] = React.useState("");

  const reload = React.useCallback(async () => {
    try {
      const d = await window.SonarAPI.getMarcaciones();
      window.SONAR_MARCACIONES = d || { tipo: [], motivo: [], autoridad: [] };
      setData(window.SONAR_MARCACIONES);
      if (onCountChange) onCountChange((d.tipo || []).length + (d.motivo || []).length + (d.autoridad || []).length);
    } catch (e) { /* conserva lo cacheado */ }
  }, [onCountChange]);
  React.useEffect(() => { reload(); }, [reload]);

  const save = async (form) => {
    setBusy(true); setErr(""); setMsg("");
    try {
      if (form.id != null) {
        const r = await window.SonarAPI.updateMarcacion(form.id, { nombre: (form.nombre || "").trim() });
        await reload();
        setMsg(`«${(form.nombre || "").trim()}» guardada${r && r.afectados ? ` · ${r.afectados} dato(s) actualizado(s)` : ""}.`);
      } else {
        await window.SonarAPI.addMarcacion({ clase: form.clase, nombre: (form.nombre || "").trim() });
        await reload();
        setMsg("Categoría agregada.");
      }
      setEdit(null);
    } catch (e) {
      setErr(/409/.test(String(e && e.message)) ? "Ya existe una categoría con ese nombre." : "No se pudo guardar la categoría.");
    }
    setBusy(false);
  };
  const del = async (item) => {
    setBusy(true); setErr(""); setMsg("");
    try { await window.SonarAPI.removeMarcacion(item.id); await reload(); }
    catch (e) { setErr(/409/.test(String(e && e.message)) ? "Esa categoría está en uso; renómbrala o reasigna sus datos primero." : "No se pudo eliminar."); }
    setBusy(false);
  };

  const Banner = ({ tone, children, onClose }) => (
    <div className="fade-in" style={{ marginBottom: 16, padding: "10px 13px", borderRadius: 9, fontSize: 12.5,
      display: "flex", alignItems: "center", gap: 8,
      background: tone === "ok" ? "rgba(37,211,102,0.10)" : "rgba(229,51,63,0.10)",
      border: `1px solid ${tone === "ok" ? "rgba(37,211,102,0.35)" : "rgba(229,51,63,0.35)"}`,
      color: tone === "ok" ? "var(--g-text-cream)" : "var(--g-signal-red-soft)" }}>
      <window.Icon name={tone === "ok" ? "check-circle-2" : "triangle-alert"} size={14}
        color={tone === "ok" ? "var(--g-signal-whatsapp)" : "var(--g-signal-red-soft)"}/>
      <span style={{ flex: 1 }}>{children}</span>
      <button className="btn btn--ghost" style={{ padding: "2px 6px" }} onClick={onClose}><window.Icon name="x" size={13}/></button>
    </div>
  );

  return (
    <div className="fade-in" style={{ marginTop: 24 }}>
      {err && <Banner tone="err" onClose={() => setErr("")}>{err}</Banner>}
      {msg && <Banner tone="ok" onClose={() => setMsg("")}>{msg}</Banner>}
      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 16 }}>
        {MARCA_CLASES.map(cfg => {
          const items = data[cfg.clase] || [];
          return (
            <section key={cfg.clase} className="surface" style={{ padding: 16, background: "var(--g-surface-on-dark)" }}>
              <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                <window.Icon name={cfg.icon} size={15} color="var(--accent, var(--g-celadon))"/>
                <span style={{ fontSize: 14, fontWeight: 600, color: "var(--g-text-cream)" }}>{cfg.titulo}</span>
                <span className="meta" style={{ marginLeft: "auto" }}>{items.length}</span>
              </div>
              <div style={{ fontSize: 11.5, color: "var(--g-text-mute-dark)", margin: "3px 0 12px" }}>{cfg.sub}</div>
              <div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
                {items.length === 0 && <div style={{ fontSize: 12, color: "var(--g-text-mute-dark)" }}>Sin categorías.</div>}
                {items.map(it => (
                  <div key={it.id} style={{ display: "flex", alignItems: "center", gap: 6, padding: "8px 11px",
                    borderRadius: 9, background: "var(--s-panel)", border: "1px solid var(--g-border-on-dark)" }}>
                    <span style={{ fontSize: 13, color: "var(--g-text-cream)", flex: 1, minWidth: 0,
                      whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{it.nombre}</span>
                    <button className="btn btn--ghost" title="Renombrar" style={{ padding: "4px 7px" }}
                      onClick={() => setEdit({ clase: cfg.clase, id: it.id, nombre: it.nombre })}>
                      <window.Icon name="pencil" size={13}/>
                    </button>
                    <button className="btn btn--ghost" title="Eliminar" style={{ padding: "4px 7px" }} onClick={() => del(it)}>
                      <window.Icon name="trash-2" size={13}/>
                    </button>
                  </div>
                ))}
              </div>
              <button onClick={() => setEdit({ clase: cfg.clase, nombre: "" })} style={{
                marginTop: 12, width: "100%", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 7,
                padding: "9px", borderRadius: 9, cursor: "pointer",
                border: "1px dashed var(--g-border-on-dark)", background: "transparent",
                color: "var(--g-text-mute-dark)", fontFamily: "var(--font-sans)", fontSize: 12.5, fontWeight: 600 }}>
                <window.Icon name="plus" size={14}/> Agregar
              </button>
            </section>
          );
        })}
      </div>
      <div style={{ fontSize: 11, color: "var(--g-text-mute-dark)", marginTop: 18, display: "flex", alignItems: "center", gap: 6 }}>
        <window.Icon name="info" size={12}/>
        Estas categorías alimentan el detalle de marcación al registrar placas. Al renombrar una, los registros que ya la usan se actualizan solos.
      </div>
      {edit && <NodoMarcaEdit entry={edit} busy={busy} onClose={() => setEdit(null)} onSave={save}/>}
    </div>
  );
};

const NodosScreen = ({ onNavigate, onFocusNode, buscar = "" }) => {
  const [, force] = React.useReducer(x => x + 1, 0);
  const [alta, setAlta] = React.useState(null);            // {open}
  const [tab, setTab] = React.useState("nodos");           // "nodos" | "directorio"
  const [nuevoContacto, setNuevoContacto] = React.useState(false);
  const [solicitarGrupo, setSolicitarGrupo] = React.useState(false);
  const [contactsCount, setContactsCount] = React.useState((window.SONAR_CONTACTOS || []).length);
  const [marcacionCount, setMarcacionCount] = React.useState(() => {
    const m = window.SONAR_MARCACIONES || {};
    return (m.tipo || []).length + (m.motivo || []).length + (m.autoridad || []).length;
  });
  // Si llega una búsqueda por código (desde el "ver más" del mapa), abre la pestaña Nodos.
  React.useEffect(() => { if (buscar) setTab("nodos"); }, [buscar]);

  const nodes = window.SONAR_NODES;
  const statusOf = (n) => n.status;

  const onMap = (node) => { if (onFocusNode) onFocusNode(node); onNavigate("mapa"); };

  // KPIs — red de detección (cámaras + puestos). Las fronteras van aparte (siempre activas).
  const red = nodes.filter(n => n.kind !== "frontera");
  const operativos = red.filter(n => statusOf(n) === "active").length;
  const sinConexion = red.filter(n => statusOf(n) !== "active").length;
  const camaras = nodes.filter(n => n.kind === "camara").length;
  const puestos = nodes.filter(n => n.kind === "puesto").length;
  const fronterasN = nodes.filter(n => n.kind === "frontera").length;
  const capturasHoy = red.reduce((s, n) => s + (n.capturasHoy || 0), 0);

  return (
    <div className="fade-in sonar-main__inner scroll" style={{ height: "100%", overflow: "auto", position: "relative" }}>
      <div style={{ maxWidth: 980, margin: "0 auto", padding: "28px var(--density-pad) 48px" }}>
        {/* Header estándar (eyebrow + título + switch animado + explicación) */}
        <div className="eyebrow eyebrow--accent">Administración · red y contactos</div>
        <h1 style={{ fontSize: 27, fontWeight: 600, letterSpacing: -0.4, color: "var(--g-text-cream)", marginTop: 6 }}>
          Operatividad
        </h1>

        <div style={{ marginTop: 16 }}>
          <window.SonarSegmented value={tab} onChange={setTab} options={[
            { id: "nodos", label: "Nodos ANPR", icon: "router", badge: nodes.length },
            { id: "directorio", label: "Directorio de contactos", icon: "contact", badge: contactsCount },
            { id: "marcacion", label: "Marcación", icon: "tag", badge: marcacionCount },
          ]}/>
        </div>

        {/* Explicación + acción contextual */}
        <div style={{ display: "flex", alignItems: "flex-start", marginTop: 16, gap: 16 }}>
          <p style={{ fontSize: 14, color: "var(--g-text-mute-dark)", lineHeight: 1.55, maxWidth: 640, margin: 0 }}>
            {tab === "nodos"
              ? "Estado y salud de cada cámara de la red, más los puestos de control fijos de policía que también funcionan como nodos de detección."
              : tab === "directorio"
              ? "Teléfonos y canales vinculados a los puntos del plan candado, por entidad territorial: comando de policía, grupos móviles y unidades investigativas."
              : "Categorías del detalle de marcación (tipo, motivo y autoridad) que se usan al registrar placas. Renombrar una actualiza también los registros que ya la usan."}
          </p>
          {tab === "nodos" ? (
            <button className="btn btn--primary" style={{ marginLeft: "auto", padding: "10px 16px", flexShrink: 0 }} onClick={() => setAlta({})}>
              <window.Icon name="shield-plus" size={16}/> Agregar puesto de control
            </button>
          ) : tab === "directorio" ? (
            <div style={{ marginLeft: "auto", display: "flex", gap: 10, flexShrink: 0 }}>
              <button className="btn" style={{ padding: "10px 14px", color: "var(--g-signal-whatsapp)", borderColor: "rgba(37,211,102,0.4)" }}
                onClick={() => setSolicitarGrupo(true)}>
                <window.Icon name="users-round" size={16}/> Solicitar acceso a grupo de WhatsApp
              </button>
              <button className="btn btn--primary" style={{ padding: "10px 16px" }} onClick={() => setNuevoContacto(true)}>
                <window.Icon name="user-plus" size={16}/> Agregar contacto
              </button>
            </div>
          ) : null}
        </div>

      {tab === "nodos" && (<>
        {/* KPIs — reales */}
        <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(168px, 1fr))", gap: 14, marginTop: 24 }}>
          <NodoKpi icon="router" label="Nodos" value={nodes.length}
            sub={`${camaras} cámaras · ${puestos} puestos · ${fronterasN} fronteras`}/>
          <NodoKpi icon="wifi" label="Operativos" value={operativos} sub="con enlace al edge"
            accent="var(--g-signal-green)"/>
          <NodoKpi icon="wifi-off" label="Sin conexión" value={sinConexion}
            sub={sinConexion ? "sin heartbeat" : "todos en línea"}
            accent={sinConexion > 0 ? "var(--g-signal-red-soft)" : undefined}/>
          <NodoKpi icon="activity" label="Capturas hoy" value={capturasHoy.toLocaleString("es-CO")} sub="en toda la red"/>
        </div>

        {/* Tabla de nodos */}
        <div style={{ display: "flex", alignItems: "baseline", margin: "30px 0 14px" }}>
          <div className="eyebrow">Cámaras de la red</div>
          <div className="meta" style={{ marginLeft: "auto" }}>{operativos} operativos de {nodes.length}</div>
        </div>
        <NodoTabla nodes={nodes} onMap={onMap} initialQuery={buscar}/>
      </>)}

      {tab === "directorio" && <NodoDirectorio nuevoContacto={nuevoContacto} onCloseNuevo={() => setNuevoContacto(false)}
        solicitarGrupo={solicitarGrupo} onCloseSolicitar={() => setSolicitarGrupo(false)} onCountChange={setContactsCount}/>}

      {tab === "marcacion" && <NodoMarcaciones onCountChange={setMarcacionCount}/>}
      </div>

      {/* Alta de puesto de control */}
      {alta && (
        <NodoAlta onClose={() => setAlta(null)}
          onCreate={() => { setAlta(null); force(); }}/>
      )}
    </div>
  );
};

window.NodosScreen = NodosScreen;
