/* Asignación Real (operatoria BRAINS) — el producto copiloto.
   Carga la planilla de MELI (XLSX), el motor propone chofer+vehículo por cada ruta con sus reglas,
   y el planificador AJUSTA el chofer final en 1 clic (candidatos válidos ya ordenados). CRÁNEO explica
   cada decisión en vivo. Requiere el backend (dev-server / Docker). window.CR.screens.AsignacionReal */
(function () {
  var React = window.React;
  var ui = window.CR.ui, store = window.CR.store;
  var h = React.createElement;
  var useState = React.useState, useEffect = React.useEffect;

  // Selector integrado para listas operativas largas. Mantiene el orden recibido y permite
  // encontrar por patente, chofer, unidad o motivo sin sacar al planificador de esta pantalla.





  function SalioBackup(props) {
    var r = props.r;
    var ds = useState(''), draft = ds[0], setDraft = ds[1];
    var as2 = useState(false), abierto = as2[0], setAbierto = as2[1];
    var actual = r.salio === 'si' ? 'si' : (r.salio === 'no' ? 'no' : '');
    var idOk = /^\d{4,20}$/.test(draft.trim());
    return h('div', { style: { display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' } },
      h('span', { style: { fontSize: 12, fontWeight: 700 } }, 'Back Up — \u00bfsali\u00f3?'),
      h(ui.Select, {
        value: abierto ? 'si' : actual, disabled: props.busy, placeholder: 'Sin marcar',
        'aria-label': '\u00bfEl Back Up ' + (r.idRuta || '') + ' sali\u00f3?',
        options: [{ value: 'si', label: 'S\u00cd \u2014 sali\u00f3' }, { value: 'no', label: 'NO \u2014 paga 50%' }],
        onChange: function (v) {
          if (v === 'no') { setAbierto(false); props.onGuardar(r.id, { salio: 'no' }); }
          else if (v === 'si') { setAbierto(true); setDraft(r.idRutaMeli || ''); }
          else { setAbierto(false); props.onGuardar(r.id, { salio: '' }); }
        } }),



      abierto ? h('input', {
        className: 'input', style: { width: 150 }, value: draft, placeholder: 'ID MELI (ej. 169263650)',
        'aria-label': 'ID num\u00e9rico de la ruta de MELI', disabled: props.busy, inputMode: 'numeric',
        onChange: function (e) { setDraft(String(e.target.value || '').replace(/[.\s]/g, '')); } }) : null,
      abierto && draft.trim() && !idOk ? h('span', { className: 'muted', role: 'alert', style: { fontSize: 11 } },
        'El ID de MELI es un n\u00famero de 4 a 20 d\u00edgitos, sin letras (ej. 182949770).') : null,
      abierto ? h(ui.Button, { size: 'sm', variant: 'primary', disabled: props.busy || !idOk,
        title: idOk ? undefined : 'Escrib\u00ed el ID num\u00e9rico de MELI para poder guardar',
        onClick: function () { setAbierto(false); props.onGuardar(r.id, { salio: 'si', idRutaMeli: draft.trim() }); } },
        'Guardar') : null,
      abierto ? h(ui.Button, { size: 'sm', variant: 'ghost', disabled: props.busy,
        onClick: function () { setAbierto(false); setDraft(''); } }, 'Cancelar') : null,
      !abierto && actual === 'si' ? h('span', { className: 'muted', style: { fontSize: 12 } },
        'ID MELI: ' + (r.idRutaMeli || '?')) : null,
      !abierto && actual === 'no' ? h('span', { className: 'badge badge--warn' }, 'paga 50%') : null,
      !abierto && !actual ? h('span', { className: 'muted', style: { fontSize: 12 } },
        'sin marcar \u2014 al cerrar se avisa') : null);
  }

  // Cuantas se DIBUJAN sin buscar. Hay un selector por ruta: sin tope, un dia de 75 rutas serian
  // miles de <option> en el DOM. El tope es de dibujo, NO de busqueda.
  var TOPE_DIBUJO = 25;

  function SelectorChofer(props) {
    var state = useState(''), filtro = state[0], setFiltro = state[1];
    if (props.onLoad && !props.loaded) {
      return h('div', { className: 'selector-chofer' },
        props.error ? h('div', { className: 'rr-bad', role: 'alert' }, props.error) : null,
        h(ui.Button, { size: 'sm', variant: 'ghost', disabled: props.loading || props.disabled, onClick: props.onLoad },
          props.loading ? 'Cargando opciones…' : (props.error ? 'Reintentar opciones' : 'Ver todas las opciones')));
    }
    var options = props.options || [];
    if (props.loaded && !options.length) return h('div', { className: 'selector-chofer' },
      h('div', { className: 'rr-bad', role: 'status' }, 'El servidor no encontró unidades seleccionables para este trabajo.'),
      props.onLoad ? h(ui.Button, { size: 'sm', variant: 'ghost', disabled: props.disabled, onClick: props.onLoad }, 'Actualizar opciones') : null);
    var forzadas = options.filter(function (o) { return o && o.fueraRegla; });
    var elegibles = options.filter(function (o) { return o && !o.disabled; });
    var texto = filtro.trim().toLowerCase();
    // Buscar mira TODAS las opciones, no solo las dibujadas. Ese era el bug: una unidad valida mas
    // alla del tope no aparecia ni escribiendo su patente, y el cartel mandaba a otra pantalla.
    var coinciden = texto
      ? options.filter(function (o) {
          if (!o || o.disabled) return false;
          return String(o.label || o).toLowerCase().indexOf(texto) >= 0;
        })
      : options;
    var visibles = texto ? coinciden : coinciden.slice(0, TOPE_DIBUJO);
    var sinDibujar = texto ? 0 : Math.max(0, elegibles.length - visibles.filter(function (o) { return o && !o.disabled; }).length);
    return h('div', { className: 'selector-chofer' },
      forzadas.length ? h('div', { className: 'selector-chofer__alerta', role: 'note' },
        '⚠ FUERA DE REGLA — podés forzar cualquiera bajo tu criterio') : null,
      // El buscador aparece SIEMPRE que haya varias opciones, no solo cuando hay fuera-de-regla:
      // una camioneta de otra zona no rompe ninguna regla, y era justo la que no se podia encontrar.
      elegibles.length > 1 ? h('input', {
        className: 'input selector-chofer__buscar', type: 'search', value: filtro,
        placeholder: 'Buscar entre ' + elegibles.length + ' por patente o chofer…',
        'aria-label': 'Buscar unidad por patente, chofer, unidad o motivo',
        onChange: function (e) { setFiltro(e.target.value); }, disabled: props.disabled
      }) : null,
      // lo que no se dibuja se DICE, y se dice como encontrarlo. Nada se oculta en silencio.
      sinDibujar ? h('p', { className: 'muted', style: { margin: '2px 0 4px', fontSize: 12 } },
        'Se listan ' + (elegibles.length - sinDibujar) + ' de ' + elegibles.length +
        '. Escribí arriba para buscar entre todas.') : null,
      h(ui.Select, {
        value: '', placeholder: texto && !visibles.length ? 'Sin coincidencias' : props.placeholder,
        disabled: props.disabled || !!(texto && !visibles.length), options: visibles,
        title: props.title, onChange: props.onChange, 'aria-label': props['aria-label']
      }));
  }

  var SVC = [
    { k: 'COLECTA', label: 'Colecta', color: 'info' },
    { k: 'FULL', label: 'Full', color: 'neutral' },
    { k: 'BACKUP', label: 'BackUp', color: 'warn' },
    { k: 'OTRO', label: 'Otros', color: 'neutral' }
  ];
  function nuevaOperacion() {
    if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID();
    var b = new Uint8Array(16); window.crypto.getRandomValues(b); b[6] = (b[6] & 15) | 64; b[8] = (b[8] & 63) | 128;
    var x = Array.prototype.map.call(b, function (n) { return n.toString(16).padStart(2, '0'); }).join('');
    return x.slice(0,8) + '-' + x.slice(8,12) + '-' + x.slice(12,16) + '-' + x.slice(16,20) + '-' + x.slice(20);
  }
  function money(n) { return '$' + Number(n || 0).toLocaleString('es-AR'); }

  // paleta de las barras. S/C (sin macro-zona) va en ámbar: es un hueco de datos, no una zona más.
  var ZONA_COLOR = { CABA: '#1a73e8', NORTE: '#7c4dff', OESTE: '#00acc1', SUR: '#ec407a', 'LA PLATA': '#43a047', ROSARIO: '#ff8f00', 'S/C': '#fbbc04' };
  var SVC_COLOR = { COLECTA: '#1a73e8', FULL: '#7c4dff', BACKUP: '#fbbc04', OTRO: '#80868b' };
  function zonaDe(r) { return r.macroZona || 'S/C'; }
  function svcDe(r) { return r.servicio || 'OTRO'; }


  var PROVINCIAS = ['BUENOS AIRES', 'ROSARIO', 'OTROS'];
  function provinciaDe(r) {
    if (svcDe(r) === 'OTRO') return 'OTROS';
    if (r.macroZona === 'ROSARIO') return 'ROSARIO';
    return 'BUENOS AIRES';   // todos los prefijos conocidos no-Rosario son AMBA (incluye S/C con servicio conocido)
  }
  // cuenta sobre TODAS las rutas (el backend calculaba porZona sólo con las asignadas -> los totales no cerraban)
  function contar(rutas, fn, colores) {
    var m = {}; (rutas || []).forEach(function (r) { var k = fn(r); m[k] = (m[k] || 0) + 1; });
    return Object.keys(m).map(function (k) { return { key: k, label: k, value: m[k], color: colores[k] || 'var(--primary)' }; })
      .sort(function (a, b) { return b.value - a.value; });
  }

  var ZONAS_EDIT = ['CABA', 'NORTE', 'OESTE', 'SUR', 'LA PLATA', 'ROSARIO'];
  var UNIDADES_EDIT = ['CAMIONETA', 'CHASIS LIVIANO', 'CHASIS', 'CHASIS PESADO', 'SEMI'];
  function semiCompatible(v, r) {
    function tipo(x) { return String(x || '').trim().toUpperCase(); }
    return (tipo(v.unidad) === 'SEMI') === (tipo(r.vehiculoSugerido) === 'SEMI');
  }
  function unidadSugDe(r) { var u = String(r.vehiculoSugerido || '').trim().toUpperCase(); return UNIDADES_EDIT.indexOf(u) >= 0 ? u : '(sin unidad)'; }

  // Editor manual de una ruta (situaciones puntuales del día: cambió el horario, el barrio venía mal,
  // MELI pidió otra unidad, o hay que dejarle una nota al chofer). El server valida y re-evalúa todo.
  function RutaEditor(props) {
    var r = props.ruta;
    // DIFF-ONLY (patrón del QA v0.11.0): se manda SOLO lo que cambió. Mandar los 5 campos siempre
    // (a) re-validaba la hora cruda de MELI ('12:30 hs') y bloqueaba con 422 editar km/zona, y
    // (b) un guardado sin cambios marcaba "✎ editada" y desconfirmaba la ruta.
    var inicial = { hora: r.hora || '', km: r.km != null ? String(r.km) : '', macroZona: r.macroZona || '', vehiculoSugerido: r.vehiculoSugerido || '', nota: r.nota || '' };
    var st = useState(inicial);
    var f = st[0], setF = st[1];
    var errState = useState(null); var errLocal = errState[0], setErrLocal = errState[1];
    function campo(k) { return function (v) { var n = Object.assign({}, f); n[k] = v; setF(n); }; }
    function input(k, ph, extra) {
      return h('input', Object.assign({ className: 'input', value: f[k], placeholder: ph,
        onChange: function (e) { campo(k)(e.target.value); } }, extra || {}));
    }
    return h('div', { className: 'rr-edit' },
      h('div', { className: 'rr-edit__grid' },
        h('label', null, h('span', null, 'Horario'), input('hora', 'ej: 07:30')),
        h('label', null, h('span', null, 'Km'), input('km', 'ej: 32.5', { type: 'number', min: 0, step: '0.1' })),
        h('label', null, h('span', null, 'Zona'),
          h(ui.Select, { value: f.macroZona, placeholder: 'S/C (sin zona)', options: ZONAS_EDIT, onChange: campo('macroZona'), 'aria-label': 'Zona de la ruta ' + r.idRuta })),
        h('label', null, h('span', null, 'Unidad que pide MELI'),
          h(ui.Select, { value: f.vehiculoSugerido, placeholder: '(sin unidad)', options: UNIDADES_EDIT, onChange: campo('vehiculoSugerido'), 'aria-label': 'Unidad sugerida de la ruta ' + r.idRuta })),
        h('label', { className: 'rr-edit__nota' }, h('span', null, 'Nota (va en el aviso al chofer)'), input('nota', 'ej: entrar por el portón de atrás'))),
      h('div', { className: 'rr-edit__acts' },
        h(ui.Button, { variant: 'primary', size: 'sm', disabled: props.busy, onClick: function () {
          var cambios = {};
          Object.keys(f).forEach(function (k) { if (f[k] !== inicial[k]) cambios[k] = f[k]; });
          if ('km' in cambios) {
            var km = String(cambios.km == null ? '' : cambios.km).trim().replace(',', '.');   // coma decimal argentina
            if (km === '' && r.km != null && !confirm('El campo de km quedó vacío: se va a BORRAR el km de la ruta ' + r.idRuta + '. ¿Seguro?')) return;
            cambios.km = km;
          }
          if (!Object.keys(cambios).length) { setErrLocal('No cambiaste nada.'); return; }
          setErrLocal(null);
          props.onSave(cambios);
        } }, props.busy ? 'Guardando…' : 'Guardar cambios'),
        h(ui.Button, { variant: 'ghost', size: 'sm', disabled: props.busy, onClick: props.onCancel }, 'Cancelar'),
        h('span', { className: 'muted', style: { fontSize: 12 } }, 'Al guardar se recalculan candidatos, costo y discrepancias.'),
        errLocal ? h('span', { style: { color: 'var(--danger)', fontSize: 12 } }, '⚠ ' + errLocal) : null));
  }

  function AsignacionReal(props) {
    window.CR.useStore();
    var backendOn = !!(window.CR.backend && window.CR.backend.enabled);
    // el resultado vive en el store (fuente única): asignación + swaps re-renderizan solos
    var data = store.list('asignacionReal')[0] || null;
    var busyState = useState(false); var busy = busyState[0], setBusy = busyState[1];
    var errState = useState(null); var err = errState[0], setErr = errState[1];
    var selState = useState(null); var selId = selState[0], setSelId = selState[1];
    var metaState = useState(null); var meta = metaState[0], setMeta = metaState[1];
    // filtro activo desde las barras: { tipo:'zona'|'servicio', valor:'CABA' } | null
    var filtroState = useState(null); var filtro = filtroState[0], setFiltro = filtroState[1];









    var pendState = useState(null); var pend = pendState[0], setPend = pendState[1];
    var cierreState = useState({ fecha: null, ok: null, err: null });
    var cierre = cierreState[0], setCierre = cierreState[1];





    var permisos = window.CR.permisos;
    var puedeCerrar = !!(permisos && (permisos.puedeSeccion(props && props.session, 'cierre-dia')
      || permisos.puedeSeccion(props && props.session, 'cierre-manual')));
    var puedeGerencia = !!(permisos && permisos.puedeSeccion(props && props.session, 'cierre-manual'));

    var puedeDesestimar = !!(permisos && permisos.puedeSeccion(props && props.session, 'asignacion-real'));







    var puedePesos = !!(permisos && permisos.puedeSeccion(props && props.session, 'pesos-solver'));
    var pesosState = useState(null); var pesos = pesosState[0], setPesos = pesosState[1];
    var pesosFormState = useState({ cercania: '', cercaniaD0: '', busy: false, err: null, ok: null });
    var pesosForm = pesosFormState[0], setPesosForm = pesosFormState[1];
    function numeroDe(v) { var n = Number(String(v == null ? '' : v).trim().replace(',', '.')); return String(v == null ? '' : v).trim() === '' ? NaN : n; }
    function pesosValidos(f) {
      var c = numeroDe(f.cercania), d = numeroDe(f.cercaniaD0);
      return isFinite(c) && c >= 0 && c <= 100 && isFinite(d) && d >= 1 && d <= 500;
    }
    function cargarPesos() {
      if (!backendOn || !puedePesos || !window.CR.backend.pesosSolver) { setPesos(null); return; }
      window.CR.backend.pesosSolver().then(function (p) {
        setPesos(p);
        setPesosForm({ cercania: String(p.pesos.cercania), cercaniaD0: String(p.pesos.cercaniaD0), busy: false, err: null, ok: null });
      }).catch(function () { setPesos(null); });
    }
    useEffect(function () { cargarPesos(); }, [backendOn, puedePesos]);
    function guardarPesos() {
      if (!pesosValidos(pesosForm)) return;
      setPesosForm(Object.assign({}, pesosForm, { busy: true, err: null, ok: null }));
      window.CR.backend.guardarPesosSolver({ cercania: numeroDe(pesosForm.cercania), cercaniaD0: numeroDe(pesosForm.cercaniaD0) }).then(function (p) {
        setPesos(p);
        setPesosForm({ cercania: String(p.pesos.cercania), cercaniaD0: String(p.pesos.cercaniaD0), busy: false, err: null,
          ok: p.apagado
            ? 'Guardado: la cercanía queda APAGADA (peso 0). El motor propone igual que hasta ahora.'
            : 'Guardado: cercanía ENCENDIDA, ' + p.pesos.cercania + ' puntos a 0 km, 0 puntos desde ' + p.pesos.cercaniaD0 + ' km. Se aplica en la próxima propuesta o evaluación; no reasigna sola.' });
      }).catch(function (e) {
        setPesosForm(Object.assign({}, pesosForm, { busy: false, err: (e && e.message) || 'No pude guardar los pesos.' }));
      });
    }
    function refrescarPendientes() {
      if (!backendOn || !puedeCerrar) { setPend(null); return Promise.resolve(); }
      return window.CR.backend.diasPendientes().then(setPend);
    }
    useEffect(function () { refrescarPendientes(); }, [backendOn, puedeCerrar]);

    /* ---- REABRIR EL DIA OPERATIVO (v0.69.0) ---------------------------------------------------
       El 01/09 un bug cerro la jornada del 02/09 antes de que se operara, y el hotfix dejo el dato
       como estaba. Al dia siguiente TODO lo que guarda —cargar la planilla, asignar, editar una
       ruta, dar de alta una unidad— rebotaba con "el dia esta cerrado", y el unico sintoma que vio
       el operador fue un chofer dado de alta que no aparecia. Reabrirlo era una fila de UPDATE y no
       habia ninguna pantalla que la hiciera: hizo falta soporte con acceso a la base.

       La card se dibuja SOLO si el dia operativo esta cerrado con rutas adentro. Un cartel que
       aparece todos los dias diciendo "todo bien" se deja de leer, y el dia que importe nadie lo ve. */
    var rbState = useState({ motivo: '', busy: false, err: null, ok: null });
    var reab = rbState[0], setReab = rbState[1];
    function reabrirDia() {
      var op = pend && pend.diaOperativo;
      if (!op || !op.puedeReabrir) return;
      var motivo = (reab.motivo || '').trim();
      if (motivo.length < 5) { setReab(Object.assign({}, reab, { err: 'Escribí por qué se reabre: queda en el historial.' })); return; }
      setReab(Object.assign({}, reab, { busy: true, err: null, ok: null }));
      window.CR.backend.reabrirDia(op.fecha, motivo).then(function (r) {
        setReab({ motivo: '', busy: false, err: null, ok: (r && r.mensaje) || 'El día quedó abierto.' });
        return refrescarPendientes();
      }).catch(function (e) {
        setReab(Object.assign({}, reab, { busy: false, err: (e && e.message) || 'No pude reabrir el día.' }));
      });
    }
    function accionarDia(d) {
      var recongela = d.accion === 'recongelar';
      var cuantas = recongela ? d.vivas_sin_congelar : d.vivas;
      /* Confirmacion explicita: esto cambia los numeros de una liquidacion. El texto dice la fecha
         Y la cantidad — "¿seguro?" a secas no le da al operador con que decidir. */
      var aviso = recongela
        ? ('Vas a congelar las ' + cuantas + ' ruta(s) que le faltan al ' + d.fecha + '.')
        : ('Vas a cerrar el ' + d.fecha + ' y congelar sus ' + cuantas + ' ruta(s).');
      if (!window.confirm(aviso + '\n\nEntran a la liquidación con la tarifa que quedó guardada en cada ruta. ¿Confirmás?')) return;
      setCierre({ fecha: d.fecha, ok: null, err: null });
      var p = recongela ? window.CR.backend.recongelarDia(d.fecha) : window.CR.backend.cerrarDiaManual(d.fecha);
      p.then(function (j) {
        /* Se muestra el MENSAJE DEL SERVIDOR tal cual. El servidor distingue el caso incomodo —
           congelo, pero menos filas de las vivas — y un "listo" fabricado aca lo taparia. */
        setCierre({ fecha: null, ok: (j && j.mensaje) || 'Listo.', err: null });
        return refrescarPendientes();
      }).catch(function (e) {
        setCierre({ fecha: null, ok: null, err: (e && e.message) || 'No pude completar la operación.' });
      });
    }






    function marcarDiaDePrueba(d) {
      if (!puedeGerencia || d.accion !== 'revisar') return;
      var motivo = window.prompt(
        'Vas a marcar el ' + d.fecha + ' como DÍA DE PRUEBA.\n\n'
        + 'No se recongela ni se recalcula nada: sus ' + d.cong_huerfanas + ' fila(s) guardadas y los ajustes que cuelgan de ellas quedan como están, '
        + 'y el día deja de figurar como pendiente. Se puede deshacer.\n\n'
        + 'Escribí por qué (queda en el historial):', '');
      if (motivo === null) return;
      motivo = String(motivo).trim();
      if (motivo.length < 3) { setCierre({ fecha: null, ok: null, err: 'Escribí por qué es un día de prueba: queda en el historial.' }); return; }
      if (!window.confirm('¿Confirmás marcar el ' + d.fecha + ' como día de prueba, liquidado fuera del sistema?\n\nMotivo: ' + motivo)) return;
      setCierre({ fecha: d.fecha, ok: null, err: null });
      window.CR.backend.regularizarDia(d.fecha, motivo).then(function () {
        setCierre({ fecha: null, ok: 'El ' + d.fecha + ' quedó marcado como día de prueba (liquidado fuera del sistema) y no aparece más como pendiente', err: null });
        return refrescarPendientes();
      }).catch(function (e) {
        setCierre({ fecha: null, ok: null, err: (e && e.message) || 'No pude marcar ese día como día de prueba.' });
      });
    }


    var provState = useState(''); var prov = provState[0], setProv = provState[1];
    var unidadState = useState(''); var unidadSel = unidadState[0], setUnidadSel = unidadState[1];



    var mediaState = useState(''); var mediaSel = mediaState[0], setMediaSel = mediaState[1];





  var CSV_LOGISTICS = [
    { key: 'idRuta', label: 'ID Ruta' },
    { key: 'hora', label: 'Hora de inicio' },
    { key: 'zona', label: 'Zona de inicio' },
    { key: 'km', label: 'Kms' },
    { key: 'vehiculoSugerido', label: 'Vehículo a planificar' },
    { key: 'patente', label: 'PATENTE' },
    { key: 'chofer', label: 'Chofer' },
    { key: 'unidad', label: 'Unidad asignada' },
    { key: 'macroZona', label: 'Macro-zona' },
    { key: 'servicio', label: 'Servicio' },
    { key: 'dobleCiclo', label: 'Doble ciclo', csv: function (r) { return r.dobleCiclo ? 'Si' : 'No'; } },
    /* La marca de media jornada es del 25/08 y cambia lo que se factura y lo que se liquida: si el
       archivo va a servir para cargar en Logistics, tiene que viajar. */
    { key: 'mediaJornada', label: 'Media jornada', csv: function (r) { return r.mediaJornada ? 'Si' : 'No'; } }
  ];

  // buscador de rutas (id, chofer, patente, zona, unidad) + qué grupo está plegado + "ver más" por grupo
    var buscarState = useState(''); var buscar = buscarState[0], setBuscar = buscarState[1];
    var plegadoState = useState({}); var plegado = plegadoState[0], setPlegado = plegadoState[1];
    var verMasState = useState({}); var verMas = verMasState[0], setVerMas = verMasState[1];
    var editState = useState(null); var editId = editState[0], setEditId = editState[1];
    var telState = useState({}); var flotaTel = telState[0], setFlotaTel = telState[1];


    var flotaFullState = useState([]); var flotaFull = flotaFullState[0], setFlotaFull = flotaFullState[1];
    // En una programación FM1 las opciones completas llegan bajo demanda del servidor. Se guardan
    // por ruta+contexto y se descartan al cambiar jornada/snapshot/firma; nunca se mezclan con la
    // reconstrucción local legacy ni se reutiliza una respuesta tardía.
    var fmos = useState({}); var fmOpciones = fmos[0], setFmOpciones = fmos[1];
    var fmps = useState(null); var fmPendiente = fmps[0], setFmPendiente = fmps[1];
    // Cada ruta/contexto mantiene su propia secuencia. Una lectura nueva de QA-Y no debe
    // cancelar QA-X; un cambio de programacion si invalida todas mediante `epoca`.
    var fmLecturas = React.useRef({ firma: null, epoca: 0, porClave: {} });
    var firmaFm = data && data.fm ? [data.fechaOperacion, data.fm.programacionId, data.fm.versionOpciones, data.fm.generacionActual].join('|') : 'legacy';
    // La ref cambia durante el render que observa la nueva foto, antes de que pueda resolverse
    // una Promise vieja entre el commit y el efecto que limpia la UI.
    if (fmLecturas.current.firma !== firmaFm) {
      fmLecturas.current.firma = firmaFm;
      fmLecturas.current.epoca += 1;
      fmLecturas.current.porClave = {};
    }
    useEffect(function () {
      setFmOpciones({}); setFmPendiente(null);
    }, [firmaFm]);
    function esFm1() { return !!(data && data.fm && data.fm.politica === 'FM1'); }
    function claveFm(rutaId, contexto) { return contexto + ':' + rutaId; }
    function estadoOpciones(rutaId, contexto) { return fmOpciones[claveFm(rutaId, contexto)] || { loaded: false, loading: false, error: null, data: null }; }
    function cargarOpcionesFm(ruta, contexto) {
      if (!ruta || !esFm1() || !window.CR.backend.opcionesPrimeraMilla) return Promise.resolve(null);
      var clave = claveFm(ruta.id, contexto);
      var lecturas = fmLecturas.current;
      var epoca = lecturas.epoca;
      var token = (lecturas.porClave[clave] || 0) + 1;
      lecturas.porClave[clave] = token;
      function vigente() {
        return fmLecturas.current.epoca === epoca && fmLecturas.current.porClave[clave] === token;
      }
      setFmOpciones(function (m) { var n = Object.assign({}, m); n[clave] = { loaded: false, loading: true, error: null, data: null }; return n; });
      return window.CR.backend.opcionesPrimeraMilla(ruta.id, contexto, data.fechaOperacion).then(function (r) {
        if (!vigente()) return null;
        if (!r || r.contrato !== 1 || r.completo !== true || !Array.isArray(r.opciones)
          || r.programacionId !== data.fm.programacionId || r.fecha !== data.fechaOperacion) {
          throw new Error('El servidor devolvió opciones incompletas o de otra programación. Actualizá la jornada.');
        }
        setFmOpciones(function (m) { var n = Object.assign({}, m); n[clave] = { loaded: true, loading: false, error: null, data: r }; return n; });
        return r;
      }).catch(function (e) {
        if (vigente()) setFmOpciones(function (m) { var n = Object.assign({}, m); n[clave] = { loaded: false, loading: false, error: (e && e.message) || 'No pude cargar las opciones.', data: null }; return n; });
        return null;
      });
    }
    var confPendState = useState(null); var confPend = confPendState[0], setConfPend = confPendState[1];
    var confState = useState('idle'); var confPhase = confState[0], setConfPhase = confState[1];
    var notifState = useState(null); var notif = notifState[0], setNotif = notifState[1];
    var cargaState = useState(null); var carga = cargaState[0], setCarga = cargaState[1];
    var bajasState = useState(false); var aceptarBajas = bajasState[0], setAceptarBajas = bajasState[1];

    useEffect(function () { refreshTel(); }, [backendOn]);
    // (el upgrade path del snapshot guardado — kmDescarga v0.12, barrioKey y candidatos.antiguedad
    //  v0.16 — corre UNA vez en backend.init(), no acá: ver lib/backend.js)
















    var refState = useState(null); var refrescoAviso = refState[0], setRefrescoAviso = refState[1];
    var busyRef = React.useRef(false); busyRef.current = busy || !!carga;


    var desestState = useState(null); var desest = desestState[0], setDesest = desestState[1];


    var cubrirState = useState(null); var cubrir = cubrirState[0], setCubrir = cubrirState[1];
    var editRef = React.useRef(null); editRef.current = editId || (desest && desest.rutaId) || (cubrir && cubrir.rutaId) || null;
    function recargarDelServidor() {
      if (!window.CR.backend.sincronizarDia) return Promise.resolve();
      return window.CR.backend.sincronizarDia(store)
        .then(function () { setRefrescoAviso(null); })
        .catch(function () { /* la pantalla sigue con lo que tiene: un refresco no puede romper el trabajo */ });
    }
    useEffect(function () {
      if (!backendOn) return undefined;
      var id = setInterval(function () {
        if (!window.CR.backend.versionRutas) return;
        window.CR.backend.versionRutas().then(function (v) {
          var mia = store.list('asignacionReal')[0] || {};
          if (!v || !mia.version) return;
          var mismaRuta = v.version === mia.version;
          var firmaRemota = v.fm && v.fm.versionOpciones;
          var firmaMia = mia.fm && mia.fm.versionOpciones;
          var mismaFm = (firmaRemota || null) === (firmaMia || null)
            && ((v.fm && v.fm.generacionActual) || null) === ((mia.fm && mia.fm.generacionActual) || null);
          if (mismaRuta && mismaFm) return;
          if (busyRef.current || editRef.current) {
            setRefrescoAviso(mismaRuta
              ? 'Cambió la configuración para próximas programaciones. Se actualizará cuando termines lo que estás haciendo.'
              : 'Otro operador guardó cambios. Se van a mostrar cuando termines lo que estás haciendo.');
            return;
          }
          recargarDelServidor().then(function () {
            setRefrescoAviso('Se actualizó con los cambios de otro operador.');
          });
        }).catch(function () {});
      }, 30000);
      return function () { clearInterval(id); };
    }, [backendOn]);

    function aplicar(res) {
      if (res && res.fechaOperacion && window.CR.backend.recordarFecha) window.CR.backend.recordarFecha(res.fechaOperacion);
      store.bulkSet('asignacionReal', [res]);
      setConfPend(null);   // la foto cambió: la advertencia de confirmación (si había) quedó vieja
      // si el dock de CRÁNEO tiene una ruta seleccionada, refrescarla (tras swap/edición quedaría vieja);
      // y si la ruta YA NO EXISTE (se ingirió otro día), limpiarla — explicar una ruta de ayer es mentir
      var sel = store.list('copilotoRuta')[0];
      if (sel) {
        var fresca = ((res && res.rutas) || []).filter(function (r) { return r.id === sel.id; })[0];
        if (fresca) store.bulkSet('copilotoRuta', [fresca]);
        else { store.bulkSet('copilotoRuta', []); setSelId(null); }
      }
    }
    function cambiarJornada(fecha) {
      if (!fecha || busy || carga || editId || confPend || confPhase === 'confirming') return;
      setBusy(true); setErr(null);
      window.CR.backend.sincronizarDia(store, fecha).then(function (ok) {
        if (!ok) throw new Error('No pude cargar la jornada. Se conserva la anterior.');
        setSelId(null); setNotif(null); setConfPend(null); setConfPhase('idle'); setMeta(null); setFiltro(null);
        refrescarPendientes();
      }).catch(function (ex) { setErr(ex.message); }).then(function () { setBusy(false); });
    }
    // teléfonos de la flota (patente -> telefono) para notificar + la flota completa (para "fuera de regla")
    function refreshTel() {
      if (!backendOn || !window.CR.backend.flotaReal) return;
      window.CR.backend.flotaReal().then(function (fl) {
        var m = {}; (fl || []).forEach(function (v) { if (v.patente) m[String(v.patente).toUpperCase()] = { telefono: v.telefono, nombre: v.nombre }; });
        setFlotaTel(m);
        setFlotaFull(fl || []);
      }).catch(function () {});
    }
    function mensajeReal(r) {
      return ['Hola ' + (r.chofer || '') + ', te paso tu ruta para mañana:',
        'Ruta: ' + r.idRuta + (r.servicio ? ' (' + r.servicio + ')' : ''),
        'Zona: ' + (r.macroZona || r.zona || '—'),
        r.hora ? ('Horario: ' + r.hora) : null,
        r.km ? ('Km aprox.: ' + r.km) : null,
        'Unidad: ' + (r.unidad || '—') + ' · Patente: ' + (r.patente || '—'),
        r.nota ? ('Nota: ' + r.nota) : null,
        'Respondé "ok" para confirmar. Gracias.'].filter(Boolean).join('\n');
    }




    function confirmarYNotificar(force) {
      if (!data) return;
      var bloq = (data.discrepancias || []).filter(function (d) { return d.bloqueante; });
      var duras = bloq.filter(function (d) { return d.tipo !== 'sin_recurso' && d.tipo !== 'doble_ciclo_partido'; });
      if (duras.length) { setErr('Hay ' + duras.length + ' problema(s) de integridad (chofer duplicado/inexistente/asignación inválida): resolvelos antes de confirmar.'); return; }
      var pendientes = bloq.filter(function (d) { return d.tipo === 'sin_recurso' || d.tipo === 'doble_ciclo_partido'; });
      // Back Up asignados SIN marcar ¿salio?: avisan antes de confirmar. Se puede seguir igual
      // (pagan completo y el Historial los marca), pero nunca en silencio — asi quedaron congelados
      // los 3 Back Up de prueba que despues hubo que corregir a mano.
      var backupsSinMarcar = (data.rutas || []).filter(function (r) {
        return String(r.servicio || '').toUpperCase() === 'BACKUP' && r.patente
          && r.estado !== 'sin_asignar' && !r.salio;
      });
      /* LA CONFIRMACION ES SIEMPRE, no solo cuando hay pendientes. Este boton manda un WhatsApp
         REAL a los grupos de choferes —hoy 89 grupos activos con gente adentro— y hasta ahora, con
         el dia sin pendientes, salia de una sin preguntar nada. En una capacitacion, o cualquier dia
         que alguien entre a mirar como quedo la planificacion, un clic de mas sale a la calle.
         El mismo criterio que ya rige para activar un grupo: previa obligatoria que dice a cuantos
         les va a llegar. Un envio a gente real no se hace sin que alguien lo lea antes. */
      var aNotificar = (data.rutas || []).filter(function (r) {
        return r.patente && r.estado !== 'manual' && r.estado !== 'sin_asignar';
      });
      if (force !== true) {
        setConfPend({
          avisos: aNotificar.length,
          n: pendientes.length,
          ids: pendientes.map(function (d) { return d.idRuta; }),
          sinCubrir: pendientes.filter(function (d) { return d.tipo === 'sin_recurso'; }).length,
          ciclos: pendientes.filter(function (d) { return d.tipo === 'doble_ciclo_partido'; }).length,
          backups: backupsSinMarcar.length,
          backupIds: backupsSinMarcar.map(function (r) { return r.idRuta; })
        });
        return;
      }
      setConfPend(null);
      var asignadas = (data.rutas || []).filter(function (r) { return r.patente && r.estado !== 'manual' && r.estado !== 'sin_asignar'; });
      if (!asignadas.length) return;
      setConfPhase('confirming'); setErr(null);
      var nd = Object.assign({}, data, { rutas: (data.rutas || []).map(function (r) {
        return (r.patente && r.estado !== 'manual' && r.estado !== 'sin_asignar') ? Object.assign({}, r, { estado: 'confirmada' }) : r;
      }) });
      store.bulkSet('asignacionReal', [nd]);
      // El backend resuelve grupos, doble gate, coalescing e idempotencia. El front nunca decide destinos.
      /* SE NOTIFICA LA JORNADA QUE LA PANTALLA ESTA MOSTRANDO, no "hoy". El 01/09 a las 20:54 el
         cliente cargo las rutas del 02/09, las asigno y apreto este boton: la jornada se resolvia con
         hoy() —01/09, ya avisada desde las 12:29— y el aviso salio para esa (68 omitidas, 0 enviadas,
         12 sin grupo), mientras `cerrarDia`, que si usa la fecha operativa, cerro y congelo el 02/09
         SIN avisarlo. Dos fechas distintas en la misma accion. El servidor manda `jornadaId` y
         `fechaOperacion` en cada payload de esta pantalla; hoy() queda solo como ultimo recurso para
         un servidor viejo que no los traiga. */
      var jornadaObjetivo = data.jornadaId || null;
      var fecha = data.fechaOperacion || window.CR.const.hoy();
      var p = (jornadaObjetivo
        ? Promise.resolve({ jornadaId: jornadaObjetivo })
        : window.CR.backend.tableroAcks(fecha)).then(function (tablero) {
        return window.CR.backend.notificarJornada(tablero.jornadaId, false).then(function (res) {
          return { jornadaId: tablero.jornadaId, resultado: res };
        });
      });
      p.then(function (out) {
        var res = out.resultado || {};
        var enviados = res.enviados || 0, omitidos = res.omitidos || 0, fallidos = res.fallidos || 0;
        // pendientesGrupo = rutas cuyo grupo de WhatsApp todavía no está activo. NO son fallas
        // (el backend nunca las intentó), pero sin contarlas el panel mostraba "0/0" y culpaba
        // al gateway de un problema que era de datos.
        var pendGrupo = res.pendientesGrupo || 0;
        var detalles = res.porRuta || [];
        var okCount = enviados + omitidos;
        setNotif({ jornadaId: out.jornadaId, total: enviados + omitidos + fallidos + pendGrupo, ok: okCount,
          enviados: enviados, omitidos: omitidos, fallidos: fallidos, pendGrupo: pendGrupo,
          gruposFaltantes: detalles.filter(function (x) { return x.estado === 'pendiente_grupo'; })
            .map(function (x) { return x.rutaId; }),
          gated: detalles.filter(function (x) { return x.gated; }).length,
          fallback: detalles.filter(function (x) { return x.fallback; }).length,
          pend: pendientes.length, modo: 'WhatsApp orquestado por backend' });
        setConfPhase('done');





      }).catch(function (e) { setNotif({ total: asignadas.length, ok: 0, error: true, mensaje: e.message }); setConfPhase('done'); });
    }

    function reintentarFallidos() {
      if (!notif || !notif.jornadaId) return;
      setConfPhase('confirming'); setErr(null);
      window.CR.backend.notificarJornada(notif.jornadaId, true).then(function (res) {
        var enviados = res.enviados || 0, omitidos = res.omitidos || 0, fallidos = res.fallidos || 0;
        var detalles = res.porRuta || [];
        setNotif(Object.assign({}, notif, {
          total: enviados + omitidos + fallidos, ok: enviados + omitidos,
          enviados: enviados, omitidos: omitidos, fallidos: fallidos,
          gated: detalles.filter(function (x) { return x.gated; }).length,
          fallback: detalles.filter(function (x) { return x.fallback; }).length,
          error: false, mensaje: null
        }));
      }).catch(function (e) { setErr('No pude reintentar las notificaciones: ' + e.message); })
        .then(function () { setConfPhase('done'); });
    }

    function revisarArchivo(b64, nombre, modo) {
      setBusy(true); setErr(null); setAceptarBajas(false);
      return window.CR.backend.ingestarXlsx(b64, nombre, { accion: 'previa', modo: modo })
        .then(function (ing) {
          if (!ing.previa || !ing.previa.revision) throw new Error('El servidor no ofrece revisión segura. Actualizá la página; no se confirmó la carga.');
          setCarga({ b64: b64, nombre: nombre, modo: modo, ing: ing });
        }).catch(function (ex) { setErr(ex.message || 'No pude revisar la planilla.'); })
        .then(function () { setBusy(false); });
    }
    function onFile(e) {
      var f = e.target.files && e.target.files[0]; if (!f) return;
      setErr(null); setBusy(true);
      var reader = new FileReader();
      reader.onload = function () { revisarArchivo(String(reader.result).split(',')[1], f.name, 'incremental'); };
      reader.onerror = function () { setErr('No pude leer el archivo.'); setBusy(false); };
      reader.readAsDataURL(f);
      e.target.value = '';
    }
    function confirmarArchivo() {
      if (!carga || busy) return;
      setBusy(true); setErr(null);
      window.CR.backend.ingestarXlsx(carga.b64, carga.nombre, { accion: 'confirmar', modo: carga.modo,
        revision: carga.ing.previa.revision, confirmarBajas: aceptarBajas })
        .then(function (res) {
          var metaCap = res.meta || {}; setMeta(metaCap);
          aplicar(res); refreshTel(); setNotif(null); setConfPhase('idle'); setCarga(null);
              var avisos = [];
              /* PRIMERO de todo: para que dia quedo cargado esto. Va SIEMPRE, tambien cuando es
                 hoy — que se vea la fecha es lo unico que separa "cargue el archivo de manana a
                 proposito" de "lo cargue sin darme cuenta". Cuando NO sale del nombre del archivo
                 se dice tambien de donde salio, para que nadie suponga que alguien la eligio. */
              if (metaCap.fechaOperacion) {
                avisos.push(metaCap.origenFecha === 'archivo'
                  ? ('📅 Cargado para operar el ' + metaCap.fechaOperacion + ' (la fecha sale del nombre del archivo).')
                  : metaCap.origenFecha === 'jornada' ? ('📅 Cargado para operar el ' + metaCap.fechaOperacion + ' (jornada activa).')
                  : ('📅 Cargado para operar HOY, ' + metaCap.fechaOperacion + '. El nombre del archivo no trae fecha, así que se usó la de hoy.'));
              }
              if (metaCap.avisoFecha) avisos.push('⚠ ' + metaCap.avisoFecha + ' Se cargó para hoy.');
              if (metaCap.rutasDescartadas && metaCap.rutasLeidas) avisos.push('Se descartaron ' + metaCap.rutasDescartadas + ' filas que no son rutas (tabla manual / apellidos).');
              if (metaCap.altasPreservadas) avisos.push('Se conservaron ' + metaCap.altasPreservadas + ' vehículos de alta manual.');
              if ((metaCap.edicionesPreservadas || []).length) avisos.push('Se conservaron tus ediciones de estado: ' + metaCap.edicionesPreservadas.join(', ') + '.');
              if ((metaCap.edicionesPisadas || []).length) avisos.push('⚠ La base nueva pisó ediciones tuyas: ' + metaCap.edicionesPisadas.join(', ') + ' — revisá esas unidades.');
              var pc = metaCap.paresCiclo;
              if (pc) {
                if (pc.pares) avisos.push('Se vincularon ' + pc.pares + ' par(es) de doble ciclo (C1/C2 → mismo chofer).');
                if ((pc.incompletos || []).length) avisos.push('⚠ Doble ciclo incompleto (falta C1 o C2): ' + pc.incompletos.join(', ') + '.');
                if ((pc.ambiguos || []).length) avisos.push('⚠ Doble ciclo ambiguo (ids repetidos): ' + pc.ambiguos.join(', ') + ' — se dejaron sin vincular.');
                if ((pc.inconsistentes || []).length) avisos.push('⚠ Sufijo C1/C2 sin "Doble Ciclo = SI": ' + pc.inconsistentes.join(', ') + '.');
              }

          avisos.push('Carga guardada: ' + (metaCap.rutasTotal || 0) + ' rutas. Se conservaron las asignaciones existentes; sólo se propusieron unidades para las rutas nuevas.');
          if (metaCap.rutasConservadas) avisos.push(metaCap.rutasConservadas + ' rutas que no venían en el archivo se conservaron.');
          if (metaCap.rutasRetiradas) avisos.push(metaCap.rutasRetiradas + ' rutas retiradas del día tras tu confirmación.');
          if (!metaCap.flotaTotal) avisos.push('Falta cargar la base de flota para asignar unidades.');
          if (!metaCap.rutasTotal) avisos.push('Flota guardada. Ahora podés cargar las rutas del día.');
          setErr('ℹ ' + avisos.join(' '));
        }).catch(function (ex) { setErr(ex.message || 'No pude guardar la planilla.'); })
        .then(function () { setBusy(false); });
    }
    function reasignar() {
      setBusy(true); setErr(null); setNotif(null); setConfPhase('idle');
      window.CR.backend.asignarReal(12).then(aplicar).catch(function (ex) { setErr(ex.message); }).then(function () { setBusy(false); });
    }

    // Aplica uno o varios cambios de chofer y REVALIDA en el backend: sin esto el costo, la cobertura
    // y las discrepancias quedan congelados en la foto de la asignación inicial (el panel mentiría).
    // Si la revalidación falla, se vuelve al snapshot previo (el optimista no puede quedar a medias),
    // y un contador de secuencia descarta respuestas viejas si hubiera dos vuelos cruzados.
    var revalSeq = React.useRef(0);
    function aplicarCambios(cambios, seleccionesFm, operacionFm) {
      var snapshot = data;
      var tocoConfirmada = false;
      // Un doble ciclo es una sola decisión operativa: cualquier camino de cambio (fila, discrepancia
      // o acción masiva) manda ambas mitades juntas usando sus ids internos.
      var cambiosSync = Object.assign({}, cambios);
      (data.rutas || []).forEach(function (r) {

        if (cambios[r.id] && r.cicloPar && !cambiosSync[r.cicloPar] && !partida(r)) cambiosSync[r.cicloPar] = cambios[r.id];
      });
      if ((data.rutas || []).some(function (r) {
        var c = cambiosSync[r.id]; return c && c.id && !semiCompatibleConVueltas(c, r);
      })) { setErr('SEMI sólo puede cubrir rutas SEMI, en ambas vueltas.'); return; }
      var nuevas = (data.rutas || []).map(function (r) {
        var c = cambiosSync[r.id]; if (!c) return r;
        if (r.estado === 'confirmada') tocoConfirmada = true;   // cambió el chofer: esa confirmación ya no vale
        var prop = r._propuesta || r.patente || null;
        return Object.assign({}, r, {
          choferId: c.id, vehiculoId: c.id, patente: c.patente, chofer: c.nombre,
          unidad: c.unidad != null ? c.unidad : r.unidad, agencia: c.agencia != null ? c.agencia : r.agencia,
          estado: 'asignada', tarifa: c.tarifa != null ? c.tarifa : r.tarifa,
          forzada: !!c.forzada,   // elegir un candidato válido LIMPIA la marca; el server la re-valida igual
          _propuesta: prop, ajustada: prop ? c.patente !== prop : true
        });
      });
      store.bulkSet('asignacionReal', [Object.assign({}, data, { rutas: nuevas })]);
      if (tocoConfirmada) { setNotif(null); setConfPhase('idle'); }
      if (!window.CR.backend.revalidar) return;
      var seq = ++revalSeq.current;
      setBusy(true); setErr(null);
      /* `snapshot.rutas` es la foto ANTES de este cambio: con ella el servidor sabe cuales rutas
         toco este operador y cuales venian del otro. `data.version` es la que le dio el servidor la
         ultima vez. */
      var fmDecision = esFm1() && seleccionesFm && seleccionesFm.length ? {
        contrato: 1, programacionId: data.fm.programacionId, operacionId: operacionFm || nuevaOperacion(), selecciones: seleccionesFm
      } : undefined;
      window.CR.backend.revalidar(nuevas, 12, (snapshot && snapshot.rutas) || null, data && data.version, fmDecision)
        .then(function (res) { if (seq === revalSeq.current) { aplicar(res); setFmPendiente(null); } })
        .catch(function (ex) {
          if (seq !== revalSeq.current) return;
          store.bulkSet('asignacionReal', [snapshot]);   // rollback: mejor perder el clic que mentir
          if (ex && ex.status === 409 && esFm1()) {
            setErr((ex.message || 'La opción quedó desactualizada.') + ' No se guardó. Conservamos tu elección para que actualices las opciones y vuelvas a decidir.');
            return;
          }
          /* Un choque con otro operador NO es una caida de red y no se cuenta igual: acá el cambio
             no entró porque alguien más tocó esas mismas rutas, y lo que corresponde es traer lo
             último y que la persona decida — no reintentar a ciegas, que sería pisarle el trabajo. */
          if (/otro operador|qued[oó] vieja/i.test(ex.message || '')) {
            setErr(ex.message + ' (Actualizando la pantalla con lo último…)');
            recargarDelServidor();
            return;
          }
          setErr('No pude validar el cambio con el servidor (' + (ex.message || 'sin conexión') + '). Volví al estado anterior — probá de nuevo.');
        })
        .then(function () { if (seq === revalSeq.current) setBusy(false); });
    }


    function exclusivaLastMile(v) {
      if (!v) return false;
      var estado = String(v.estado || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim().replace(/\s+/g, ' ').toUpperCase();
      return estado === 'ULTIMA MILLA' || String(v.unidad || '').trim().toUpperCase() === 'FIORINO';
    }
    function candidatoPrimeraMilla(c) {
      return !exclusivaLastMile(c) && !exclusivaLastMile((flotaFull || []).find(function (v) { return v.id === c.id; }))
        && !(data.rutas || []).some(function (r) { return r.choferId === c.id && exclusivaLastMile(r); });
    }
    function seleccionFm(r, opcion, contexto, aceptaAdvertencias) {
      var ids = opcion && opcion.trabajo && opcion.trabajo.rutaIds;
      if (!r || !Array.isArray(ids) || !ids.length || ids.indexOf(r.id) < 0) {
        setErr('La opción no identifica el trabajo de esta ruta. Actualizá las opciones.'); return null;
      }
      return { rutaId: ids[0], unidadId: opcion.id, contexto: contexto,
        revisionOpcion: opcion.revisionOpcion, aceptaAdvertencias: aceptaAdvertencias === true };
    }
    function swap(rutaId, candId, opcionFm, aceptaAdvertencias, operacionFm) {
      if (!candId || String(candId).charAt(0) === '_') return;   // separadores (_sep, _sepv, _sepmore) no son elegibles
      var r = (data.rutas || []).filter(function (x) { return x.id === rutaId; })[0];
      if (!r) return;
      if (esFm1() && !opcionFm) { setErr('No pude verificar esa opción con el servidor. Volvé a cargar la lista.'); return; }
      var seleccion = opcionFm ? seleccionFm(r, opcionFm, 'ASIGNACION', aceptaAdvertencias) : null;
      if (opcionFm && !seleccion) return;
      if (opcionFm && opcionFm.requiereAceptacionFm && aceptaAdvertencias !== true) {
        setFmPendiente({ rutaId: rutaId, opcion: opcionFm, contexto: 'ASIGNACION', operacionId: operacionFm || nuevaOperacion() }); return;
      }
      var c = opcionFm ? Object.assign({}, opcionFm, { forzada: !!opcionFm.requiereForzadaPrevia })
        : (r.candidatos || []).filter(function (x) { return x.id === candId; })[0];
      if (!c) {
        var v = (flotaFull || []).filter(function (x) { return x.id === candId; })[0];
        if (!v) return;
        c = { id: v.id, patente: v.patente, nombre: v.nombre, unidad: v.unidad, agencia: v.agencia, forzada: true };
      }
      if (!candidatoPrimeraMilla(c)) return;
      if (!semiCompatibleConVueltas(c, r)) { setErr('SEMI sólo puede cubrir rutas SEMI, en ambas vueltas.'); return; }
      var cambios = {}; cambios[rutaId] = c;
      if (r.cicloPar && !partida(r)) cambios[r.cicloPar] = c;   // #84 (v1.0.18): partida, cada vuelta lo suyo
      aplicarCambios(cambios, seleccion ? [seleccion] : null, operacionFm);
    }

    // edición manual de la ruta (hora / km / zona / unidad / nota) — la valida y persiste el server.
    // Los `warnings` del server (borraste el km, la ruta dejó de estar confirmada…) se muestran, no se tragan.








    function desestimar(rutaId, motivo) {
      var r = rutaDe(rutaId); if (!r) return;
      var fecha = (data && data.fechaOperacion) || window.CR.backend.fechaOperacion || window.CR.const.hoy();
      setBusy(true); setErr(null);
      window.CR.backend.desestimarRuta(fecha, rutaId, motivo)
        .then(function (res) {
          aplicar(res); setDesest(null);
          setErr('✔ Ruta ' + r.idRuta + ' desestimada: no se propone más, no bloquea el día y no se avisa. ' + (res.warnings || []).join(' '));
        })
        .catch(function (ex) { setErr('No pude desestimar la ruta ' + r.idRuta + ': ' + (ex.message || 'sin conexión') + (ex.clave ? '' : ' Probá de nuevo.')); })
        .then(function () { setBusy(false); });
    }
    function restituir(rutaId) {
      var r = rutaDe(rutaId); if (!r) return;
      var fecha = (data && data.fechaOperacion) || window.CR.backend.fechaOperacion || window.CR.const.hoy();
      if (!window.confirm('Vas a RESTITUIR la ruta ' + r.idRuta + '.\n\nVuelve a "sin asignar": el motor puede volver a proponerla y, mientras no tenga chofer, bloquea la confirmación del día. Queda registrado.\n\n¿Confirmás?')) return;
      setBusy(true); setErr(null);
      window.CR.backend.restituirRuta(fecha, rutaId)
        .then(function (res) { aplicar(res); setErr('✔ Ruta ' + r.idRuta + ' restituida. ' + (res.warnings || []).join(' ')); })
        .catch(function (ex) { setErr('No pude restituir la ruta ' + r.idRuta + ': ' + (ex.message || 'sin conexión') + (ex.clave ? '' : ' Probá de nuevo.')); })
        .then(function () { setBusy(false); });
    }

    function abrirCubrir(r) {
      if (cubrir && cubrir.rutaId === r.id) { setCubrir(null); return; }
      setCubrir({ rutaId: r.id, choferId: '', motivo: '', aceptaAdvertencias: false, operacionId: nuevaOperacion() });
      if (esFm1()) cargarOpcionesFm(r, 'COBERTURA');
    }
    function cubrirVuelta(rutaId, choferId, motivo) {
      var r = rutaDe(rutaId); if (!r) return;
      var fecha = (data && data.fechaOperacion) || window.CR.backend.fechaOperacion || window.CR.const.hoy();
      var fmDecision;
      if (esFm1()) {
        var estado = estadoOpciones(rutaId, 'COBERTURA');
        var opcion = estado.data && estado.data.opciones.filter(function (o) { return o.id === choferId; })[0];
        if (!opcion || !opcion.seleccionable) { setErr('La opción de cobertura ya no está disponible. Actualizá la lista.'); return; }
        if (opcion.requiereAceptacionFm && !(cubrir && cubrir.aceptaAdvertencias)) { setErr('Leé y aceptá las advertencias de Primera Milla para continuar.'); return; }
        var seleccion = seleccionFm(r, opcion, 'COBERTURA', cubrir.aceptaAdvertencias);
        if (!seleccion) return;
        fmDecision = { contrato: 1, programacionId: data.fm.programacionId,
          operacionId: cubrir.operacionId, selecciones: [seleccion] };
      }
      setBusy(true); setErr(null);
      window.CR.backend.cubrirMitad(fecha, rutaId, choferId, motivo, fmDecision)
        .then(function (res) {
          aplicar(res); setCubrir(null);
          setErr('✔ Vuelta ' + r.idRuta + ' cubierta por otra unidad. ' + (res.warnings || []).join(' '));
        })
        .catch(function (ex) { setErr('No pude cubrir la vuelta ' + r.idRuta + ': ' + (ex.message || 'sin conexión') + (ex.clave ? '' : ' Probá de nuevo.')); })
        .then(function () { setBusy(false); });
    }
    function ejecutarDeshacerCobertura(r, opcion, aceptaAdvertencias, operacionId) {
      var fecha = (data && data.fechaOperacion) || window.CR.backend.fechaOperacion || window.CR.const.hoy();
      var seleccion = opcion ? seleccionFm(r, opcion, 'DESHACER_COBERTURA', aceptaAdvertencias) : null;
      if (opcion && !seleccion) return;
      var fmDecision = opcion ? { contrato: 1, programacionId: data.fm.programacionId, operacionId: operacionId,
        selecciones: [seleccion] } : undefined;
      setBusy(true); setErr(null);
      window.CR.backend.deshacerCobertura(fecha, r.id, fmDecision)
        .then(function (res) { aplicar(res); setFmPendiente(null); setErr('✔ Cobertura de ' + r.idRuta + ' deshecha. ' + (res.warnings || []).join(' ')); })
        .catch(function (ex) { setErr('No pude deshacer la cobertura de ' + r.idRuta + ': ' + (ex.message || 'sin conexión') + (ex.clave ? '' : ' Probá de nuevo.')); })
        .then(function () { setBusy(false); });
    }
    function deshacerCobertura(rutaId) {
      var r = rutaDe(rutaId); if (!r) return;
      var quien = (r.cubierta && r.cubierta.patente) || 'la unidad que la cubre';
      if (!window.confirm('Vas a DESHACER la cobertura de la vuelta ' + r.idRuta + '.\n\nVuelve al titular (la doble entera con la misma unidad) y ' + quien + ' queda libre. Queda registrado.\n\n¿Confirmás?')) return;
      if (!esFm1()) { ejecutarDeshacerCobertura(r, null, false, null); return; }
      setBusy(true); setErr(null);
      cargarOpcionesFm(r, 'DESHACER_COBERTURA').then(function (resp) {
        setBusy(false);
        if (!resp) { setErr('No pude revisar las condiciones para deshacer la cobertura. No se cambió nada.'); return; }
        var par = parDe(r);
        var titularId = (r.cubierta && r.cubierta.titular && r.cubierta.titular.choferId) || (par && par.choferId);
        var opcion = resp.opciones.filter(function (o) { return o.id === titularId && o.seleccionable; })[0];
        if (!opcion) { setErr('La unidad titular ya no es seleccionable. Actualizá la jornada antes de deshacer.'); return; }
        var op = nuevaOperacion();
        if (opcion.requiereAceptacionFm) {
          setFmPendiente({ rutaId: r.id, opcion: opcion, contexto: 'DESHACER_COBERTURA', operacionId: op }); return;
        }
        ejecutarDeshacerCobertura(r, opcion, false, op);
      });
    }
    function guardarEdicion(rutaId, cambios) {
      setBusy(true); setErr(null);
      window.CR.backend.editarRuta(rutaId, cambios)
        .then(function (res) {
          aplicar(res); setEditId(null);
          if (res.warnings && res.warnings.length) setErr('✔ Guardado, con avisos: ' + res.warnings.join(' '));
        })
        .catch(function (ex) { setErr('No pude guardar la edición: ' + (ex.message || 'sin conexión') + '. Probá de nuevo.'); })
        .then(function () { setBusy(false); });
    }

    // el dock lateral de CRÁNEO lee la ruta seleccionada desde el store (fuente única app-wide)
    function elegirParaCraneo(r) {
      var yaSel = selId === r.id;
      setSelId(yaSel ? null : r.id);
      store.bulkSet('copilotoRuta', yaSel ? [] : [r]);
      if (!yaSel) window.dispatchEvent(new CustomEvent('cr-copiloto-open'));
    }

    if (!backendOn) {
      return h('div', null, h(ui.Page, { title: 'Asignación de rutas (MELI)', subtitle: 'El producto real: cargás la planilla de Mercado Libre y el motor asigna chofer + vehículo.' }),
        h(ui.Card, null, h(ui.EmptyState, { title: 'Necesito el backend prendido' },
          h('span', null, 'Esta pantalla corre el motor real en el servidor. Levantá el dev-server:'),
          h('pre', { style: { marginTop: 10, padding: 12, background: 'var(--bg-soft, #0002)', borderRadius: 8, overflow: 'auto' } }, 'cd craneo-backend\nnode local/dev-server.cjs\n# luego abrí  http://localhost:8787/app/'))));
    }

    var rutas = (data && data.rutas) || [];
    var resumen = (data && data.resumen) || null;
    var disc = (data && data.discrepancias) || [];
    var selRuta = selId ? rutas.filter(function (r) { return r.id === selId; })[0] || null : null;
    var usedPat = {}; rutas.forEach(function (r) { if (r.patente) usedPat[r.patente] = r.id; });
    var usedById = {}; rutas.forEach(function (r) { if (r.choferId) usedById[r.choferId] = r.id; });

    // ---- resolución de discrepancias -------------------------------------------------------
    function rutaDe(id) { return rutas.filter(function (r) { return r.id === id; })[0] || null; }




    function esCubierta(r) { return !!(r && r.cubierta && typeof r.cubierta === 'object'); }
    function parDe(r) { return r && r.cicloPar ? rutaDe(r.cicloPar) : null; }
    function partida(r) {
      var p = parDe(r);
      return esCubierta(r) || esCubierta(p) || (!!r && r.estado === 'desestimada') || (!!p && p.estado === 'desestimada');
    }
    function semiCompatibleConVueltas(v, r) {
      var p = parDe(r);
      var actual = (flotaFull || []).find(function (x) { return x.id === v.id; })
        || (data.rutas || []).find(function (x) { return x.choferId === v.id; });
      var candidato = v.unidad == null && actual ? actual : v;
      var compatible = function (ruta) { return semiCompatible(candidato, ruta) && (!actual || semiCompatible(actual, ruta)); };
      return compatible(r) && (partida(r) || !p || compatible(p));
    }




    function unidadesParaCubrir(r) {
      if (esFm1()) {
        var estadoFm = estadoOpciones(r.id, 'COBERTURA');
        return estadoFm.data ? estadoFm.data.opciones.filter(function (o) { return o.seleccionable; }) : [];
      }
      return (flotaFull || []).filter(function (v) {
        // `usedById` ya trae al titular y a esta misma vuelta: cualquier unidad con una ruta hoy queda afuera
        if (!v.disponible || usedById[v.id]) return false;
        if (!semiCompatible(v, r)) return false;
        if (exclusivaLastMile(v)) return false;
        if (r.macroZona === 'ROSARIO' ? !v.esRosario : !!v.esRosario) return false;
        return !(RANK[r.vehiculoSugerido] && (RANK[v.unidad] || 0) < RANK[r.vehiculoSugerido]);
      }).map(function (v) { return Object.assign({}, v, { zonaMatch: String(v.zona || '').toUpperCase() === String(r.macroZona || '').toUpperCase() }); })
        .sort(function (a, b) { return ((b.zonaMatch ? 1 : 0) - (a.zonaMatch ? 1 : 0)) || String(a.patente).localeCompare(String(b.patente)); });
    }
    function usadoPorOtro(c, rutaId) {
      if (partida(rutaDe(rutaId)) && usedById[c.id] && usedById[c.id] !== rutaId) return true;
      var dueno = usedById[c.id];
      if (!dueno || dueno === rutaId) return false;
      var r = rutaDe(rutaId);
      return !(r && r.cicloPar === dueno);
    }



    var RANK = { 'CAMIONETA': 1, 'CHASIS LIVIANO': 2, 'CHASIS': 3, 'CHASIS PESADO': 4 };






    function extrasPool(r) {
      var enPool = {}; (r.candidatos || []).forEach(function (c) { enPool[c.id] = 1; });
      var validas = [], fuera = [];
      (flotaFull || []).forEach(function (v) {
        if (!v.disponible || exclusivaLastMile(v) || enPool[v.id] || v.id === r.choferId) return;
        if (!semiCompatibleConVueltas(v, r)) return;
        /* Misma regla de unicidad que el pool válido, y por eso se LLAMA a la misma función en vez de
           repetir la condición. Acá había una copia —`usedById[v.id] && usedById[v.id] !== r.id`— que
           se quedó sin la excepción de `cicloPar`: a un doble ciclo partido cuyo camión no estaba en
           `candidatos` no se lo ofrecía ni forzado, aunque poner el mismo camión en las dos mitades
           es exactamente lo que un doble ciclo ES. Dos copias de una regla son dos reglas apenas una
           de las dos cambia. */
        if (usadoPorOtro(v, r.id)) return;   // ya usado en otra ruta: ni forzado (salvo su ciclo par)
        var motivo = null;
        if (r.macroZona === 'ROSARIO' && !v.esRosario) motivo = 'no es de Rosario';
        else if (v.esRosario && r.macroZona !== 'ROSARIO') motivo = 'es de Rosario';
        else if (RANK[r.vehiculoSugerido] && (RANK[v.unidad] || 0) < RANK[r.vehiculoSugerido]) motivo = 'unidad menor a ' + r.vehiculoSugerido;
        var reg = { id: v.id, patente: v.patente, nombre: v.nombre, unidad: v.unidad, agencia: v.agencia, motivo: motivo,
          antiguedad: v.antiguedad, tarifaDiferencial: v.tarifaDiferencial, estado: v.estado,
          zonaMatch: String(v.zona || '').toUpperCase() === String(r.macroZona || '').toUpperCase() };
        (motivo ? fuera : validas).push(reg);
      });
      // sin score de ruta acá (el pool ya trae el top-N ordenado): al menos subimos las de la zona de la ruta.
      validas.sort(function (a, b) { return (b.zonaMatch ? 1 : 0) - (a.zonaMatch ? 1 : 0); });
      // Sin corte: el tope ahora vive en el selector y solo afecta al dibujo, no a la busqueda.
      // Cortar aca dejaba fuera del alcance del buscador justo las de otra zona, que el sort de
      // arriba manda al final.
      return { validas: validas, validasTot: validas.length, fuera: fuera };
    }
    // opciones del desplegable: pool válido + "otras válidas" (sin ⚠) + "fuera de regla" (con ⚠).
    function opcionesConForzadas(r, opts) {
      if (esFm1()) return opcionesServidor(r, 'ASIGNACION');
      var ex = extrasPool(r);
      var out = opts;
      if (ex.validas.length) {
        out = out.concat(
          [{ value: '_sepv', disabled: true, label: '── otras válidas (fuera del top) ──' }],
          ex.validas.map(function (v) { return { value: v.id, label: v.patente + ' · ' + v.nombre + ' (' + (v.unidad || '?') + ')' + window.CR.candAttrs(v) }; }));
      }
      if (ex.fuera.length) {
        out = out.concat(
          [{ value: '_sep', disabled: true, label: '⚠ FUERA DE REGLA — forzar bajo tu criterio' }],
          ex.fuera.map(function (v) { return { value: v.id, fueraRegla: true, label: '⚠ ' + v.patente + ' · ' + v.nombre + ' (' + (v.unidad || '?') + ' — ' + v.motivo + ')' + window.CR.candAttrs(v) }; }));
      }
      return out;
    }
    function textoMotivosFm(o) {
      return (o && o.motivos || []).map(function (m) { return m.detalle || m.codigo; }).filter(Boolean).join(' · ');
    }
    function opcionFmSelect(o) {
      var motivo = textoMotivosFm(o);
      var tipo = o.automatica ? 'automática' : 'manual';
      return { value: o.id, disabled: !o.seleccionable,
        fueraRegla: !!(o.requiereAceptacionFm || o.requiereForzadaPrevia),
        label: (o.requiereAceptacionFm ? '⚠ ' : '') + o.patente + ' · ' + (o.nombre || 's/chofer')
          + ' (' + (o.unidad || '?') + (o.score != null ? ' · ' + o.score + ' pts' : '') + ' · ' + tipo + ')'
          + (motivo ? ' — ' + motivo : '') };
    }
    function opcionesServidor(r, contexto) {
      var estado = estadoOpciones(r.id, contexto);
      return estado.data ? estado.data.opciones.map(opcionFmSelect) : [];
    }
    function seleccionarFm(r, contexto, id) {
      var estado = estadoOpciones(r.id, contexto);
      var opcion = estado.data && estado.data.opciones.filter(function (o) { return o.id === id; })[0];
      if (!opcion || !opcion.seleccionable) { setErr('Esa unidad ya no está disponible. Actualizá las opciones.'); return; }
      if (contexto === 'ASIGNACION') swap(r.id, id, opcion, false);
    }
    function cargarOpcionesDiscrepancia(rutaId) {
      var r = rutaDe(rutaId); if (!r || !esFm1()) return Promise.resolve(null);
      setBusy(true); setErr(null);
      return cargarOpcionesFm(r, 'ASIGNACION').then(function (res) {
        setBusy(false);
        if (!res) setErr('No pude consultar las opciones de la discrepancia. Actualizá la jornada antes de elegir.');
        return res;
      });
    }
    function seleccionarDiscrepancia(rutaId, candId) {
      if (!esFm1()) return swap(rutaId, candId);
      var r = rutaDe(rutaId); if (!r) return;
      var estado = estadoOpciones(r.id, 'ASIGNACION');
      var opcion = estado.data && estado.data.opciones.find(function (o) { return o.id === candId && o.seleccionable; });
      if (!opcion) { setErr('Consultá las opciones de esta discrepancia antes de elegir una unidad.'); return; }
      // El mismo diálogo de la ruta exige aceptar las excepciones; nunca el panel las acepta por el usuario.
      if (opcion.requiereAceptacionFm) verRuta(r.id);
      seleccionarFm(r, 'ASIGNACION', candId);
    }
    // Candidato que corrige la discrepancia, sin cambiarla por una PEOR. El motor ya ordena la gravedad
    // en sus pesos: tarifa-dif mal amortizada (-22) pesa más que sobre-rotación (-12). Entonces:
    //  · menosDias    -> nunca acepta una unidad con tarifa-dif sin amortizar (sería un canje negativo).
    //  · sinTarifaDif -> puede aceptar un chofer con más días: saca la falta grave y deja la leve.
    // null = no hay reemplazo libre que sirva (el botón lo dice, no lo esconde).
    function corrige(r, tipoAccion, usados, accion) {
      if (tipoAccion === 'unificarCiclo') {
        if (esFm1()) {
          var opciones = ((estadoOpciones(r.id, 'ASIGNACION').data || {}).opciones || []);
          return opciones.find(function (c) {
            return accion && c.id === accion.choferId && c.seleccionable && c.automatica && !c.requiereAceptacionFm
              && candidatoPrimeraMilla(c) && semiCompatibleConVueltas(c, r);
          }) || null;
        }
        return accion && accion.choferId && candidatoPrimeraMilla({ id: accion.choferId, unidad: accion.unidad })
          && semiCompatibleConVueltas({ id: accion.choferId, unidad: accion.unidad }, r)
          ? { id: accion.choferId, patente: accion.patente, nombre: accion.nombre }
          : null;
      }
      var fuente = esFm1()
        ? ((estadoOpciones(r.id, 'ASIGNACION').data || {}).opciones || []).filter(function (c) { return c.seleccionable && c.automatica; })
        : (r.candidatos || []);
      var libres = fuente.filter(function (c) {
        if (!semiCompatibleConVueltas(c, r)) return false;
        if (c.id === r.choferId || !candidatoPrimeraMilla(c)) return false;
        var dueno = usados[c.id]; return !dueno || dueno === r.id;
      });



      var caraSinAmortizar = function (c) {
        if (!c.tarifaDiferencial) return false;
        if (r.tarifaDifRuta) return true;               // en rutas E_ (pagan 23% menos) siempre es mala
        if (r.km == null) return true;
        var kmEf = r.km + (r.kmDescarga != null ? r.kmDescarga : 30);
        return kmEf < 51 || kmEf > 100;
      };
      var rotado = function (c) { return (c.dias || 0) >= 5; };
      if (tipoAccion === 'menosDias') {
        return libres.filter(function (c) { return !rotado(c) && !caraSinAmortizar(c); })[0] || null;
      }
      if (tipoAccion === 'sinTarifaDif') {
        var base = libres.filter(function (c) { return !c.tarifaDiferencial; });
        return base.filter(function (c) { return !rotado(c); })[0] || base[0] || null;
      }
      return null;
    }
    function sugerido(d) {
      var r = rutaDe(d.rutaId); if (!r) return null;
      return corrige(r, d.accion && d.accion.tipo, usedById, d.accion);
    }
    // Plan del grupo: simula la asignación SECUENCIAL (un chofer no puede ir a dos rutas), así el número
    // del botón es el que realmente se va a aplicar. Antes contaba con `sugerido` (que mira el estado
    // inicial) y prometía más de lo que resolvía.
    function planGrupo(tipo) {
      var usados = Object.assign({}, usedById);
      var cambios = {};
      disc.filter(function (d) { return d.tipo === tipo; }).forEach(function (d) {
        var r = rutaDe(d.rutaId); if (!r) return;
        var c = corrige(r, d.accion && d.accion.tipo, usados, d.accion);
        if (!c) return;
        if (r.choferId) delete usados[r.choferId];
        usados[c.id] = r.id;
        cambios[r.id] = c;
      });
      return cambios;
    }
    function resolverGrupo(tipo) {
      var cambios = planGrupo(tipo);
      var ids = Object.keys(cambios);
      if (!ids.length) return;
      var selecciones = null;
      if (esFm1()) {
        selecciones = [];
        for (var i = 0; i < ids.length; i++) {
          var id = ids[i], s = seleccionFm(rutaDe(id), cambios[id], 'ASIGNACION', false);
          if (!s) return;
          var previa = selecciones.find(function (x) { return x.rutaId === s.rutaId; });
          if (previa && (previa.unidadId !== s.unidadId || previa.revisionOpcion !== s.revisionOpcion)) {
            setErr('Las dos vueltas requieren una única elección. Actualizá sus opciones.'); return;
          }
          if (!previa) selecciones.push(s);
        }
      }
      aplicarCambios(cambios, selecciones);
    }
    function verRuta(id) {
      setFiltro(null); setBuscar(''); setProv(''); setUnidadSel(''); setSelId(id);
      var rSel = rutaDe(id);
      if (rSel) store.bulkSet('copilotoRuta', [rSel]);   // la fila marca "✓ CRÁNEO": el dock tiene que coincidir
      // desplegar el grupo que contiene la ruta (si estaba plegado o recortado, el salto no la encontraría)
      var r = rutaDe(id);
      if (r) {
        var k = svcDe(r);
        var p = Object.assign({}, plegado); delete p[k]; setPlegado(p);
        var v = Object.assign({}, verMas); v[k] = true; setVerMas(v);
      }
      setTimeout(function () {
        var el = document.getElementById('rr-' + id);
        if (!el) return;
        el.setAttribute('tabindex', '-1');
        el.focus({ preventScroll: true });   // el foco viaja con el salto (lectores de pantalla incluidos)
        el.scrollIntoView({ behavior: 'smooth', block: 'center' });
        el.classList.add('rr--flash');
        setTimeout(function () { el.classList.remove('rr--flash'); }, 1600);
      }, 60);
    }

    var cobertura = resumen ? Math.round((resumen.asignadas) / Math.max(1, resumen.asignadas + resumen.sinCubrir) * 100) : 0;
    var ajustadas = rutas.filter(function (r) { return r.ajustada; }).length;

    // barras: se calculan sobre TODAS las rutas -> "por zona" y "por servicio" suman lo mismo
    var datosZona = contar(rutas, zonaDe, ZONA_COLOR);
    var datosSvc = contar(rutas, svcDe, SVC_COLOR);
    function pasaFiltro(r) {
      if (!filtro) return true;
      return filtro.tipo === 'zona' ? zonaDe(r) === filtro.valor : svcDe(r) === filtro.valor;
    }
    // búsqueda libre sobre lo que el planificador tiene en la cabeza: id, chofer, patente, barrio, unidad
    function pasaBusqueda(r) {
      var q = buscar.trim().toUpperCase();
      if (!q) return true;
      return [r.idRuta, r.chofer, r.patente, r.zona, r.macroZona, r.unidad, r.vehiculoSugerido, r.nota]
        .some(function (v) { return v && String(v).toUpperCase().indexOf(q) >= 0; });
    }
    var visibles = rutas.filter(function (r) {
      return pasaFiltro(r) && pasaBusqueda(r) && (!prov || provinciaDe(r) === prov)
        && (!unidadSel || unidadSugDe(r) === unidadSel)
        && (!mediaSel || (mediaSel === 'si') === !!r.mediaJornada);
    });
    var nFiltradas = visibles.length;
    // conteo por provincia (sobre todas las rutas: los chips muestran el total real de cada una)
    var porProvincia = rutas.reduce(function (m, r) { var p = provinciaDe(r); m[p] = (m[p] || 0) + 1; return m; }, {});
    var porUnidad = rutas.reduce(function (m, r) { var u = unidadSugDe(r); m[u] = (m[u] || 0) + 1; return m; }, {});
    var nMedia = rutas.filter(function (r) { return !!r.mediaJornada; }).length;
    function pickZona(k) { setFiltro(k ? { tipo: 'zona', valor: k } : null); }
    function pickSvc(k) { setFiltro(k ? { tipo: 'servicio', valor: k } : null); }
    function limpiarTodo() { setUnidadSel(''); setProv(''); setFiltro(null); setBuscar(''); setMediaSel(''); }

    function motivoChips(r) {
      var m = r.motivo || {}; var chips = [];
      if (m.flotaNueva) chips.push(['flota nueva', 'ok']);
      if (/PARTICULAR/i.test(m.agencia || r.agencia || '')) chips.push(['particular', 'info']);
      if (m.tarifaDif) chips.push(['tarifa-dif', 'warn']);
      if (r.tarifaDifRuta) chips.push(['ruta tarifa-dif', 'info']);
      if (m.zonaMatch) chips.push(['zona propia', 'neutral']);
      if (m.desempeno) chips.push([String(m.desempeno).toLowerCase(), 'neutral']);
      return chips.map(function (c, i) { return h('span', { key: i, className: 'badge badge--' + c[1], style: { fontSize: 11 } }, c[0]); });
    }

    function rutaRow(r) {
      var selected = selId === r.id;
      var sinCubrir = r.estado === 'sin_asignar';
      var manual = r.estado === 'manual';
      var estadoAsig = estadoOpciones(r.id, 'ASIGNACION');
      var candidatosCompatibles = (r.candidatos || []).filter(function (c) { return candidatoPrimeraMilla(c) && semiCompatibleConVueltas(c, r); });
      var candLegacy = candidatosCompatibles.map(function (c) {
        var usedElsewhere = usadoPorOtro(c, r.id);
        return { value: c.id, disabled: !!usedElsewhere,
          label: c.patente + ' · ' + c.nombre + ' (' + c.score + ')' + window.CR.candAttrs(c) + (usedElsewhere ? ' — ya usado' : '') };
      });
      var candOpts = esFm1() ? opcionesServidor(r, 'ASIGNACION') : candLegacy;
      // sin chofer (sin cubrir o BackUp): se resuelve acá mismo — con el pool válido, y si no alcanza,
      // FORZANDO una unidad fuera de regla (sección ⚠ del mismo desplegable)
      var libres = candidatosCompatibles.filter(function (c) { return !usadoPorOtro(c, r.id); }).map(function (c) {
        return { value: c.id, label: c.patente + ' · ' + c.nombre + ' (' + c.unidad + ')' + window.CR.candAttrs(c) };
      });
      var libresExt = esFm1() ? candOpts : opcionesConForzadas(r, libres);
      function selectorRuta(placeholder, options) {
        return h(SelectorChofer, { placeholder: placeholder, disabled: busy, options: options,
          loaded: !esFm1() || estadoAsig.loaded, loading: estadoAsig.loading, error: estadoAsig.error,
          onLoad: esFm1() ? function () { cargarOpcionesFm(r, 'ASIGNACION'); } : null,
          'aria-label': 'Elegir unidad para la ruta ' + r.idRuta,
          onChange: function (v) { if (!v) return; if (esFm1()) seleccionarFm(r, 'ASIGNACION', v); else swap(r.id, v); } });
      }
      var elegirChofer = (esFm1() || libresExt.length)
        ? h('div', { style: { maxWidth: 380, marginTop: 6 } },
          selectorRuta(esFm1() ? 'Elegir una opción del servidor…' : (libres.length ? 'Elegir chofer…' : 'Forzar unidad fuera de regla…'), libresExt))
        : null;
      var editando = editId === r.id;

      var desestimada = r.estado === 'desestimada';
      var marca = (desestimada && r.desestimada) || {};
      var formDesest = !!(desest && desest.rutaId === r.id);




      var cubierta = !desestimada && esCubierta(r);
      var marcaCub = cubierta ? r.cubierta : {};
      var parR = parDe(r);
      var parCubierto = !!parR && esCubierta(parR);
      var mvR = r.mediaVuelta || null;
      var formCubrir = !!(cubrir && cubrir.rutaId === r.id);
      var puedeCubrirAca = puedeDesestimar && !desestimada && !cubierta && !!parR && !parCubierto && parR.estado !== 'desestimada' && !!parR.choferId;
      return h('div', { key: r.id, id: 'rr-' + r.id, 'data-estado': r.estado || '',
          className: 'rr' + (selected ? ' rr--sel' : '') + (sinCubrir ? ' rr--bad' : '') + (editando ? ' rr--edit' : '') + (cubierta ? ' rr--cubierta' : '') + (desestimada ? ' rr--desestimada' : '') },
        h('div', { className: 'rr-top' },
          h('span', { className: 'badge badge--' + (SVC.filter(function (s) { return s.k === r.servicio; })[0] || { color: 'neutral' }).color, style: { fontSize: 11 } }, r.servicio),
          h('b', { className: 'rr-id' }, r.idRuta),
          r.ciclo && r.cicloParIdRuta ? h('span', { className: 'badge badge--info', style: { fontSize: 11 } },
            '⛓ doble ciclo · C' + r.ciclo + ' · par ' + r.cicloParIdRuta) : null,
          h('span', { className: 'muted', style: { fontSize: 12 },
            title: r.km && r.kmDescarga != null
              ? (r.tarifaDifRuta
                ? 'Ruta E_ (se paga 23% menos): acá la tarifa-dif nunca conviene. Descarga: +' + r.kmDescarga + ' km' + (r.centroDescarga ? ' a ' + (r.centroNombre || r.centroDescarga) : '')
                : 'Km efectivos para tarifa-dif: ' + r.km + ' MELI + ' + r.kmDescarga + ' de descarga' + (r.centroDescarga ? ' a ' + (r.centroNombre || r.centroDescarga) : ' (centro no identificado, estimado)') + ' = ' + (Math.round((r.km + r.kmDescarga) * 10) / 10))
              : undefined },
            (r.macroZona || r.zona || '') + (r.hora ? ' · ' + r.hora : '') + (r.km ? ' · ' + r.km + ' km' + (r.kmDescarga != null ? ' (+' + r.kmDescarga + ' desc.)' : '') : '') + (r.vehiculoSugerido ? ' · sug: ' + r.vehiculoSugerido : '')),
          r.ajustada ? h('span', { className: 'badge badge--info', style: { fontSize: 11 } }, '✎ ajustada') : null,
          r.editada ? h('span', { className: 'badge badge--warn', style: { fontSize: 11 } }, '✎ ruta editada') : null,
          r.forzada ? h('span', { className: 'badge badge--danger', style: { fontSize: 11 }, title: 'Asignación fuera de regla, forzada por vos' }, '⚠ forzada') : null,

          cubierta ? h('span', { className: 'badge badge--info', style: { fontSize: 11 }, 'data-cubierta': r.id,
            title: 'Fuerza mayor: esta vuelta la hace otra unidad y se le liquida el 75 % de su tarifa. Motivo: ' + (marcaCub.motivo || 'sin motivo') },
            '⇄ La cubre ' + (marcaCub.patente || 'otra unidad') + ' · 75 %') : null,
          !cubierta && mvR && (mvR.rol === 'titular' || mvR.rol === 'mitad_sola') ? h('span', { className: 'badge badge--warn', style: { fontSize: 11 }, 'data-media-vuelta': r.id,
            title: mvR.rol === 'titular'
              ? 'La otra vuelta (' + (mvR.parIdRuta || '') + ') la cubre otra unidad: esta se liquida al 50 % de la tarifa.'
              : 'La otra vuelta (' + (mvR.parIdRuta || '') + ') se desestimó: esta sale sola y se liquida al 50 % (media jornada).' },
            mvR.rol === 'titular' ? '½ vuelta · 50 %' : '½ vuelta sola · 50 %') : null,
          desestimada ? h('span', { className: 'badge badge--neutral', style: { fontSize: 11 }, 'data-desestimada': r.id,
            title: 'No la hacemos nosotros: MELI la cubre con otra empresa. Motivo: ' + (marca.motivo || 'sin motivo') },
            '⛔ Desestimada · ' + (marca.motivo || 'sin motivo')) : null,
          !desestimada ? h('button', { type: 'button', className: 'rr-editbtn', title: 'Editar la ruta (horario, km, zona, unidad, nota)',
            'aria-label': 'Editar la ruta ' + r.idRuta, onClick: function () { setEditId(editando ? null : r.id); } }, editando ? '✕ cerrar' : '✎ editar') : null,
          /* Solo con la seccion de esta pantalla (la misma que editar): un boton que contesta 403 no se dibuja. */

          puedeCubrirAca ? h('button', { type: 'button', className: 'rr-editbtn rr-cubrir-btn', disabled: busy,
            'data-novedad': 'cubrir-media-vuelta',
            title: 'Fuerza mayor: esta vuelta la hace otra unidad libre y la otra sigue con el titular. Se liquida el 75 % a la que cubre y el 50 % al titular.',
            'aria-label': 'Cubrir con otro chofer la vuelta ' + r.idRuta,
            onClick: function () { abrirCubrir(r); } }, formCubrir ? '✕ cerrar' : '⇄ cubrir con otro chofer') : null,
          // una vuelta de una doble cubierta no se desestima: primero se deshace la cobertura (el servidor contesta 409)
          !desestimada && !cubierta && !parCubierto && puedeDesestimar ? h('button', { type: 'button', className: 'rr-editbtn rr-desestimar-btn', disabled: busy,
            'data-novedad': 'desestimar-ruta',
            title: 'Desestimar la ruta: no la hacemos nosotros (fuerza mayor). Queda sin chofer, no bloquea el día y no se avisa.',
            'aria-label': 'Desestimar la ruta ' + r.idRuta,
            onClick: function () { setDesest(formDesest ? null : { rutaId: r.id, motivo: '' }); } }, formDesest ? '✕ cerrar' : '⛔ desestimar') : null),
        r.nota ? h('div', { className: 'rr-nota' }, '📝 ' + r.nota) : null,
        fmPendiente && fmPendiente.rutaId === r.id ? h('div', { className: 'fm-decision', role: 'alert', 'data-fm-decision': r.id },
          h('b', null, fmPendiente.contexto === 'DESHACER_COBERTURA' ? 'Recomponer la doble requiere una excepción.' : 'Esta elección incumple una condición configurada.'),
          h('p', { style: { margin: '4px 0' } }, 'La decisión manual sigue permitida. Revisá los motivos antes de continuar igualmente:'),
          h('ul', { className: 'fm-motivos' }, (fmPendiente.opcion.motivos || []).map(function (m, i) {
            return h('li', { key: m.codigo || i }, m.detalle || m.codigo);
          })),
          h('div', { className: 'fm-actions' },
            h(ui.Button, { variant: 'primary', size: 'sm', disabled: busy,
              onClick: function () {
                if (fmPendiente.contexto === 'DESHACER_COBERTURA') ejecutarDeshacerCobertura(r, fmPendiente.opcion, true, fmPendiente.operacionId);
                else swap(r.id, fmPendiente.opcion.id, fmPendiente.opcion, true, fmPendiente.operacionId);
              } }, fmPendiente.contexto === 'DESHACER_COBERTURA' ? 'Deshacer igualmente' : 'Asignar igualmente'),
            h(ui.Button, { variant: 'ghost', size: 'sm', disabled: busy, onClick: function () { setFmPendiente(null); } }, 'Cancelar'))) : null,
        editando ? h(RutaEditor, { key: 'ed-' + r.id, ruta: r, busy: busy,
          onSave: function (cambios) { guardarEdicion(r.id, cambios); },
          onCancel: function () { setEditId(null); } }) : null,
        /* El motivo es obligatorio y la confirmacion es el segundo clic: el primero abre esto. */
        formDesest ? h('div', { className: 'rr-edit rr-desestimar', 'data-desestimar-form': r.id },
          h('p', { style: { margin: '0 0 6px', fontSize: 13 } },
            h('b', null, 'Desestimar la ruta ' + r.idRuta + ': '),
            'queda sin chofer, el motor no la vuelve a proponer y no bloquea la confirmación del día. '
            + (r.patente ? 'La unidad ' + r.patente + ' queda libre para otras rutas. ' : '') + 'Se puede restituir después.'),
          h('input', { className: 'input', value: desest.motivo, disabled: busy, maxLength: 300,
            placeholder: 'Motivo (obligatorio, queda en el historial): ej. falla mecánica, MELI la cubre con otra empresa',
            'aria-label': 'Motivo para desestimar la ruta ' + r.idRuta,
            onChange: function (e) { setDesest({ rutaId: r.id, motivo: e.target.value }); } }),
          h('div', { className: 'rr-edit__acts' },
            h(ui.Button, { variant: 'danger', size: 'sm', disabled: busy || String(desest.motivo || '').trim().length < 3,
              'aria-label': 'Confirmar desestimar la ruta ' + r.idRuta,
              title: String(desest.motivo || '').trim().length < 3 ? 'Escribí el motivo (3 caracteres o más) para poder desestimar' : 'La ruta queda desestimada. Se puede restituir después.',
              onClick: function () { desestimar(r.id, String(desest.motivo || '').trim()); } }, busy ? 'Desestimando…' : 'Sí, desestimar'),
            h(ui.Button, { variant: 'ghost', size: 'sm', disabled: busy, onClick: function () { setDesest(null); } }, 'Cancelar'))) : null,

        formCubrir ? (function () {
          var opciones = unidadesParaCubrir(r);
          var estadoCob = estadoOpciones(r.id, 'COBERTURA');
          var elegida = esFm1() && estadoCob.data ? estadoCob.data.opciones.filter(function (o) { return o.id === cubrir.choferId; })[0] : null;
          var requiereAceptacion = !!(elegida && elegida.requiereAceptacionFm);
          var listo = !!cubrir.choferId && String(cubrir.motivo || '').trim().length >= 3 && (!requiereAceptacion || cubrir.aceptaAdvertencias);
          return h('div', { className: 'rr-edit rr-cubrir', 'data-cubrir-form': r.id },
            h('p', { style: { margin: '0 0 6px', fontSize: 13 } },
              h('b', null, 'Cubrir la vuelta ' + r.idRuta + ' con otra unidad (fuerza mayor): '),
              'la hace una unidad libre que hoy no tiene ruta, y ' + parR.idRuta + ' sigue con ' + (parR.patente || 'el titular') + '. '
              + 'Se liquida el 75 % de la tarifa de la que cubre y el 50 % de la del titular. Se puede deshacer.'),
            esFm1()
              ? h(SelectorChofer, { placeholder: 'Elegí la unidad que la cubre…', disabled: busy,
                  loaded: estadoCob.loaded, loading: estadoCob.loading, error: estadoCob.error,
                  onLoad: function () { cargarOpcionesFm(r, 'COBERTURA'); }, options: opciones.map(opcionFmSelect),
                  'aria-label': 'Unidad que cubre la vuelta ' + r.idRuta,
                  onChange: function (v) { setCubrir(Object.assign({}, cubrir, { choferId: v, aceptaAdvertencias: false })); } })
              : opciones.length
              ? h('select', { className: 'input', value: cubrir.choferId, disabled: busy, 'aria-label': 'Unidad que cubre la vuelta ' + r.idRuta,
                  onChange: function (e) { setCubrir(Object.assign({}, cubrir, { choferId: e.target.value })); } },
                [h('option', { key: '_', value: '' }, 'Elegí la unidad que la cubre…')].concat(opciones.map(function (v) {
                  return h('option', { key: v.id, value: v.id }, v.patente + ' · ' + (v.nombre || 's/chofer') + ' (' + (v.unidad || '?') + ')' + (v.zonaMatch ? ' · misma zona' : ''));
                })))
              : h('div', { className: 'rr-bad', 'data-cubrir-sin-unidades': r.id },
                '⚠ No hay ninguna unidad libre que sirva para esta vuelta (todas tienen ruta hoy, o no cumplen la unidad pedida o la plaza). Si nadie la puede hacer, desestimala.'),
            elegida && (elegida.motivos || []).length ? h('div', { className: 'fm-decision', role: 'note' },
              h('b', null, 'Revisión de Primera Milla'),
              h('ul', { className: 'fm-motivos' }, elegida.motivos.map(function (m, i) { return h('li', { key: m.codigo || i }, m.detalle || m.codigo); })),
              requiereAceptacion ? h('label', { className: 'fm-check' },
                h('input', { type: 'checkbox', checked: !!cubrir.aceptaAdvertencias, disabled: busy,
                  onChange: function (e) { setCubrir(Object.assign({}, cubrir, { aceptaAdvertencias: e.target.checked })); } }),
                h('span', null, 'Entiendo la advertencia y decido cubrir esta vuelta igualmente.')) : null) : null,
            h('input', { className: 'input', value: cubrir.motivo, disabled: busy, maxLength: 300, style: { marginTop: 6 },
              placeholder: 'Motivo (obligatorio, queda en el historial y en la liquidación): ej. el titular se rompió después de la primera vuelta',
              'aria-label': 'Motivo para cubrir la vuelta ' + r.idRuta,
              onChange: function (e) { setCubrir(Object.assign({}, cubrir, { motivo: e.target.value })); } }),
            h('div', { className: 'rr-edit__acts' },
              h(ui.Button, { variant: 'primary', size: 'sm', disabled: busy || !listo,
                'aria-label': 'Confirmar cubrir la vuelta ' + r.idRuta,
                title: listo ? 'La vuelta la hace la unidad elegida. Se puede deshacer.' : 'Elegí la unidad y escribí el motivo (3 caracteres o más)',
                onClick: function () { cubrirVuelta(r.id, cubrir.choferId, String(cubrir.motivo || '').trim()); } }, busy ? 'Cubriendo…' : 'Sí, cubrir'),
              h(ui.Button, { variant: 'ghost', size: 'sm', disabled: busy, onClick: function () { setCubrir(null); } }, 'Cancelar')));
        })() : null,
        desestimada
          ? h('div', { className: 'rr-desestimada', 'data-desestimada-fila': r.id },
            h('div', { className: 'muted', style: { fontSize: 13 } },
              'No la hacemos nosotros (MELI la cubre con otra empresa). ',
              marca.patente ? 'Tenía ' + marca.patente + (marca.chofer ? ' (' + marca.chofer + ')' : '') + ', que quedó libre. ' : 'No tenía unidad. ',
              'No se propone, no bloquea el día y no se avisa.'),
            /* Si ya se le habia avisado al chofer, alguien tiene que decirle que no sale: el sistema no lo hace. */
            marca.avisoPrevio ? h('div', { className: 'alerta alerta--media', role: 'alert', 'data-aviso-previo': r.id, style: { marginTop: 6 } },
              '📲 Ya se le había avisado al chofer' + (marca.chofer ? ' ' + marca.chofer : '') + ' por WhatsApp: avisale que no sale. El sistema no manda ningún mensaje.') : null,
            puedeDesestimar ? h('div', { className: 'rr-actions', style: { marginTop: 6 } },
              h(ui.Button, { variant: 'ghost', size: 'sm', disabled: busy, 'aria-label': 'Restituir la ruta ' + r.idRuta,
                title: 'Vuelve a "sin asignar": el motor puede volver a proponerla', onClick: function () { restituir(r.id); } }, '↩ Restituir')) : null)


          : cubierta
          ? h('div', { className: 'rr-body rr-cubierta', 'data-cubierta-fila': r.id },
            h('div', { className: 'rr-prop' },
              h('b', null, r.patente || marcaCub.patente || '—'), ' ', h('span', { className: 'muted' }, r.chofer || marcaCub.chofer || ''),
              h('span', { className: 'muted', style: { fontSize: 12 } }, ' · ' + (r.unidad || '') + (window.CR.canSeeCosts && r.tarifa != null ? ' · ' + money(r.tarifa) + ' (75 %)' : ''))),
            h('div', { className: 'muted', style: { fontSize: 13, marginTop: 4 } },
              'La hace ' + (marcaCub.patente || 'otra unidad') + ' por fuerza mayor: ' + (marcaCub.motivo || 'sin motivo') + '. ',
              (parR ? parR.idRuta + ' sigue con ' + (parR.patente || 'el titular') + ' (50 %). ' : ''),
              'Le llega el aviso normal de esta vuelta cuando notifiques.'),
            /* Si la vuelta ya se le habia avisado al titular, alguien tiene que decirle que esa no la hace. */
            marcaCub.avisoPrevio ? h('div', { className: 'alerta alerta--media', role: 'alert', 'data-aviso-previo': r.id, style: { marginTop: 6 } },
              '📲 Ya se le había avisado esta vuelta a ' + ((marcaCub.titular && (marcaCub.titular.chofer || marcaCub.titular.patente)) || 'el titular') + ' por WhatsApp: avisale que esa no la hace. El sistema no manda ningún mensaje nuevo.') : null,
            puedeDesestimar ? h('div', { className: 'rr-actions rr-deshacer-cobertura', style: { marginTop: 6 } },
              h(ui.Button, { variant: 'ghost', size: 'sm', disabled: busy, 'aria-label': 'Deshacer la cobertura de la vuelta ' + r.idRuta,
                title: 'La vuelta vuelve al titular (la doble entera con la misma unidad) y la que cubría queda libre',
                onClick: function () { deshacerCobertura(r.id); } }, '↩ Deshacer cobertura')) : null)
          : sinCubrir
          ? h('div', null,
            h('div', { className: 'rr-bad' }, libres.length
              ? '⚠ Sin chofer asignado. Hay ' + libres.length + ' unidad(es) libre(s) compatible(s): elegí una.'
              : '⚠ Sin chofer/vehículo elegible. Liberá una unidad de la zona o sumá flota.'),
            elegirChofer)
          : manual
            ? h('div', null,
              h('div', { className: 'muted', style: { fontSize: 13 } }, 'BackUp — se asigna a mano (pedido de flota por mail).'),
              elegirChofer)
            : h('div', { className: 'rr-body' },
              h('div', { className: 'rr-prop' },
                h('b', null, r.patente || '—'), ' ', h('span', { className: 'muted' }, r.chofer || ''),
                h('span', { className: 'muted', style: { fontSize: 12 } }, ' · ' + (r.unidad || '') + (window.CR.canSeeCosts && r.tarifa != null ? ' · ' + money(r.tarifa) : '')),


                r.agencia ? h('span', { className: 'muted rr-agencia', style: { fontSize: 12 } },
                  ' · ' + (/PARTICULAR/i.test(r.agencia) ? 'particular' : r.agencia)) : null,
                window.CR.canSeeCosts && r.tarifaNota ? h('span', { className: 'muted', style: { display: 'block', fontSize: 11, marginTop: 2 } }, r.tarifaNota) : null,
                h('div', { className: 'rr-chips' }, motivoChips(r))),
              String(r.servicio || '').toUpperCase() === 'BACKUP'
                ? h(SalioBackup, { r: r, busy: busy, onGuardar: guardarEdicion }) : null,
              h('div', { className: 'rr-actions' },
                h('div', { style: { flex: 1, minWidth: 200 } },
                  selectorRuta('Cambiar chofer…', esFm1() ? candOpts : opcionesConForzadas(r, candOpts))),
                h(ui.Button, { variant: selected ? 'primary' : 'ghost', size: 'sm', onClick: function () { elegirParaCraneo(r); } }, selected ? '✓ CRÁNEO' : '¿Por qué?'))));
    }

    // grupos por servicio: colapsables, con tope de filas + "mostrar más" (nada se oculta en silencio).
    // Si hay búsqueda activa se muestran TODAS las coincidencias (para eso buscás).
    var TOPE_RUTAS = 15;
    var grupos = SVC.map(function (s) {
      var arr = visibles.filter(function (r) { return svcDe(r) === s.k; })
        .sort(function (a, b) { return String(a.macroZona || '').localeCompare(String(b.macroZona || '')) || String(a.idRuta).localeCompare(String(b.idRuta)); });
      if (!arr.length) return null;
      // la búsqueda gana al plegado: si buscás algo que está en un grupo cerrado, se abre solo
      var cerrado = !!plegado[s.k] && !buscar.trim();
      var expandido = !!verMas[s.k] || !!buscar.trim();
      var filas = expandido ? arr : arr.slice(0, TOPE_RUTAS);
      var ocultas = arr.length - filas.length;
      var pend = arr.filter(function (r) { return r.estado === 'sin_asignar'; }).length;
      var des = arr.filter(function (r) { return r.estado === 'desestimada'; }).length;
      return h('section', { key: s.k, className: 'card rg' + (cerrado ? ' rg--cerrado' : '') },
        h('button', { type: 'button', className: 'rg__head', 'aria-expanded': !cerrado,
          onClick: function () { var n = Object.assign({}, plegado); n[s.k] = !cerrado; setPlegado(n); } },
          h('span', { className: 'rg__tit' }, s.label),
          h('span', { className: 'rg__n' }, arr.length),
          pend ? h('span', { className: 'badge badge--danger', style: { fontSize: 11 } }, pend + ' sin cubrir') : null,
          des ? h('span', { className: 'badge badge--neutral', style: { fontSize: 11 } }, des + ' desestimada' + (des === 1 ? '' : 's')) : null,
          h('span', { className: 'rg__chev', 'aria-hidden': true }, '▾')),
        !cerrado ? h('div', { className: 'rg__body' },
          filas.map(rutaRow),
          ocultas > 0 ? h('button', { type: 'button', className: 'rg__mas',
            onClick: function () { var n = Object.assign({}, verMas); n[s.k] = true; setVerMas(n); } },
            'Mostrar las ' + ocultas + ' restantes de ' + s.label) : null) : null);
    });



    var chipsProvincia = rutas.length ? h('div', { className: 'prov-chips', role: 'group', 'aria-label': 'Filtrar por provincia' },
      [''].concat(PROVINCIAS.filter(function (p) { return porProvincia[p]; })).map(function (p) {
        var label = p === '' ? ('Todas · ' + rutas.length) : (p + ' · ' + porProvincia[p]);
        var activo = prov === p;
        return h('button', { key: p || 'todas', type: 'button', className: 'rep-chip' + (activo ? ' rep-chip--on' : ''),
          'aria-pressed': activo, onClick: function () { setProv(activo && p !== '' ? '' : p); } }, label);
      }),
      porProvincia['OTROS'] ? h('span', { className: 'muted', style: { fontSize: 11.5 } },
        '"Otros" = rutas con nomenclatura que el sistema no reconoce — avisanos el prefijo y lo sumamos.') : null) : null;

    var hayFiltro = !!(unidadSel || prov || filtro || buscar.trim() || mediaSel);
    var chipsUnidad = rutas.length ? h('div', { className: 'prov-chips', role: 'group', 'aria-label': 'Filtrar por tipo de unidad (sugerida por MELI)' },
      [''].concat(UNIDADES_EDIT.filter(function (u) { return porUnidad[u]; }))
        .concat(porUnidad['(sin unidad)'] ? ['(sin unidad)'] : [])
        .map(function (u) {
          var label = u === '' ? ('Todas · ' + rutas.length) : (u + ' · ' + porUnidad[u]);
          var activo = unidadSel === u;
          return h('button', { key: u || 'todas', type: 'button', className: 'rep-chip' + (activo ? ' rep-chip--on' : ''),
            'aria-pressed': activo, onClick: function () { setUnidadSel(activo && u !== '' ? '' : u); } }, label);
        }),
      nMedia ? h('button', { type: 'button', className: 'rep-chip' + (mediaSel === 'si' ? ' rep-chip--on' : ''),
        'aria-pressed': mediaSel === 'si', title: 'Ver solo las rutas de media jornada',
        onClick: function () { setMediaSel(mediaSel === 'si' ? '' : 'si'); } }, '½ jornada · ' + nMedia) : null,
      nMedia ? h('button', { type: 'button', className: 'rep-chip' + (mediaSel === 'no' ? ' rep-chip--on' : ''),
        'aria-pressed': mediaSel === 'no', title: 'Ver solo las rutas de jornada completa',
        onClick: function () { setMediaSel(mediaSel === 'no' ? '' : 'no'); } }, 'Jornada completa · ' + (rutas.length - nMedia)) : null,
      hayFiltro ? h('span', { className: 'muted', style: { fontSize: 12 } }, nFiltradas + ' de ' + rutas.length + ' rutas') : null,
      hayFiltro ? h('button', { type: 'button', className: 'rutas-buscar__x', title: 'Limpiar todos los filtros',
        'aria-label': 'Limpiar todos los filtros', onClick: limpiarTodo }, '✕ limpiar') : null) : null;

    /* SOLO LAS ASIGNADAS: el archivo es para cargar asignaciones en Logistics, y una fila sin
       patente no es una asignación. Y sobre lo que se está VIENDO, con el título diciéndolo: en
       este sistema ya hubo un botón que actuaba sobre 119 filas mientras se veían 3. */
    var paraLogistics = visibles.filter(function (r) {
      return r.patente && r.estado !== 'sin_asignar';
    });
    var btnExport = rutas.length ? h('button', {
      type: 'button', className: 'cc__btn', disabled: !paraLogistics.length,
      title: hayFiltro
        ? 'Baja las ' + paraLogistics.length + ' rutas asignadas que estás viendo, no las ' + rutas.length + ' del día'
        : 'Baja las ' + paraLogistics.length + ' rutas asignadas del día (las que todavía no tienen unidad no entran)',
      onClick: function () {
        window.CR.csv.descargar(
          'craneo-asignaciones-logistics-' + new Date().toISOString().slice(0, 10)
            + (hayFiltro ? '-filtrada' : '') + '.csv',
          CSV_LOGISTICS, paraLogistics);
      }
    }, '⬇ Excel para Logistics (' + paraLogistics.length + ')') : null;

    var buscador = rutas.length ? h('div', { className: 'rutas-buscar' },
      h('input', { className: 'input', type: 'search', value: buscar, placeholder: '🔎 Buscar ruta, chofer, patente, barrio…',
        'aria-label': 'Buscar rutas', onChange: function (e) { setBuscar(e.target.value); } }),
      buscar.trim() ? h('span', { className: 'muted', style: { fontSize: 12, whiteSpace: 'nowrap' } },
        nFiltradas + ' resultado' + (nFiltradas === 1 ? '' : 's')) : null,
      buscar.trim() ? h('button', { type: 'button', className: 'rutas-buscar__x', 'aria-label': 'Limpiar búsqueda',
        onClick: function () { setBuscar(''); } }, '✕') : null) : null;

    return h('div', null,
      h(ui.Page, { title: 'Asignación de rutas (MELI)', subtitle: 'El motor propone chofer + vehículo con tus reglas. Vos ajustás el chofer final en 1 clic. CRÁNEO te explica cada decisión.' }),

      backendOn ? h(ui.Card, { title: 'Jornada de trabajo' },
        h('div', { style: { display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' } },
          h('label', null, 'Día de operación ', h('input', { type: 'date', className: 'input',
            'aria-label': 'Día de operación', min: window.CR.const.hoy(),
            max: new Date(new Date(window.CR.const.hoy() + 'T12:00:00Z').getTime() + 14 * 86400000).toISOString().slice(0,10),
            value: data && data.fechaOperacion || window.CR.backend.fechaOperacion || window.CR.const.hoy(),
            disabled: busy || !!carga || !!editId || !!confPend || confPhase === 'confirming',
            onChange: function (e) { cambiarJornada(e.target.value); } })),
          h('span', { className: 'muted' }, 'La selección es sólo de esta pestaña. Cada día conserva sus rutas y confirmaciones.')),
        data && data.jornadasProgramadas && data.jornadasProgramadas.length ? h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 12 }, 'aria-label': 'Jornadas cargadas' },
          data.jornadasProgramadas.map(function (j) { return h(ui.Button, { key: j.fecha,
            variant: data.fechaOperacion === j.fecha ? 'primary' : 'ghost', size: 'sm',
            disabled: busy || !!carga || !!editId || !!confPend || confPhase === 'confirming',
            onClick: function () { cambiarJornada(j.fecha); } }, j.fecha + ' · ' + j.rutas + ' rutas · ' + j.estado); })) : null) : null,

      data && data.fm ? h(ui.Card, { className: 'fm-jornada', title: 'Primera Milla en esta programación' },
        h('div', { className: 'fm-jornada__coverage' },
          h('span', { className: 'badge badge--' + (data.fm.politica === 'FM1' ? 'info' : 'neutral') }, data.fm.politica === 'FM1' ? 'Condiciones por patente' : 'Programación anterior'),
          data.fm.cambiosParaProximas ? h('span', { className: 'badge badge--warn' }, 'Hay cambios para próximas programaciones') : null,
          data.fm.coberturaDiaria && data.fm.coberturaDiaria.aplica ? h('span', { className: 'badge badge--neutral' },
            (data.fm.coberturaDiaria.cubiertas || []).length + ' de ' + (data.fm.coberturaDiaria.comprometidas || []).length + ' salidas diarias cubiertas') : null),
        data.fm.cambiosParaProximas ? h('p', { className: 'muted', style: { margin: 0 } }, 'Esta jornada conserva las condiciones guardadas al crear la programación, aunque todavía no se haya confirmado. Los cambios en Flota se aplican a nuevas programaciones; no actualizan las condiciones de esta jornada ni sus asignaciones.') : null,
        data.fm.coberturaDiaria && (data.fm.coberturaDiaria.sinCobertura || []).length ? h('div', { className: 'fm-banner fm-banner--warn', role: 'status' },
          h('b', null, 'Cobertura de programación incompleta — no bloquea confirmar, notificar ni cerrar.'),
          h('span', null, 'No confirma salida efectiva.'),
          h('ul', { className: 'fm-motivos' }, data.fm.coberturaDiaria.sinCobertura.map(function (x, i) {
            return h('li', { key: x.patente || i }, (x.patente ? x.patente + ': ' : '') + (x.detalle || (x.codigos || []).join(', ')));
          }))) : null,
        (data.fm.avisos || []).length ? h('div', { className: 'fm-banner', role: 'status' },
          h('b', null, 'Revisión operativa'),
          h('ul', { className: 'fm-motivos' }, data.fm.avisos.map(function (a, i) {
            return h('li', { key: a.codigo + ':' + i }, a.detalle || a.codigo);
          }))) : null) : null,


      puedePesos && pesos ? h(ui.Card, { className: 'pesos-solver', title: 'Cercanía por localidad de salida (pesos del asignador)' },
        h('p', { style: { marginTop: 0 } },
          'Sólo gerencia. Estado: ', h('b', { 'data-pesos-estado': pesos.apagado ? 'apagada' : 'encendida' },
            pesos.apagado ? 'APAGADA' : 'ENCENDIDA (' + pesos.pesos.cercania + ' puntos a 0 km, 0 desde ' + pesos.pesos.cercaniaD0 + ' km)'),
          '. Con el peso en 0 el motor propone exactamente igual que hasta ahora. Antes de encenderla, mirá la medición del ticket #77: km promedio y cuántas asignaciones cambian con cada peso.'),
        h('div', { className: 'grid grid--2' },
          h(ui.Field, { label: 'Peso de la cercanía (0 = apagado · referencia: unidad exacta 30, zona 24)',
            'data-novedad': 'cercania-peso' },
            h(ui.Input, { type: 'number', value: pesosForm.cercania, disabled: pesosForm.busy, placeholder: '0',
              'aria-label': 'Peso de la cercanía',
              onChange: function (v) { setPesosForm(Object.assign({}, pesosForm, { cercania: v, ok: null })); } })),
          h(ui.Field, { label: 'D0: km a los que deja de sumar (a 0 km suma el peso entero)' },
            h(ui.Input, { type: 'number', value: pesosForm.cercaniaD0, disabled: pesosForm.busy, placeholder: '40',
              'aria-label': 'Distancia D0 en km',
              onChange: function (v) { setPesosForm(Object.assign({}, pesosForm, { cercaniaD0: v, ok: null })); } }))),
        h('p', { className: 'muted', style: { fontSize: 12 } },
          ((pesos.ayuda && pesos.ayuda.cercania) || 'Peso de la cercanía. 0 = apagado.') + ' ' + ((pesos.ayuda && pesos.ayuda.cercaniaD0) || '')
            + ' Defaults: ' + (pesos.defaults ? pesos.defaults.cercania + ' / ' + pesos.defaults.cercaniaD0 + ' km' : '0 / 40 km')
            + '. Una unidad sin localidad de salida (o sin coordenada) queda neutra: 0 puntos, y el desplegable de candidatos lo dice.'),
        h(ui.Button, { variant: 'primary', disabled: pesosForm.busy || !pesosValidos(pesosForm),
          'aria-label': 'Guardar pesos del asignador', onClick: guardarPesos }, pesosForm.busy ? 'Guardando…' : 'Guardar pesos'),
        pesosForm.ok ? h('div', { className: 'alerta alerta--ok', role: 'status' }, pesosForm.ok) : null,
        pesosForm.err ? h('div', { className: 'alerta alerta--media', role: 'alert' }, '⚠ ' + pesosForm.err) : null) : null,

      h(ui.Toolbar, null,
        h('label', { className: 'btn btn--primary', 'data-novedad': 'macrozonas-importacion', title: 'Subí la base de flota (una vez) o las rutas del día. El sistema detecta cuál es.' },
          busy ? '⏳ Procesando…' : '📄 Cargar archivo (flota o rutas)',
          h('input', { type: 'file', accept: '.xlsx,.xls', style: { display: 'none' }, disabled: busy, onChange: onFile })),
        rutas.length ? h(ui.Button, { variant: 'ghost', onClick: reasignar, disabled: busy, icon: '⚙' }, 'Re-asignar (reset propuesta)') : null,
        rutas.length ? h(ui.Button, { variant: 'primary', onClick: confirmarYNotificar, disabled: busy || confPhase === 'confirming' || !resumen || !resumen.asignadas, icon: '✓',

          style: { fontSize: 16, fontWeight: 700, padding: '12px 24px' } }, confPhase === 'confirming' ? 'Notificando…' : 'Confirmar y notificar') : null,
        h('div', { className: 'spacer' }),
        resumen ? h('span', { className: 'badge badge--ok', dot: true }, resumen.asignadas + ' asignadas') : null,
        resumen && resumen.sinCubrir ? h('span', { className: 'badge badge--danger', dot: true }, resumen.sinCubrir + ' sin cubrir') : null,
        resumen && resumen.backupManual ? h('span', { className: 'badge badge--warn', dot: true }, resumen.backupManual + ' BackUp manual') : null,

        resumen && resumen.cubiertas ? h('span', { className: 'badge badge--info', 'data-cubiertas': resumen.cubiertas,
          title: 'Vueltas de una doble que hace otra unidad por fuerza mayor: 75 % a la que cubre y 50 % al titular' },
          '⇄ ' + resumen.cubiertas + ' vuelta' + (resumen.cubiertas === 1 ? ' cubierta' : 's cubiertas')) : null,
        resumen && resumen.desestimadas ? h('span', { className: 'badge badge--neutral', 'data-desestimadas': resumen.desestimadas,
          title: 'Rutas que no hacemos nosotros (MELI las cubre con otra empresa): no cuentan como sin cubrir' },
          resumen.desestimadas + ' desestimada' + (resumen.desestimadas === 1 ? '' : 's')) : null,
        ajustadas ? h('span', { className: 'badge badge--info', dot: true }, ajustadas + ' ajustadas por vos') : null,
        filtro ? h('span', { className: 'chip-filtro' },
          'Viendo ' + filtro.valor + ' · ' + nFiltradas + ' ruta' + (nFiltradas === 1 ? '' : 's'),
          h('button', { type: 'button', title: 'Quitar el filtro', 'aria-label': 'Quitar el filtro', onClick: function () { setFiltro(null); } }, '✕')) : null),

      err ? h(ui.Card, { className: 'card--danger' }, h('div', { style: { color: 'var(--danger)' } }, '⚠ ' + err)) : null,

      carga ? h('div', { className: 'modal-bg' },
        h('section', { className: 'modal', role: 'dialog', 'aria-modal': true, 'aria-labelledby': 'titulo-revision-carga', style: { maxWidth: 760 },
          onKeyDown: function (e) {
            if (e.key === 'Escape' && !busy) { setCarga(null); setErr(null); }
            if (e.key === 'Tab') {
              var controles = Array.prototype.slice.call(e.currentTarget.querySelectorAll('button:not(:disabled),select:not(:disabled),input:not(:disabled)'));
              var primero = controles[0], ultimo = controles[controles.length - 1];
              if (e.shiftKey && document.activeElement === primero) { e.preventDefault(); ultimo.focus(); }
              else if (!e.shiftKey && document.activeElement === ultimo) { e.preventDefault(); primero.focus(); }
            }
          } },
          h('div', { className: 'modal-h' }, h('h3', { id: 'titulo-revision-carga' }, 'Revisar carga antes de guardar')),
          h('div', { className: 'modal-b' },
            h('p', { style: { marginTop: 0, overflowWrap: 'anywhere' } }, h('b', null, carga.nombre)),
            h('p', null, 'Jornada de destino: ', h('b', null, carga.ing.previa.fechaOperacion), '. ',
              carga.ing.meta.origenFecha === 'archivo' ? 'La fecha sale del nombre del archivo.' : 'Se usa la jornada activa; el archivo no determina otra fecha.'),
            h('p', { className: 'muted' }, 'Todavía no se guardó nada. Cancelar deja rutas, flota y jornada sin cambios.'),
            carga.ing.meta.rutasLeidas ? h('label', null, 'Tipo de carga',
              h('select', { className: 'input', autoFocus: true, 'aria-label': 'Tipo de carga', value: carga.modo, disabled: busy,
                onChange: function (e) { revisarArchivo(carga.b64, carga.nombre, e.target.value); } },
                h('option', { value: 'incremental' }, 'Agregar / actualizar rutas (conservar las demás)'),
                h('option', { value: 'reemplazar' }, 'Reemplazar el ruteo completo del día'))) : null,
            h('p', null, 'Existentes: ', h('b', null, carga.ing.previa.existentes), ' · Nuevas: ', h('b', null, carga.ing.previa.nuevas),
              ' · Coincidentes: ', h('b', null, carga.ing.previa.coincidentes), ' · Total después de guardar: ', h('b', null, carga.ing.previa.total)),
            h('p', null, carga.modo === 'incremental' ? 'Se conservan todas las rutas que no vienen en el archivo.' : 'Las rutas que no vienen en el archivo se retirarán de la jornada y no se liquidarán al cerrarla.'),
            carga.ing.previa.flotaLeida ? h('p', null, 'Este archivo también actualizará la base de flota: ', carga.ing.previa.flotaLeida, ' unidades leídas.') : null,
            carga.ing.previa.fueraDelArchivo.length ? h('div', null,
              h('h4', null, carga.modo === 'reemplazar' ? 'Rutas que se retirarán' : 'Rutas que se conservarán aunque no estén en el archivo'),
              h('ul', { style: { maxHeight: 180, overflowY: 'auto', overflowWrap: 'anywhere' } }, carga.ing.previa.fueraDelArchivo.map(function (r) {
                return h('li', { key: r.id }, r.idRuta, ' · ', r.patente || 'Sin patente', ' · ', r.estado, ' (', r.id, ')');
              }))) : null,
            carga.ing.previa.retiradas.length ? h('label', { style: { display: 'flex', gap: 10, alignItems: 'flex-start' } },
              h('input', { type: 'checkbox', checked: aceptarBajas, disabled: busy, onChange: function (e) { setAceptarBajas(e.target.checked); } }),
              'Confirmo retirar estas ' + carga.ing.previa.retiradas.length + ' rutas del día, incluidas las que puedan estar asignadas o notificadas.') : null,
            carga.ing.previa.cerrada ? h('p', { role: 'alert' }, 'Esta jornada está cerrada. Cancelá y reabrila desde Asignación antes de cargar rutas.') : null,
            err ? h('p', { role: 'alert', style: { color: 'var(--danger)' } }, err) : null),
          h('div', { className: 'modal-f', style: { flexWrap: 'wrap' } },
            h('button', { className: 'btn btn--ghost', autoFocus: !carga.ing.meta.rutasLeidas, disabled: busy, onClick: function () { setCarga(null); setErr(null); } }, 'Cancelar carga'),
            h('button', { className: 'btn btn--ghost', disabled: busy, onClick: function () { revisarArchivo(carga.b64, carga.nombre, carga.modo); } }, 'Actualizar revisión'),
            h('button', { className: 'btn btn--primary', disabled: busy || carga.ing.previa.cerrada || (carga.ing.previa.retiradas.length > 0 && !aceptarBajas), onClick: confirmarArchivo }, busy ? 'Procesando…' : 'Confirmar carga')))) : null,

      /* TRABAJO SIMULTANEO: que el operador SEPA que la pantalla se movio sola, o que hay algo
         esperando a que suelte. Un refresco silencioso es peor que ninguno: la ruta que estaba
         mirando cambia de chofer y parece un fantasma. */
      refrescoAviso ? h('div', { className: 'alerta alerta--baja', role: 'status', 'aria-label': 'Aviso de trabajo simultáneo' },
        '🔄 ' + refrescoAviso) : null,

      /* Va ARRIBA, antes de la operacion del dia: son rutas que estan afuera de la liquidacion.
         Si no hay pendientes la card no se dibuja — un tablero que muestra "0 pendientes" todos los
         dias se deja de mirar, y el dia que diga 4 nadie lo va a ver. */
      /* EL DIA QUE SE ESTA OPERANDO, si quedo cerrado. Va ANTES que los dias anteriores porque
         bloquea el trabajo de AHORA: mientras siga cerrado no entra ni una carga ni un alta. */
      pend && pend.diaOperativo && pend.diaOperativo.puedeReabrir && puedeGerencia ? h(ui.Card, {
        /* Clase PROPIA: `.cierre-historico` es el gancho del arnes de los dias anteriores y
           esta card va ANTES en el DOM — compartirla haria que aquellas aserciones midieran esta. */
        className: 'reabrir-dia card--danger', title: 'El día ' + pend.diaOperativo.fecha + ' está CERRADO' },
        h('p', { style: { marginTop: 0 } },
          h('b', null, 'Mientras siga cerrado no se puede cargar la planilla, asignar, editar rutas ni dar de alta unidades.'),
          ' Tiene ' + pend.diaOperativo.vivas + ' ruta(s) cargadas'
            + (pend.diaOperativo.congeladas ? ' y ' + pend.diaOperativo.congeladas + ' fila(s) guardadas de un cierre anterior' : '')
            + '. Reabrirlo no borra nada: al volver a cerrar el día, esas filas se recalculan con las tarifas y los ajustes de hoy.'),
        h(ui.Field, { label: 'Por qué se reabre (queda en el historial)' },
          h(ui.Input, { value: reab.motivo, disabled: reab.busy,
            'aria-label': 'Motivo para reabrir el día',
            placeholder: 'Ej: se cerró por error antes de operar',
            onChange: function (v) { setReab(Object.assign({}, reab, { motivo: v })); } })),
        h(ui.Button, { variant: 'danger', disabled: reab.busy || (reab.motivo || '').trim().length < 5,
          'aria-label': 'Reabrir el día ' + pend.diaOperativo.fecha,
          onClick: reabrirDia }, reab.busy ? 'Reabriendo…' : 'Reabrir el día'),
        reab.ok ? h('div', { className: 'alerta alerta--ok', role: 'status' }, reab.ok) : null,
        reab.err ? h('div', { className: 'alerta alerta--media', role: 'alert' }, '⚠ ' + reab.err) : null) : null,
      /* Sin la llave de gerencia se AVISA igual: un operador que no sabe que el dia esta cerrado ve
         "un chofer dado de alta que no aparece" (02/09) y no tiene con que explicarlo. */
      pend && pend.diaOperativo && pend.diaOperativo.puedeReabrir && !puedeGerencia ? h(ui.Card, {
        className: 'reabrir-dia card--danger', title: 'El día ' + pend.diaOperativo.fecha + ' está CERRADO' },
        h('p', { style: { marginTop: 0, marginBottom: 0 } },
          h('b', null, 'Mientras siga cerrado no se puede cargar la planilla, asignar, editar rutas ni dar de alta unidades.'),
          ' Tiene ' + pend.diaOperativo.vivas + ' ruta(s) cargadas. Reabrirlo es de gerencia: pedilo antes de seguir.')) : null,





      pend && pend.diaHoy && pend.diaHoy.existe && pend.diaHoy.estado === 'abierta' && pend.diaHoy.vivas > 0 && puedeCerrar ? h(ui.Card, {
        className: 'cerrar-hoy', title: 'Cerrar el día de hoy (' + pend.diaHoy.fecha + ')' },
        h('p', { style: { marginTop: 0 } },
          'Cuando terminaron todas las rutas de hoy, cerralo: sus ' + pend.diaHoy.vivas + ' ruta(s) entran a la liquidación con la tarifa por patente. ',
          'Si ya cargaste la planilla de mañana, no pasa nada: se cierra hoy, no mañana.'),
        h(ui.Button, { variant: 'primary', disabled: !!cierre.fecha,
          'aria-label': 'Cerrar el día de hoy ' + pend.diaHoy.fecha,
          onClick: function () { accionarDia({ fecha: pend.diaHoy.fecha, accion: 'cerrar', vivas: pend.diaHoy.vivas }); } },
          cierre.fecha === pend.diaHoy.fecha ? 'Cerrando…' : 'Cerrar el día de hoy'),
        cierre.ok && !(pend.dias && pend.dias.length) ? h('div', { className: 'alerta alerta--ok', role: 'status' }, cierre.ok) : null,
        cierre.err && !(pend.dias && pend.dias.length) ? h('div', { className: 'alerta alerta--media', role: 'alert' }, '⚠ ' + cierre.err) : null) : null,

      pend && pend.dias && pend.dias.length ? h(ui.Card, {
        className: 'cierre-historico card--danger', title: 'Días sin cerrar de días anteriores' },
        h('p', { style: { marginTop: 0 } },
          h('b', null, pend.rutasAfuera + ' ruta(s)'),
          ' de días anteriores están fuera de la liquidación. Un día sin congelar no se factura.'),
        h('div', { className: 'tablewrap' },
          h('table', { className: 'tbl' },
            h('thead', null, h('tr', null,
              h('th', null, 'Día'), h('th', null, 'Estado'),
              h('th', { style: { textAlign: 'right' } }, 'Rutas afuera'), h('th', null, ''))),
            h('tbody', null, pend.dias.map(function (d) {
              var trabajando = cierre.fecha === d.fecha;
              return h('tr', { key: d.fecha },
                h('td', null, h('b', null, d.fecha)),
                /* El rotulo distingue las DOS familias. No son intercambiables: sobre un dia ya
                   cerrado el cierre no hace nada, porque su UPDATE exige estado='abierta'. */
                h('td', null, d.familia === 'sin_cerrar'
                  ? 'Quedó sin cerrar'
                  : 'Cerrado, pero sin guardar sus rutas'),
                h('td', { style: { textAlign: 'right' } },
                  d.familia === 'sin_cerrar' ? d.vivas : d.vivas_sin_congelar),
                h('td', null, d.accion === 'revisar'





                  ? h('span', null,
                      h('span', { className: 'badge badge--warn', title: 'Tiene ' + d.cong_huerfanas + ' fila(s) guardadas que no corresponden a sus rutas de ese día.' },
                        'Necesita revisión manual'),
                      puedeGerencia ? h(ui.Button, {
                          variant: 'default', size: 'sm', disabled: !!cierre.fecha, style: { marginLeft: 8 },
                          title: 'Deja el día cerrado tal como está, sin recongelar ni recalcular, y lo saca de esta lista. Se puede deshacer.',
                          'aria-label': 'Marcar el ' + d.fecha + ' como día de prueba',
                          onClick: function () { marcarDiaDePrueba(d); } },
                          trabajando ? 'Procesando…' : 'Marcar como día de prueba') : null)
                  /* Recongelar reescribe una foto ya congelada: sigue siendo de gerencia. Al operador
                     se le dice quien lo hace, no se le dibuja un boton que contesta 403. */
                  : (d.accion === 'recongelar' && !puedeGerencia)
                  ? h('span', { className: 'badge badge--warn', title: 'Congelar las rutas que faltan de un día ya cerrado es de gerencia.' },
                      'Lo congela gerencia')
                  : h(ui.Button, {
                      variant: 'primary', disabled: !!cierre.fecha,
                      'aria-label': (d.accion === 'recongelar' ? 'Congelar las rutas que faltan del ' : 'Cerrar y congelar el ') + d.fecha,
                      onClick: function () { accionarDia(d); } },
                      trabajando ? 'Procesando…'
                        : (d.accion === 'recongelar' ? 'Congelar las que faltan' : 'Cerrar y congelar'))));
            })))),
        cierre.ok ? h('div', { className: 'alerta alerta--ok', role: 'status' }, cierre.ok) : null,
        cierre.err ? h('div', { className: 'alerta alerta--media', role: 'alert' }, '⚠ ' + cierre.err) : null,
        h('p', { className: 'muted', style: { fontSize: 12, marginBottom: 0 } },
          'Cada día se cierra con la tarifa que quedó guardada en sus rutas. Queda registrado quién lo cerró y cuándo.')) : null,

      // advertencia de confirmación con pendientes operativos: se puede avanzar igual (decisión del planificador)
      confPend ? h(ui.Card, { className: 'card--danger' },
        /* Lo primero que se lee es a cuanta gente real le llega. Los pendientes operativos van
           despues: son un detalle del dia, esto es un envio que no se puede deshacer. */
        h('p', { style: { fontWeight: 700, marginBottom: 6 } },
          '📲 Vas a enviar un WhatsApp REAL a los grupos de choferes de ' + (confPend.avisos || 0)
          + ' ruta' + ((confPend.avisos || 0) === 1 ? '' : 's') + '.'),
        h('p', { className: 'muted', style: { fontSize: 13, marginTop: 0 } },
          'Los mensajes salen a los grupos de WhatsApp reales y no se pueden deshacer. Si estás ',
          'probando o mostrando el sistema, cancelá.'),
        h('div', { style: { fontWeight: 700, marginBottom: 6 } },
          (confPend.n || confPend.backups ? '⚠ ' : '') + (confPend.n ? 'Quedan ' + confPend.n + ' pendiente' + (confPend.n === 1 ? '' : 's') + ' operativo' + (confPend.n === 1 ? '' : 's') + ': ' + confPend.ids.join(', ') : '') +
          (confPend.backups ? (confPend.n ? ' · ' : '') + confPend.backups + ' Back Up sin marcar ¿salió?: ' + (confPend.backupIds || []).join(', ') : '')),
        h('p', { className: 'muted', style: { fontSize: 13, margin: '0 0 12px' } },
          'Si confirmás igual, se notifica a los choferes asignados. ' +
          (confPend.sinCubrir ? confPend.sinCubrir + ' ruta(s) quedarán sin cubrir. ' : '') +
          (confPend.ciclos ? confPend.ciclos + ' doble(s) ciclo(s) quedarán partidos. ' : '') +
          'Podés cancelar y resolverlos primero.'),
        h('div', { style: { display: 'flex', gap: 10, flexWrap: 'wrap' } },
          h(ui.Button, { variant: 'primary', onClick: function () { confirmarYNotificar(true); } }, '✓ Confirmar igual (' + (confPend.n + (confPend.backups || 0)) + ' aviso' + ((confPend.n + (confPend.backups || 0)) === 1 ? '' : 's') + ')'),
          h(ui.Button, { variant: 'ghost', onClick: function () { setConfPend(null); } }, 'Cancelar y resolver primero'))) : null,

      notif ? h(ui.Card, null, (function () {
        var full = notif.total > 0 && notif.ok === notif.total;
        var none = notif.ok === 0;
        var col = (notif.error || none) ? 'var(--danger)' : (full ? 'var(--ok)' : 'var(--warn)');
        // Si NO se envió nada y TODO lo que falta son grupos sin activar, el gateway no tiene la
        // culpa: mandar al operador a "revisá el WhatsApp" lo hace buscar donde no está el problema.
        var soloFaltanGrupos = notif.pendGrupo > 0 && notif.ok === 0 && !notif.fallidos;
        /* "Notificados 68/80" con CERO enviados es lo que el cliente leyo el 01/09 como "notifico 68
           pero no llego ninguna". Las omitidas por idempotencia ya estaban avisadas: se dice eso,
           no se las cuenta como avisadas ahora. */
        var nadaNuevo = !notif.error && (notif.enviados || 0) === 0 && (notif.omitidos || 0) > 0 && !notif.fallidos;
        var txt = notif.error
          ? '⚠ Programación confirmada, pero falló la notificación por WhatsApp (revisá que el gateway esté prendido).'
          : nadaNuevo
            ? 'ℹ No salió ningún aviso NUEVO: las ' + notif.omitidos + ' ruta' + (notif.omitidos === 1 ? '' : 's')
              + ' ya estaban avisadas para esta jornada, así que no se repiten. Si una cambia de chofer o de grupo, se reavisa sola al confirmar.'
          : soloFaltanGrupos
            ? '⚠ Programación confirmada, pero NO se notificó ninguna ruta: ' + notif.pendGrupo
              + (notif.pendGrupo === 1 ? ' ruta quedó pendiente porque su grupo de WhatsApp todavía no está activo.'
                                       : ' rutas quedaron pendientes porque sus grupos de WhatsApp todavía no están activos.')
              + ' Activalos en Onboarding de grupos y volvé a notificar. El WhatsApp está bien: no hace falta tocar el gateway.'
            : full
              ? '✅ Programación confirmada. Notificados ' + notif.ok + '/' + notif.total + ' choferes · ' + notif.modo + (notif.demo ? ' · DEMO → todo a ' + notif.demo : '')
              : none
                ? '⚠ Programación confirmada, pero NO se notificó a ningún chofer (0/' + notif.total + '). Revisá que el WhatsApp esté conectado.'
                : '⚠ Programación confirmada. Se notificaron ' + notif.ok + '/' + notif.total + ' (faltaron ' + (notif.total - notif.ok) + ' — revisá el gateway).';
        // pendientes por grupo sin activar: se informan SIEMPRE, aunque parte sí se haya enviado.
        if (notif.pendGrupo && !soloFaltanGrupos) {
          txt += notif.pendGrupo === 1
            ? ' · 1 ruta quedó pendiente por grupo de WhatsApp sin activar.'
            : ' · ' + notif.pendGrupo + ' rutas quedaron pendientes por grupos de WhatsApp sin activar.';
        }
        if (notif.pend) txt += notif.pend === 1 ? ' · 1 ruta sin cubrir quedó pendiente.' : ' · ' + notif.pend + ' rutas sin cubrir quedaron pendientes.';
        return h('div', null,
          h('div', { style: { color: col, fontWeight: 600 } }, txt),
          !notif.error ? h('div', { className: 'muted', style: { marginTop: 6, fontSize: 13 } },
            'Resultado real: ' + (notif.enviados || 0) + ' enviados · ' + (notif.fallidos || 0) + ' fallidos · ' + (notif.omitidos || 0) + ' omitidos por idempotencia' +
            (notif.pendGrupo ? ' · ' + notif.pendGrupo + ' pendientes por grupo sin activar' : '') +
            ((notif.gated || notif.fallback) ? ' · ' + (notif.gated || 0) + ' gated · ' + (notif.fallback || 0) + ' fallback' : '')) : null,
          notif.fallidos ? h(ui.Button, { variant: 'ghost', size: 'sm', disabled: confPhase === 'confirming',
            onClick: reintentarFallidos }, confPhase === 'confirming' ? 'Reintentando…' : 'Reintentar fallidos') : null);
      })()) : null,

      !rutas.length && !busy ? h(ui.Card, null, h(ui.EmptyState, { title: 'Cargá los archivos de Mercado Libre' },
        h('div', { style: { textAlign: 'left', maxWidth: 560, margin: '0 auto' } },
          h('p', { style: { margin: '0 0 8px' } }, h('b', null, '1) Base de flota (una sola vez):'), ' subí el XLSX con las unidades/choferes. Queda guardada.'),
          h('p', { style: { margin: '0 0 8px' } }, h('b', null, '2) Rutas del día:'), ' seleccioná el archivo de MELI ("Rutas DD.MM.YYYY.xlsx"), revisá la fecha y el tipo de carga y confirmá para guardar. Las asignaciones anteriores se conservan.'),
          h('p', { className: 'muted', style: { margin: '8px 0 0', fontSize: 12 } }, 'Con el mismo botón "Cargar" subís cualquiera de los dos: el sistema detecta si es flota o rutas y las combina.')))) : null,

      resumen ? h(ui.Card, null,
        h('div', { className: 'resumen-row' },
          h(ui.StatCard, { label: 'Cobertura', value: cobertura + '%', tone: cobertura >= 95 ? 'ok' : 'warn' }),
          h(ui.StatCard, { label: 'Rutas', value: resumen.rutas }),
          h(ui.StatCard, { label: 'Asignadas', value: resumen.asignadas }),
          window.CR.canSeeCosts ? h(ui.StatCard, { label: 'Costo del día', value: money(resumen.costoTotal) }) : null,
          h(ui.StatCard, { label: 'Km', value: resumen.kmTotal }))) : null,

      resumen ? h(ui.Card, { title: 'KPIs del día' },
        h('div', { className: 'resumen-row', style: { marginBottom: 14 } },
          h(ui.StatCard, { label: 'Choferes usados', value: Object.keys(usedPat).length }),
          h(ui.StatCard, { label: 'Ajustadas por vos', value: ajustadas }),
          h(ui.StatCard, { label: 'BackUp manual', value: resumen.backupManual || 0 }),
          h(ui.StatCard, { label: 'Sin cubrir', value: resumen.sinCubrir || 0, tone: resumen.sinCubrir ? 'warn' : 'ok' }),
          resumen.desestimadas ? h(ui.StatCard, { label: 'Desestimadas', value: resumen.desestimadas, sub: 'no se hacen ni se avisan' }) : null,
          h(ui.StatCard, { label: 'Notificados', value: notif ? (notif.ok + '/' + notif.total) : '—' })),
        h('div', { style: { display: 'flex', gap: 24, flexWrap: 'wrap' } },
          h('div', { style: { flex: 1, minWidth: 260 } },
            h('div', { className: 'muted', style: { fontSize: 12, marginBottom: 6, fontWeight: 600 } }, 'Rutas por zona · ' + rutas.length + ' en total'),
            h(window.CR.Barras, { data: datosZona, activo: filtro && filtro.tipo === 'zona' ? filtro.valor : null, onPick: pickZona })),
          h('div', { style: { flex: 1, minWidth: 260 } },
            h('div', { className: 'muted', style: { fontSize: 12, marginBottom: 6, fontWeight: 600 } }, 'Rutas por servicio · ' + rutas.length + ' en total'),
            h(window.CR.Barras, { data: datosSvc, activo: filtro && filtro.tipo === 'servicio' ? filtro.valor : null, onPick: pickSvc })))) : null,

      rutas.length ? h('div', { className: 'arg-main' },
        h(window.CR.Discrepancias, {
          'data-novedad': 'pm-alerta-servicio-programacion',
          disc: disc, busy: busy, rutaDe: rutaDe, sugerido: sugerido, usadoPorOtro: usadoPorOtro,
          planGrupo: planGrupo, opcionesConForzadas: opcionesConForzadas, onPick: seleccionarDiscrepancia,
          onCargarOpciones: esFm1() ? cargarOpcionesDiscrepancia : null, onResolverGrupo: resolverGrupo, onVer: verRuta
        }),
        chipsProvincia,
        chipsUnidad,
        buscador,
        btnExport ? h('div', { className: 'toolbar', style: { marginTop: 6 } }, btnExport) : null,
        grupos) : null);
  }

  window.CR.screens = window.CR.screens || {};
  window.CR.screens.AsignacionReal = AsignacionReal;
})();
