// ─── Ruptura ──────────────────────────────────────────────────────────────────
// Produtos zerados (ruptura) e prestes a zerar (risco), por filial. Lê o retrato que o
// coletor grava em loja/{cnpj}/ruptura/atual e os nomes em .../ruptura/catalogo.
//
// O retrato traz só número: estoque, venda/dia, curva, preço e as datas da última compra
// e da última venda. "Zera em N dias", "sem comprar há N dias" e "sem vender há N dias"
// são calculados AQUI — subir o número pronto o deixaria velho já no primeiro dia,
// enquanto a divisão custa nada.

const RUP_CURVAS = ['A', 'B', 'C'];

// Janela de venda que o Coletor usa para calcular o venda/dia (RupturaBuilder.
// DiasHistoricoVenda). Ele grava Math.Round(venda90 / 90, 3), então o total do período
// volta multiplicando — não precisa mandar mais um número por produto num doc que já
// tem 14 mil itens. Se mudar lá, mude aqui: o número vira mentira silenciosamente.
const RUP_DIAS_HIST = 90;

// ── Conserto de acento quebrado nos nomes vindos do Farmax ───────────────────
// O byte cp1252 do acento foi lido como UTF-8 em algum ponto e virou U+FFFD ("�").
// O caractere original SE PERDEU — não dá para decodificar de volta, só reconhecer
// o padrão. Por isso: dicionário primeiro (decisão segura, palavra a palavra) e
// regras genéricas só onde o português não é ambíguo. Nesta base são 40 nomes de
// 5.564 (0,7%); o certo é consertar na origem (o coletor deveria ler cp1252), e
// quando isso acontecer estas regras viram inócuas — nada mais terá "�".
const RUP_PALAVRAS = [
  // -ÃO: indistinguível de -ÇO sem saber a palavra (VERÃO × LENÇO), então vai na mão
  [/UNI�O/gi, 'UNIÃO'],   [/REGI�O/gi, 'REGIÃO'],  [/VER�O/gi, 'VERÃO'],
  [/SAB�O/gi, 'SABÃO'],   [/ALGOD�O/gi, 'ALGODÃO'],[/CARV�O/gi, 'CARVÃO'],
  [/LIM�O/gi, 'LIMÃO'],   [/MAM�O/gi, 'MAMÃO'],    [/MEL�O/gi, 'MELÃO'],
  [/PADR�O/gi, 'PADRÃO'], [/LE�O/gi, 'LEÃO'],
  // -ÇO/-ÇA com consoante antes: o genérico não alcança
  // LENÇO antes de NÃO: senão "LEN�O" casa em "N�O" e vira "LENÃO"
  [/LEN�O/gi, 'LENÇO'],   [/SERVI�O/gi, 'SERVIÇO'], [/\bN�O\b/gi, 'NÃO'],
  // vogais acentuadas soltas
  [/�GUA/gi, 'ÁGUA'],     [/\bG�S\b/gi, 'GÁS'],     [/CAN�RIO/gi, 'CANÁRIO'],
  [/IMPERME�VEL/gi, 'IMPERMEÁVEL'], [/HORTEL�/gi, 'HORTELÃ'],
  [/MA��(?=\s|$)/gi, 'MAÇÃ'], [/\bCAF�\b/gi, 'CAFÉ'], [/\bBEB�\b/gi, 'BEBÊ'],
  [/�LCOOL/gi, 'ÁLCOOL'], [/�LEO/gi, 'ÓLEO'],       [/PROTE�NA/gi, 'PROTEÍNA'],
];

// Genéricas, só onde não há ambiguidade em português:
//   letra + � + A   → Ç    (segurança, força, pinça, postiça)
//   A|O  + � + O    → Ç    (braço, palhaço, poço) — "aão"/"oão" não existem
//   letra + �� + O  → ÇÃO  (prestação, aplicação, cognição)
//   dígito + �      → °    (360°)
// "E�O" (preço × leão) e "I�O" (serviço × união) ficam de fora: sem a palavra,
// chutar troca uma pela outra — e um "�" na tela é menos pior que a palavra errada.
function rupFixTexto(s) {
  let t = String(s ?? '');
  if (!t.includes('�')) return t;
  for (const [re, sub] of RUP_PALAVRAS) t = t.replace(re, sub);
  t = t.replace(/(\d)�/g, '$1°');
  t = t.replace(/([A-Za-zÀ-ÿ])��([oO])/g, (m, p1, p2) => p1 + (p2 === 'O' ? 'ÇÃ' : 'çã') + p2);
  t = t.replace(/([A-Za-zÀ-ÿ])�([aA])/g,  (m, p1, p2) => p1 + (p2 === 'A' ? 'Ç'  : 'ç')  + p2);
  t = t.replace(/([AaOo])�([oO])/g,        (m, p1, p2) => p1 + (p2 === 'O' ? 'Ç'  : 'ç')  + p2);
  return t;
}

// Amarelo só da barra por curva. O token --warning é laranja escuro (#B3640A) porque
// serve de cor de TEXTO no resto do site; como preenchimento de barra ao lado do
// vermelho os dois se confundem. Aqui é bloco de cor, então amarelo aberto separa melhor.
const RUP_AMARELO = '#EAB308';

// Uma linha por curva (A, B, C), cada uma com seus pontos. Empilhado escondia a curva
// individual — e a pergunta aqui é "quanto a curva A está sangrando", não a soma.
// O eixo X NÃO é o calendário: é o tempo que o item está sem reposição (ver perdaPorTempo).
// `sel` é um CONJUNTO de células período×curva ("2|A"), não um ponto só: a pergunta de
// quem compra costuma ser "o que está parado há mais de 30 dias" (dois períodos) ou "tudo
// que é curva A" (uma linha inteira), e não uma bolinha isolada. Clique alterna: no ponto
// pega a célula, na coluna o período inteiro, na legenda a curva inteira.
function RupLinhasCurva({ dados, metrica, sel = new Set(), onSel, onFiltrar, filtroRot }) {
  const [hover, setHover] = React.useState(null);   // { k, i }
  const f = dados.faixas;
  const W = 320, H = 168, padL = 40, padR = 10, padT = 12, padB = 26;
  const iw = W - padL - padR, ih = H - padT - padB;

  const CURVAS = [
    { k: 'A', cor: 'var(--danger)' },
    { k: 'B', cor: RUP_AMARELO },
    { k: 'C', cor: 'var(--ink-300)' },
  ];
  const valor = (x, k) => (metrica === 'reais' ? x.reais[k] : x.unid[k]);

  // Célula = período × curva. A chave carrega o índice da faixa, e faixa que sumiu (porque
  // outro filtro esvaziou o período) simplesmente não é desenhada nem contada — as leituras
  // sempre partem de `f`, então índice velho no conjunto é ignorado em vez de quebrar.
  const chave    = (i, k) => `${i}|${k}`;
  const marcado  = (i, k) => sel.has(chave(i, k));
  const alternar = (celulas) => {
    const n = new Set(sel);
    // Tudo já marcado → o clique desmarca. É o que faz o mesmo botão servir de liga e
    // desliga, sem precisar de tecla modificadora (que em toque não existe).
    const todas = celulas.every(c => n.has(chave(c.i, c.k)));
    for (const c of celulas) {
      const kk = chave(c.i, c.k);
      if (todas) n.delete(kk); else n.add(kk);
    }
    onSel?.(n);
  };
  const celulasDaFaixa = (i) => CURVAS.map(c => ({ i, k: c.k }));
  const celulasDaCurva = (k) => f.map((_, i) => ({ i, k }));

  // Resumo do que está marcado, para a frase e para o botão.
  const marcadas = [];
  f.forEach((x, i) => CURVAS.forEach(c => { if (marcado(i, c.k)) marcadas.push({ i, k: c.k, x }); }));
  const somaQtd = marcadas.reduce((s, m) => s + (m.x.qtd[m.k] ?? 0), 0);
  const somaVal = marcadas.reduce((s, m) => s + valor(m.x, m.k), 0);
  const periodos = new Set(marcadas.map(m => m.i)).size;
  // Escala pelo MAIOR ponto individual: sem empilhar, o topo é o pico de uma curva só.
  const max = Math.max(1, ...f.flatMap(x => CURVAS.map(c => valor(x, c.k))));
  const px = (i) => padL + (f.length === 1 ? iw / 2 : (i / (f.length - 1)) * iw);
  const py = (v) => padT + ih - (v / max) * ih;

  // Desempate horizontal: onde duas ou três curvas caem no MESMO valor — o caso do "até
  // 7d", em que as três costumam estar em zero — os pontos ficavam um dentro do outro e
  // só o último desenhado recebia o clique. Aqui eles se abrem em leque de 6,5px dentro
  // da própria faixa. A linha usa o mesmo deslocamento, senão o ponto sairia de cima dela.
  // Agrupa por PROXIMIDADE, não por valor idêntico: em "até 7d" as três curvas raramente
  // dão o mesmo número, mas caem a um ou dois pixels umas das outras — visualmente é o
  // mesmo ponto, e clicar pegava sempre a última desenhada (a C).
  const dx = f.map((x) => {
    const pts = CURVAS.map(c => ({ k: c.k, y: py(valor(x, c.k)) })).sort((a, b) => a.y - b.y);
    const off = {};
    let grupo = [];
    const fechar = () => {
      grupo.forEach((p, j) => { off[p.k] = grupo.length === 1 ? 0 : (j - (grupo.length - 1) / 2) * 8; });
      grupo = [];
    };
    for (const p of pts) {
      if (grupo.length && Math.abs(p.y - grupo[grupo.length - 1].y) > 7) fechar();
      grupo.push(p);
    }
    fechar();
    return off;
  });
  const cx = (i, k) => px(i) + (dx[i]?.[k] ?? 0);
  const ticks = [0, 0.25, 0.5, 0.75, 1].map(p => p * max);
  const fmtY = (v) => metrica === 'reais'
    ? (v >= 1000 ? `${(v / 1000).toFixed(0)}k` : Math.round(v).toString())
    : Math.round(v).toString();
  const fmtV = (v) => (metrica === 'reais' ? BRL(v) : `${NUM(Math.round(v))} un`);

  // Frase padrão: o ponto que mais sangra. É a informação que o usuário procuraria sozinho.
  const pior = (() => {
    let melhor = null;
    f.forEach((x, i) => CURVAS.forEach(c => {
      const v = valor(x, c.k);
      if (v > 0 && (!melhor || v > melhor.v)) melhor = { k: c.k, i, v, qtd: x.qtd[c.k], rot: x.rot };
    }));
    return melhor;
  })();
  // Ordem de precedência da frase: o que o mouse aponta, senão a seleção, senão o pior
  // ponto. Com uma célula só a frase é a detalhada; com várias vira resumo, porque listar
  // "curva A em 16–30d, curva B em 31–60d…" numa linha de 11px não se lê.
  const foco = hover && hover.i < f.length ? hover : null;
  const alvo = foco
    ? { ...foco, v: valor(f[foco.i], foco.k), qtd: f[foco.i].qtd[foco.k], rot: f[foco.i].rot }
    : marcadas.length === 1
      ? { i: marcadas[0].i, k: marcadas[0].k, v: valor(marcadas[0].x, marcadas[0].k),
          qtd: marcadas[0].x.qtd[marcadas[0].k], rot: marcadas[0].x.rot }
      : marcadas.length > 1 ? null : pior;

  return (
    <div style={{ marginTop: 10 }}>
      <svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', height: 168, display: 'block' }}
        onMouseLeave={() => setHover(null)}>
        {ticks.map((v, i) => (
          <g key={i}>
            <line x1={padL} x2={padL + iw} y1={py(v)} y2={py(v)} stroke="var(--ink-100)" />
            <text x={padL - 6} y={py(v) + 3.5} fontSize="9" fill="var(--ink-400)" textAnchor="end">{fmtY(v)}</text>
          </g>
        ))}

        {/* Faixa inteira como alvo de clique, ATRÁS dos pontos: pegar a curva certa num
            gráfico de 320px de largura é mira fina demais. Clicando na coluna seleciona o
            período todo (A+B+C); clicando no ponto, só aquela curva. */}
        {f.map((x, i) => {
          const meio = f.length === 1 ? iw : iw / (f.length - 1);
          const larg = Math.max(18, meio * 0.9);
          const doPeriodo = CURVAS.filter(c => marcado(i, c.k)).length;
          return (
            <rect key={`b${i}`} x={px(i) - larg / 2} y={padT} width={larg} height={ih}
              fill={doPeriodo ? 'var(--brand-500)' : 'transparent'}
              opacity={doPeriodo === CURVAS.length ? 0.1 : doPeriodo ? 0.05 : 1}
              onClick={() => alternar(celulasDaFaixa(i))}
              style={{ cursor: 'pointer' }}>
              <title>{`${x.rot}: clique para marcar ou desmarcar o período inteiro`}</title>
            </rect>
          );
        })}

        {CURVAS.map(c => {
          const d = f.map((x, i) => `${i === 0 ? 'M' : 'L'}${cx(i, c.k)},${py(valor(x, c.k))}`).join(' ');
          // Apaga a curva que o mouse não aponta e, quando há seleção, a que não tem
          // nenhuma célula marcada — assim dá para ver de longe o que está no filtro.
          const apagada = hover ? hover.k !== c.k
                        : marcadas.length > 0 && !marcadas.some(m => m.k === c.k);
          return (
            <g key={c.k} opacity={apagada ? 0.28 : 1}>
              {f.length > 1 && <path d={d} fill="none" stroke={c.cor} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" style={{ pointerEvents: 'none' }} />}
              {f.map((x, i) => {
                const aceso = (hover && hover.k === c.k && hover.i === i) || marcado(i, c.k);
                const escolher = () => alternar([{ i, k: c.k }]);
                return (
                  <g key={i}>
                    {/* Alvo invisível: o ponto visível tem 3,2px de raio, menos que a
                        precisão de um mouse. Onde as curvas estão espremidas (dx ≠ 0) o
                        alvo encolhe para 4px, senão o círculo da curva desenhada por
                        último cobriria o centro das outras e roubaria o clique de novo. */}
                    <circle cx={cx(i, c.k)} cy={py(valor(x, c.k))} r={dx[i]?.[c.k] ? 4 : 9} fill="transparent"
                      onMouseEnter={() => setHover({ k: c.k, i })}
                      onClick={escolher} style={{ cursor: 'pointer' }} />
                    <circle cx={cx(i, c.k)} cy={py(valor(x, c.k))} r={aceso ? 5 : 3.2}
                      fill={aceso ? c.cor : 'var(--white)'} stroke={c.cor} strokeWidth="2"
                      style={{ pointerEvents: 'none' }} />
                  </g>
                );
              })}
            </g>
          );
        })}

        {f.map((x, i) => (
          <text key={i} x={px(i)} y={H - 8} fontSize="9" fill="var(--ink-400)" textAnchor="middle">{x.rot}</text>
        ))}
      </svg>

      {/* Frase: o que está apontado ou marcado, em português */}
      <div style={{ fontSize: 11.5, color: 'var(--ink-600)', lineHeight: 1.5, marginTop: 6, minHeight: 34 }}>
        {alvo && alvo.v > 0 ? (
          <>
            Produtos <b style={{ color: 'var(--ink-900)' }}>curva {alvo.k}</b>
            {alvo.rot === 'nunca' ? ' nunca comprados' : ` há ${alvo.rot} sem reposição`}:{' '}
            <b style={{ color: 'var(--ink-900)' }}>{NUM(alvo.qtd)}</b> {alvo.qtd === 1 ? 'item deixando' : 'itens deixando'} de vender{' '}
            <b style={{ color: 'var(--danger)' }}>{fmtV(alvo.v)}</b> por dia.
          </>
        ) : marcadas.length > 1 ? (
          <>
            <b style={{ color: 'var(--ink-900)' }}>{NUM(marcadas.length)}</b> seleções em{' '}
            <b style={{ color: 'var(--ink-900)' }}>{NUM(periodos)}</b> {periodos === 1 ? 'período' : 'períodos'}:{' '}
            <b style={{ color: 'var(--ink-900)' }}>{NUM(somaQtd)}</b> itens deixando de vender{' '}
            <b style={{ color: 'var(--danger)' }}>{fmtV(somaVal)}</b> por dia.
          </>
        ) : 'Nenhum item zerado com venda no filtro atual.'}
      </div>

      {/* O gráfico só dizia onde dói; daqui se chega nos itens. O clique seleciona, o
          botão aplica — separado de propósito, senão passar o mouse pelo gráfico ficaria
          trocando a lista embaixo do usuário. */}
      {(marcadas.length > 0 || filtroRot) && (
        <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 8 }}>
          {marcadas.length > 0 && (
            <button onClick={() => onFiltrar?.(marcadas.map(m => ({
              curva: m.k, rot: m.x.rot, min: m.x.min, max: m.x.max,
            })))}
              style={{
                fontSize: 11.5, fontWeight: 800, fontFamily: 'inherit', padding: '6px 14px',
                borderRadius: 999, cursor: 'pointer', border: '1px solid var(--brand-500)',
                background: 'var(--brand-500)', color: '#fff',
              }}>
              Filtrar {marcadas.length === 1
                ? `curva ${marcadas[0].k} · ${marcadas[0].x.rot === 'nunca' ? 'nunca comprados' : marcadas[0].x.rot}`
                : `${NUM(marcadas.length)} seleções`}
              {' '}({NUM(somaQtd)})
            </button>
          )}
          {marcadas.length > 0 && (
            <button onClick={() => onSel?.(new Set())}
              style={{
                fontSize: 11, fontWeight: 700, fontFamily: 'inherit', padding: '5px 12px',
                borderRadius: 999, cursor: 'pointer', border: '1px solid var(--ink-200)',
                background: 'var(--white)', color: 'var(--ink-500)',
              }}>
              desmarcar
            </button>
          )}
          {filtroRot && (
            <button onClick={() => { onFiltrar?.(null); onSel?.(new Set()); }}
              style={{
                fontSize: 11, fontWeight: 700, fontFamily: 'inherit', padding: '5px 12px',
                borderRadius: 999, cursor: 'pointer', border: '1px solid var(--ink-200)',
                background: 'var(--white)', color: 'var(--ink-500)',
              }}>
              limpar filtro · {filtroRot}
            </button>
          )}
        </div>
      )}

      {/* A legenda também é controle: clicar em "curva A" marca (ou desmarca) a curva
          inteira, em todos os períodos — é o atalho para "quero tudo que é A". */}
      <div style={{ display: 'flex', gap: 10, fontSize: 10.5, color: 'var(--ink-400)', fontWeight: 600, flexWrap: 'wrap', alignItems: 'center' }}>
        {CURVAS.map(c => {
          const todaMarcada = f.length > 0 && f.every((_, i) => marcado(i, c.k));
          return (
            <button key={c.k} onClick={() => alternar(celulasDaCurva(c.k))}
              title={`Marcar ou desmarcar a curva ${c.k} em todos os períodos`}
              style={{
                display: 'inline-flex', alignItems: 'center', gap: 4, fontFamily: 'inherit',
                fontSize: 10.5, fontWeight: todaMarcada ? 800 : 600, cursor: 'pointer',
                padding: '3px 9px', borderRadius: 999,
                border: `1px solid ${todaMarcada ? c.cor : 'var(--ink-200)'}`,
                background: todaMarcada ? 'var(--brand-50)' : 'var(--white)',
                color: todaMarcada ? 'var(--ink-800)' : 'var(--ink-400)',
              }}>
              <i style={{ width: 8, height: 8, borderRadius: 2, background: c.cor, display: 'inline-block' }} />
              curva {c.k}
            </button>
          );
        })}
        <span style={{ marginLeft: 'auto', color: 'var(--ink-500)' }}>
          total {metrica === 'reais' ? BRL(dados.totR) : `${NUM(Math.round(dados.totU))} un`}/dia
        </span>
      </div>
    </div>
  );
}

// Retratos da REDE, não só o da matriz. Numa rede interligada (filiais tipo I) cada loja
// tem CNPJ e banco próprios, e grava o seu `loja/{cnpjFilial}/ruptura/atual` chaveado pelo
// cd_filial LOCAL — quase sempre "1". Lendo só o CNPJ do login, a tela mostrava a matriz e
// mais nada, e o filtro de loja do topo não casava com ninguém (caso rede FUX 02940497000153,
// 8 filiais tipo I). Aqui cada CNPJ vira um bloco com o retrato e o catálogo DELE: os códigos
// de produto são de cadastros diferentes e não podem ser somados entre lojas — o mesmo 006603
// é GARDENAL numa e HUGGIES noutra.
function useRuptura(user) {
  const [blocos,  setBlocos]  = React.useState([]);   // [{ cnpj, storeKey, snap, catalogo, codigos, recebido }]
  const [loading, setLoading] = React.useState(true);

  React.useEffect(() => {
    if (!user?.cnpj) return;
    let cancelado = false;
    // O coletor grava o corpo como STRING JSON, não como mapa: com 13 mil produtos o
    // Firestore recusava o documento por passar de 40 mil entradas de índice. Nada aqui
    // é consultado por dentro — o doc é sempre lido inteiro —, então string serve.
    const parse = (d) => {
      if (!d?.dados) return null;
      try { return JSON.parse(d.dados); } catch { return null; }
    };
    // Guarda por CNPJ e republica: os listeners chegam em ordem imprevisível.
    const porCnpj = new Map();
    const publicar = () => { if (!cancelado) setBlocos([...porCnpj.values()]); };
    const colRup = (cnpj) => SF.db.collection('loja').doc(cnpj).collection('ruptura');

    // ── Por que este hook é assim: os documentos são GRANDES ─────────────────
    // Medido em 29/08/2026: `atual` vai a 866 KB e `catalogo` a 631 KB. O Firestore cobra
    // transferência de saída por BYTE, e essa linha é a maior da fatura do projeto (65 GiB
    // no mês). Cada entrega de listener aqui é o documento INTEIRO de novo — não existe
    // entrega parcial. Numa rede de 8 filiais, com o coletor reescrevendo o retrato de 2
    // em 2 horas das 08h às 20h, uma aba esquecida aberta baixava 9 × 390 KB × 7 = 24 MB
    // por dia sem ninguém olhando. Daí as duas regras abaixo.

    // ── Catálogo: leitura única, rebuscado só quando o retrato traz código novo ──
    // Era `onSnapshot` na loja do login, "porque é o que muda quando o coletor atualiza a
    // versão". O que de fato interessa dessa atualização é produto NOVO — e isso dá para
    // detectar de graça, olhando se o retrato cita código que o catálogo em mãos não tem.
    // Só aí paga-se a releitura. Renomear um produto no ERP sem que ele saia do retrato é
    // o único caso que passa a esperar o próximo carregamento da tela.
    const buscandoCat = new Set();
    const carregarCatalogo = (cnpj) => {
      if (cancelado || buscandoCat.has(cnpj)) return;
      buscandoCat.add(cnpj);
      colRup(cnpj).doc('catalogo').get()
        .then(s => {
          buscandoCat.delete(cnpj);
          const b = porCnpj.get(cnpj);
          if (!b || cancelado) return;
          SF.medir('Ruptura · catálogo', s.exists ? s.data() : null);
          b.catalogo = s.exists ? parse(s.data()) : null;
          b.codigos  = new Set((b.catalogo?.produtos ?? []).map(c => c[0]));
          publicar();
        })
        .catch(() => { buscandoCat.delete(cnpj); /* sem catálogo, a tela mostra o código */ });
    };
    // ── Remanejamento: quem tem sobrando o que falta em outra filial ─────────
    // Documento irmão do retrato (loja/{cnpj}/ruptura/remanejamento), escrito pelo Coletor
    // 1.0.4.62 no MESMO tick — por isso é rebuscado quando o retrato chega, e não por
    // listener próprio: dois listeners entregariam o par em ordens diferentes e a tela
    // mostraria doador de produto que a lista já não tem.
    //
    // Custo: ~267 KB por leitura na rede medida, contra os 866 KB do retrato. Como o
    // retrato muda de 2 em 2 horas, uma aba aberta o dia todo paga ~45% a mais de saída.
    // Se um dia pesar, o caminho é adiar para a primeira abertura do painel de doadores —
    // mas aí a coluna da tabela não poderia existir, que é justamente o que dá o aviso
    // sem ninguém procurar.
    const buscandoRem = new Set();
    const carregarRemanejamento = (cnpj) => {
      if (cancelado || buscandoRem.has(cnpj)) return;
      buscandoRem.add(cnpj);
      colRup(cnpj).doc('remanejamento').get()
        .then(s => {
          buscandoRem.delete(cnpj);
          const b = porCnpj.get(cnpj);
          if (!b || cancelado) return;
          // Coletor anterior à 1.0.4.62 não grava este documento: fica null e a tela
          // esconde a coluna em vez de mostrar "0 doadores", que seria afirmação falsa.
          SF.medir('Ruptura · remanejamento', s.exists ? s.data() : null);
          b.remanejamento = s.exists ? parse(s.data()) : null;
          publicar();
        })
        .catch(() => { buscandoRem.delete(cnpj); /* sem doadores, a tela segue sem a coluna */ });
    };

    const catalogoVelho = (b) => {
      // Sem catálogo (ou catálogo vazio) não há o que comparar: a tela mostra o código, que
      // é o comportamento de sempre. Sem esta guarda, loja sem o doc rebuscaria um documento
      // inexistente a cada entrega do retrato.
      if (!b.codigos || !b.codigos.size) return false;
      // `codigos` nulo = o get() ainda está em curso e vai resolver sozinho.
      for (const lista of Object.values(b.snap ?? {})) {
        if (!Array.isArray(lista)) continue;
        for (const pr of lista) if (!b.codigos.has(pr[0])) return true;
      }
      return false;
    };

    // ── Retrato ao vivo, mas só enquanto a aba está à vista ──────────────────
    // A tela continua se atualizando sozinha exatamente como antes para quem está olhando.
    // O que deixa de acontecer é a aba em segundo plano receber megabytes o dia inteiro.
    const alvos     = [];                    // [{ cnpj, storeKey }]
    const offsAtual = new Map();             // cnpj → unsub
    const ligarRetrato = (cnpj) => {
      if (cancelado || offsAtual.has(cnpj)) return;
      offsAtual.set(cnpj, colRup(cnpj).doc('atual').onSnapshot(
        s => {
          SF.medir('Ruptura · retrato', s.exists ? s.data() : null);
          const b = porCnpj.get(cnpj);
          // `recebido` separa "ainda carregando" de "esta loja não tem retrato": é o que
          // permite avisar na tela quais lojas da rede estão fora sem piscar o aviso na
          // abertura. Loja em Coletor anterior ao retrato de ruptura cai aqui.
          if (b) {
            b.snap = s.exists ? parse(s.data()) : null;
            // Quando o coletor gravou ESTE retrato. A tela precisa disso porque a ruptura
            // não é tempo real: entre janelas o estoque do ERP anda e o daqui não.
            b.updatedAt = s.exists ? (s.data()?.updatedAt?.toDate?.() ?? null) : null;
            b.recebido = true;
            if (catalogoVelho(b)) carregarCatalogo(cnpj);
            // Retrato novo = remanejamento novo: o Coletor grava os dois no mesmo tick.
            carregarRemanejamento(cnpj);
            publicar();
          }
          if (cnpj === user.cnpj) setLoading(false);
        },
        () => { if (cnpj === user.cnpj) setLoading(false); }));
    };
    const ligarRetratos    = () => { for (const a of alvos) ligarRetrato(a.cnpj); };
    const desligarRetratos = () => {
      for (const off of offsAtual.values()) { try { off(); } catch { /* já desinscrito */ } }
      offsAtual.clear();
    };

    // ── Por que a carência é de 2 HORAS, e não de um minuto ──────────────────
    // Reassinar entrega o documento inteiro de novo. Então pausar só compensa se, no tempo
    // em que a aba ficou escondida, tivessem chegado MAIS entregas do que o download único
    // que a volta custa. O coletor publica o retrato de 2 em 2 horas: escondido por H horas,
    // ficar ao vivo custa H/2 downloads e pausar custa exatamente 1. Empata em H = 2h.
    //
    // Com carência de 1 minuto — que foi a primeira ideia — quem alterna de aba 20 vezes ao
    // dia pagaria 20 downloads no lugar dos 7 do dia inteiro ao vivo: a "otimização" sairia
    // três vezes mais cara. Em 2h a conta nunca fica negativa, e é o caso que interessa de
    // verdade: a aba esquecida aberta a noite toda, que hoje baixa o retrato de hora em hora
    // sem ninguém na frente.
    let timerOculto = null;
    const OCULTO_MS = 2 * 60 * 60 * 1000;
    const aoMudarVisibilidade = () => {
      if (document.hidden) {
        if (!timerOculto) timerOculto = setTimeout(() => { timerOculto = null; desligarRetratos(); }, OCULTO_MS);
      } else {
        if (timerOculto) { clearTimeout(timerOculto); timerOculto = null; }
        ligarRetratos();                     // volta a ficar ao vivo já com o estado atual
      }
    };
    document.addEventListener('visibilitychange', aoMudarVisibilidade);

    const registrar = (cnpj, storeKey) => {
      porCnpj.set(cnpj, { cnpj, storeKey, snap: null, catalogo: null, codigos: null, remanejamento: null, recebido: false });
      alvos.push({ cnpj, storeKey });
      carregarCatalogo(cnpj);
    };

    registrar(user.cnpj, null);
    ligarRetrato(user.cnpj);
    // Filiais com doc próprio. loadFiliaisMap já descarta a auto-referência, então a
    // matriz nunca é assinada duas vezes.
    SF.loadFiliaisMap(user.cnpj).then(() => {
      if (cancelado) return;
      for (const [storeKey, cnpjFilial] of Object.entries(SF.getFiliaisMap())) {
        if (!cnpjFilial || cnpjFilial === user.cnpj || porCnpj.has(cnpjFilial)) continue;
        registrar(cnpjFilial, storeKey);
        // ligarRetrato é idempotente por CNPJ: as filiais entram sem reassinar a matriz,
        // o que custaria outro download do retrato dela.
        ligarRetrato(cnpjFilial);
      }
      publicar();
    }).catch(() => { /* sem mapa de filiais, segue só com a loja do login */ });

    return () => {
      cancelado = true;
      if (timerOculto) clearTimeout(timerOculto);
      document.removeEventListener('visibilitychange', aoMudarVisibilidade);
      desligarRetratos();
    };
  }, [user?.cnpj]);

  return { blocos, loading };
}

// Retrato mais NOVO da rede. Com filiais em coletores diferentes cada uma manda na sua
// hora; o mais recente é o que responde "quão velho é o que estou vendo" no melhor caso —
// e o rodapé avisa quando as lojas divergem.
function rupRetratoEm(blocos) {
  const ds = blocos.map(b => b.updatedAt).filter(Boolean).map(d => d.getTime());
  return ds.length ? new Date(Math.max(...ds)) : null;
}

// Seletor de vários valores: botão que abre a lista com caixas de marcação. Nativo
// <select multiple> exige Ctrl+clique e não mostra quantos estão ativos, que é o que
// importa quando se cruza três classes com duas filiais.
function RupMulti({ rotulo, opcoes, sel, onChange, largura = 150 }) {
  const [aberto, setAberto] = React.useState(false);
  const [busca,  setBusca]  = React.useState('');
  const ref = React.useRef(null);

  React.useEffect(() => {
    if (!aberto) return;
    const fora = (e) => { if (ref.current && !ref.current.contains(e.target)) setAberto(false); };
    document.addEventListener('mousedown', fora);
    return () => document.removeEventListener('mousedown', fora);
  }, [aberto]);

  const alterna = (v) => {
    const n = new Set(sel);
    n.has(v) ? n.delete(v) : n.add(v);
    onChange(n);
  };
  // O botão mostra o RÓTULO do que está marcado, não o valor: em classe e grupo os dois são
  // iguais, mas o valor de uma loja é a chave dela ("3_1") e o que se lê é o nome.
  const rotuloDe = React.useMemo(() => new Map(opcoes.map(([v, t]) => [v, t])), [opcoes]);
  const marcados = [...sel].map(v => rotuloDe.get(v) ?? v);
  const texto = sel.size === 0 ? rotulo : sel.size === 1 ? marcados[0] : `${rotulo}: ${sel.size}`;
  // Com 78 grupos, rolar a lista é pior que digitar três letras.
  const visiveis = React.useMemo(() => {
    const q = busca.trim().toLowerCase();
    return q ? opcoes.filter(([, t]) => String(t).toLowerCase().includes(q)) : opcoes;
  }, [opcoes, busca]);

  return (
    <div ref={ref} style={{ position: 'relative' }}>
      <button onClick={() => setAberto(v => !v)} title={sel.size ? marcados.join(', ') : rotulo}
        style={{
          fontSize: 12.5, padding: '7px 12px', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit',
          fontWeight: sel.size ? 700 : 500, minWidth: largura, textAlign: 'left',
          border: `1px solid ${sel.size ? 'var(--brand-500)' : 'var(--ink-200)'}`,
          background: sel.size ? 'var(--brand-50)' : 'var(--white)',
          color: sel.size ? 'var(--brand-700)' : 'var(--ink-800)',
          whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
        }}>{texto} ▾</button>

      {aberto && (
        <div style={{
          position: 'absolute', top: 'calc(100% + 4px)', left: 0, zIndex: 60,
          minWidth: 220, maxHeight: 300, overflowY: 'auto',
          background: 'var(--white)', border: '1px solid var(--ink-200)', borderRadius: 10,
          boxShadow: '0 12px 32px rgba(0,0,0,0.14)', padding: 6,
        }}>
          {opcoes.length > 8 && (
            <input autoFocus value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar…"
              style={{
                width: '100%', boxSizing: 'border-box', fontSize: 12, fontFamily: 'inherit',
                padding: '6px 9px', marginBottom: 4, borderRadius: 6,
                border: '1px solid var(--ink-200)', background: 'var(--white)', color: 'var(--ink-800)',
              }} />
          )}
          {sel.size > 0 && (
            <button onClick={() => onChange(new Set())}
              style={{ width: '100%', textAlign: 'left', fontSize: 11.5, fontWeight: 700, fontFamily: 'inherit', padding: '6px 8px', borderRadius: 6, border: 'none', background: 'transparent', color: 'var(--danger)', cursor: 'pointer' }}>
              Limpar seleção
            </button>
          )}
          {visiveis.length === 0
            ? <div style={{ fontSize: 11.5, color: 'var(--ink-400)', padding: '8px 10px' }}>Nada encontrado</div>
            : visiveis.map(([valor, texto]) => (
                <label key={valor} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 6, cursor: 'pointer', fontSize: 12.5, color: 'var(--ink-800)' }}>
                  <input type="checkbox" checked={sel.has(valor)} onChange={() => alterna(valor)}
                    style={{ width: 14, height: 14, accentColor: 'var(--brand-500)', cursor: 'pointer', flexShrink: 0 }} />
                  <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{texto}</span>
                </label>
              ))}
        </div>
      )}
    </div>
  );
}

function RupturaView({ user, data, syncAt, selectedStoreIds = [], filterDim, filterSel = [] }) {
  const { blocos, loading } = useRuptura(user);
  const retratoEm = React.useMemo(() => rupRetratoEm(blocos), [blocos]);

  const [curvasOff, setCurvasOff] = React.useState(() => new Set());
  // Sets vazios = "todos". Multisseleção: dá para cruzar duas classes com três filiais.
  const [filiais,   setFiliais]   = React.useState(() => new Set());
  const [classes,   setClasses]   = React.useState(() => new Set());
  const [grupos,    setGrupos]    = React.useState(() => new Set());
  const [labs,      setLabs]      = React.useState(() => new Set());
  const [fornec,    setFornec]    = React.useState(() => new Set());
  const [detalhe,   setDetalhe]   = React.useState(null);   // produto aberto no mini-grid
  const [semCompraMin, setSemCompraMin] = React.useState('');  // "não compro há mais de X dias"
  const [semVendaMin,  setSemVendaMin]  = React.useState('');  // "não vende há mais de X dias"
  const [vendaMin,     setVendaMin]     = React.useState('');  // "vende mais de Y por dia"
  const [perdaMin,     setPerdaMin]     = React.useState('');  // "custa mais de R$ Z por dia parado"
  const [soDoador,     setSoDoador]     = React.useState(false); // "só o que dá para resolver por dentro"
  // Gráfico "perda por tempo de falta": células escolhidas (só destacam) e filtro aplicado
  // (mexe no grid). São dois estados porque o clique seleciona e o botão é que filtra.
  const [pontoGrafico, setPontoGrafico] = React.useState(() => new Set());  // "i|curva"
  const [faixaFiltro,  setFaixaFiltro]  = React.useState(null);  // [{ rot, min, max, curva }]
  const [situacao,  setSituacao]  = React.useState('todas'); // todas | ruptura | risco
  const [perdaMetrica, setPerdaMetrica] = React.useState('reais'); // reais | unid
  const [busca,     setBusca]     = React.useState('');
  const [limite,    setLimite]    = React.useState(30);
  // Produtos fixados pelo usuário: sobem ao topo e IGNORAM os filtros, inclusive a busca.
  // É o que permite montar a lista aos poucos — pesquisa um, fixa, pesquisa outro, fixa.
  // Sem essa isenção, o item fixado sumiria assim que a busca mudasse.
  const [fixados,   setFixados]   = React.useState(() => new Set());

  // Filtro de LOJA do topo. selectedStoreIds guarda o resultado de SF.parseStoreKey
  // ("1" → 1, "21_1" → 210001), e cada linha carrega o storeId da SUA chave pelo mesmo
  // parseStoreKey — a comparação é no mesmo espaço, sem desfazer conta nenhuma. Antes eu
  // reduzia o id ao cd_filial (id % 10000): numa rede interligada todas as filiais têm
  // cd_filial 1 no banco local, então qualquer loja escolhida virava "1" e devolvia os
  // itens da matriz.
  const filiaisDoTopo = React.useMemo(() => {
    if (!selectedStoreIds?.length) return null;          // null = topo não filtrou nada
    return new Set(selectedStoreIds.map(Number));
  }, [selectedStoreIds]);

  // Filtro de CLASSE/GRUPO do topo. Só vale quando a dimensão ativa é uma das duas —
  // 'fornecedores' e 'parametros' não existem neste retrato.
  const nomesDoTopo = React.useMemo(() => {
    if (!filterSel?.length) return null;
    if (filterDim !== 'classes' && filterDim !== 'grupos') return null;
    return { dim: filterDim, nomes: new Set(filterSel.map(n => String(n).trim().toUpperCase())) };
  }, [filterDim, filterSel]);

  // Nome da loja. `conf` são as fontes que descrevem a loja de verdade — nome do doc do
  // dia, Parametros.nome do CNPJ da filial, nome já visto em algum doc lido antes; `cad` é
  // o Parametros.lojas da matriz, usado só quando não há nada melhor, porque envelhece: na
  // rede FUX ele diz que a loja 1 é "PORTO VELHO" quando 1 é a FUX e PORTO VELHO é outra
  // filial. Sem nada disso a lista mostrava "Filial 5_1" — o código, não o nome —, e é o
  // que se vê de madrugada, quando nenhuma loja vendeu ainda e o doc do dia não traz nome.
  const nomeFilial = React.useMemo(() => {
    const conf = new Map(), cad = new Map();
    for (const [chave, nome] of Object.entries(SF.getLojasProprias?.() ?? {})) {
      const n = rupFixTexto(nome || '');
      if (n) cad.set(String(chave), n);
    }
    for (const storeKey of Object.keys(SF.getFiliaisMap?.() ?? {})) {
      const n = rupFixTexto(SF.getFilialNome?.(storeKey) || '');
      if (n) conf.set(String(storeKey), n);
    }
    // Nome aprendido (cache persistido do firebase-service) para as chaves que já apareceram
    for (const chave of [...cad.keys(), ...conf.keys()]) {
      const visto = rupFixTexto(SF.getNomeVisto?.(chave) || '');
      if (visto) conf.set(chave, visto);
    }
    // Doc do dia por último: é o nome mais atual de todos.
    for (const s of (data?.stores ?? [])) {
      const chave = String(s.storeId ?? '');
      const nome  = rupFixTexto(s.storeName) || '';
      if (chave && nome) conf.set(chave, nome);
    }
    // Só depois de TODAS as chaves exatas: o sufixo de "5_1" é "1", que costuma ser a
    // matriz. Preenchendo junto, a ordem de leitura decidiria de quem é o nome da loja 1
    // — foi assim que o painel já mostrou o nome de uma filial no lugar de outra.
    for (const s of (data?.stores ?? [])) {
      const chave = String(s.storeId ?? '');
      const nome  = rupFixTexto(s.storeName) || '';
      if (!chave || !nome) continue;
      const puro = chave.split('_').pop();
      if (puro && !conf.has(puro) && !cad.has(puro)) cad.set(puro, nome);
    }
    return { conf, cad };
  }, [data, blocos]);

  const linhas = React.useMemo(() => {
    const out = [];
    const usadas = new Set();   // chaves de loja já emitidas, para não fundir duas lojas numa
    // Um bloco por CNPJ. Cada um traz o próprio catálogo: numa rede interligada os cadastros
    // são independentes, e usar o catálogo da matriz para a filial trocaria os nomes dos
    // produtos. `chaveLoja` é sempre a chave que o resto do site usa para aquela loja — a
    // storeKey do mapa de filiais ("6_1"), e não o cd_filial local do banco dela ("1").
    for (const bloco of blocos) {
      const catalogo = bloco.catalogo;
      // Catálogo: [cod, nome, cdClasse, cdGrupo, cdLaboratorio] → mapa por código.
      const prods = new Map();
      for (const c of (catalogo?.produtos ?? [])) prods.set(c[0], c);
      const classes = catalogo?.classes ?? {};
      const grupos  = catalogo?.grupos  ?? {};
      const labsMap = catalogo?.laboratorios ?? {};
      // Fornecedor: o coletor manda o CD_DISTRIBUIDOR da ÚLTIMA compra daquele item naquela
      // loja, e o nome vem daqui, como classe/grupo/laboratório. Loja em coletor antigo não
      // tem o campo — fica vazio, e a coluna mostra "—" em vez de sumir da tela.
      const distMap = catalogo?.distribuidores ?? {};
      // Array é o formato da 1.0.4.42, que mandava a lista inteira e foi revertida antes de
      // chegar em qualquer loja. Fica aceito e reduzido ao primeiro (o mais recente) porque
      // custa uma linha e evita tela quebrada se algum EXE daquele dia estiver por aí.
      const nomeDist = (v) => rupFixTexto(distMap[Array.isArray(v) ? v[0] : v] || '');

      const chavesDoBloco = Object.keys(bloco.snap ?? {});
      for (const [cdLocal, lista] of Object.entries(bloco.snap ?? {})) {
      if (!Array.isArray(lista)) continue;
      // Filial com doc próprio entra pela storeKey dela; só desempata com o cd_filial local
      // no caso raro de um mesmo CNPJ exportar mais de uma loja no retrato.
      const chaveBase = !bloco.storeKey ? cdLocal
        : (chavesDoBloco.length > 1 ? `${bloco.storeKey}#${cdLocal}` : bloco.storeKey);
      // Duas lojas com a MESMA chave existem: na rede MT CENTRO (05673029000158) a matriz
      // exporta a loja "1" e a filial EVOLUI está cadastrada com a storeKey "1" também.
      // Sem desempatar aqui, as duas viram uma linha só no filtro de lojas. O agrupamento
      // de produto nunca as mistura (a chave dele inclui o CNPJ de origem), e a correção
      // de verdade é renomear a chave em Parametros.filiais.
      const cdFilial = usadas.has(chaveBase) ? `${chaveBase}~${bloco.cnpj}` : chaveBase;
      usadas.add(cdFilial);
      // storeId sai da chave BASE: o desempate não existe no seletor do topo, e
      // parseStoreKey nele devolveria NaN — a loja sumiria ao filtrar.
      const storeIdLoja = SF.parseStoreKey(bloco.storeKey ?? cdLocal);
      // Nome: fonte confiável pela chave exata; para a loja do próprio login com uma única
      // loja no retrato vale o nome da matriz (Parametros.nome), que é o dela; depois o
      // cadastro de lojas do banco; por último o código, que ao menos identifica.
      // A busca pelo sufixo é só para linha da matriz — para "5_1" ela devolveria o nome
      // da loja 1, que é outra loja.
      const nomeDaLoja = nomeFilial.conf.get(chaveBase)
        || (!bloco.storeKey && chavesDoBloco.length === 1 ? rupFixTexto(SF.getMatrizNome?.() || '') : '')
        || nomeFilial.cad.get(chaveBase)
        || (bloco.storeKey ? '' : nomeFilial.cad.get(String(chaveBase).split('_').pop()))
        || `Filial ${chaveBase}`;
      // Retrato: [cod, estoque, venda/dia, ultCompraYYYYMMDD, curva, preco, cdDistribuidor,
      //           ultVendaYYYYMMDD]
      for (const p of lista) {
        const cod = p[0];
        const est = safeN(p[1]);
        const vd  = safeN(p[2]);
        const cat = prods.get(cod) ?? [];
        out.push({
          key: `${cdFilial}:${cod}`,
          cdFilial, cod,
          // CD_FILIAL cru, como o coletor gravou a chave do retrato. `cdFilial` acima é
          // identidade de TELA (pode virar storeKey, ou ganhar "~cnpj" para desempatar) e
          // não serve para casar com o documento de remanejamento, que fala em CD_FILIAL.
          cdLocal,
          // Cadastro de origem: é o que separa produtos homônimos de bancos diferentes na
          // hora de agrupar. Duas lojas só somam o mesmo código se vieram do mesmo doc.
          origem: bloco.cnpj,
          storeId: storeIdLoja,
          nomeFilial: nomeDaLoja,
          nome:   rupFixTexto(cat[1]) || `Produto ${cod}`,
          classe: rupFixTexto(classes[cat[2]] || ''),
          grupo:  rupFixTexto(grupos[cat[3]] || ''),
          lab:    rupFixTexto(labsMap[cat[4]] || ''),
          // De quem veio por último nesta loja, dentro da janela de 180 dias do coletor.
          fornecedor: nomeDist(p[6]),
          curva:  p[4] || '',
          estoque: est,
          vendaDia: vd,
          preco: safeN(p[5]),
          // Zerado já está em ruptura; o resto é cobertura em dias.
          dias: est <= 0 ? 0 : (vd > 0 ? est / vd : Infinity),
          ruptura: est <= 0,
          // Quanto deixa de vender por dia enquanto falta — ordena a lista por prejuízo
          // em vez de por unidade, que é o que decide o que comprar primeiro.
          perdaDia: est <= 0 ? vd * safeN(p[5]) : 0,
          // yyyyMMdd inteiro → Date. 0 = nunca comprado.
          ultCompra: p[3] > 0
            ? new Date(Math.floor(p[3] / 10000), Math.floor(p[3] / 100) % 100 - 1, p[3] % 100)
            : null,
          // Última VENDA nesta filial (PRODUTOS.DT_ULT_VENDA_{filial}, Coletor ≥1.0.4.61).
          // Entrou no FIM do array justamente para não deslocar as posições acima.
          //
          // `temVenda` separa duas coisas que o null confundiria: retrato ANTIGO (array de
          // 7 posições, a loja ainda não manda o dado) e produto que NUNCA vendeu naquela
          // filial (posição existe e vem 0). Sem essa distinção a tela escreveria "nunca"
          // para as lojas em coletor anterior — afirmação falsa, e das que fazem comprador
          // deixar de repor item que vende.
          temVenda: p.length > 7,
          ultVenda: p[7] > 0
            ? new Date(Math.floor(p[7] / 10000), Math.floor(p[7] / 100) % 100 - 1, p[7] % 100)
            : null,
        });
      }
      }
    }
    return out;
  }, [blocos, nomeFilial]);

  // Lojas da rede que responderam sem retrato nenhum: some da lista inteira, e sem dizer
  // isso o usuário conclui que a loja está sem ruptura. Na prática é Coletor velho demais
  // para gravar ruptura/atual (visto na rede FUX: MINI PRECO em 1.0.3.1 e DROGARIA
  // ATRATIVA em 1.0.4.19, enquanto o resto da rede está na 1.0.4.41).
  const lojasSemRetrato = React.useMemo(() => blocos
    .filter(b => b.recebido && b.storeKey && !Object.keys(b.snap ?? {}).length)
    .map(b => rupFixTexto(SF.getFilialNome?.(b.storeKey) || '') || `Loja ${b.storeKey}`)
    .sort((a, b) => a.localeCompare(b, 'pt-BR')), [blocos]);

  // Lojas cujo retrato ainda não traz DT_ULT_VENDA (Coletor < 1.0.4.61). Elas somem
  // inteiras quando o filtro "sem vender há" está ligado, porque lá o vazio não pode ser
  // lido como "não vende". Vizinho do lojasSemRetrato acima e pelo mesmo motivo: lista
  // que encolhe sem explicação faz o usuário procurar defeito no filtro.
  //
  // O par com/sem existe porque a resposta é POR LOJA, não por linha: basta uma linha da
  // loja trazer o campo para o retrato dela ser da versão nova.
  const lojasSemDataVenda = React.useMemo(() => {
    const com = new Set(), sem = new Set();
    for (const l of linhas) (l.temVenda ? com : sem).add(l.nomeFilial);
    for (const n of com) sem.delete(n);
    return [...sem].sort((a, b) => a.localeCompare(b, 'pt-BR'));
  }, [linhas]);

  const opcoes = React.useMemo(() => {
    const fs = new Map(), cs = new Set(), gs = new Set(), ls = new Set(), ds = new Set();
    for (const l of linhas) {
      fs.set(l.cdFilial, l.nomeFilial);
      if (l.classe) cs.add(l.classe);
      if (l.grupo)  gs.add(l.grupo);
      if (l.lab)    ls.add(l.lab);
      if (l.fornecedor) ds.add(l.fornecedor);
    }
    return {
      // Ordena pelo storeId, não pela chave: "6_1" em Number() dá NaN e a lista saía na
      // ordem em que os listeners responderam, que muda a cada abertura da tela.
      filiais: [...fs.entries()].sort((a, b) => SF.parseStoreKey(a[0]) - SF.parseStoreKey(b[0])),
      classes: [...cs].sort(),
      grupos:  [...gs].sort(),
      labs:    [...ls].sort(),
      fornecedores: [...ds].sort(),
    };
  }, [linhas]);

  // Sem o filtro do gráfico: é desta lista que o próprio gráfico é desenhado. Alimentá-lo
  // da lista já filtrada por faixa faria ele desabar num ponto só no primeiro clique e o
  // usuário não teria mais como escolher outro período.
  const filtradasSemFaixa = React.useMemo(() => {
    const q = busca.trim().toLowerCase();
    return linhas
      .filter(l => !curvasOff.has(l.curva))
      // Filtros do topo primeiro; os de baixo refinam por cima (E lógico, não OU).
      .filter(l => !filiaisDoTopo || filiaisDoTopo.has(l.storeId))
      .filter(l => {
        if (!nomesDoTopo) return true;
        const alvo = nomesDoTopo.dim === 'classes' ? l.classe : l.grupo;
        return nomesDoTopo.nomes.has(String(alvo).trim().toUpperCase());
      })
      .filter(l => filiais.size === 0 || filiais.has(l.cdFilial))
      // "Sem comprar há mais de X dias" filtra por FILIAL, não por produto: a data de
      // compra é de cada loja. Produto nunca comprado (sem data) entra — é o caso extremo
      // do que se procura aqui.
      .filter(l => {
        const x = Number(semCompraMin);
        if (!x || x <= 0) return true;
        if (!l.ultCompra) return true;
        return (Date.now() - l.ultCompra.getTime()) / 86400000 >= x;
      })
      // "Sem vender há mais de X dias" também filtra por FILIAL: a data da última venda é
      // de cada loja, igual à de compra.
      //
      // A diferença que importa está no vazio. No de compra, sem data = nunca comprado, e
      // esse é o caso extremo do que se procura, então entra. Aqui o vazio tem DOIS
      // significados e é o `temVenda` que os separa:
      //
      //   temVenda && !ultVenda → nunca vendeu nesta filial. Entra: é o extremo.
      //   !temVenda             → o retrato desta loja ainda não traz o campo. SAI.
      //
      // O segundo caso sai porque o filtro AFIRMA um fato, e afirmar "não vende há 90
      // dias" de quem nunca mandou a data é justamente a mentira que faz comprador deixar
      // de repor item que vende. Quem some assim é anunciado embaixo da barra de filtros,
      // por loja — sumir calado seria trocar um erro por outro.
      .filter(l => {
        const x = Number(semVendaMin);
        if (!x || x <= 0) return true;
        if (!l.temVenda) return false;
        if (!l.ultVenda) return true;
        return (Date.now() - l.ultVenda.getTime()) / 86400000 >= x;
      })
      .filter(l => classes.size === 0 || classes.has(l.classe))
      .filter(l => grupos.size  === 0 || grupos.has(l.grupo))
      .filter(l => labs.size    === 0 || labs.has(l.lab))
      // Fornecedor é POR FILIAL: o filtro casa a linha da loja que compra daquele
      // distribuidor. Um produto que a filial 1 compra da Profarma e a 6 de outro entra
      // no filtro "Profarma" só com a linha da 1 — e a soma da tela segue essa escolha.
      .filter(l => fornec.size  === 0 || fornec.has(l.fornecedor))
      .filter(l => situacao === 'todas' || (situacao === 'ruptura' ? l.ruptura : !l.ruptura))
      // A busca livre também acha por laboratório e por fornecedor: digitar "ache" ou
      // "profarma" traz os produtos deles sem precisar abrir o filtro e marcar um a um.
      .filter(l => !q || l.nome.toLowerCase().includes(q) || l.cod.includes(q)
                      || l.lab.toLowerCase().includes(q)
                      || l.fornecedor.toLowerCase().includes(q))
      // Ruptura primeiro, e dentro dela o que custa mais caro por dia parado.
      .sort((a, b) => (Number(b.ruptura) - Number(a.ruptura)) || (b.perdaDia - a.perdaDia) || (a.dias - b.dias));
  }, [linhas, curvasOff, filiais, classes, grupos, labs, fornec, situacao, busca, filiaisDoTopo, nomesDoTopo, semCompraMin, semVendaMin]);

  // Filtro vindo do gráfico: lista de células (período × curva), unidas por OU. Repete
  // EXATAMENTE as condições com que o ponto foi contado — só item zerado que vendia, na
  // mesma janela de dias e na mesma curva — senão o botão diria "128 itens" e o grid
  // mostraria outro número.
  const filtradas = React.useMemo(() => {
    if (!faixaFiltro?.length) return filtradasSemFaixa;
    return filtradasSemFaixa.filter(l => {
      if (!l.ruptura || l.vendaDia <= 0) return false;
      const dias = l.ultCompra ? Math.floor((Date.now() - l.ultCompra.getTime()) / 86400000) : -1;
      return faixaFiltro.some(c => l.curva === c.curva && dias >= c.min && dias <= c.max);
    });
  }, [filtradasSemFaixa, faixaFiltro]);

  // Rótulo curto do que veio do gráfico. Agrupa pelo que houver em comum — mesma curva em
  // vários períodos, ou vários períodos na mesma curva —, senão vira "N seleções": listar
  // seis pares "curva × período" no cabeçalho não se lê.
  const rotuloFaixaFiltro = React.useMemo(() => {
    if (!faixaFiltro?.length) return null;
    const rot = (c) => (c.rot === 'nunca' ? 'nunca comprados' : c.rot);
    if (faixaFiltro.length === 1) return `curva ${faixaFiltro[0].curva} · ${rot(faixaFiltro[0])}`;
    const curvas = [...new Set(faixaFiltro.map(c => c.curva))].sort();
    const rots   = [...new Set(faixaFiltro.map(rot))];
    if (curvas.length === 1) return `curva ${curvas[0]} · ${rots.join(', ')}`;
    if (rots.length === 1)   return `curvas ${curvas.join('/')} · ${rots[0]}`;
    return `curvas ${curvas.join('/')} · ${rots.length} períodos`;
  }, [faixaFiltro]);

  // UMA LINHA POR PRODUTO, somando as filiais em escopo. Antes era uma linha por
  // produto-filial, e o mesmo item aparecia 14 vezes — foi o que fez "sem comprar" mostrar
  // 135 dias (a pior filial) para quem tinha comprado há 14 na filial 1.
  //
  // Estoque e venda/dia somam. "Zera em" é a divisão dos totais, não a média das divisões:
  // 10 unidades vendendo 1/dia numa loja e 0 unidades vendendo 9/dia noutra é ruptura em
  // 1 dia no conjunto, não em 5.
  //
  // O agrupamento é por CADASTRO + código, não por código puro: lojas que gravam no mesmo
  // documento compartilham o cadastro do Farmax e somam entre si como sempre, mas filial
  // interligada tem banco próprio e o mesmo código é outro produto (006603 é GARDENAL na
  // matriz da rede FUX e HUGGIES na VITORIA). Somar os dois criaria uma linha que não
  // existe em loja nenhuma.
  // Doadores por CNPJ de origem. Nunca por código solto: 006603 é GARDENAL numa base e
  // HUGGIES noutra, e cruzar entre origens colocaria a filial errada na tela.
  // Alguma loja da rede manda o documento de remanejamento? Governa a coluna e o botão:
  // rede inteira em Coletor antigo não deve ver controle que não filtra nada.
  const temRemanejamento = React.useMemo(() => blocos.some(b => !!b.remanejamento), [blocos]);

  const doadoresPorOrigem = React.useMemo(() => {
    const m = new Map();
    for (const b of blocos) if (b.remanejamento) m.set(b.cnpj, b.remanejamento);
    return m;
  }, [blocos]);

  // Nome de cada filial doadora. O documento de remanejamento manda só o CD_FILIAL — o
  // nome já está nas linhas do retrato, que passaram pela mesma resolução de nome que o
  // resto da tela usa. Filial que não tem NENHUM produto em ruptura não aparece em
  // `linhas` e cai no "Filial N", que ao menos identifica.
  const nomeFilialPorOrigem = React.useMemo(() => {
    const m = new Map();
    for (const l of linhas) {
      let f = m.get(l.origem);
      if (!f) m.set(l.origem, f = new Map());
      if (!f.has(String(l.cdLocal))) f.set(String(l.cdLocal), l.nomeFilial);
    }
    return m;
  }, [linhas]);

  const agregar = React.useCallback((lista) => {
    const m = new Map();
    for (const l of lista) {
      const chave = `${l.origem}:${l.cod}`;
      const g = m.get(chave) ?? {
        chave, cod: l.cod, origem: l.origem,
        nome: l.nome, classe: l.classe, grupo: l.grupo, lab: l.lab,
        curvas: new Set(), fornecedores: new Set(), estoque: 0, vendaDia: 0, perdaDia: 0,
        filiais: [], emRuptura: 0,
      };
      g.curvas.add(l.curva);
      if (l.fornecedor) g.fornecedores.add(l.fornecedor);
      g.estoque  += l.estoque;
      g.vendaDia += l.vendaDia;
      g.perdaDia += l.perdaDia;
      if (l.ruptura) g.emRuptura++;
      g.filiais.push(l);
      m.set(chave, g);
    }
    for (const g of m.values()) {
      g.dias    = g.estoque <= 0 ? 0 : (g.vendaDia > 0 ? g.estoque / g.vendaDia : Infinity);
      g.ruptura = g.estoque <= 0;
      g.curva   = [...g.curvas].sort()[0] ?? '';
      // Cada loja compra de quem quer. Com mais de um fornecedor no escopo, mostra o da
      // compra MAIS RECENTE e conta o resto num "+N" ao lado — é a mesma regra do campo
      // que vem do coletor (o último de quem veio), e nem esconde os outros nem gasta a
      // largura de um botão. A lista inteira está no title e no detalhamento por filial.
      const comFornec = g.filiais.filter(f => f.fornecedor);
      const recente = [...comFornec].sort((a, b) => (b.ultCompra?.getTime() ?? 0) - (a.ultCompra?.getTime() ?? 0))[0];
      g.fornecedor  = g.fornecedores.size === 1 ? [...g.fornecedores][0] : (recente?.fornecedor ?? '');
      g.nFornecedor = g.fornecedores.size;
      g.filiais.sort((a, b) => a.storeId - b.storeId);
      // Só faz sentido mostrar "sem comprar" quando há UMA filial: com várias, cada uma
      // comprou num dia diferente e qualquer número escolhido mentiria.
      g.ultCompra = g.filiais.length === 1 ? g.filiais[0].ultCompra : null;
      g.umaFilial = g.filiais.length === 1;
      // Com várias, o honesto não é esconder a data: é mostrar a FAIXA. Guardo a compra
      // mais recente e a mais antiga do escopo, e quantas lojas nunca compraram — assim a
      // coluna diz se a rede inteira parou de comprar ou se é uma loja atrasada.
      const datas = g.filiais.map(f => f.ultCompra).filter(Boolean).map(d => d.getTime());
      g.ultCompraRecente = datas.length ? new Date(Math.max(...datas)) : null;
      g.ultCompraAntiga  = datas.length ? new Date(Math.min(...datas)) : null;
      g.semData = g.filiais.length - datas.length;
      // Última venda: mesma regra da compra, pelo mesmo motivo — cada loja vendeu num dia,
      // então com várias filiais o honesto é a faixa, não um número só.
      g.ultVenda = g.filiais.length === 1 ? g.filiais[0].ultVenda : null;
      // Basta UMA filial com o campo para a coluna ter o que dizer; com nenhuma, ela
      // avisa que o dado não chegou em vez de fingir que ninguém vendeu.
      g.temVenda = g.filiais.some(f => f.temVenda);
      const datasV = g.filiais.map(f => f.ultVenda).filter(Boolean).map(d => d.getTime());
      g.ultVendaRecente = datasV.length ? new Date(Math.max(...datasV)) : null;
      g.ultVendaAntiga  = datasV.length ? new Date(Math.min(...datasV)) : null;
      g.semVenda = g.filiais.length - datasV.length;

      // ── Doadores ────────────────────────────────────────────────────────────
      // Quem tem este produto sobrando, vindo do documento de remanejamento da MESMA
      // origem. As filiais que já estão no grupo saem da lista: elas estão em ruptura ou
      // risco deste item, então não têm o que ceder — e o Coletor já as exclui na origem,
      // isto aqui é só cinto de segurança para retrato e remanejamento de ticks diferentes.
      const jaNoGrupo = new Set(g.filiais.map(f => String(f.cdLocal)));
      const nomes = nomeFilialPorOrigem.get(g.origem);
      // O mapa da origem existindo já responde "esta loja envia o documento". Produto
      // AUSENTE do mapa não é desconhecido: o Coletor só grava quem tem doador, então
      // ausência quer dizer "ninguém tem sobra" — que é [] e não null. Sem essa distinção
      // 1.323 produtos da base medida diriam "coletor desatualizado" sendo que o coletor
      // está em dia e a resposta é simplesmente que não há de onde tirar.
      const mapaOrigem = doadoresPorOrigem.get(g.origem);
      const brutos = mapaOrigem ? (mapaOrigem[g.cod] ?? []) : null;
      g.doadores = !brutos ? null : brutos
        .filter(d => !jaNoGrupo.has(String(d[0])))
        .map(d => ({
          cdFilial:  String(d[0]),
          nomeFilial: nomes?.get(String(d[0])) || `Filial ${d[0]}`,
          estoque:   safeN(d[1]),
          vendaDia:  safeN(d[2]),
          disponivel: safeN(d[3]),
        }));
      // null = esta loja não manda o documento (Coletor < 1.0.4.62); [] = manda, e não há
      // ninguém com sobra. A tela precisa dizer coisas diferentes nos dois casos.
      g.temDoador  = !!g.doadores?.length;
      g.dispTotal  = (g.doadores ?? []).reduce((a, d) => a + d.disponivel, 0);
    }
    return [...m.values()];
  }, [doadoresPorOrigem, nomeFilialPorOrigem]);

  const ordenar = (arr) =>
    arr.sort((a, b) => (Number(b.ruptura) - Number(a.ruptura)) || (b.perdaDia - a.perdaDia) || (a.dias - b.dias));

  // Vira função para poder rodar DUAS vezes: na lista que o grid mostra (com o filtro do
  // gráfico) e na que alimenta o próprio gráfico (sem ele).
  const montar = React.useCallback((lista) => {
    // "Vende mais de Y por dia" e "custa mais de R$ Z/dia" são aplicados DEPOIS de agregar,
    // para bater com o número que aparece na coluna — filtrar antes compararia contra a
    // venda de uma filial só.
    const y = Number(vendaMin);
    const z = Number(perdaMin);
    // "Só com doador" também é pós-agregação: doador é do PRODUTO, e só depois de juntar
    // as filiais se sabe quais delas já estão em ruptura e saem da lista de quem pode ceder.
    const normais = ordenar(agregar(lista)
        .filter(g => !y || y <= 0 || g.vendaDia >= y)
        .filter(g => !z || z <= 0 || g.perdaDia >= z)
        .filter(g => !soDoador || g.temDoador))
      .filter(g => !fixados.has(g.chave));

    // Fixados saem de `linhas` (sem filtro nenhum) para continuarem visíveis mesmo depois
    // que a busca mudou — é justamente para isso que servem. Guardam a chave cadastro+código
    // pelo mesmo motivo do agrupamento: fixar o item de uma loja não fixa o homônimo de outra.
    if (fixados.size === 0) return { lista: normais };
    const dosFixados = ordenar(agregar(linhas.filter(l => fixados.has(`${l.origem}:${l.cod}`))))
      .map(g => ({ ...g, fixado: true }));
    return { lista: [...dosFixados, ...normais] };
  }, [linhas, vendaMin, perdaMin, soDoador, fixados, agregar]);

  const { lista: agrupadas } = React.useMemo(() => montar(filtradas), [montar, filtradas]);

  // Linhas por FILIAL do que está na lista. Sai de `agrupadas` (e não de `filtradas`)
  // porque o filtro "vende mais de Y por dia" só é aplicado depois de agregar — usar
  // `filtradas` faria o KPI e o gráfico contarem produto que a tabela não mostra.
  const linhasPorFilial = React.useMemo(() => agrupadas.flatMap(g => g.filiais), [agrupadas]);

  // Fonte do gráfico: mesmas regras, menos o filtro que o próprio gráfico aplica. Sem
  // filtro ativo as duas listas são a mesma, e aí nem recalcula.
  const linhasGrafico = React.useMemo(
    () => (faixaFiltro?.length ? montar(filtradasSemFaixa).lista.flatMap(g => g.filiais) : linhasPorFilial),
    [faixaFiltro, montar, filtradasSemFaixa, linhasPorFilial]);

  const kpis = React.useMemo(() => {
    // Conta produto distinto, não produto-filial: "142 produtos em ruptura" é o que o
    // comprador entende, não "1.870 linhas".
    const emRuptura = agrupadas.filter(l => l.ruptura);
    const emRisco   = agrupadas.filter(l => !l.ruptura);
    const rupturaA  = emRuptura.filter(l => l.curva === 'A').length;
    // A PERDA sai das linhas POR FILIAL, não das agregadas: a venda perdida é da loja
    // com a prateleira vazia, e ter o item em outra não a recupera — o cliente que entrou
    // ali foi embora sem o produto. Pela base agregada, 3 unidades paradas numa loja
    // zeravam a perda de 6 lojas vazias e o número saía metade do real (medido em
    // 08804440000103: R$ 13.609/dia contra R$ 27.052/dia reais).
    // A CONTAGEM continua por produto distinto — é o que o comprador entende.
    const perda = linhasPorFilial.reduce((s, l) => s + l.perdaDia, 0);
    // delta: 0 suprime a linha de variação — o KpiCard testa `delta !== 0`, e sem o campo
    // ele cai em Math.abs(undefined) e escreve "NaN%". É a mesma convenção dos placeholders
    // do KpiRow. Aqui não há comparativo com período anterior: o retrato é do momento.
    return [
      { label: 'Produtos em ruptura', value: NUM(emRuptura.length), delta: 0, primary: true,
        info: 'Produtos de curva A, B ou C com estoque zerado ou negativo que venderam nos últimos 90 dias. '
            + 'Conta produto distinto: o mesmo item faltando em três lojas conta uma vez. '
            + 'Loja interligada tem cadastro próprio no Farmax — o mesmo código é outro produto lá —, '
            + 'então cada uma dessas conta separado.' },
      { label: 'Em risco de zerar', value: NUM(emRisco.length), delta: 0,
        info: 'Ainda têm estoque, mas acaba em até 15 dias no ritmo atual. '
            + 'Estoque ÷ venda por dia = dias de cobertura.' },
      { label: 'Curva A em ruptura', value: NUM(rupturaA), delta: 0, negative: rupturaA > 0,
        info: 'Quantos dos produtos em ruptura são curva A. A curva é definida pelo Farmax por loja, '
            + 'então o mesmo item pode ser A numa filial e C noutra.' },
      { label: 'Deixando de vender por dia', value: BRL(perda), delta: 0, negative: perda > 0,
        info: 'Somado por loja: cada filial com o produto zerado entra com a sua venda por dia * preço. '
            + 'Ter o item em outra filial não recupera a venda perdida aqui. A venda por dia é a média dos '
            + 'últimos 90 dias, já descontadas as devoluções, sem contar as promoções. É o quanto entraria no caixa por dia se o '
            + 'produto estivesse na prateleira de cada loja.' },
    ];
  }, [agrupadas, linhasPorFilial]);

  const porCurva = React.useMemo(() => RUP_CURVAS.map(k => {
    const doGrupo = agrupadas.filter(l => l.curva === k);
    return { curva: k, ruptura: doGrupo.filter(l => l.ruptura).length, risco: doGrupo.filter(l => !l.ruptura).length };
  }), [agrupadas]);

  // ── Perda por tempo de falta ───────────────────────────────────────────────
  // Não existe série temporal: `ruptura/atual` é sobrescrito a cada 2h e nada guarda
  // o retrato de ontem. Então o eixo aqui não é o calendário — é HÁ QUANTO TEMPO o
  // item está sem reposição, lido de ultCompra. É o que mais se aproxima de tendência
  // com o dado que temos: mostra se o prejuízo está em falta recente (recuperável) ou
  // em item parado há meses (problema de compra, não de ruptura pontual).
  //
  // Usa as linhas POR FILIAL, não as agregadas: curva e data de compra são de cada loja,
  // e o agregado descarta ultCompra justamente quando o produto está em mais de uma.
  const FAIXAS = [
    { rot: 'até 7d',  min: 0,   max: 7 },
    { rot: '8–15d',   min: 8,   max: 15 },
    { rot: '16–30d',  min: 16,  max: 30 },
    { rot: '31–60d',  min: 31,  max: 60 },
    { rot: '60d+',    min: 61,  max: Infinity },
    { rot: 'nunca',   min: -1,  max: -1 },   // sem data de compra
  ];
  const perdaPorTempo = React.useMemo(() => {
    const hoje = new Date();
    const base = FAIXAS.map(f => ({
      ...f, itens: 0,
      reais: { A: 0, B: 0, C: 0, total: 0 },
      unid:  { A: 0, B: 0, C: 0, total: 0 },
      qtd:   { A: 0, B: 0, C: 0 },   // itens por curva, para a frase do gráfico
    }));
    for (const l of linhasGrafico) {
      if (!l.ruptura || l.vendaDia <= 0) continue;   // só quem está zerado e vendia
      const dias = l.ultCompra ? Math.floor((hoje - l.ultCompra) / 86400000) : -1;
      const f = base.find(x => dias >= x.min && dias <= x.max) ?? base[base.length - 1];
      f.itens += 1;
      f.reais.total += l.perdaDia; f.unid.total += l.vendaDia;
      if (f.reais[l.curva] != null) {
        f.reais[l.curva] += l.perdaDia; f.unid[l.curva] += l.vendaDia; f.qtd[l.curva] += 1;
      }
    }
    const faixas = base.filter(f => f.itens > 0);
    const totR = faixas.reduce((s, f) => s + f.reais.total, 0);
    const totU = faixas.reduce((s, f) => s + f.unid.total, 0);
    return { faixas, totR, totU };
  }, [linhasGrafico]);

  // ESC fecha o mini-grid. Só registra o ouvinte enquanto ele está aberto — deixar um
  // listener global de teclado pendurado captura tecla de quem estiver digitando na busca.
  React.useEffect(() => {
    if (!detalhe) return;
    const aoTeclar = (e) => { if (e.key === 'Escape') setDetalhe(null); };
    document.addEventListener('keydown', aoTeclar);
    return () => document.removeEventListener('keydown', aoTeclar);
  }, [detalhe]);

  // Faixa de preço do produto aberto no mini-grid. Preço não se soma como estoque: a
  // linha do total mostra o valor único quando todas as filiais praticam o mesmo, e o
  // intervalo quando divergem — que é o caso que interessa ver.
  const precosDet = detalhe ? detalhe.filiais.map(f => f.preco).filter(v => v > 0) : [];
  const precoMin  = precosDet.length ? Math.min(...precosDet) : 0;
  const precoMax  = precosDet.length ? Math.max(...precosDet) : 0;

  // Padding apertado de propósito: com a coluna de fornecedor são 9 colunas, e os 12px
  // originais de cada lado somavam ~54px de folga que faltavam para a última caber.
  const th = { textAlign: 'right', fontSize: 10.5, letterSpacing: '.06em', textTransform: 'uppercase', color: 'var(--ink-400)', fontWeight: 700, padding: '9px 8px 7px', borderBottom: '1px solid var(--ink-200)', whiteSpace: 'nowrap' };
  const td = { padding: '10px 8px', textAlign: 'right', fontWeight: 600, color: 'var(--ink-700)', borderBottom: '1px solid var(--ink-100)', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' };
  const sel = { fontSize: 12.5, padding: '7px 10px', borderRadius: 8, border: '1px solid var(--ink-200)', background: 'var(--white)', color: 'var(--ink-800)', fontFamily: 'inherit' };

  const diasTxt = (l) => {
    if (l.ruptura) return 'zerado';
    if (!isFinite(l.dias)) return '—';
    const d = Math.floor(l.dias);
    return d <= 0 ? 'hoje' : d === 1 ? '1 dia' : `${d} dias`;
  };
  // "Há quantos dias" de uma data qualquer — serve às duas colunas, compra e venda.
  const haDias = (d) => {
    if (!d) return '—';
    const dias = Math.floor((Date.now() - d.getTime()) / 86400000);
    return dias <= 0 ? 'hoje' : `${NUM(dias)} d`;
  };
  const diasDe = (d) => Math.max(0, Math.floor((Date.now() - d.getTime()) / 86400000));
  // Retrato sem a posição da última venda — coletor anterior à 1.0.4.61 nesta loja.
  const SEM_CAMPO_VENDA = 'O retrato desta loja ainda não traz a data da última venda — '
    + 'coletor anterior à 1.0.4.61. Atualize o Coletor para a coluna passar a ser preenchida.';
  const traco = (titulo) => <span style={{ color: 'var(--ink-300)' }} title={titulo}>—</span>;
  // Linha agregada: "14–135 d" = a loja mais recente foi há 14 dias e a mais atrasada há
  // 135. Com todas no mesmo dia sai um número só.
  const faixaDias = (recente, antiga) => {
    if (!recente) return 'nunca';
    const a = diasDe(recente);
    const b = diasDe(antiga);
    return a === b ? (a <= 0 ? 'hoje' : `${NUM(a)} d`) : `${NUM(a)}–${NUM(b)} d`;
  };

  // Janela concreta do venda/dia. O retrato traz a média dos últimos RUP_DIAS_HIST dias
  // corridos contados de hoje; "últimos 90 dias" sozinho puxa sempre a mesma pergunta —
  // "a partir de que dia?" —, então o mini-grid por filial mostra a data de início junto.
  const janelaVenda = {
    de:  new Date(Date.now() - RUP_DIAS_HIST * 86400000).toLocaleDateString('pt-BR'),
    ate: new Date().toLocaleDateString('pt-BR'),
  };

  if (loading) return <EmptyState text="Carregando ruptura…" />;
  // Vazio só quando NENHUMA loja da rede mandou retrato: numa rede interligada a matriz
  // pode não ter o módulo e as filiais terem, e aí a tela tem o que mostrar.
  if (!linhas.length) {
    return (
      <div className="card" style={{ padding: 24 }}>
        <div style={{ fontSize: 15, fontWeight: 700, color: 'var(--ink-900)' }}>Ruptura</div>
        <div style={{ fontSize: 12.5, color: 'var(--ink-500)', marginTop: 6, lineHeight: 1.5 }}>
          Nenhum retrato recebido ainda. O coletor envia a cada poucas horas — esta loja
          precisa estar na versão com o módulo de ruptura.
        </div>
      </div>
    );
  }

  return (
    <>
      <KpiRow kpis={kpis} loading={false} syncAt={syncAt} />

      {/* ── Idade do retrato ─────────────────────────────────────────────────
          A ruptura não é tempo real: entre um envio e o outro o estoque do ERP anda e este
          número não. Dizer a HORA do retrato é o que impede o usuário de comparar com a
          tela do Farmax e achar defeito aqui.

          De propósito não se explica a agenda do coletor: a janela fixa vai das 08h às 20h,
          e anunciar isso fazia o site parecer ter horário de funcionamento. A hora do
          retrato já entrega o mesmo, sem a leitura errada. */}
      {retratoEm && (() => {
        const min = Math.max(0, Math.round((Date.now() - retratoEm.getTime()) / 60000));
        const idade = min < 60 ? `há ${min} min`
          : `há ${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}`;
        // Passou de 2h20 sem retrato novo: é quando o número tem mais chance de já não
        // bater com o ERP, e vale o destaque.
        const velho = min > 140;
        return (
          <div style={{
            display: 'flex', alignItems: 'center', gap: 7, flexWrap: 'wrap',
            fontSize: 11.5, fontWeight: 600, color: velho ? 'var(--warning)' : 'var(--ink-500)',
            margin: '10px 0 -4px',
          }}>
            <span style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
                           background: velho ? 'var(--warning)' : 'var(--positive)' }} />
            Última atualização: {retratoEm.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })} ({idade})
          </div>
        );
      })()}

      {/* ── Filtros ── */}
      <div className="card" style={{ padding: '14px 20px', marginTop: 20, display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
        <div style={{ display: 'flex', gap: 4 }}>
          {RUP_CURVAS.map(k => {
            const on = !curvasOff.has(k);
            return (
              <button key={k} onClick={() => setCurvasOff(p => { const n = new Set(p); n.has(k) ? n.delete(k) : n.add(k); return n; })}
                style={{
                  padding: '6px 14px', fontSize: 12, fontWeight: 800, fontFamily: 'inherit', borderRadius: 8, cursor: 'pointer',
                  border: `1px solid ${on ? 'var(--brand-500)' : 'var(--ink-200)'}`,
                  background: on ? 'var(--brand-50)' : 'var(--white)',
                  color: on ? 'var(--brand-700)' : 'var(--ink-400)',
                }}>{k}</button>
            );
          })}
        </div>

        <select value={situacao} onChange={e => setSituacao(e.target.value)} style={sel}>
          <option value="todas">Ruptura e risco</option>
          <option value="ruptura">Só ruptura</option>
          <option value="risco">Só risco</option>
        </select>

        <RupMulti rotulo="Filiais" opcoes={opcoes.filiais} sel={filiais} onChange={setFiliais} largura={140} />
        <RupMulti rotulo="Classes" opcoes={opcoes.classes.map(c => [c, c])} sel={classes} onChange={setClasses} largura={150} />
        <RupMulti rotulo="Grupos"  opcoes={opcoes.grupos.map(g => [g, g])}  sel={grupos}  onChange={setGrupos}  largura={150} />
        <RupMulti rotulo="Laboratórios" opcoes={opcoes.labs.map(l => [l, l])} sel={labs} onChange={setLabs} largura={160} />
        {/* Só aparece com coletor >= 1.0.4.41 na loja; antes disso não há de quem filtrar
            e um seletor vazio na barra só faria o usuário procurar defeito. */}
        {opcoes.fornecedores.length > 0 && (
          <RupMulti rotulo="Fornecedores" opcoes={opcoes.fornecedores.map(f => [f, f])} sel={fornec} onChange={setFornec} largura={170} />
        )}

        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11.5, color: 'var(--ink-500)', fontWeight: 600 }}>
          sem comprar há
          <input type="number" min="0" value={semCompraMin} onChange={e => setSemCompraMin(e.target.value)}
            placeholder="—" style={{ ...sel, width: 62, padding: '7px 8px', textAlign: 'right' }} />
          dias
        </span>

        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11.5, color: 'var(--ink-500)', fontWeight: 600 }}
          title="Deixa na lista só o produto que não vende há pelo menos este tanto de dias naquela filial. Produto que nunca vendeu ali entra — é o caso extremo. Loja cujo retrato ainda não traz a data da última venda fica de fora, e é avisada abaixo.">
          sem vender há
          <input type="number" min="0" value={semVendaMin} onChange={e => setSemVendaMin(e.target.value)}
            placeholder="—" style={{ ...sel, width: 62, padding: '7px 8px', textAlign: 'right' }} />
          dias
        </span>

        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11.5, color: 'var(--ink-500)', fontWeight: 600 }}>
          vende mais de
          <input type="number" min="0" step="0.5" value={vendaMin} onChange={e => setVendaMin(e.target.value)}
            placeholder="—" style={{ ...sel, width: 62, padding: '7px 8px', textAlign: 'right' }} />
          /dia
        </span>

        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11.5, color: 'var(--ink-500)', fontWeight: 600 }}
          title="Deixa na lista só o produto que, zerado, custa pelo menos isto de venda por dia. Quem ainda tem estoque custa R$ 0,00 e sai.">
          custa mais de R$
          <input type="number" min="0" step="1" value={perdaMin} onChange={e => setPerdaMin(e.target.value)}
            placeholder="—" style={{ ...sel, width: 62, padding: '7px 8px', textAlign: 'right' }} />
          /dia
        </span>

        {/* Só aparece se ALGUMA loja da rede manda o documento de remanejamento: sem isso
            o botão filtraria tudo para zero e pareceria defeito. */}
        {temRemanejamento && (
          <button onClick={() => setSoDoador(v => !v)}
            title="Deixa na lista só o que outra filial tem sobrando e pode ceder sem se quebrar."
            style={{
              display: 'inline-flex', alignItems: 'center', gap: 6,
              padding: '7px 11px', fontSize: 11.5, fontWeight: 700, fontFamily: 'inherit',
              border: `1.5px solid ${soDoador ? 'var(--brand-500, #3B82F6)' : 'var(--ink-200)'}`,
              borderRadius: 8, cursor: 'pointer',
              background: soDoador ? 'var(--brand-50, #EFF6FF)' : 'var(--white)',
              color:      soDoador ? 'var(--brand-700, #1a4789)' : 'var(--ink-500)',
              transition: 'all .12s',
            }}>
            <svg width="12" height="12" viewBox="0 -960 960 960" fill="currentColor" style={{ flexShrink: 0 }}>
              <path d="M280-160 80-360l200-200 56 57-103 103h287v80H233l103 103-56 57zm400-240-56-57 103-103H440v-80h287L624-743l56-57 200 200-200 200z"/>
            </svg>
            Tem em outra filial
          </button>
        )}

        <input value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar produto, código ou laboratório…"
          style={{ ...sel, flex: 1, minWidth: 160 }} />
      </div>

      {/* A coluna da direita (rosquinha e gráfico) tem largura própria e não cresce: com
          '1.65fr 1fr' ela puxava ~38% da tela e a tabela, que agora tem 9 colunas, perdia
          justamente as últimas — "zera em" e "sem comprar". Fixa em 320px, a lista fica
          com todo o resto. minmax(0,1fr) é o que permite a lista encolher em vez de
          empurrar o layout; abaixo disso a tabela rola dentro do card. */}
      <div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 320px', gap: 20, marginTop: 20, alignItems: 'start' }}>
        {/* ── Lista ── */}
        <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
          <div style={{ padding: '16px 24px 4px' }}>
            <div style={{ fontSize: 15, fontWeight: 700 }}>Produtos</div>
            <div style={{ fontSize: 12, color: 'var(--ink-500)', fontWeight: 500 }}>
              {NUM(agrupadas.length)} produto(s) · ruptura primeiro, depois o que custa mais caro por dia parado
            </div>
            {/* Presta contas do corte: sem isto o usuário vê a lista encolher e não sabe
                o que ficou de fora nem quanto dinheiro foi embora junto. */}
            {/* O grid mudou porque veio do gráfico: sem dizer isso, a lista encolhe e o
                usuário procura defeito no filtro de cima. */}
            {rotuloFaixaFiltro && (
              <div style={{ fontSize: 11.5, fontWeight: 700, color: 'var(--brand-700)', marginTop: 4 }}>
                Do gráfico: {rotuloFaixaFiltro}
                <button onClick={() => { setFaixaFiltro(null); setPontoGrafico(new Set()); }}
                  style={{ marginLeft: 8, fontFamily: 'inherit', fontSize: 11, fontWeight: 700, padding: '2px 9px', borderRadius: 999, cursor: 'pointer', border: '1px solid var(--ink-200)', background: 'var(--white)', color: 'var(--ink-500)' }}>
                  limpar
                </button>
              </div>
            )}
            {lojasSemRetrato.length > 0 && (
              <div style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--warning)', marginTop: 4 }}
                title="O retrato de ruptura só existe a partir de uma versão mais nova do Coletor. Enquanto essas lojas não atualizarem, elas não entram nesta tela.">
                Fora da lista: {lojasSemRetrato.join(' · ')} — {lojasSemRetrato.length === 1 ? 'não envia' : 'não enviam'} dados de ruptura (Atualizar coletores)
              </div>
            )}
            {Number(semVendaMin) > 0 && lojasSemDataVenda.length > 0 && (
              <div style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--warning)', marginTop: 4 }}
                title="O filtro afirma há quantos dias o produto não vende. Onde o retrato não traz a data da última venda não dá para afirmar isso, então a linha fica de fora em vez de entrar por engano.">
                Fora do filtro: {lojasSemDataVenda.join(' · ')} — {lojasSemDataVenda.length === 1 ? 'não envia' : 'não enviam'} a data da última venda (Atualizar coletores)
              </div>
            )}
            {fixados.size > 0 && (
              <div style={{ fontSize: 11.5, fontWeight: 700, color: 'var(--brand-700)', marginTop: 4 }}>
                {NUM(fixados.size)} fixado(s) no topo · aparecem mesmo fora dos filtros
                <button onClick={() => setFixados(new Set())}
                  style={{ marginLeft: 8, fontFamily: 'inherit', fontSize: 11, fontWeight: 700, padding: '2px 9px', borderRadius: 999, cursor: 'pointer', border: '1px solid var(--ink-200)', background: 'var(--white)', color: 'var(--ink-500)' }}>
                  limpar
                </button>
              </div>
            )}
          </div>

          {!agrupadas.length
            ? <EmptyState text="Nenhum produto com os filtros atuais." style={{ padding: '20px 24px 28px' }} />
            : (
              <div style={{ overflowX: 'auto' }}>
                <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12.5 }}>
                  <thead>
                    <tr>
                      <th style={{ ...th, width: 30, padding: '9px 4px 7px' }} />
                      <th style={{ ...th, textAlign: 'left' }}>Produto</th>
                      <th style={{ ...th, textAlign: 'left' }} title="Distribuidor da última compra deste item, por loja, nos últimos 180 dias.">Fornecedor</th>
                      <th style={th}>Estoque</th>
                      <th style={th} title={`Média diária dos últimos ${RUP_DIAS_HIST} dias: de ${janelaVenda.de} a ${janelaVenda.ate}.`}>Venda/dia</th>
                      {/* O número que ORDENA a lista e que o filtro "custa mais de R$ X/dia"
                          corta. Sem ele a tela pedia para confiar num critério invisível. */}
                      <th style={th} title="Venda por dia × preço, somado nas filiais em que o item está zerado. É o que deixa de entrar no caixa enquanto falta.">Perde/dia</th>
                      <th style={th}>Zera em</th>
                      <th style={th} title="Há quantos dias esta loja não compra o item. Faixa quando são várias.">Sem comprar</th>
                      {/* Ao lado do "sem comprar" de propósito: as duas juntas separam a
                          ruptura de verdade (vendia e acabou) do item parado (zerado há
                          meses porque ninguém procura, e recomprar é jogar dinheiro fora). */}
                      <th style={th} title="Há quantos dias o item não é vendido nesta loja. Faixa quando são várias.">Sem vender</th>
                      {/* Última das colunas de dado, e só existe quando a rede manda o
                          documento: é a resposta ao "e agora?" que todas as outras levantam. */}
                      {temRemanejamento && (
                        <th style={th} title="Filiais que têm este produto sobrando e podem ceder sem furar o próprio estoque de segurança. Clique na linha para ver quais.">Tem em</th>
                      )}
                      <th style={{ ...th, width: 1 }} />
                    </tr>
                  </thead>
                  <tbody>
                    {agrupadas.slice(0, limite).map(l => (
                      <tr key={l.chave} style={l.fixado ? { background: 'var(--brand-50)' } : undefined}>
                        <td style={{ ...td, padding: '0 4px', textAlign: 'center' }}>
                          <button onClick={() => setFixados(p => {
                            const n = new Set(p);
                            n.has(l.chave) ? n.delete(l.chave) : n.add(l.chave);
                            return n;
                          })}
                            title={l.fixado ? 'Soltar do topo' : 'Fixar no topo da lista'}
                            style={{
                              border: 'none', background: 'transparent', cursor: 'pointer',
                              padding: 4, lineHeight: 0, display: 'inline-flex',
                            }}>
                            <span style={{
                              width: 12, height: 12, borderRadius: '50%', display: 'block',
                              boxSizing: 'border-box',
                              background: l.fixado ? 'var(--pin-on)' : 'transparent',
                              border: `1.5px solid ${l.fixado ? 'var(--pin-on)' : 'var(--ink-300)'}`,
                            }} />
                          </button>
                        </td>
                        <td style={{ ...td, textAlign: 'left', whiteSpace: 'normal', maxWidth: 250, minWidth: 170 }}>
                          <span style={{ display: 'block', fontWeight: 700, color: 'var(--ink-900)' }}>{l.nome}</span>
                          <span style={{ display: 'block', fontSize: 10.5, color: 'var(--ink-400)', fontWeight: 600, marginTop: 1 }}>
                            {l.cod}{l.classe ? ` · ${l.classe}` : ''}{l.grupo ? ` · ${l.grupo}` : ''}{l.lab ? ` · ${l.lab}` : ''}
                            <span style={{ marginLeft: 6, fontWeight: 800, color: 'var(--brand-600)' }}>{[...l.curvas].sort().join('/')}</span>
                            {/* De qual loja é a linha. Numa rede interligada cada linha é de
                                uma loja só (cadastros separados não somam), e sem o nome aqui
                                o mesmo produto aparecia repetido sem dizer de quem era. */}
                            {l.umaFilial && opcoes.filiais.length > 1 && (
                              <span style={{ marginLeft: 6, fontWeight: 700, color: 'var(--ink-500)' }}>· {l.filiais[0].nomeFilial}</span>
                            )}
                          </span>
                        </td>
                        <td style={{ ...td, textAlign: 'left', fontSize: 11.5, fontWeight: 600, maxWidth: 140, overflow: 'hidden', textOverflow: 'ellipsis' }}
                          title={l.nFornecedor > 1
                            ? `Compra mais recente: ${l.fornecedor} · outras lojas compram de: ${[...l.fornecedores].filter(f => f !== l.fornecedor).join(' · ')}`
                            : (l.fornecedor || 'Sem compra nos últimos 180 dias')}>
                          {l.fornecedor
                            ? <>
                                {l.fornecedor}
                                {l.nFornecedor > 1 && (
                                  <span style={{ marginLeft: 5, fontSize: 10, fontWeight: 800, color: 'var(--brand-700)', background: 'var(--brand-50)', border: '1px solid var(--brand-200)', borderRadius: 999, padding: '1px 5px' }}>
                                    +{l.nFornecedor - 1}
                                  </span>
                                )}
                              </>
                            : <span style={{ color: 'var(--ink-300)' }}>—</span>}
                        </td>
                        {/* Estoque é o botão do detalhamento: soma das filiais em escopo,
                            e o clique abre a quebra loja a loja. */}
                        <td style={{ ...td, padding: 0 }}>
                          <button onClick={() => setDetalhe(l)} title="Ver estoque, venda e última compra em cada filial"
                            style={{
                              width: '100%', padding: '10px 12px', textAlign: 'right', fontFamily: 'inherit',
                              fontSize: 12.5, fontWeight: 800, cursor: 'pointer', border: 'none',
                              background: 'transparent', fontVariantNumeric: 'tabular-nums',
                              color: l.ruptura ? 'var(--danger)' : 'var(--brand-600)',
                              textDecoration: 'underline', textUnderlineOffset: 3,
                              textDecorationColor: 'var(--ink-200)',
                            }}>{NUM(l.estoque)}</button>
                        </td>
                        <td style={td}>{l.vendaDia.toFixed(1)}</td>
                        {/* Só quem está zerado perde: com estoque na prateleira a venda
                            ainda acontece, e escrever "R$ 0,00" sugeriria produto sem giro. */}
                        <td style={{ ...td, fontWeight: 800, color: l.perdaDia > 0 ? 'var(--danger)' : 'var(--ink-300)' }}>
                          {l.perdaDia > 0 ? BRL(l.perdaDia) : '—'}
                        </td>
                        <td style={{ ...td, fontWeight: 800, color: l.ruptura ? 'var(--danger)' : l.dias <= 7 ? 'var(--warning)' : 'var(--ink-700)' }}>
                          {diasTxt(l)}
                        </td>
                        {/* Com mais de uma filial não há UMA data de compra: cada loja
                            comprou num dia. Em vez de esconder o número atrás de um botão,
                            mostra a faixa (mais recente–mais antiga) e o clique abre a
                            quebra loja a loja, que é onde cada data tem dono. */}
                        <td style={{ ...td, color: 'var(--ink-500)', padding: l.umaFilial ? undefined : 0 }}>
                          {l.umaFilial
                            ? haDias(l.ultCompra)
                            : <button onClick={() => setDetalhe(l)}
                                title={(l.ultCompraRecente
                                          ? `Compra mais recente: ${haDias(l.ultCompraRecente)} · mais antiga: ${haDias(l.ultCompraAntiga)}`
                                          : 'Nenhuma das lojas comprou este item')
                                       + (l.semData > 0 ? ` · ${NUM(l.semData)} loja(s) nunca compraram` : '')
                                       + ' — clique para ver por filial'}
                                style={{
                                  width: '100%', padding: '10px 8px', textAlign: 'right', fontFamily: 'inherit',
                                  fontSize: 12.5, fontWeight: 600, cursor: 'pointer', border: 'none',
                                  background: 'transparent', color: 'var(--ink-500)', whiteSpace: 'nowrap',
                                  fontVariantNumeric: 'tabular-nums',
                                  textDecoration: 'underline', textUnderlineOffset: 3,
                                  textDecorationColor: 'var(--ink-200)',
                                }}>
                                {faixaDias(l.ultCompraRecente, l.ultCompraAntiga)}
                                {l.semData > 0 && (
                                  <span style={{ marginLeft: 4, fontSize: 10, fontWeight: 800, color: 'var(--ink-400)' }}>
                                    +{NUM(l.semData)} nunca
                                  </span>
                                )}
                              </button>}
                        </td>
                        {/* Sem vender: mesma leitura da coluna ao lado. Retrato de coletor
                            anterior à 1.0.4.61 não traz a data — aí é "—" na linha inteira,
                            e não zero, que passaria por "vendeu hoje". */}
                        <td style={{ ...td, color: 'var(--ink-500)', padding: (l.umaFilial || !l.temVenda) ? undefined : 0 }}>
                          {!l.temVenda
                            ? traco(SEM_CAMPO_VENDA)
                            : l.umaFilial
                            ? haDias(l.ultVenda)
                            : <button onClick={() => setDetalhe(l)}
                                title={(l.ultVendaRecente
                                          ? `Venda mais recente: ${haDias(l.ultVendaRecente)} · mais antiga: ${haDias(l.ultVendaAntiga)}`
                                          : 'Nenhuma das lojas vendeu este item')
                                       + (l.semVenda > 0 ? ` · ${NUM(l.semVenda)} loja(s) nunca venderam` : '')
                                       + ' — clique para ver por filial'}
                                style={{
                                  width: '100%', padding: '10px 8px', textAlign: 'right', fontFamily: 'inherit',
                                  fontSize: 12.5, fontWeight: 600, cursor: 'pointer', border: 'none',
                                  background: 'transparent', color: 'var(--ink-500)', whiteSpace: 'nowrap',
                                  fontVariantNumeric: 'tabular-nums',
                                  textDecoration: 'underline', textUnderlineOffset: 3,
                                  textDecorationColor: 'var(--ink-200)',
                                }}>
                                {faixaDias(l.ultVendaRecente, l.ultVendaAntiga)}
                                {l.semVenda > 0 && (
                                  <span style={{ marginLeft: 4, fontSize: 10, fontWeight: 800, color: 'var(--ink-400)' }}>
                                    +{NUM(l.semVenda)} nunca
                                  </span>
                                )}
                              </button>}
                        </td>
                        {temRemanejamento && (
                          <td style={td}>
                            {!l.doadores
                              // Retrato desta loja não traz o documento: não dá para afirmar
                              // que ninguém tem, então não afirma.
                              ? traco('Esta loja ainda não envia o mapa de remanejamento (Atualizar coletores)')
                              : l.doadores.length === 0
                                // Travessão aqui era ambíguo: ele já significa "sem dado"
                                // nas outras colunas, e esta resposta não é falta de dado —
                                // é uma informação, e das que evitam o telefonema.
                                ? <span style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-400)', whiteSpace: 'nowrap' }}
                                    title="Nenhuma filial da rede tem este produto com sobra acima do próprio estoque de segurança.">
                                    não tem na rede
                                  </span>
                                : (
                                  <span title={l.doadores.map(d => `${d.nomeFilial}: ${NUM(d.disponivel)} un livre(s) de ${NUM(d.estoque)}`).join(' · ')}>
                                    <b style={{ color: 'var(--positive, #16A34A)', fontWeight: 800 }}>{NUM(l.dispTotal)}</b>
                                    <span style={{ color: 'var(--ink-400)' }}> un</span>
                                    <span style={{ display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--ink-400)', marginTop: 1 }}>
                                      {l.doadores.length} filial{l.doadores.length === 1 ? '' : 'is'}
                                    </span>
                                  </span>
                                )}
                          </td>
                        )}
                        {/* Ação da linha, no fim: a coluna "Filial" saiu do grid, então é
                            por aqui que se vê em qual loja o item está e como. O title
                            guarda o resumo que a coluna mostrava, para não exigir o clique
                            só para saber quantas lojas são. */}
                        <td style={{ ...td, padding: '6px 8px' }}>
                          <button onClick={() => setDetalhe(l)}
                            title={l.umaFilial
                              ? l.filiais[0].nomeFilial
                              : `${NUM(l.filiais.length)} filiais${l.emRuptura > 0 ? ` · ${NUM(l.emRuptura)} zerada(s)` : ''}`}
                            style={{
                              fontFamily: 'inherit', fontSize: 10.5, fontWeight: 700,
                              padding: '4px 11px', borderRadius: 999, cursor: 'pointer',
                              border: '1px solid var(--brand-200)', background: 'var(--brand-50)',
                              color: 'var(--brand-700)', whiteSpace: 'nowrap',
                            }}>
                            ver por filial
                          </button>
                        </td>
                      </tr>
                    ))}
                  </tbody>
                </table>
                {agrupadas.length > limite && (
                  <div style={{ padding: '12px 24px 18px', textAlign: 'center' }}>
                    <button onClick={() => setLimite(n => n + 300)}
                      style={{ fontSize: 12, fontWeight: 700, fontFamily: 'inherit', padding: '8px 18px', borderRadius: 999, cursor: 'pointer', border: '1px solid var(--brand-200)', background: 'var(--brand-50)', color: 'var(--brand-700)' }}>
                      Mostrar mais ({NUM(agrupadas.length - limite)} restantes)
                    </button>
                  </div>
                )}
              </div>
            )}
        </div>

        {/* Coluna da direita: os dois cards empilhados. Sem este wrapper eles viram
            filhos diretos do grid de 2 colunas — o segundo cairia na coluna 1, linha 2,
            ou seja, embaixo da lista de produtos. */}
        <div style={{ display: 'flex', flexDirection: 'column', gap: 20, minWidth: 0 }}>

        {/* ── Resumo por curva ── */}
        <div className="card" style={{ padding: '20px 24px' }}>
          <div style={{ fontSize: 15, fontWeight: 700 }}>Por curva</div>
          <div style={{ fontSize: 12, color: 'var(--ink-500)', fontWeight: 500, marginBottom: 14 }}>
            Curva é por filial — o mesmo item pode ser A numa loja e C noutra
          </div>
          {porCurva.map(c => {
            const tot = c.ruptura + c.risco;
            const pct = tot > 0 ? (c.ruptura / tot) * 100 : 0;
            return (
              <div key={c.curva} style={{ marginBottom: 14 }}>
                <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12.5, fontWeight: 700, marginBottom: 5 }}>
                  <span style={{ color: 'var(--ink-800)' }}>Curva {c.curva}</span>
                  <span style={{ color: 'var(--ink-500)', fontWeight: 600 }}>
                    {NUM(c.ruptura)} em ruptura · {NUM(c.risco)} em risco
                  </span>
                </div>
                <span style={{ display: 'flex', height: 8, borderRadius: 4, background: 'var(--ink-100)', overflow: 'hidden' }}>
                  <span style={{ width: `${pct}%`, background: 'var(--danger)' }} />
                  <span style={{ width: `${100 - pct}%`, background: RUP_AMARELO }} />
                </span>
              </div>
            );
          })}
        </div>

        {/* ── Perda por tempo de falta ── */}
        <div className="card" style={{ padding: '20px 24px' }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
            <div style={{ minWidth: 0 }}>
              <div style={{ fontSize: 15, fontWeight: 700 }}>Deixando de vender por dia</div>
              <div style={{ fontSize: 12, color: 'var(--ink-500)', fontWeight: 500 }}>
                Por quanto tempo o item está sem reposição
              </div>
            </div>
            <div style={{ display: 'flex', background: 'var(--ink-100)', borderRadius: 8, padding: 2, flexShrink: 0 }}>
              {[['reais', 'R$'], ['unid', 'Unid']].map(([v, t]) => (
                <button key={v} onClick={() => setPerdaMetrica(v)} style={{
                  padding: '4px 10px', fontSize: 11, fontWeight: 700, fontFamily: 'inherit',
                  border: 0, borderRadius: 6, cursor: 'pointer',
                  background: perdaMetrica === v ? 'var(--white)' : 'transparent',
                  color: perdaMetrica === v ? 'var(--brand-700)' : 'var(--ink-500)',
                }}>{t}</button>
              ))}
            </div>
          </div>

          {perdaPorTempo.faixas.length === 0
            ? <div style={{ fontSize: 12.5, color: 'var(--ink-400)', padding: '18px 0 4px' }}>
                Nenhum item zerado com venda no filtro atual.
              </div>
            : <RupLinhasCurva dados={perdaPorTempo} metrica={perdaMetrica}
                sel={pontoGrafico} onSel={setPontoGrafico}
                onFiltrar={(cels) => { setFaixaFiltro(cels); setLimite(30); }}
                filtroRot={rotuloFaixaFiltro} />}
        </div>
        </div>{/* fim da coluna da direita */}
      </div>

      {/* ── Mini-grid: o mesmo produto em cada filial ────────────────────────
          É aqui que "sem comprar" volta a fazer sentido, porque a linha é de UMA loja. */}
      {detalhe && (
        <>
          <div onClick={() => setDetalhe(null)}
            style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', zIndex: 210, backdropFilter: 'blur(2px)' }} />
          <div style={{
            position: 'fixed', top: '8%', left: 0, right: 0, margin: '0 auto',
            width: 'min(880px, 94vw)', maxHeight: '84vh', background: 'var(--white)',
            borderRadius: 20, boxShadow: '0 24px 64px rgba(0,0,0,0.3)', zIndex: 211,
            display: 'flex', flexDirection: 'column', overflow: 'hidden',
          }}>
            <div style={{ padding: '18px 24px 14px', borderBottom: '1px solid var(--ink-100)', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
              <div style={{ minWidth: 0 }}>
                <div style={{ fontSize: 16, fontWeight: 700, color: 'var(--ink-900)' }}>{detalhe.nome}</div>
                <div style={{ fontSize: 12, color: 'var(--ink-500)', fontWeight: 500, marginTop: 2 }}>
                  {detalhe.cod}{detalhe.classe ? ` · ${detalhe.classe}` : ''}{detalhe.grupo ? ` · ${detalhe.grupo}` : ''}{detalhe.lab ? ` · ${detalhe.lab}` : ''}
                  {' · '}{NUM(detalhe.filiais.length)} filial(is) no filtro atual
                </div>
                {/* A janela da venda por extenso: o "90" aparece em três lugares da tela,
                    mas a data em que ela começa não aparecia em nenhum. */}
                <div style={{ fontSize: 11.5, color: 'var(--ink-400)', fontWeight: 600, marginTop: 4 }}>
                  Venda dos últimos {RUP_DIAS_HIST} dias · {janelaVenda.de} a {janelaVenda.ate}
                </div>
              </div>
              <button onClick={() => setDetalhe(null)} style={{
                width: 30, height: 30, borderRadius: '50%', border: '1px solid var(--ink-200)',
                background: 'var(--ink-50)', cursor: 'pointer', fontSize: 17, color: 'var(--ink-500)',
                display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1, flexShrink: 0,
              }}>×</button>
            </div>

            {/* overflowX junto: com fornecedor e preço são 8 colunas dentro de 740px,
                e nome de distribuidor é texto longo — sem isto a última coluna sairia
                por baixo da borda do modal em vez de rolar. */}
            <div style={{ overflowY: 'auto', overflowX: 'auto', padding: '4px 16px 16px' }}>
              <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12.5 }}>
                <thead>
                  <tr>
                    <th style={{ ...th, textAlign: 'left' }}>Filial</th>
                    {/* Aqui a coluna faz sentido linha a linha: a compra é de cada loja. */}
                    <th style={{ ...th, textAlign: 'left' }}>Fornecedor</th>
                    <th style={th}>Curva</th>
                    {/* Preço é POR FILIAL no retrato do coletor — duas lojas da mesma rede
                        vendem o mesmo item por valores diferentes, e é esse preço que
                        multiplica a venda/dia na coluna "Perde/dia" da tela. */}
                    <th style={th} title="Preço de venda praticado nesta filial, do último retrato enviado pelo coletor.">Preço</th>
                    <th style={th}>Estoque</th>
                    <th style={th} title={`Média diária dos últimos ${RUP_DIAS_HIST} dias: de ${janelaVenda.de} a ${janelaVenda.ate}.`}>Venda/dia</th>
                    <th style={th}>Zera em</th>
                    <th style={th}>Sem comprar</th>
                    <th style={th}>Sem vender</th>
                  </tr>
                </thead>
                <tbody>
                  {detalhe.filiais.map(f => (
                    <tr key={f.key}>
                      <td style={{ ...td, textAlign: 'left', fontWeight: 700, color: 'var(--ink-900)' }}>{f.nomeFilial}</td>
                      <td style={{ ...td, textAlign: 'left', fontSize: 11.5, maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis' }}
                        title={f.fornecedor || 'Sem compra nos últimos 180 dias'}>
                        {f.fornecedor || <span style={{ color: 'var(--ink-300)' }}>—</span>}
                      </td>
                      <td style={{ ...td, fontWeight: 800, color: 'var(--brand-600)' }}>{f.curva}</td>
                      <td style={td}>
                        {f.preco > 0 ? BRL(f.preco) : <span style={{ color: 'var(--ink-300)' }} title="Retrato sem preço para este item nesta filial">—</span>}
                      </td>
                      <td style={{ ...td, fontWeight: 800, color: f.ruptura ? 'var(--danger)' : 'var(--ink-700)' }}>{NUM(f.estoque)}</td>
                      <td style={td}>
                        {f.vendaDia.toFixed(1)}
                        <span style={{ display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--ink-400)', marginTop: 1 }}>
                          {NUM(Math.round(f.vendaDia * RUP_DIAS_HIST))} un/{RUP_DIAS_HIST}d
                        </span>
                      </td>
                      <td style={{ ...td, fontWeight: 800, color: f.ruptura ? 'var(--danger)' : f.dias <= 7 ? 'var(--warning)' : 'var(--ink-700)' }}>{diasTxt(f)}</td>
                      <td style={{ ...td, color: 'var(--ink-500)' }}>
                        {f.ultCompra
                          ? <>{haDias(f.ultCompra)} <span style={{ fontSize: 10.5, color: 'var(--ink-400)' }}>· {f.ultCompra.toLocaleDateString('pt-BR')}</span></>
                          : 'nunca'}
                      </td>
                      <td style={{ ...td, color: 'var(--ink-500)' }}>
                        {!f.temVenda
                          ? traco(SEM_CAMPO_VENDA)
                          : f.ultVenda
                            ? <>{haDias(f.ultVenda)} <span style={{ fontSize: 10.5, color: 'var(--ink-400)' }}>· {f.ultVenda.toLocaleDateString('pt-BR')}</span></>
                            : 'nunca'}
                      </td>
                    </tr>
                  ))}
                  <tr>
                    <td style={{ ...td, textAlign: 'left', fontWeight: 800, color: 'var(--ink-900)', borderTop: '1.5px solid var(--ink-200)', borderBottom: 0 }} colSpan={3}>Total no filtro</td>
                    <td style={{ ...td, fontWeight: 800, color: 'var(--ink-900)', borderTop: '1.5px solid var(--ink-200)', borderBottom: 0 }}>
                      {!precosDet.length
                        ? <span style={{ color: 'var(--ink-300)' }}>—</span>
                        : precoMin === precoMax
                          ? BRL(precoMin)
                          : <>
                              {BRL(precoMin)}
                              <span style={{ display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--ink-400)', marginTop: 1 }}>
                                até {BRL(precoMax)}
                              </span>
                            </>}
                    </td>
                    <td style={{ ...td, fontWeight: 800, color: 'var(--ink-900)', borderTop: '1.5px solid var(--ink-200)', borderBottom: 0 }}>{NUM(detalhe.estoque)}</td>
                    <td style={{ ...td, fontWeight: 800, borderTop: '1.5px solid var(--ink-200)', borderBottom: 0 }}>
                      {detalhe.vendaDia.toFixed(1)}
                      <span style={{ display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--ink-400)', marginTop: 1 }}>
                        {NUM(Math.round(detalhe.vendaDia * RUP_DIAS_HIST))} un/{RUP_DIAS_HIST}d
                      </span>
                    </td>
                    <td style={{ ...td, fontWeight: 800, borderTop: '1.5px solid var(--ink-200)', borderBottom: 0 }}>{diasTxt(detalhe)}</td>
                    <td style={{ ...td, borderTop: '1.5px solid var(--ink-200)', borderBottom: 0 }} />
                    <td style={{ ...td, borderTop: '1.5px solid var(--ink-200)', borderBottom: 0 }} />
                  </tr>
                </tbody>
              </table>
              {/* ── Quem tem sobrando ────────────────────────────────────────────
                  A tabela de cima responde "onde está faltando"; esta responde "de onde
                  tirar". Vem do documento de remanejamento, que traz JUSTAMENTE as filiais
                  que o retrato não carrega — as que estão com o estoque saudável. */}
              {detalhe.doadores && detalhe.doadores.length > 0 && (
                <div style={{ padding: '18px 12px 0' }}>
                  <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 8 }}>
                    <div style={{ fontSize: 13.5, fontWeight: 700, color: 'var(--ink-900)' }}>Tem sobrando em</div>
                    <div style={{ fontSize: 11.5, color: 'var(--ink-500)', fontWeight: 500 }}>
                      {NUM(detalhe.doadores.length)} filial{detalhe.doadores.length === 1 ? '' : 'is'}
                      {' · '}<b style={{ color: 'var(--positive, #16A34A)' }}>{NUM(detalhe.dispTotal)} un</b> disponível(is) no total
                    </div>
                  </div>
                  <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12.5 }}>
                    <thead>
                      <tr>
                        <th style={{ ...th, textAlign: 'left' }}>Filial</th>
                        <th style={th}>Estoque</th>
                        <th style={th} title={`Média diária dos últimos ${RUP_DIAS_HIST} dias nesta filial.`}>Venda/dia</th>
                        <th style={th} title="Quanto cobre o estoque dela depois de ceder tudo o que está na coluna ao lado.">Fica com</th>
                        <th style={th} title="Estoque menos o estoque de segurança da própria filial. É o que ela pode mandar sem entrar em risco.">Pode ceder</th>
                      </tr>
                    </thead>
                    <tbody>
                      {detalhe.doadores.map(d => {
                        // Quanto tempo a doadora aguenta DEPOIS de ceder tudo. É o número que
                        // decide se a transferência é segura — sem ele o "pode ceder" pede
                        // confiança cega no cálculo do coletor.
                        const sobra = d.estoque - d.disponivel;
                        const diasPos = d.vendaDia > 0 ? sobra / d.vendaDia : Infinity;
                        return (
                          <tr key={d.cdFilial}>
                            <td style={{ ...td, textAlign: 'left', fontWeight: 700, color: 'var(--ink-900)' }}>{d.nomeFilial}</td>
                            <td style={td}>{NUM(d.estoque)}</td>
                            <td style={td}>
                              {d.vendaDia.toFixed(1)}
                              <span style={{ display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--ink-400)', marginTop: 1 }}>
                                {NUM(Math.round(d.vendaDia * RUP_DIAS_HIST))} un/{RUP_DIAS_HIST}d
                              </span>
                            </td>
                            <td style={{ ...td, color: 'var(--ink-500)' }}>
                              {NUM(sobra)}
                              <span style={{ display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--ink-400)', marginTop: 1 }}>
                                {diasPos === Infinity ? 'sem giro' : `~${Math.floor(diasPos)}d`}
                              </span>
                            </td>
                            <td style={{ ...td, fontWeight: 800, color: 'var(--positive, #16A34A)' }}>{NUM(d.disponivel)}</td>
                          </tr>
                        );
                      })}
                    </tbody>
                  </table>
                </div>
              )}

              {/* Três avisos distintos, um parágrafo cada: emendados viravam um bloco só
                  e o do meio — o que evita a comparação errada com o relatório de
                  movimentos — passava despercebido no meio da frase anterior. */}
              <div style={{ fontSize: 10.5, color: 'var(--ink-400)', padding: '12px 12px 0', lineHeight: 1.5, display: 'flex', flexDirection: 'column', gap: 7 }}>
                <div>
                  Só aparecem as filiais que passaram pelos filtros. A loja com estoque igual ou acima do estoque
                  de segurança deste produto não entra na tabela de cima — é ela que aparece em <b>Tem sobrando em</b>,
                  quando o coletor daquela loja já envia o mapa de remanejamento.
                </div>
                <div style={{ color: 'var(--ink-500)' }}>
                  <b>Não compare os totais com o relatório de movimentos:</b> lá entram todas as filiais,
                  aqui só as que estão em ruptura ou em risco.
                </div>
                <div>
                  <b>Venda/dia</b> é a média dos últimos {RUP_DIAS_HIST} dias — de {janelaVenda.de} a {janelaVenda.ate} —,
                  já descontadas as devoluções; embaixo dela, o total de unidades vendidas no período.
                </div>
                {/* Só quando divergem: repetir "o preço é de cada loja" com um preço só na
                    tela seria ruído, mas com preços diferentes explica a perda/dia de cada uma. */}
                {precoMin !== precoMax && (
                  <div>
                    <b>Preço</b> é o praticado por cada filial — aqui vai de {BRL(precoMin)} a {BRL(precoMax)} —,
                    e é o preço da própria loja que multiplica a venda/dia dela no <b>Perde/dia</b>.
                  </div>
                )}
              </div>
            </div>
          </div>
        </>
      )}
    </>
  );
}
