// js/components/pages/App.jsx
// Root component — shell que monta todas as pages e coordena autenticação + realtime.
// Extraído de index.html em Fase 6 (2026-04-29): L719-L1749
// Realtime BLOCKED (Fase 7): subscriptions permanecem em useEffect do App
// NÃO toca em: useStore.js, dualWrite.js, _critWrite.js, reconcileSync.js
(function() {
  'use strict';
  const {useState, useEffect, useRef, useMemo} = React;

function App(){
  // [Wave 5 MINI + MEDIUM 2026-05-16] Refs widgets externos + fail-loud agregado.
  // Padrão: bloco único agregado (regra_estender_bloco_refs_fail_loud).
  // Wave 5 MINI: RaceBarVendedor + ZNXOrderCounter.
  // Wave 5 MEDIUM: TopBar + DriftModal + ZNXSyncHelpers + ZNXAppInitFixes.
  const RaceBarVendedor = window.ZNX?.widgets?.app?.RaceBarVendedor;
  const ZNXOrderCounter = window.ZNXOrderCounter;
  const TopBar = window.ZNX?.widgets?.app?.TopBar;
  const DriftModal = window.ZNX?.widgets?.app?.DriftModal;
  const ZNXSyncHelpers = window.ZNXSyncHelpers;
  const ZNXAppInitFixes = window.ZNXAppInitFixes;
  if (!RaceBarVendedor || !ZNXOrderCounter || !TopBar || !DriftModal || !ZNXSyncHelpers || !ZNXAppInitFixes) {
    const _msg = `[App] widgets faltando: RaceBarVendedor=${!!RaceBarVendedor}, ZNXOrderCounter=${!!ZNXOrderCounter}, TopBar=${!!TopBar}, DriftModal=${!!DriftModal}, ZNXSyncHelpers=${!!ZNXSyncHelpers}, ZNXAppInitFixes=${!!ZNXAppInitFixes}`;
    console.error(_msg);
    window.Sentry?.captureMessage?.(_msg, 'error');
  }

  const[appReady,setAppReady]=useState(false);
  const[currentUser,setCurrentUser]=useState(()=>{
    try{const u=localStorage.getItem('znx_user');if(!u)return null;const p=JSON.parse(u);return(p&&p.data&&p.data.username)?p.data:p;}catch{return null}
  });
  const[page,setPage]=useState('dashboard');

  // [WIRE Novo Orçamento] listener global navega pra Orçamentos quando event dispara
  useEffect(()=>{
    const handler=()=>setPage('orcamentos');
    window.addEventListener('znx:openQuoteForClient',handler);
    return()=>window.removeEventListener('znx:openQuoteForClient',handler);
  },[]);

  // [Onda 4b 20260505] Approval flow — vendedora clica "Disparar agora" → navega pra clientes
  useEffect(()=>{
    const handler=()=>setPage('clientes');
    window.addEventListener('znx:openApprovedCampaign',handler);
    return()=>window.removeEventListener('znx:openApprovedCampaign',handler);
  },[]);

  // [Onda P5 20260506] Navegação por event bus pra redirects de migração
  useEffect(()=>{
    const handler=(e)=>{const target=e.detail||e;if(typeof target==='string')setPage(target);};
    window.addEventListener('znx:goto-page',handler);
    return()=>window.removeEventListener('znx:goto-page',handler);
  },[]);

  // Fase 5 — Estado do indicador de sincronização: 'connecting' | 'online' | 'syncing' | 'offline'
  const[syncStatus,setSyncStatus]=useState('connecting');
  const syncPulseRef=useRef(null);
  function pulseSyncing(){
    setSyncStatus('syncing');
    if(syncPulseRef.current)clearTimeout(syncPulseRef.current);
    syncPulseRef.current=setTimeout(()=>setSyncStatus(prev=>prev==='syncing'?'online':prev),1500);
  }

  const[productsAll,setProducts,_setProducts]=useStore('products',INITIAL_PRODUCTS);
  // Fase 4 — `products` é a visão filtrada (sem tombstones). Todos os consumidores de UI
  // leem este derivado. `productsAll` é raw (incluindo deletados) usado por merge/aplicação.
  const products=useMemo(()=>productsAll.filter(p=>!p||!p.deletedAt),[productsAll]);
  const productsAllRef=useRef(productsAll);
  productsAllRef.current=productsAll;
  const[suppliers,setSuppliers,_setSuppliers]=useStore('suppliers',INITIAL_SUPPLIERS);
  const[clients,setClients,_setClients]=useStore('clients',INITIAL_CLIENTS);
  const[sales,setSales,_setSales]=useStore('sales',INITIAL_SALES);
  const[quotes,setQuotes,_setQuotes]=useStore('quotes',INITIAL_QUOTES);
  const[payables,setPayables,_setPayables]=useStore('payables',INITIAL_PAYABLES);
  const[receivables,setReceivables,_setReceivables]=useStore('receivables',INITIAL_RECEIVABLES);
  const[entries,setEntries,_setEntries]=useStore('entries',INITIAL_STOCK_ENTRIES);
  const[notasFreteiro,setNotasFreteiro,_setNotasFreteiro]=useStore('notasFreteiro',INITIAL_NOTAS_FRETEIRO);
  const[purchases,setPurchases,_setPurchases]=useStore('purchases',INITIAL_PURCHASES);
  const[devolucoes,setDevolucoes,_setDevolucoes]=useStore('devolucoes',INITIAL_DEVOLUCOES);
  const[activityLog,setActivityLog,_setActivityLog]=useStore('activityLog',[]);
  const[gastos,setGastos,_setGastos]=useStore('gastos',INITIAL_GASTOS);
  const[pagamentos,setPagamentos,_setPagamentos]=useStore('pagamentos',INITIAL_PAGAMENTOS);
  const[discountRequests,setDiscountRequests,_setDiscountRequests]=useStore('discountRequests',[]);
  const[cancelRequests,setCancelRequests,_setCancelRequests]=useStore('cancelRequests',[]);
  const[metas,setMetas,_setMetas]=useStore('metas',INITIAL_METAS);
  const[metasBase,setMetasBase,_setMetasBase]=useStore('metasBase',{});
  // [ONDA H 2026-05-07] Meta global empresa por mês — formato: { '2026-05': 1500000, '2026-06': 1800000 }
  const[metasEmpresa,setMetasEmpresa,_setMetasEmpresa]=useStore('metasEmpresa',{});
  const[extraUsers,setExtraUsers,_setExtraUsers]=useStore('extraUsers',[]);
  // [FIX USER-PERSIST 20260422-B] app_users carregados da tabela relacional (fonte de verdade pós-cutover)
  const[appUsersFromDB,setAppUsersFromDB]=useState([]);
  // [v224.31 RECEBER-TRANSFERENCIA 20260526] Count global pra badge sidebar estoquista (Ibra/Abbes/Munir)
  const[pendingTransfersCount,setPendingTransfersCount]=useState(0);
  // [v224.32 VENDAS-DESPACHO 20260526] Counts: estoquista (vendas não-impressas 3 dias úteis) + admin (reprints pendentes)
  const[pendingPrintCount,setPendingPrintCount]=useState(0);
  const[pendingReprintCount,setPendingReprintCount]=useState(0);
  const[nextOrderNum,setNextOrderNum]=useStore('nextOrderNum',1);
  const nextOrderNumRef=useRef(nextOrderNum);
  nextOrderNumRef.current=nextOrderNum;
  const[showDiscountModal,setShowDiscountModal]=useState(false);
  const[showCancelModal,setShowCancelModal]=useState(false);
  // [FEAT 20260504] Drift radar — admin/financeiro veem badge se houver inconsistencias
  const[driftSummary,setDriftSummary]=useState(null);
  const[showDriftModal,setShowDriftModal]=useState(false);
  const[driftDetails,setDriftDetails]=useState(null);
  // Re-checa drift ao logar e a cada 5 min enquanto admin estiver online
  useEffect(()=>{
    if(!currentUser||!['admin','financeiro'].includes(currentUser.role))return;
    let mounted=true;
    async function check(){
      try{
        const r=await getDriftSummary();
        if(mounted&&r.success)setDriftSummary(r);
      }catch(e){console.warn('[ZNX] drift check failed',e?.message);}
    }
    check();
    const iv=setInterval(check,5*60*1000); // 5 min
    return ()=>{mounted=false;clearInterval(iv);};
  },[currentUser?.id,currentUser?.role]);
  // now como ref — sem setInterval que força re-render global a cada 30s
  const nowRef=useRef(Date.now());
  const _approvalInFlight=useRef(new Set()); // [FIX BUG-003] double-submit guard
  // [ONDA-A #9 2026-05-11] regra_idem_estavel — idem key estável por req.id durante retry.
  // ANTES: cada call gerava UUID novo → backend tratava como request NOVO se falhasse retry.
  // AGORA: Map<reqId, idemKey> persiste entre retries; entry deletada APÓS sucesso confirmado.
  const _approvalIdemMap=useRef(new Map());
  // [ONDA-A #9 2026-05-11] regra_loading_state_obrigatorio — espelha _approvalInFlight em state
  // pra modal disabled re-renderizar. Set ref sozinho não dispara re-render.
  const[approvalInFlightIds,setApprovalInFlightIds]=useState([]);
  function _markApprovalStart(id){_approvalInFlight.current.add(id);setApprovalInFlightIds(prev=>prev.includes(id)?prev:[...prev,id]);}
  function _markApprovalEnd(id){_approvalInFlight.current.delete(id);setApprovalInFlightIds(prev=>prev.filter(x=>x!==id));}

  // [Wave 5 MINI 2026-05-17] Order Counter helpers extraídos pra lib/orderCounter.js (factory pattern).
  // Fix 2.1 — ÚNICO CONTADOR UNIFICADO para ORC e VND. Sempre use znxCounter.reserveVNDAsync/reserveORCAsync.
  // [REVERT 2026-05-07] Bíblia linha 173: reserveVNDAsync/reserveORCAsync atomicidade RPC NÃO MEXER.
  // Fallback local preservado dentro do orderCounter.js (Sentry captura RPC fail + offline).
  const fmtORC = ZNXOrderCounter.fmtORC;
  const fmtVND = ZNXOrderCounter.fmtVND;
  const reserveNextNum = ZNXOrderCounter.createReserveNextNum({sales, quotes, nextOrderNumRef, setNextOrderNum});
  const reserveNextOrderNumber = ZNXOrderCounter.createReserveNextOrderNumber({sb, Sentry: typeof Sentry!=='undefined'?Sentry:undefined, setNextOrderNum, reserveNextNum});
  // Wire global counter so external components (NovaVendaPage, Vendas, Orcamentos) use it
  znxCounter.reserve=reserveNextNum;
  znxCounter.reserveAsync=reserveNextOrderNumber;
  znxCounter.reserveVNDAsync=async()=>{const n=await reserveNextOrderNumber();return'VND-'+String(n).padStart(4,'0');};
  znxCounter.reserveORCAsync=async()=>{const n=await reserveNextOrderNumber();return'ORC-'+String(n).padStart(4,'0');};

  function applyRows(rows,isInit){
    if(!rows||!rows.length)return;
    const m={};
    const wrappedFixes={}; // chaves cujo dado remoto estava em {ts,data} — auto-corrigir após loop
    rows.forEach(r=>{
      // Normaliza a chave: remove prefixo znx_ se vier corrompido do Supabase
      // Ex: r.key='znx_products' → cleanKey='products' (sem duplo prefixo no localStorage)
      const cleanKey=toRemoteKey(r.key);
      // Usa updated_at para detectar eco do próprio save() e dados obsoletos
      // Imune a reordenação de chaves JSONB que afetava a comparação por JSON.stringify
      const ts=r.updated_at||'';
      const cached=_syncCache[cleanKey]||'';
      // [FIX PR-SYNC-20260427] 'products': bypass row-level ts guards — per-product updatedAt em
      // mergeProductsFromRemote já trata echo/staleness sem depender de clock inter-machine.
      // Problema: doConvert (stock-only) chama setProducts → stampProductChanges → save(), que
      // escreve _syncCache['products'] = T_vendedora. Admin's Realtime chega com updated_at=T_admin.
      // Se T_admin ≤ T_vendedora (clock drift ou corrida), Layer 1 bloqueia silenciosamente.
      // Para outras chaves: guards mantidos (proteção contra restore de dados deletados, etc.)
      if(cleanKey!=='products'){
        // Fix C — Pula se: (a) mesmo timestamp = eco do save, (b) dado mais antigo, ou (c) ts ausente com cache presente
        if(cached&&(!ts||ts<=cached))return;
        // Fix C — Extra guard for Realtime updates: also compare against localStorage timestamp directly
        // Fix C.2: ALWAYS check localStorage timestamp (init AND realtime)
        // Protects against deleted data being restored from Supabase
        const localRaw=localStorage.getItem(toLocalKey(cleanKey));
        if(localRaw){
          try{
            const localParsed=JSON.parse(localRaw);
            const localTs=localParsed.ts||'';
            if(ts&&localTs&&ts<=localTs){
              console.log('[ZNX] applyRows: blocking older/equal remote for',cleanKey,'remote:',ts,'local:',localTs);
              return;
            }
          }catch(e){console.warn('[ZNX]',e);}
        }
      }
      // GUARD [A-03] [PATCH 1/4]: se dado remoto é vazio/null/undefined e local já tem dado real, NÃO sobrescreve
      // Expandido para cobrir: null, undefined, '', 0, {}, [] — antes só cobria array vazio
      if(ARRAY_KEYS.has(cleanKey)&&isEmptyOrNull(r.data)){
        console.debug('[ZNX] GUARD [A-03] detectou dado remoto vazio/null para',cleanKey,'tipo:',typeof r.data,'valor:',r.data===null?'null':r.data===undefined?'undefined':JSON.stringify(r.data).slice(0,50));
        const existing=localStorage.getItem(toLocalKey(cleanKey));
        if(existing){
          try{
            const parsed=JSON.parse(existing);
            const localData=parsed.data??parsed;
            const localHasData=Array.isArray(localData)?localData.length>0:(localData&&typeof localData==='object'?Object.keys(localData).length>0:!!localData);
            if(localHasData){
              console.debug('[ZNX] GUARD [A-03] BLOQUEOU overwrite em',cleanKey,'— local tem',Array.isArray(localData)?localData.length+' itens':'dados válidos','| remoto era:',r.data===null?'null':typeof r.data);
              return;
            }
            console.log('[ZNX] GUARD [A-03] local também vazio para',cleanKey,'— permitindo sync');
          }catch(e){console.warn('[ZNX] GUARD [A-03] parse falhou para',cleanKey,e?.message);}
        }
      }
      _syncCache[cleanKey]=ts;
      // [FIX 2] Unwrap recursivo: desembrulha TODAS as camadas de {ts,data} aninhadas
      // Antes: 1 nível (ternário); agora: while até chegar no valor flat real
      const rDataRaw=r.data;
      let rData=rDataRaw;
      while(rData&&typeof rData==='object'&&!Array.isArray(rData)&&rData.data!==undefined&&typeof rData.ts==='string'){
        rData=rData.data;
      }
      if(rData!==rDataRaw){
        // Agenda auto-correção: vai gravar flat de volta no Supabase após o loop
        wrappedFixes[cleanKey]=rData;
        console.warn('[ZNX] applyRows: wrapped detectado em "'+cleanKey+'" (desembrulhado recursivamente) — auto-corrigindo Supabase');
      }
      // Fase 1 — hidrata updatedAt em produtos chegando de Macs sem migração [A-06]
      const dataHydrated=(cleanKey==='products')?hydrateProductTimestamps(rData):rData;
      m[cleanKey]=dataHydrated;
      // Escreve formato {data,ts} para que _syncCache seja pré-populado no próximo refresh
      // Usa toLocalKey(cleanKey) → sempre 'znx_products' (nunca 'znx_znx_products')
      // FIX-INTERMITENTE C: usa safe setter pra absorver QuotaExceededError
      const _safeSet=(window.ZNX&&window.ZNX._safeSetLocal);
      if(_safeSet){_safeSet(toLocalKey(cleanKey),JSON.stringify({data:dataHydrated,ts}),dataHydrated);}
      else{try{localStorage.setItem(toLocalKey(cleanKey),JSON.stringify({data:dataHydrated,ts}));}catch(qe){console.warn('[ZNX] applyRows setItem fail '+cleanKey,qe?.message);}}
    });
    // Auto-corrige chaves com dado wrapped no Supabase: grava versão flat
    // save() sobrescreve _syncCache com timestamp mais recente → bloqueia echo do Realtime
    if(Object.keys(wrappedFixes).length){
      Object.keys(wrappedFixes).forEach(k=>{
        console.warn('[ZNX] applyRows: autocorrigindo "'+k+'" wrapped→flat no Supabase');
        save(k,wrappedFixes[k]);
      });
    }
    // Usa _set* (raw setState sem save()) para não re-disparar upserts no Supabase
    // Merge inteligente: nunca perde registros locais que não existem no remoto
    if(m.products){
      // Fase 4 — merge por produto. Usa ref para acessar raw state no closure da Realtime.
      const merged=mergeProductsFromRemote(productsAllRef.current||[],m.products,isInit);
      // [FIX PR-SYNC-20260427] Skip re-render se nenhuma ref de produto mudou (echo guard pós-bypass Layer 1).
      // Set.has() usa identidade de referência — O(n) total. Evita re-render global para ecos do próprio save().
      const cur=productsAllRef.current||[];
      const curSet=new Set(cur);
      if(merged.length!==cur.length||merged.some(p=>!curSet.has(p)))_setProducts(merged);
    }
        if(m.suppliers)_setSuppliers(m.suppliers);
    if(m.clients)_setClients(m.clients);
    // D14 — merge-based para evitar full-replace race condition em escritas concorrentes
    if(m.sales)_setSales(prev=>mergeArrayById(prev,m.sales));
    if(m.quotes)_setQuotes(prev=>mergeArrayById(prev,m.quotes));
    if(m.payables)_setPayables(prev=>mergeArrayById(prev,m.payables));
    if(m.receivables)_setReceivables(prev=>mergeArrayById(prev,m.receivables));
    if(m.entries)_setEntries(prev=>mergeArrayById(prev,m.entries));
    if(m.notasFreteiro)_setNotasFreteiro(prev=>mergeArrayById(prev,m.notasFreteiro));
    if(m.purchases)_setPurchases(prev=>mergeArrayById(prev,m.purchases));
    if(m.devolucoes)_setDevolucoes(prev=>mergeArrayById(prev,m.devolucoes));
    if(m.gastos)_setGastos(m.gastos);
    if(m.pagamentos)_setPagamentos(m.pagamentos);
    if(m.discountRequests)_setDiscountRequests(m.discountRequests);
    if(m.cancelRequests)_setCancelRequests(m.cancelRequests);
    if(m.metas)_setMetas(m.metas);
    if(m.metasBase)_setMetasBase(m.metasBase);
    if(m.extraUsers)_setExtraUsers(m.extraUsers);
    // marcas é gerenciado pelo useStore local dentro do componente Marcas
  }
  // [Wave 5 MEDIUM 2026-05-16] mergeArrayById extraído pra lib/syncHelpers.js (função pura).
  // D14 — Merge por id+updatedAt para entidades críticas (sales, quotes, purchases, entries, etc.)
  const mergeArrayById = ZNXSyncHelpers.mergeArrayById;
  useEffect(()=>{
    if(!appReady)return;
    const el=document.getElementById('znx-loading');
    if(el){el.classList.add('hidden');setTimeout(()=>el?.remove(),400);}
  },[appReady]);
  // [Wave 5 MEDIUM 2026-05-16] Init fixes extraídos pra lib/appInitFixes.js (factory functions).
  // useEffects preservados aqui pra manter dependency array + lifecycle. Body delegado.
  useEffect(()=>{ if(!appReady)return; ZNXAppInitFixes.runOrphanFix({quotes, sales, setQuotes}); },[appReady]);
  useEffect(()=>{ if(!appReady)return; ZNXAppInitFixes.runDeduplicate({products, setProducts}); },[appReady]);
  useEffect(()=>{ if(!appReady)return; ZNXAppInitFixes.runCodeLimit({products, setProducts}); },[appReady]);
  useEffect(()=>{ if(!appReady)return; ZNXAppInitFixes.runTombstoneCleanup({productsAllRef, setProducts}); },[appReady]);
  // GUARD A-04: detecta drift relacional-JSONB (simétrico ao GUARD-WRITE A-03)
  // Se tabela relacional está vazia mas JSONB tem dados → drift (registros pré-dual-write)
  // Avisa no console; não bloqueia. Ação futura: migration de backfill.
  useEffect(()=>{
    if(!appReady)return;
    (async()=>{
      try{
        const[cr,pr]=await Promise.all([
          sb.from('clients').select('id',{count:'exact',head:true}),
          sb.from('products').select('id',{count:'exact',head:true})
        ]);
        const drifts=[];
        if((cr.count||0)===0&&clients.length>0)
          drifts.push('clients (JSONB='+clients.length+', relacional=0)');
        if((pr.count||0)===0&&products.length>0)
          drifts.push('products (JSONB='+products.length+', relacional=0)');
        if(drifts.length){
          console.warn('[GUARD A-04] Drift relacional-JSONB detectado:',drifts.join('; '));
          console.warn('[GUARD A-04] Registros pré-dual-write existem apenas no JSONB. Backfill pendente.');
        }
      }catch(e){console.warn('[GUARD A-04] check falhou:',e?.message);}
    })();
  },[appReady]);

  // [v223.37 REALTIME 20260520] Expor raw setters pra realtime callback
  // (raw _set* = SEM save remoto, evita loop subscribe→save→trigger→subscribe)
  useEffect(()=>{
    window.ZNX = window.ZNX || {};
    window.ZNX.realtimeSetters = { _setSales, _setQuotes };
    return ()=>{ if(window.ZNX) delete window.ZNX.realtimeSetters; };
  },[]);

  // [v224.26 BUG-OFFDUTY-VISIBILITY fix 20260525] Expor appUsersFromDB globalmente
  // pra Modelo C client-side (visibleClients em Clientes/QuoteFormBody/novavenda lib).
  // Decisão honesta: window.__ZNX_APP_USERS__ evita prop drilling em 4+ consumers (custo +50L).
  useEffect(()=>{
    if(typeof window!=='undefined') window.__ZNX_APP_USERS__ = appUsersFromDB;
  },[appUsersFromDB]);

  // [v224.27 ESTOQUISTA-PRIVACY 20260526] Helpers role-based pra esconder $/fornecedor de estoquista.
  // fail-closed em null user (defesa em depth). Estoquistas reais: Ibra · Abbes · Munir.
  useEffect(()=>{
    if(typeof window==='undefined') return;
    window.canSeeCost = function(u){
      if(!u || !u.role) return false; // fail-closed
      return u.role === 'admin' || u.role === 'financeiro';
    };
    window.canSeeSupplier = function(u){
      if(!u || !u.role) return false; // fail-closed
      return u.role !== 'estoquista';
    };
    // [v224.30 ESTOQUISTA-TRANSFER-WORKFLOW 20260526] Libera workflow transferência completo
    // pra estoquista (Ibra/Abbes/Munir): approve/send/receive/resolve_divergence/cancel.
    // Backend: 3 RPCs alteradas (approve/cancel/resolve) · 2 já permitiam (send/receive).
    // Privacy preservada: nenhuma RPC retorna cost/price · v224.27.x intacto.
    window.canManageTransfer = function(u){
      if(!u || !u.role) return false; // fail-closed
      if(u.role === 'admin') return true;
      // [v224.40 D 20260526] Estoquista só com flag · Karim Nasser FALSE default · bloqueado workflow transfer
      return u.role === 'estoquista' && u.can_confirm_transfer_actions === true;
    };
    // [v224.40 E 20260526] Helper receber-frete · admin OR estoquista com flag (Abbes+Munir)
    window.canReceiveFrete = function(u){
      if(!u || !u.role) return false; // fail-closed
      if(u.role === 'admin') return true;
      return u.role === 'estoquista' && u.can_receive_frete === true;
    };
  },[]);

  // [v224.31 RECEBER-TRANSFERENCIA 20260526] Count pendingTransfers + Realtime subscription pra badge sidebar.
  // Trigger SOMENTE pra estoquista (Ibra/Abbes/Munir) · outras roles ZERO custo (early return).
  // Channel name distinto ('znx-pending-transfers') pra não colidir com Realtime de ReceberTransferencia.jsx.
  useEffect(function(){
    if(!currentUser || currentUser.role !== 'estoquista') return;
    var sb = window.sb;
    if(!sb) return;
    function fetchCount(){
      sb.from('stock_transfers').select('id',{count:'exact',head:true})
        .not('status','in','("RECEBIDA","CANCELADA")')
        .then(function(r){ setPendingTransfersCount(r.count || 0); })
        .catch(function(e){ znxLogWarn('[v224.31 pendingTransfersCount]', e); });
    }
    fetchCount();
    // [v224.74] lib hardened com fallback raw
    const handle = window.ZNX && window.ZNX.lib && window.ZNX.lib.realtime
      ? window.ZNX.lib.realtime.subscribe('stock_transfers', function(){ fetchCount(); })
      : (function(){
          const ch = sb.channel('znx-pending-transfers')
            .on('postgres_changes',{event:'*',schema:'public',table:'stock_transfers'},fetchCount)
            .subscribe();
          return { _ch: ch, _fallback: true };
        })();
    return function(){
      if(handle && handle._fallback) sb.removeChannel(handle._ch);
      else if(handle && window.ZNX?.lib?.realtime) window.ZNX.lib.realtime.unsubscribe(handle);
    };
  },[currentUser && currentUser.id, currentUser && currentUser.role]);

  // [v224.32 VENDAS-DESPACHO 20260526] Counts vendas a despachar (estoquista) + reprints pendentes (admin).
  // Janela 3 dias úteis CONTANDO sábado (pula domingo) · pra estoquista contar vendas não-impressas.
  // Admin: count global reprint_pending=true (qualquer data).
  // Channel name distinto ('znx-vendas-despacho-counts') pra não colidir com Realtime de VendasDespacho.jsx.
  useEffect(function(){
    if(!currentUser) return;
    var role = currentUser.role;
    if(role !== 'estoquista' && role !== 'admin') return;
    var sb = window.sb;
    if(!sb) return;
    function getWindowStartISO(){
      var t = new Date(); t.setHours(0,0,0,0);
      var count = (t.getDay() !== 0) ? 1 : 0;
      var cur = new Date(t);
      while(count < 3){
        cur.setDate(cur.getDate() - 1);
        if(cur.getDay() !== 0) count++;
      }
      return cur.toISOString().slice(0,10);
    }
    function fetchCounts(){
      if(role === 'estoquista'){
        sb.from('sales').select('id',{count:'exact',head:true})
          .gte('date', getWindowStartISO())
          .is('printed_at', null)
          .is('deleted_at', null)
          .then(function(r){ setPendingPrintCount(r.count || 0); })
          .catch(function(e){ znxLogWarn('[v224.32 pendingPrintCount]', e); });
      }
      if(role === 'admin'){
        sb.from('sales').select('id',{count:'exact',head:true})
          .eq('reprint_pending', true)
          .is('deleted_at', null)
          .then(function(r){ setPendingReprintCount(r.count || 0); })
          .catch(function(e){ znxLogWarn('[v224.32 pendingReprintCount]', e); });
      }
    }
    fetchCounts();
    var channel = sb.channel('znx-vendas-despacho-counts')
      .on('postgres_changes',{event:'*',schema:'public',table:'sales'},fetchCounts)
      .subscribe();
    return function(){ sb.removeChannel(channel); };
  },[currentUser && currentUser.id, currentUser && currentUser.role]);

  // [v224.114 UX desconto 2026-06-02] Realtime PUSH pra vendedora: toast imediato quando admin
  // aprova/recusa o pedido de desconto DELA (caso ORC-1719: aprovado e Isabella não soube).
  useEffect(function(){
    if(!currentUser || !currentUser.id || currentUser.role !== 'vendedor') return;
    var ch = sb.channel('discount_feedback_'+currentUser.id)
      .on('postgres_changes', {event:'UPDATE', schema:'public', table:'discount_requests', filter:'requested_by_id=eq.'+currentUser.id}, function(payload){
        var oldStatus = payload.old && payload.old.status;
        var newStatus = payload.new && payload.new.status;
        if(oldStatus && oldStatus === newStatus) return;
        if(newStatus !== 'approved' && newStatus !== 'denied') return;
        var val = Number((payload.new && payload.new.total_desconto) || 0);
        var valFmt = (typeof window.fmt === 'function') ? window.fmt(val) : 'R$ '+val.toFixed(2);
        var client = (payload.new && payload.new.client_name) || '—';
        var resolver = (payload.new && payload.new.resolved_by_name) || 'admin';
        if(newStatus === 'approved'){
          toast('✅ Desconto '+valFmt+' aprovado por '+resolver+' · '+client, 'success');
        } else {
          var reason = (payload.new && payload.new.denied_reason) || '(sem motivo)';
          toast('❌ Desconto '+valFmt+' recusado · motivo: "'+reason+'"', 'warning');
        }
        try{
          _setDiscountRequests(function(prev){
            return (prev||[]).map(function(r){
              return r.id === payload.new.id
                ? Object.assign({}, r, {status:newStatus, resolvedAt:payload.new.resolved_at, resolvedByName:payload.new.resolved_by_name, deniedReason:payload.new.denied_reason})
                : r;
            });
          });
        }catch(_){}
      })
      .subscribe();
    return function(){ try{ sb.removeChannel(ch); }catch(_){} };
  },[currentUser && currentUser.id, currentUser && currentUser.role]);

  // [v223.37 REALTIME 20260520] Subscribe sales + quotes — push direto pós-INSERT/UPDATE/DELETE
  // Resolve classe Bug VND-870/ORC-889 "sumir-pos-conversao" (pg_cron rebuild_full era único path antes).
  // Mantém pg_cron */5min como fallback safety (ADD não REPLACE).
  useEffect(()=>{
    if(!appReady)return;
    const rt = window.ZNX && window.ZNX.lib && window.ZNX.lib.realtime;
    if(!rt || typeof rt.subscribe !== 'function'){
      console.warn('[ZNX v223.37] realtime lib não carregada — fallback pg_cron */5min ainda ativo');
      return;
    }
    const salesSub = rt.subscribe('sales', function(event){
      _setSales(function(prev){ return rt.mergeRealtimeEvent(prev, event); });
    });
    const quotesSub = rt.subscribe('quotes', function(event){
      _setQuotes(function(prev){ return rt.mergeRealtimeEvent(prev, event); });
    });
    return ()=>{
      rt.unsubscribe(salesSub);
      rt.unsubscribe(quotesSub);
    };
  },[appReady]);

  // [Wave 18 v223.45 20260521] Subscribe clients + gastos + receivables + payables
  // Resolve classe BUG-LUANNA-SANDRA (cliente cadastrado sumir cross-tab)
  // E classe cards Caixa Líquido stale (gastos/receivables não atualizavam realtime)
  useEffect(()=>{
    if(!appReady)return;
    const rt = window.ZNX && window.ZNX.lib && window.ZNX.lib.realtime;
    if(!rt || typeof rt.subscribe !== 'function')return; // já logou warn no useEffect anterior
    const clientsSub = rt.subscribe('clients', function(event){
      _setClients(function(prev){ return rt.mergeRealtimeEvent(prev, event); });
    });
    const gastosSub = rt.subscribe('gastos', function(event){
      _setGastos(function(prev){ return rt.mergeRealtimeEvent(prev, event); });
    });
    const receivablesSub = rt.subscribe('receivables', function(event){
      _setReceivables(function(prev){ return rt.mergeRealtimeEvent(prev, event); });
    });
    const payablesSub = rt.subscribe('payables', function(event){
      _setPayables(function(prev){ return rt.mergeRealtimeEvent(prev, event); });
    });
    return ()=>{
      rt.unsubscribe(clientsSub);
      rt.unsubscribe(gastosSub);
      rt.unsubscribe(receivablesSub);
      rt.unsubscribe(payablesSub);
    };
  },[appReady]);

  // [v224.25 BUG-CACHE-DIVERGE fix 20260525] Polling defesa em depth 5min.
  // ROOT CAUSE: Realtime ATIVO pode perder events silenciosamente (rede pisca, CPU stress,
  // tab background). startPoll() L542 só ativa em CHANNEL_ERROR — não cobre miss silencioso.
  // Caso real Jamal 25/05 17:04 BRT: 2 macs admin com diff R$ 1.005 vs banco R$ 1.872 stale.
  // Solução: re-fetch periódico SILENCIOSO via applyRows existente (L141).
  // Cobre 95%+ dos casos de drift cross-tab sem precisar BroadcastChannel (backlog #737).
  useEffect(()=>{
    if(!appReady)return;
    const sb = window.sb;
    if(!sb || typeof sb.from !== 'function')return;
    const REFETCH_INTERVAL_MS = 5 * 60 * 1000; // 5min
    const intervalId = setInterval(function(){
      if(typeof navigator !== 'undefined' && navigator.onLine === false) return; // offline → skip
      if(typeof document !== 'undefined' && document.visibilityState === 'hidden') return; // bg tab → skip
      sb.from('store').select('key,data,updated_at').then(function(res){
        if(res.error || !Array.isArray(res.data)) return;
        applyRows(res.data, false); // false = merge (não isInit replace)
        if(window.Sentry) window.Sentry.addBreadcrumb({
          category: 'polling-defesa-em-depth',
          level: 'info',
          message: 'v224.25 5min re-fetch OK · ' + res.data.length + ' keys'
        });
      }).catch(function(e){
        znxLogWarn('[ZNX v224.25 polling-defesa] re-fetch falhou', e);
      });
    }, REFETCH_INTERVAL_MS);
    return ()=>{ clearInterval(intervalId); };
  },[appReady]);

  // [Wave 18 v223.45.1 HOTFIX 20260521] Heartbeat Realtime — banner amarelo se >5min sem evento
  // Threshold aumentado 2min → 5min · razão: 2min era falso positivo recorrente em horários
  // calmos (almoço/entre vendas/fim tarde) onde naturalmente não há mutação em sales/quotes/
  // clients/gastos/receivables/payables. 5min mantém detecção em prazo operacional aceitável
  // (CHANNEL_ERROR Sentry breadcrumb aparece em 32s mesmo · banner +3min latência aviso vs zero
  // falso positivo). Consome getLastEventAt() da lib hardened (Zona 2A.0.5).
  const [rtBannerVisible, setRtBannerVisible] = useState(false);
  useEffect(()=>{
    if(!appReady)return;
    const rt = window.ZNX && window.ZNX.lib && window.ZNX.lib.realtime;
    if(!rt || typeof rt.getLastEventAt !== 'function')return;
    const heartbeat = setInterval(function(){
      const gap = Date.now() - rt.getLastEventAt();
      if(gap > 300000){ // >5min sem evento (era 120000 v223.45 · ajuste hotfix v223.45.1)
        setRtBannerVisible(true);
        if(window.Sentry) window.Sentry.addBreadcrumb({ category: 'realtime', message: 'gap '+Math.round(gap/1000)+'s', level: 'warning' });
      } else {
        setRtBannerVisible(false);
      }
    }, 30000);
    return ()=> clearInterval(heartbeat);
  },[appReady]);

  // [Wave 18 v223.45] Start cacheAudit se admin — 5min interval anti-drift cache JSONB vs banco
  useEffect(()=>{
    if(!appReady || !currentUser)return;
    const ca = window.ZNX && window.ZNX.lib && window.ZNX.lib.cacheAudit;
    if(!ca || typeof ca.start !== 'function')return;
    ca.start(currentUser.role);
    return ()=>{ if(typeof ca.stop === 'function') ca.stop(); };
  },[appReady, currentUser]);

  // Fase 5 — Escuta eventos de merge remoto (disparados em mergeProductsFromRemote
  // e _saveProductsMergedInner). Atualiza badge e mostra toasts.
  useEffect(()=>{
    function onMerged(e){
      pulseSyncing();
      queueSyncToast(e.detail&&e.detail.updates||[]);
    }
    // [BUG-FIX 20260506] stock-only synced em silencio: pulsa badge mas NAO toast.
    // Antes: venda paralela com 10 itens disparava "10 produtos atualizados" mas catalogo nao mudou.
    function onStockSynced(e){
      pulseSyncing();
      // Sem toast — usuario ve indicador de sync no badge (ja existente)
    }
    function onConflict(e){
      pulseSyncing();
      const conflicts=(e.detail&&e.detail.conflicts)||[];
      conflicts.forEach(rp=>{
        showModalConflictToast(rp,()=>{
          // Recarregar: aplica versão remota diretamente (raw, sem novo save) e fecha modal.
          _setProducts(prev=>prev.map(p=>p.id===rp.id?rp:p));
          window.dispatchEvent(new CustomEvent('znx:close-product-modal'));
        });
      });
    }
    window.addEventListener('znx:products-merged-remote',onMerged);
    window.addEventListener('znx:products-stock-synced',onStockSynced);
    window.addEventListener('znx:products-modal-conflict',onConflict);
    return()=>{
      window.removeEventListener('znx:products-merged-remote',onMerged);
      window.removeEventListener('znx:products-stock-synced',onStockSynced);
      window.removeEventListener('znx:products-modal-conflict',onConflict);
    };
  },[]);
  useEffect(()=>{
    // Remove legacy keys (BLOCO 9)
    try{localStorage.removeItem('znx_nextSaleNumber');}catch(e){console.warn('[ZNX]',e);}
    // Timeout de segurança: se Supabase demorar >8s, libera o app
    const safetyTimer=setTimeout(()=>setAppReady(true),8000);
    const znxSt=document.getElementById('znx-status');
    if(znxSt)znxSt.textContent='Conectando ao servidor...';
    // [F10-01 v223.33 LAZY BOOT — 2026-05-20] Split boot fetch em 2:
    // Essencial (~3MB): TODAS keys EXCETO quotes+sales — boot bloqueante rápido (~2s)
    // Lazy background (~13MB): quotes+sales pós setAppReady — não bloqueia UI
    // Resultado: Dashboard render ~2s vs ~12s antes (80% mais rápido pra equipe 3G/4G)
    // Realtime channel continua intocado — deltas sincronizam normalmente.
    const LAZY_BOOT_KEYS = ['quotes', 'sales'];
    const LAZY_KEYS_FILTER = '(' + LAZY_BOOT_KEYS.map(k => `"${k}"`).join(',') + ')';

    // Carga inicial ESSENCIAL (sem quotes+sales)
    sb.from('store').select('key,data,updated_at').not('key','in',LAZY_KEYS_FILTER).then(({data:rows})=>{
      clearTimeout(safetyTimer);
      if(znxSt)znxSt.textContent='Carregando dados...';
      applyRows(rows,true);
      setAppReady(true);

      // [F10-01 v223.33] Lazy load quotes+sales em background — NÃO bloqueia UI
      // Falha silenciosa PROIBIDA: try/catch + Sentry log se falhar
      // Realtime channel já conectado, merge deltas posteriores normalmente
      setTimeout(()=>{
        sb.from('store').select('key,data,updated_at').in('key', LAZY_BOOT_KEYS).then(({data:lazyRows, error})=>{
          if (error) {
            console.error('[ZNX F10-01] Lazy load fail:', error.message);
            if (window.Sentry) {
              window.Sentry.captureMessage('[F10-01 v223.33] Lazy load fail', {
                level: 'error',
                extra: { error: error.message, keys: LAZY_BOOT_KEYS }
              });
            }
            return;
          }
          if (lazyRows && lazyRows.length > 0) {
            applyRows(lazyRows, false); // false = não é init, é merge
            console.log('[ZNX F10-01] Lazy load quotes+sales OK:', lazyRows.length, 'rows');
          }
        }).catch(e=>{
          console.error('[ZNX F10-01] Lazy load exception:', e?.message);
          if (window.Sentry) window.Sentry.captureException(e);
        });
      }, 100); // small delay garante setAppReady processou antes

      // BLOCO 3 — Migrate plain-text passwords for extraUsers (runs once)
      // [FIX 3] Removido setExtraUsers(prev=>prev) — era ESCRITOR 2 do wrap-loop:
      //   setExtraUsers disparava save() com stateRef stale (valor do load() antes do re-render)
      //   mesmo que prev estivesse wrapped, causando re-introdução do wrapping no Supabase.
      // Solução: lê direto via load() (pós-applyRows, já gravou flat no localStorage)
      //   → nunca toca em setExtraUsers → nunca dispara save() acidental.
      (async()=>{
        if(!localStorage.getItem('znx_passMigrateDone')){
          try{
            // load() após applyRows retorna o valor flat (FIX 1 + FIX 2 garantem isso)
            // NOTA: Depende do cleanup IIFE (L326) ter rodado no boot.
            // Se o IIFE for removido no futuro, este load() pode retornar
            // valor wrapped e quebrar o React state.
            const currentUsers=load('extraUsers',[]);
            const safeUsers=Array.isArray(currentUsers)?currentUsers:[];
            const{users:migrated,changed}=await migratePasswords(safeUsers.map(u=>({...u})));
            if(changed){
              save('extraUsers',migrated);   // FIX 1 garante que migrated flat → Supabase flat
              _setExtraUsers(migrated);       // atualiza React state diretamente, sem passar por save() duplo
            }
          }catch(e){znxLogWarn('[ZNX] migratePasswords erro', e);}
          localStorage.setItem('znx_passMigrateDone','1');
        }
      })();
      // [A-06] Lazy migration: popular updatedAt em registros legados sem esse campo
      // [FIX ESCRITOR 3] Substituído setSales/setQuotes/setClients/setProducts(fn) por load() direto.
      // O padrão setXxx(fn) lia stateRef.current que estava stale (= []) no mesmo tick de
      // applyRows, porque _setXxx() só atualiza stateRef após o próximo render. Resultado:
      // save(key,[]) gravava [] no Supabase silenciosamente (causou incidente 14:20:15 BR).
      // Solução idêntica ao FIX 3 do PR #16 para extraUsers:
      //   1. load(key,[]) lê o localStorage real (já populado pelo applyRows/cleanIIFE)
      //   2. Guard de array vazio: se local está vazio, não toca — nunca escreve [] por cima de nada
      //   3. save() cirúrgico apenas se stampArr mudou algum registro
      if(!localStorage.getItem('znx_updatedAtMigrateDone')){
        const fallbackTs=new Date(0).toISOString();
        const stampArr=(arr)=>arr.map(r=>r.updatedAt?r:{...r,updatedAt:r.createdAt||r.date||fallbackTs});
        // doStamp: lê localStorage diretamente, evita staleRef, tem guard anti-zeragem
        const doStamp=(key)=>{
          const current=load(key,[]);
          if(!Array.isArray(current)||current.length===0)return; // guard: nunca escreve [] por cima de nada
          const stamped=stampArr(current);
          if(stamped.some((r,i)=>r!==current[i]))save(key,stamped);
        };
        doStamp('sales');
        doStamp('quotes');
        doStamp('clients');
        doStamp('products'); // products: save() direto OK aqui — apenas adiciona updatedAt, sem alterar valores
        localStorage.setItem('znx_updatedAtMigrateDone','1');
      }
      // Sync contador com Supabase na inicialização para evitar duplicatas entre usuários
      (async()=>{
        try{
          const{data:row}=await sb.from('store').select('data').eq('key','nextOrderNum').single();
          if(row&&row.data!=null){
            const remoteVal=typeof row.data==='object'?(row.data.data||1):Number(row.data)||1;
            const localRaw=localStorage.getItem('znx_nextOrderNum');
            let localVal=1;
            try{const p=JSON.parse(localRaw);localVal=p&&typeof p==='object'?(p.data||1):Number(p)||1;}catch(e){console.warn('[ZNX]',e);}
            const maxVal=Math.max(remoteVal,localVal);
            if(maxVal>localVal){
              const ts=new Date().toISOString();
              localStorage.setItem('znx_nextOrderNum',JSON.stringify({data:maxVal,ts}));
              setNextOrderNum(maxVal);
            }
          }
        }catch(e){znxLogWarn('[ZNX] syncNextOrderNum falhou', e);}
      })();
    }).catch(()=>{clearTimeout(safetyTimer);setAppReady(true);});
    // [FIX USER-PERSIST 20260422-B] Boot fetch de app_users — corre em paralelo com store fetch
    if(window.__ZNX_NEW_AUTH_ENABLED__){
      sb.from('app_users').select('id,username,name,role,active,auth_user_id,is_on_duty,admin_notes,admin_notes_updated_at,admin_notes_updated_by,can_manage_notes_freteiro,can_confirm_transfer_actions,can_receive_frete').eq('active',true)
        .then(({data,error})=>{
          if(error){console.warn('[ZNX] app_users boot fetch falhou:',error.message);return;}
          if(Array.isArray(data)&&data.length>0){setAppUsersFromDB(data);console.log('[ZNX] app_users carregado:',data.length,'usuário(s)');}
        });
    }
    // ── Polling fallback: só roda se Realtime desconectar ──
    let pollId=null;
    function startPoll(){
      if(pollId)return;
      pollId=setInterval(()=>{
        sb.from('store').select('key,data,updated_at').then(({data:rows,error})=>{if(!error)applyRows(rows,false);}).catch(()=>{});
      },8000); // [S24 Fix1] 60s→8s — Tab 2 nunca fica presa >8s mesmo sem Realtime
      console.log('[zaynex] Polling fallback ativado');
    }
    function stopPoll(){if(pollId){clearInterval(pollId);pollId=null;console.log('[zaynex] Polling desativado — Realtime ativo');}}
    // Realtime como primário
    const channel=sb.channel('zaynex-store-realtime')
      .on('postgres_changes',{event:'*',schema:'public',table:'store'},(payload)=>{
        const row=payload.new;
        if(!row||!row.key)return;
        // SYNC-002: detecta drift entre payload Realtime e estado local
        try{const _lRaw=localStorage.getItem(toLocalKey(toRemoteKey(row.key)));
          reconcileSync(row.key,row.updated_at,_lRaw?JSON.parse(_lRaw)?.ts:null,1);}
        catch(e){}
        // [S24 RT-SIGNAL] Usa evento como sinal — busca dado fresco via SELECT.
        // Resolve: (A) payload.new.data truncado por limite Realtime, (B) bg-tab
        // dropped event, (C) race ts/cache. Fallback: payload original se SELECT falhar.
        sb.from('store').select('key,data,updated_at').eq('key',toRemoteKey(row.key)).single()
          .then(({data:freshRow,error:fe})=>{
            if(fe||!freshRow){applyRows([row],false);return;}
            applyRows([freshRow],false);
          })
          .catch(()=>applyRows([row],false));
      })
      .subscribe((status)=>{
        if(status==='SUBSCRIBED'){stopPoll();setSyncStatus('online');console.log('[zaynex] Realtime conectado ✓');}
        else if(status==='CHANNEL_ERROR'||status==='TIMED_OUT'){startPoll();setSyncStatus('offline');console.warn('[zaynex] Realtime desconectado — polling ativado');}
      });
    // [FIX USER-PERSIST 20260422-B] Realtime: refletir UPDATE/INSERT/DELETE em app_users
    let channelAppUsers=null;
    if(window.__ZNX_NEW_AUTH_ENABLED__){
      channelAppUsers=sb.channel('znx-app-users-changes')
        .on('postgres_changes',{event:'*',schema:'public',table:'app_users'},()=>{
          sb.from('app_users').select('id,username,name,role,active,auth_user_id,is_on_duty,admin_notes,admin_notes_updated_at,admin_notes_updated_by,can_manage_notes_freteiro,can_confirm_transfer_actions,can_receive_frete').eq('active',true)
            .then(({data})=>{if(Array.isArray(data))setAppUsersFromDB(data);});
        })
        .subscribe();
    }
    // Polling inicial como safety até Realtime confirmar conexão
    startPoll();
    // [FIX 20260507] Refresh on focus — quando vendedora volta pra aba (visibilitychange)
    // ou ganha foco da janela, força SELECT fresh do store. Resolve casos onde realtime
    // dropa eventos em background (Page Visibility API restringe WebSocket).
    function refreshOnFocus(){
      if(document.visibilityState==='visible'){
        sb.from('store').select('key,data,updated_at').then(({data:rows,error})=>{
          if(!error){applyRows(rows,false);console.log('[zaynex] refresh on focus → store atualizado');}
        }).catch(()=>{});
      }
    }
    document.addEventListener('visibilitychange',refreshOnFocus);
    window.addEventListener('focus',refreshOnFocus);
    // ── WATCHDOG: repara znx_znx_* a cada 60s (Phase 4) ──
    const watchdogId=setInterval(()=>{
      try{
        const bad=[];
        for(let i=0;i<localStorage.length;i++){const k=localStorage.key(i);if(k&&k.startsWith('znx_znx_'))bad.push(k);}
        if(!bad.length)return;
        console.debug('[zaynex] WATCHDOG corrigindo',bad.length,'chave(s) corrompida(s)');
        // [PATCH 2/4] Watchdog: escolher vencedor por updated_at em vez de string.length
        bad.forEach(badKey=>{
          const goodKey=toLocalKey(badKey);
          const bv=localStorage.getItem(badKey)||'';
          const gv=localStorage.getItem(goodKey)||'';
          let winner=bv; // default: bad key value
          try{
            const bParsed=JSON.parse(bv);
            const gParsed=JSON.parse(gv);
            const bTs=bParsed.ts||bParsed.updated_at||'';
            const gTs=gParsed.ts||gParsed.updated_at||'';
            // Extrair updated_at do primeiro item se for array dentro de .data
            const extractDeepTs=(obj)=>{
              if(obj.ts)return obj.ts;
              if(obj.updated_at)return obj.updated_at;
              const d=obj.data;
              if(Array.isArray(d)&&d.length>0){
                // Pegar o updated_at mais recente de qualquer item do array
                let maxTs='';
                d.forEach(item=>{const t=item.updated_at||item.updatedAt||item.ts||'';if(t>maxTs)maxTs=t;});
                return maxTs;
              }
              return '';
            };
            const bDeep=extractDeepTs(bParsed);
            const gDeep=extractDeepTs(gParsed);
            const bFinal=bTs||bDeep;
            const gFinal=gTs||gDeep;
            if(bFinal&&gFinal){
              winner=bFinal>=gFinal?bv:gv;
              console.debug('[ZNX] WATCHDOG: escolheu por timestamp para',badKey,'bad:',bFinal,'good:',gFinal,'vencedor:',bFinal>=gFinal?'bad':'good');
            }else{
              // Fallback: comparar por length (comportamento antigo) se timestamps não disponíveis
              winner=bv.length>=gv.length?bv:gv;
              console.debug('[ZNX] WATCHDOG: fallback por length para',badKey,'(sem timestamps)','bad:',bv.length,'good:',gv.length);
            }
          }catch(e){
            // Fallback robusto: se JSON.parse falhar, usar length
            winner=bv.length>=gv.length?bv:gv;
            console.warn('[ZNX] WATCHDOG: fallback por length (parse error) para',badKey,e?.message);
          }
          // [FIX ONDA-A #10 20260512] WATCHDOG escreve arrays grandes (sales/quotes/clients).
          // Usa _safeSetLocal pra trim+retry quando cache estoura cota. Antes era setItem nu.
          if(winner&&winner.length>2){
            if(window.ZNX&&window.ZNX._safeSetLocal){
              let _rawVal=null;
              try{const _p=JSON.parse(winner);_rawVal=(_p&&_p.data!==undefined)?_p.data:_p;}catch(_){}
              window.ZNX._safeSetLocal(goodKey,winner,_rawVal);
            }else{
              try{localStorage.setItem(goodKey,winner);}catch(qe){console.warn('[ZNX] WATCHDOG setItem fail '+goodKey,qe?.message);}
            }
          }
          try{localStorage.removeItem(badKey);}catch(_){}
        });
      }catch(e){console.error('[zaynex] WATCHDOG erro:',e?.message);}
    },60000);
    return()=>{clearTimeout(safetyTimer);stopPoll();clearInterval(watchdogId);sb.removeChannel(channel);if(channelAppUsers)sb.removeChannel(channelAppUsers);document.removeEventListener('visibilitychange',refreshOnFocus);window.removeEventListener('focus',refreshOnFocus);};
  },[]);

  // [FEAT 20260504] Online status REAL — vem de auth.users.last_sign_in_at via RPC.
  // Antes: lia activityLog do localStorage (era visivel só no proprio navegador).
  // Agora: estado global, admin ve quem esta online de verdade.
  const[onlineStatusMap,setOnlineStatusMap]=useState({}); // {username: {online, lastSeen}}
  // [BUG-ONLINE v211 20260512] Fix DEFINITIVO + cleanup do debug v210:
  // - Removido window._onlineStatusMap / _currentUser (eram leak debug v210)
  // - Logs atrás de flag window.__ZNX_DEBUG_ONLINE (default false em prod)
  // - Retry após 5s se map vazio (mitiga race condition no mount)
  // - Toast + Sentry se RPC falha 3x consecutivas (regra_falha_silenciosa_proibida)
  useEffect(()=>{
    const dbg = (...a) => { if(window.__ZNX_DEBUG_ONLINE) console.log('[ZNX online]', ...a); };
    dbg('mount. role=', currentUser?.role, 'id=', currentUser?.id);
    if(!currentUser||!['admin','financeiro'].includes(currentUser.role)){
      dbg('early return — role não é admin/financeiro');
      return;
    }
    let mounted=true, failureCount=0, retryTimer=null;
    async function refresh(retryAttempt=0){
      try{
        const r=await getUsersOnlineStatus();
        dbg('RPC:', {success:r.success, total:(r.users||[]).length, online:(r.users||[]).filter(u=>u.online).length});
        if(!mounted)return;
        if(!r.success){
          failureCount++;
          // [v218.22 SENTRY-FIX-3 2026-05-13] AbortError "Lock broken" é multi-tab esperado.
          // Mesma classe de ERP-1P/18. Não inflar Sentry (47 events em 21h era 99% Lock broken).
          const errMsgZN = String(r?.errorMessage || r?.errorCode || '');
          const isLockBrokenZN = /Lock broken|AbortError|aborterror__lock_broken/i.test(errMsgZN);
          if(typeof Sentry!=='undefined' && !isLockBrokenZN){
            Sentry.captureMessage('[ZNX online] RPC success=false',{level:'warning',extra:{r,failureCount}});
          } else if(isLockBrokenZN && typeof Sentry!=='undefined') {
            // Breadcrumb leve em vez de capture
            Sentry.addBreadcrumb({category:'online-status', message:'AbortError silently ignored', level:'info', data:{failureCount, errCode:r?.errorCode}});
          }
          if(failureCount===3 && typeof toast==='function' && !isLockBrokenZN){
            toast('⚠️ Status online não disponível agora. Recarregue se persistir.','warning');
          }
          return;
        }
        failureCount=0;
        const map={};
        for(const u of (r.users||[])){
          map[u.username]={online:u.online,lastSeen:u.last_seen_at||u.last_sign_in_at,route:u.last_route||null};
        }
        dbg('set map:', Object.keys(map).length, 'entries,', Object.values(map).filter(v=>v.online).length, 'online');
        setOnlineStatusMap(map);
      }catch(e){
        failureCount++;
        dbg('CATCH:', e?.message);
        if(typeof Sentry!=='undefined') Sentry.captureException(e,{tags:{flow:'online_status'},extra:{retryAttempt,failureCount}});
      }
    }
    refresh();
    // [v211] Retry após 5s se map ainda vazio (race condition mount)
    retryTimer=setTimeout(()=>{
      if(!mounted)return;
      setOnlineStatusMap(prev=>{
        if(Object.keys(prev).length===0){
          dbg('retry: map ainda vazio após 5s, re-disparando refresh');
          refresh(1);
        }
        return prev;
      });
    },5000);
    const iv=setInterval(()=>refresh(),60*1000);
    return ()=>{mounted=false;clearTimeout(retryTimer);clearInterval(iv);dbg('cleanup');};
  },[currentUser?.id,currentUser?.role]);

  function isUserOnline(username){
    // Self é sempre online (proprio navegador)
    if(currentUser&&currentUser.username===username)return{online:true,lastSeen:new Date().toISOString()};
    // Demais users: lê do mapa do banco
    const status=onlineStatusMap[username];
    if(status)return status;
    // Fallback legado: activityLog (caso RPC ainda não retornou)
    const entry=(activityLog||[]).find(e=>e.username===username&&(e.action==='heartbeat'||e.action==='login'));
    if(!entry)return{online:false,lastSeen:null};
    const diff=Date.now()-new Date(entry.timestamp).getTime();
    return{online:diff<300000,lastSeen:entry.timestamp};
  }
  function formatLastSeen(ts){
    if(!ts)return'Nunca acessou';
    const diff=Date.now()-new Date(ts).getTime();
    if(diff<60000)return'Agora';
    if(diff<3600000)return'Há '+Math.floor(diff/60000)+' min';
    if(diff<86400000)return'Há '+Math.floor(diff/3600000)+'h';
    return new Date(ts).toLocaleDateString('pt-BR')+' '+new Date(ts).toLocaleTimeString('pt-BR',{hour:'2-digit',minute:'2-digit'});
  }

  // [v224.43 HOTFIX P1 20260527] Enriquece currentUser com flags estoquista se sessão persistida
  // foi salva ANTES de v224.40 (sem can_manage_notes_freteiro/can_confirm_transfer_actions/can_receive_frete).
  // Hard refresh NÃO desloga (sessão Supabase mantém) → currentUser vem do localStorage sem flags →
  // canManageTransfer/canReceiveFrete falhavam silenciosamente (workflow estoquista quebrado · bomba-relógio).
  // Fix 1 (auth.js L302) cobre login fresco · Fix 2 (este) cobre sessão persistida sem precisar relogin.
  // Guard `=== undefined` previne loop infinito (só dispara se faltam flags · merge define elas).
  React.useEffect(function(){
    if(!currentUser || !Array.isArray(appUsersFromDB) || appUsersFromDB.length === 0) return;
    if(currentUser.can_confirm_transfer_actions !== undefined) return; // já enriquecido
    var fresh = appUsersFromDB.find(function(u){ return u.id === currentUser.id; });
    if(!fresh || fresh.can_confirm_transfer_actions === undefined) return;
    var merged = Object.assign({}, currentUser, {
      can_manage_notes_freteiro: fresh.can_manage_notes_freteiro,
      can_confirm_transfer_actions: fresh.can_confirm_transfer_actions,
      can_receive_frete: fresh.can_receive_frete
    });
    setCurrentUser(merged);
    try{ localStorage.setItem('znx_user', JSON.stringify(merged)); }catch(_){}
    if(window.Sentry) window.Sentry.captureMessage('[v224.43 HOTFIX P1] currentUser enriquecido com flags de sessão persistida pré-v224.40', {level:'info', extra:{userId:currentUser.id, role:currentUser.role}});
  }, [currentUser && currentUser.id, appUsersFromDB]);

  // [A-02] allUsers: merge app_users (verdade pós-cutover) + extraUsers (password legado)
  // [FIX USER-PERSIST 20260422-B] Se appUsersFromDB disponível, usa como fonte de verdade + preserva passwords do JSONB
  const allUsers=useMemo(()=>{
    if(!window.__ZNX_NEW_AUTH_ENABLED__||appUsersFromDB.length===0)return extraUsers;
    return appUsersFromDB.map(u=>{
      const legacy=extraUsers.find(e=>(e.auth_user_id&&e.auth_user_id===u.auth_user_id)||e.username===u.username||Number(e.id)===Number(u.id));
      return legacy?{...u,password:legacy.password,passwordV2:legacy.passwordV2}:u;
    });
  },[appUsersFromDB,extraUsers]);

  const[showTrocarSenha,setShowTrocarSenha]=useState(false);

  function login(u){const{password:_pw,...safeUser}=u;setCurrentUser(safeUser);localStorage.setItem('znx_user',JSON.stringify(safeUser));if(window.ZNX_setSentryUser)window.ZNX_setSentryUser(safeUser);setPage('dashboard');
    // Activity: registrar login
    setActivityLog(prev=>[{id:genId(),username:safeUser.username,name:safeUser.name,action:'login',timestamp:new Date().toISOString()},...(prev||[]).slice(0,499)]);
    // [SEC-001] Pre-cache server role on login (async, non-blocking)
    if(window.__ZNX_SERVER_ROLE_CHECK_ENABLED__&&safeUser.auth_user_id){
      znxVerifyRole(safeUser.auth_user_id).then(result=>{
        if(result&&result.verified&&result.role!==safeUser.role){
          console.warn('[ZNX] SEC-001: Login role mismatch! localStorage="'+safeUser.role+'", server="'+result.role+'". Correcting.');
          // [BUG-FIX 20260512 v203] Sentry rastreia mismatches — segurança crítica (privilege escalation attempt ou stale cache)
          if(typeof Sentry!=='undefined') Sentry.captureMessage('[ZNX SEC-001] Login role mismatch',{level:'warning',tags:{flow:'security',type:'role_mismatch_login'},extra:{authUserId:safeUser.auth_user_id,localRole:safeUser.role,serverRole:result.role}});
          const corrected={...safeUser,role:result.role};
          setCurrentUser(corrected);localStorage.setItem('znx_user',JSON.stringify(corrected));
        }
      }).catch(()=>{});
    }
  }
  function logout(){
    // Activity: registrar logout (wrapped in try-catch to never block logout)
    try{if(currentUser)setActivityLog(prev=>[{id:genId(),username:currentUser.username,name:currentUser.name,action:'logout',timestamp:new Date().toISOString()},...(prev||[]).slice(0,499)]);}catch(e){znxLogWarn('[ZNX] logout activity log failed', e);}
    setCurrentUser(null);localStorage.removeItem('znx_user');localStorage.removeItem('znx_session_ts');
  }
  // [BUG-FIX 20260506] Sessao persistida em localStorage NAO chama login() — activityLog nao tinha
  // entry de login hoje, paginas Usuarios mostrava "Nao logou hoje" mesmo pra user online.
  // [H1b v205 20260512] Atalho `/` global foca campo busca (padrão GitHub/Linear)
  // Skip se já dentro de input/textarea/contentEditable pra não interferir com digitação normal
  React.useEffect(()=>{
    function onKey(e){
      if(e.key!=='/')return;
      const tag=(e.target?.tagName||'').toLowerCase();
      if(tag==='input'||tag==='textarea'||e.target?.isContentEditable)return;
      // Procura primeiro input visível com placeholder de busca (Produtos/Clientes/Vendas/etc)
      const inputs=document.querySelectorAll('input[placeholder]');
      for(const inp of inputs){
        const p=(inp.placeholder||'').toLowerCase();
        if(p.includes('busca')||p.includes('procur')||p.includes('digite o nome')||p.includes('perfume')||p.includes('cliente')){
          // Confirma que está visível (não escondido em modal fechado)
          const r=inp.getBoundingClientRect();
          if(r.width===0||r.height===0)continue;
          e.preventDefault();
          inp.focus();
          if(typeof inp.select==='function')try{inp.select();}catch(_){}
          return;
        }
      }
    }
    document.addEventListener('keydown',onKey);
    return()=>document.removeEventListener('keydown',onKey);
  },[]);

  // Fix: ao montar com currentUser, garante que existe entry login hoje.
  React.useEffect(()=>{
    if(!currentUser)return;
    setActivityLog(prev=>{
      const list=prev||[];
      const todayStart=new Date();todayStart.setHours(0,0,0,0);
      const hasLoginToday=list.some(e=>e.username===currentUser.username&&e.action==='login'&&new Date(e.timestamp)>=todayStart);
      if(hasLoginToday)return list;
      return[{id:genId(),username:currentUser.username,name:currentUser.name,action:'login',timestamp:new Date().toISOString()},...list.slice(0,499)];
    });
  },[currentUser?.username]);

  // Activity: heartbeat — atualiza lastSeen a cada 2 minutos
  // [BUG-FIX 20260507] Antes só atualizava activityLog (JSONB local). RPC online status
  // usava auth.users.last_sign_in_at que só atualiza no login → vendedora ficava 'offline'
  // após 5min do login mesmo trabalhando. Agora chama updateMyPresence() RPC tb que
  // grava em user_presence.last_seen_at (autoritativo, cross-device).
  React.useEffect(()=>{
    if(!currentUser)return;
    const updatePresence=()=>{
      // 1. Atualiza JSONB activityLog (visível pra outros via realtime do store)
      setActivityLog(prev=>{
        const now=new Date().toISOString();
        const recent=(prev||[]);
        const lastHB=recent.find(e=>e.username===currentUser.username&&e.action==='heartbeat');
        if(lastHB){
          return recent.map(e=>e===lastHB?{...e,timestamp:now}:e);
        }
        return[{id:genId(),username:currentUser.username,name:currentUser.name,action:'heartbeat',timestamp:now},...recent.slice(0,499)];
      });
      // 2. Atualiza user_presence no banco (fonte autoritativa pra get_users_online_status)
      try{
        if(typeof window.updateMyPresence==='function'){
          window.updateMyPresence(page||null).catch(()=>{});
        }
      }catch(_e){}
    };
    updatePresence();
    const hbId=setInterval(updatePresence,120000);
    return()=>clearInterval(hbId);
  },[currentUser?.username,page]);

  // [SEC-001] Periodic role re-verification (every 5 min while user is active)
  React.useEffect(()=>{
    if(!currentUser||!currentUser.auth_user_id||!window.__ZNX_SERVER_ROLE_CHECK_ENABLED__)return;
    const verifyId=setInterval(async()=>{
      try{
        znxInvalidateRoleCache(currentUser.auth_user_id); // No-op since Phase 4 (JWT session, no local cache). Kept for API compatibility.
        const result=await znxVerifyRole(currentUser.auth_user_id);
        if(!result)return; // Server unreachable — don't disrupt
        if(result.code==='USER_INACTIVE'){
          toast('⚠️ Sua conta foi desativada pelo administrador. Saindo...','error');
          setTimeout(()=>{logout();},2000);
          return;
        }
        if(result.verified&&result.role!==currentUser.role){
          console.warn('[ZNX] SEC-001: Periodic check detected role mismatch. Server="'+result.role+'", client="'+currentUser.role+'"');
          // [BUG-FIX 20260512 v203] Sentry — mismatch periódico indica drift ou ataque
          if(typeof Sentry!=='undefined') Sentry.captureMessage('[ZNX SEC-001] Periodic role mismatch',{level:'warning',tags:{flow:'security',type:'role_mismatch_periodic'},extra:{authUserId:currentUser.auth_user_id,localRole:currentUser.role,serverRole:result.role}});
          const corrected={...currentUser,role:result.role};
          setCurrentUser(corrected);localStorage.setItem('znx_user',JSON.stringify(corrected));
          toast('ℹ️ Sua permissão foi atualizada para: '+result.role,'info');
        }
      }catch(e){console.warn('[ZNX] SEC-001 periodic verify error:',e); if(typeof Sentry!=='undefined') Sentry.captureException(e,{tags:{flow:'security',type:'role_verify_failed'}});}
    },5*60*1000); // 5 minutes
    return()=>clearInterval(verifyId);
  },[currentUser?.id,currentUser?.role]);

  function trocarSenha(novaSenha){
    // [BUG-FIX 20260504-v2] Senha real JÁ foi salva em auth.users via sb.auth.updateUser()
    // dentro de TrocarSenhaModal.jsx. extraUsers é cache JSONB legacy pre-Supabase Auth.
    // RLS de store só permite admin atualizar essa chave; setExtraUsers (setter normal do
    // useStore) chama save() automaticamente → erro vermelho.
    // Fix: usar _setExtraUsers (setter PURO sem save) — atualiza state local apenas.
    const updated={...currentUser,password:novaSenha};
    const prev=extraUsers||[];
    const idx=prev.findIndex(u=>u.username===currentUser.username);
    const newList=idx>=0?prev.map((u,i)=>i===idx?updated:u):[...prev,updated];
    _setExtraUsers(newList);
    const{password:_pw,...safeUpdated}=updated;setCurrentUser(safeUpdated);localStorage.setItem('znx_user',JSON.stringify(safeUpdated));
    setShowTrocarSenha(false);
  }
  async function updatePerfil(updated){
    // [SEC-001] Server-side role check — only admin can edit other users' profiles
    if(updated.username!==currentUser.username){
      if(!await znxGuard(['admin']))return;
    }
    // SAFETY: never save if password is undefined/null/empty — preserve existing password
    if(!updated.password){
      const existing=(allUsers||[]).find(u=>u.username===updated.username||u.id===updated.id);
      if(existing&&existing.password){updated={...updated,password:existing.password};}
      else{console.error('updatePerfil blocked: no password found');return;}
    }
    // [FIX USER-PERSIST 20260422-A] Persistir name/username em app_users (fonte de verdade pós-cutover)
    if(window.__ZNX_NEW_AUTH_ENABLED__){
      try{
        const{data:{session}}=await sb.auth.getSession();
        if(session?.user?.id){
          const{error:upErr}=await sb.from('app_users')
            .update({name:updated.name,username:updated.username})
            .eq('auth_user_id',session.user.id);
          if(upErr){
            console.warn('[v224.71] updatePerfil: app_users update failed:',upErr.message);
            if(window.Sentry) window.Sentry.captureException(upErr,{tags:{wave:'v224.71'}, extra:{action:'updatePerfil'}});
            if(typeof window.ZNX_toast === 'function') window.ZNX_toast('❌ Erro atualizar perfil: '+upErr.message, 'error');
          }
          else{setAppUsersFromDB(prev=>prev.map(u=>u.auth_user_id===session.user.id?{...u,name:updated.name,username:updated.username}:u));}
        }
      }catch(e){
        console.warn('[v224.71] updatePerfil: unexpected error updating app_users:',e);
        if(window.Sentry) window.Sentry.captureException(e,{tags:{wave:'v224.71'}, extra:{action:'updatePerfilOuter'}});
        if(typeof window.ZNX_toast === 'function') window.ZNX_toast('❌ Erro inesperado atualizar perfil', 'error');
      }
    }
    // [BUG-FIX 20260504-v2] name/username já persistidos em app_users acima (linhas 644-648).
    // Usa _setExtraUsers (puro, sem save) — setExtraUsers normal chamaria save() e
    // RLS bloquearia non-admin disparando erro vermelho.
    const prev2=extraUsers||[];
    const idx2=prev2.findIndex(u=>u.username===currentUser.username||u.id===currentUser.id);
    const newList2=idx2>=0?prev2.map((u,i)=>i===idx2?updated:u):[...prev2,updated];
    _setExtraUsers(newList2);
    const{password:_pw2,...safeUpdated2}=updated;setCurrentUser(safeUpdated2);localStorage.setItem('znx_user',JSON.stringify(safeUpdated2));
  }

  async function clearAllData(){
    // [SEC-001] Server-side role check — FAIL-CLOSED for destructive action
    const roleCheck=await znxRequireRole(currentUser,['admin']);
    if(!roleCheck.allowed){toast('⛔ Acesso negado.','error');if(roleCheck.reason==='role_escalation_detected'){logout();}return;}
    if(roleCheck.reason==='server_unreachable_fallback'){toast('⛔ Ação destrutiva bloqueada: servidor de validação indisponível. Tente novamente.','error');return;}
    if(!await showConfirm({title:'Apagar TODOS os dados?',message:'Esta ação é IRREVERSÍVEL.\nTodos os registros serão permanentemente apagados.',confirmText:'Sim, apagar tudo',confirmColor:'#DC2626'}))return;
    // [BUG-005] Usa _set* (raw setState sem save()) + save(...,{force:true}) para bypass GUARD-WRITE
    // setXxx([]) → useStore.set → save(key,[]) sem force → GUARD-WRITE bloquearia operação legítima
    const _clearPairs=[
      ['products',_setProducts],['suppliers',_setSuppliers],['clients',_setClients],
      ['sales',_setSales],['quotes',_setQuotes],['payables',_setPayables],
      ['receivables',_setReceivables],['entries',_setEntries],['notasFreteiro',_setNotasFreteiro],
      ['purchases',_setPurchases],['devolucoes',_setDevolucoes],['gastos',_setGastos],
      ['pagamentos',_setPagamentos],['discountRequests',_setDiscountRequests],['cancelRequests',_setCancelRequests]
    ];
    _clearPairs.forEach(([k,setter])=>{setter([]);save(k,[],{force:true});});
    setMetas({}); // objeto (não array) — GUARD-WRITE não actua, path normal OK
    setPage('dashboard');
  }

  // [PATCH 4/4] approveDiscount/denyDiscount via createSaleAtomic
  // ANTES: criava venda direto no state React, sem validação de estoque, sem RPC atômico
  // DEPOIS: usa createSaleAtomic() que valida estoque, detecta duplicata e grava atomicamente no banco
  // [SEC-001] Server-side role check before approve/deny
  async function approveDiscount(req){
    if(!await znxGuard(['admin']))return;
    // [FIX BUG-003] guard duplo clique — síncrono, sem re-render
    if(_approvalInFlight.current.has(req.id))return;
    const _curReq=discountRequests.find(r=>r.id===req.id);
    if(!_curReq||_curReq.status!=='pending')return;
    _markApprovalStart(req.id);
    const fd=req.formData;
    // Preparar form no formato que createSaleAtomic espera
    const form={
      ...fd,
      clientId:fd.clientId,
      date:today(),
      sellerName:fd.sellerName||req.requestedBy||'',
      paymentStatus:fd.paymentStatus||'Pendente',
      paymentMethod:fd.paymentMethod||'Pix',
      frete:fd.frete||'',
      embalagem:fd.embalagem||0,
      canal:fd.canal||'',
      obs:fd.obs||'',
      nfEnabled:fd.nfEnabled||false,
      notaFiscal:fd.notaFiscal||null,
      status:'Aberto'
    };
    const freteV=Number(fd.freteValor||fd.frete_valor||0);
    // [FEAT 20260504] Aprovar desconto NUNCA cria venda. Sempre atualiza orçamento.
    // Vendedora continua atendendo, ela mesma converte em venda quando terminar.
    const targetQuoteId = req.quoteId || req.formData?.quoteId;
    if(targetQuoteId){
      try{
        // [FIX BUG-DESC-CONVERT 20260511] Le quote fresh do banco pra detectar
        // se vendedora ja CONVERTEU o orcamento (antes do admin aprovar). Se sim,
        // o desconto ja esta gravado em sale_items.discount_pct na conversao -
        // chamar update_quote_v2 quebra com 'quote_terminal_status'. Caminho
        // retroativo: pula updateQuoteAtomic + marca request approved + audit log.
        // Regras: regra_fresh_select_antes_update + regra_mapear_fluxos_conectados.
        let isRetroactive=false;
        let freshQuoteStatus=null;
        try{
          const{data:fresh}=await sb.from('quotes').select('id,status,sale_number,sale_id').eq('id',targetQuoteId).maybeSingle();
          if(fresh){
            freshQuoteStatus=fresh.status;
            if(fresh.status==='Convertido') isRetroactive=true;
            else if(['Cancelado','Cancelada','Recusado','recusado'].includes(fresh.status)){
              toast('⚠️ Orçamento foi '+fresh.status.toLowerCase()+'. Não pode mais aprovar este desconto. Recuse o pedido.','warn');
              return;
            }
          }
        }catch(e){znxLogWarn('[ZNX] approveDiscount fresh fetch fail', e);}

        if(isRetroactive){
          // CAMINHO RETROATIVO: desconto ja esta em sale_items pela conversao.
          // So marca request approved + admin_audit_log com action=discount_approved_retroactive.
          const apprResult=await approveDiscountRequestAtomic({id:req.id,retroactive:true});
          if(!apprResult||!apprResult.success){
            const _msg=apprResult?(mapErrorToUX(apprResult.errorCode,apprResult.errorMessage)||apprResult.errorMessage):'RPC sem retorno';
            toast('❌ Erro ao aprovar pedido retroativo: '+_msg,'error');
            // [ONDA-S B 2026-05-13] expected codes → level=info
            if(typeof window.znxCaptureRpcError==='function'){
              window.znxCaptureRpcError('approveDiscountRequest retroactive',apprResult?.errorCode||'failed',{reqId:req.id,quoteId:targetQuoteId,errorMessage:apprResult?.errorMessage});
            } else if(typeof Sentry!=='undefined'){
              Sentry.captureException(new Error(apprResult?.errorCode||'approveDiscountRequest retroactive failed'),{extra:{reqId:req.id,quoteId:targetQuoteId,errorMessage:apprResult?.errorMessage}});
            }
            return;
          }
          setDiscountRequests(prev=>prev.map(r=>r.id===req.id?{...r,status:'approved',approvedAt:new Date().toISOString()}:r));
          toast('✅ Desconto aprovado retroativamente. Venda já refletia o desconto na conversão.');
          return;
        }

        // CAMINHO NORMAL: quote ainda esta Aberto/Aprovado/Rascunho - aplica desconto.
        const formApproved={...form,status:'Aprovado'};
        const result=await updateQuoteAtomic({quoteId:targetQuoteId,form:formApproved,expectedUpdatedAt:null});
        if(!result.success){
          // Race condition: se entre o fetch fresh e o updateQuoteAtomic o quote virou
          // Convertido (vendedora correu na frente), tenta retroativo.
          if(result.errorCode==='quote_terminal_status'){
            const apprRetry=await approveDiscountRequestAtomic({id:req.id,retroactive:true});
            if(apprRetry?.success){
              setDiscountRequests(prev=>prev.map(r=>r.id===req.id?{...r,status:'approved',approvedAt:new Date().toISOString()}:r));
              toast('✅ Desconto aprovado retroativamente (orçamento foi convertido durante aprovação).');
              return;
            }
          }
          toast('❌ Erro ao aplicar desconto: '+mapErrorToUX(result.errorCode,result.errorMessage),'error');
          // [ONDA-S B 2026-05-13] expected codes → level=info
          if(typeof window.znxCaptureRpcError==='function'){
            window.znxCaptureRpcError('approveDiscount updateQuote',result.errorCode||'failed',{reqId:req.id,quoteId:targetQuoteId});
          } else if(typeof Sentry!=='undefined'){
            Sentry.captureException(new Error(result.errorCode||'approveDiscount failed'),{extra:{reqId:req.id,quoteId:targetQuoteId}});
          }
          return;
        }
        // [FIX BUG-DESC 20260511] RPC approve_discount_request_v2 SEM .catch silencioso.
        // ANTES: .catch(()=>{}) engolia erro de RPC; local state virava 'approved' mesmo
        // se a RPC falhasse → JSONB cache divergia da tabela → bell some no admin.
        const apprResult=await approveDiscountRequestAtomic({id:req.id});
        if(!apprResult||!apprResult.success){
          const _msg=apprResult?(mapErrorToUX(apprResult.errorCode,apprResult.errorMessage)||apprResult.errorMessage):'RPC sem retorno';
          toast('❌ Erro ao aprovar pedido: '+_msg,'error');
          // [ONDA-S B 2026-05-13] expected codes → level=info
          if(typeof window.znxCaptureRpcError==='function'){
            window.znxCaptureRpcError('approveDiscountRequest RPC',apprResult?.errorCode||'failed',{reqId:req.id,quoteId:targetQuoteId,errorMessage:apprResult?.errorMessage});
          } else if(typeof Sentry!=='undefined'){
            Sentry.captureException(new Error(apprResult?.errorCode||'approveDiscountRequest RPC failed'),{extra:{reqId:req.id,quoteId:targetQuoteId,errorMessage:apprResult?.errorMessage}});
          }
          // NÃO muta state local — deixa pending pra próxima tentativa.
          return;
        }
        setQuotes(prev=>prev.map(q=>q.id===targetQuoteId?{...q,...form,status:'Aprovado',total:result.total,updatedAt:result.updatedAt}:q));
        setDiscountRequests(prev=>prev.map(r=>r.id===req.id?{...r,status:'approved',approvedAt:new Date().toISOString()}:r));
        toast('✅ Desconto aprovado. Vendedor pode converter o orçamento em venda quando terminar atendimento.');
      }catch(e){
        toast('❌ Erro inesperado: '+(e?.message||'unknown'),'error');
        if(typeof Sentry!=='undefined')Sentry.captureException(e,{extra:{context:'approveDiscount',reqId:req.id}});
      }finally{
        _markApprovalEnd(req.id);
      }
      return;
    }
    // Sem quote_id (caso legado/NovaVendaPage admin criando venda direta) — só marca approved.
    // [FIX BUG-DESC 20260511] mesmo no caminho legado, validar resultado da RPC antes de mutar state.
    try{
      const apprResult=await approveDiscountRequestAtomic({id:req.id});
      if(!apprResult||!apprResult.success){
        const _msg=apprResult?(mapErrorToUX(apprResult.errorCode,apprResult.errorMessage)||apprResult.errorMessage):'RPC sem retorno';
        toast('❌ Erro ao aprovar pedido: '+_msg,'error');
        // [ONDA-S B 2026-05-13] expected codes → level=info
        if(typeof window.znxCaptureRpcError==='function'){
          window.znxCaptureRpcError('approveDiscountRequest RPC legacy',apprResult?.errorCode||'failed',{reqId:req.id,errorMessage:apprResult?.errorMessage});
        } else if(typeof Sentry!=='undefined'){
          Sentry.captureException(new Error(apprResult?.errorCode||'approveDiscountRequest RPC failed (legacy path)'),{extra:{reqId:req.id,errorMessage:apprResult?.errorMessage}});
        }
        return;
      }
      toast('⚠ Pedido aprovado, mas sem orçamento vinculado. Crie a venda manualmente se for o caso.','warn');
      setDiscountRequests(prev=>prev.map(r=>r.id===req.id?{...r,status:'approved',approvedAt:new Date().toISOString()}:r));
    }finally{
      _markApprovalEnd(req.id);
    }
  }

  async function denyDiscount(reqId){
    // [SEC-001] Server-side role check
    if(!await znxGuard(['admin']))return;
    if(_approvalInFlight.current.has(reqId))return;
    const req=discountRequests.find(r=>r.id===reqId);
    if(!req||req.status!=='pending')return;
    _markApprovalStart(reqId);
    // [FEAT 20260504] Recusar desconto NUNCA cria venda. Marca request denied.
    // Orçamento (se existir) volta pra status 'Recusado' — vendedora pode editar e tentar de novo.
    const targetQuoteId = req.quoteId || req.formData?.quoteId;
    try{
      // [FIX BUG-DESC 20260511] RPC deny_discount_request_v2 SEM .catch silencioso.
      // ANTES: .catch(()=>{}) engolia erro de RPC; local state virava 'denied' mesmo
      // se a RPC falhasse → JSONB cache divergia da tabela → bell some no admin.
      const denyResult=await denyDiscountRequestAtomic({id:reqId,reason:'Desconto recusado pelo admin'});
      if(!denyResult||!denyResult.success){
        const _msg=denyResult?(mapErrorToUX(denyResult.errorCode,denyResult.errorMessage)||denyResult.errorMessage):'RPC sem retorno';
        toast('❌ Erro ao recusar pedido: '+_msg,'error');
        // [ONDA-S B 2026-05-13] expected codes → level=info
        if(typeof window.znxCaptureRpcError==='function'){
          window.znxCaptureRpcError('denyDiscountRequest RPC',denyResult?.errorCode||'failed',{reqId,errorMessage:denyResult?.errorMessage});
        } else if(typeof Sentry!=='undefined'){
          Sentry.captureException(new Error(denyResult?.errorCode||'denyDiscountRequest RPC failed'),{extra:{reqId,errorMessage:denyResult?.errorMessage}});
        }
        // NÃO muta state local — deixa pending pra próxima tentativa.
        return;
      }
      setDiscountRequests(prev=>prev.map(r=>r.id===reqId?{...r,status:'denied',deniedAt:new Date().toISOString()}:r));
      // Se houver orçamento, marca como Recusado pra vendedora ver
      if(targetQuoteId){
        try{
          await sb.from('quotes').update({status:'Recusado',updated_at:new Date().toISOString()}).eq('id',targetQuoteId);
          setQuotes(prev=>prev.map(q=>q.id===targetQuoteId?{...q,status:'Recusado'}:q));
        }catch(e){
          console.warn('[ZNX] denyDiscount: marcar quote Recusado falhou',e?.message);
          // [BUG-FIX 20260512 v203] Toast + Sentry — admin recusou desconto mas quote ficou em estado intermediário (vendedora não vê "Recusado")
          toast('⚠️ Desconto recusado, mas falha ao marcar orçamento como Recusado. Recarregue a página.','warning');
          if(typeof Sentry!=='undefined') Sentry.captureException(e,{tags:{flow:'discount_request',step:'mark_quote_recusado'},extra:{quoteId:targetQuoteId,reqId}});
        }
      }
      toast('Desconto recusado. Vendedor foi notificado.');
    }finally{
      _markApprovalEnd(reqId);
    }
  }

  async function approveCancelRequest(req){
    // [SEC-001] Server-side role check — approve cancel request
    if(!await znxGuard(['admin']))return;
    const sale=req.sale;
    if(!sale)return;
    // [v123 FT5/RG1 — 2026-05-09] Double-submit guard (mesmo padrão de approveDiscountRequest)
    // Antes: admin clicava 2x rápido em Aprovar → 2 RPCs disparadas → tentativa de duplicar cancelamento
    if(_approvalInFlight.current.has(req.id))return;
    _markApprovalStart(req.id);
    const ts=new Date().toISOString();
    try {
      // [BUG-FIX 20260504] approve via RPC atomica (sale + receivables + quotes + cancel_request).
      // Antes: 3 UPDATEs separados — se um falhar no meio, fica drift (sale Cancelada mas
      // receivables Pendente, ou request eternamente pending).
      // [ONDA-A #9 2026-05-11] regra_idem_estavel — idem key persistida por req.id.
      // Retry após erro mantém MESMA key → backend faz replay sem duplicar.
      if(!_approvalIdemMap.current.has(req.id)){
        _approvalIdemMap.current.set(req.id,(window.crypto?.randomUUID?.()||(Date.now()+'-'+Math.random())));
      }
      const _idemAppr=_approvalIdemMap.current.get(req.id);
      const{error:rpcErr}=await sb.rpc('approve_cancel_request_v2',{p_req_id:req.id,p_sale_id:sale.id,p_idem_key:_idemAppr});
      if(rpcErr){
        toast('❌ Erro ao aprovar cancelamento: '+rpcErr.message);
        if(typeof Sentry!=='undefined')Sentry.captureException(rpcErr,{extra:{context:'approveCancelRequest_rpc',reqId:req.id,saleId:sale.id}});
        return;
      }
      // Side effects locais (stock return + state sync)
      setProducts(prev=>prev.map(p=>{
        const it=sale.items.find(i=>nid(i.productId,p.id));
        return it?{...p,stock:p.stock+Number(it.qty)}:p;
      }));
      setReceivables(prev=>prev.map(r=>r.saleId===sale.id?{...r,status:'Cancelado'}:r));
      setSales(prev=>prev.map(s=>s.id===sale.id?{...s,status:'Cancelada',canceledAt:ts,canceledBy:currentUser.name}:s));
      setCancelRequests(prev=>prev.map(r=>r.id===req.id?{...r,status:'approved',approvedAt:ts,approvedBy:currentUser.name,saleNumber:sale.number,saleId:sale.id,stockReturned:true}:r));
      // [ONDA-A #9] sucesso confirmado → reset entry idem map
      _approvalIdemMap.current.delete(req.id);
    } catch(e) {
      const msg=e?.message||'Erro inesperado ao aprovar cancelamento.';
      toast('❌ '+msg);
      if(typeof Sentry!=='undefined')Sentry.captureException(e,{extra:{context:'approveCancelRequest',reqId:req.id,saleId:sale.id}});
    } finally {
      _markApprovalEnd(req.id);
    }
  }

  async function denyCancelRequest(reqId){
    if(!await znxGuard(['admin']))return;
    // [v123 FT5/RG1 — 2026-05-09] Double-submit guard (consistente com approveDiscountRequest)
    if(_approvalInFlight.current.has(reqId))return;
    _markApprovalStart(reqId);
    const ts=new Date().toISOString();
    try {
      // [BUG-FIX 20260504] deny via RPC (admin guard + UPDATE em transacao)
      // [ONDA-A #9 2026-05-11] regra_idem_estavel — idem key persistida por reqId.
      if(!_approvalIdemMap.current.has(reqId)){
        _approvalIdemMap.current.set(reqId,(window.crypto?.randomUUID?.()||(Date.now()+'-'+Math.random())));
      }
      const _idemDeny=_approvalIdemMap.current.get(reqId);
      const{error}=await sb.rpc('deny_cancel_request_v2',{p_req_id:reqId,p_idem_key:_idemDeny});
      if(error){
        toast('❌ Erro ao recusar cancelamento: '+error.message);
        if(typeof Sentry!=='undefined')Sentry.captureException(error,{extra:{context:'denyCancelRequest',reqId}});
        return;
      }
      setCancelRequests(prev=>prev.map(r=>r.id===reqId?{...r,status:'denied',deniedAt:ts,deniedBy:currentUser.name}:r));
      // [ONDA-A #9] sucesso confirmado → reset entry idem map
      _approvalIdemMap.current.delete(reqId);
    } catch(e) {
      if(typeof Sentry!=='undefined')Sentry.captureException(e,{extra:{context:'denyCancelRequest',reqId}});
    } finally {
      _markApprovalEnd(reqId);
    }
  }

  if(!appReady)return(
    <div style={{display:'flex',alignItems:'center',justifyContent:'center',height:'100vh',background:'#F0F2F5',flexDirection:'column',gap:14}}>
      <div style={{width:48,height:48,border:'5px solid #E4E7EC',borderTop:'5px solid #2563EB',borderRadius:'50%',animation:'spin 0.75s linear infinite'}}/>
      <div style={{fontFamily:"'Plus Jakarta Sans',sans-serif",fontWeight:800,color:'#1C1C28',fontSize:22,letterSpacing:'-.02em'}}>ZAYNEX</div>
      <div style={{color:'#9CA3AF',fontSize:13,fontWeight:500}}>Sincronizando dados...</div>
    </div>
  );

  if(!currentUser)return(<div><div style={{position:'fixed',top:10,right:14,fontSize:10.5,fontWeight:700,zIndex:9999,pointerEvents:'none',fontFamily:'ui-monospace, "SF Mono", Menlo, monospace',color:'#92700A',background:'linear-gradient(135deg,#FEF9E7,#FFFBEB)',border:'1px solid #C8A95155',padding:'4px 10px',borderRadius:999,letterSpacing:'0.5px',boxShadow:'0 1px 3px rgba(184,152,64,0.15)'}}>{ZNX_SYSTEM_VERSION}</div><Login onLogin={login} allUsers={allUsers} setExtraUsers={setExtraUsers}/></div>);

  // [Bug #642 fase 2 20260518] `gastos` adicionado ao `data` — DashboardV2/Dashboard
  // destructure pra calcular Lucro Líquido alinhado Bug #619 (cross-page consistency).
  // [v224.28 DASHBOARD-ESTOQUISTA] +notasFreteiro pra Dashboard estoquista card "Fretes Aguardando"
  const data={products,suppliers,clients,sales,quotes,payables,receivables,entries,purchases,gastos,notasFreteiro};
  const pendingDiscounts=discountRequests.filter(r=>r.status==='pending');
  const pendingCancels=cancelRequests.filter(r=>r.status==='pending');
  // [ESCALATION 5min v114 2026-05-08] financeiro vê descontos só APÓS 5min sem resposta admin
  // (mesmo padrão do cancelamento). Backend RPC approve/deny_discount_request_v2 também
  // valida 5min server-side (defesa em profundidade).
  const escalatedDiscounts=pendingDiscounts.filter(r=>{
    const ts=r.requestedAt||r.requested_at;
    if(!ts)return false;
    return nowRef.current-new Date(ts).getTime()>5*60*1000;
  });
  const escalatedCancels=pendingCancels.filter(r=>nowRef.current-new Date(r.requestedAt).getTime()>5*60*1000);
  const showCancelBell=(currentUser.role==='admin'&&pendingCancels.length>0)||(currentUser.role==='financeiro'&&escalatedCancels.length>0);
  const cancelBellCount=currentUser.role==='financeiro'?escalatedCancels.length:pendingCancels.length;
  const showDiscountBell=(currentUser.role==='admin'&&pendingDiscounts.length>0)||(currentUser.role==='financeiro'&&escalatedDiscounts.length>0);
  const discountBellCount=currentUser.role==='financeiro'?escalatedDiscounts.length:pendingDiscounts.length;

  // [SEC-001] ALLOWED_PAGES — route guard per role
  const ALLOWED_PAGES={
    vendedor:['dashboard','vendas','orcamentos','clientes','catalogo','historico','regioes','perfil'],
    // FRT-C3 (2026-05-09) — Abbes recebe frete via mobile-first
    // [v224.27 ESTOQUISTA-PRIVACY 20260526] Removido 'compras' + 'fornecedores' — estoquista
    // não deve ver R$/cost/total dessas pages (Compras tem todo fluxo $; Fornecedores lista).
    // [v224.31 RECEBER-TRANSFERENCIA 20260526] +receber-transferencia (page dedicada · badge sidebar com count)
    // [v224.32 VENDAS-DESPACHO 20260526] +vendas-despacho (workflow imprimir 1× + reprint admin-approved)
    estoquista:['dashboard','produtos','entradas','receber-frete','receber-transferencia','vendas-despacho','perfil'],
    // [FEAT 20260504] +clientes (pra adicionar credito); -relatorio (nao deve ver)
    // FRT-C4 (2026-05-09) +aprovacoes-frete (read-only — RPC bloqueia mutation)
    financeiro:['dashboard','vendas','orcamentos','clientes','produtos','entradas','compras','devolucoes','fornecedores','pagar','receber','gastos','pagamentos','aprovacoes-frete','perfil'],
    // BC-002 (2026-05-02): admin explicitado para bloquear 'config' (endpoints /api/* não existem)
    // Para reverter: descomentar linha abaixo e deletar a lista explícita
    // admin:null, // tudo permitido
    // [v224.32 VENDAS-DESPACHO 20260526] +vendas-despacho (admin revisa reprints pendentes via badge sidebar)
    admin:['dashboard','insights','relatorio','historico','regioes','produtos','marcas','entradas','compras','vendas','orcamentos','clientes','fornecedores','devolucoes','pagar','receber','gastos','pagamentos','atividade','auditoria','catalogo','freteiros','notas-frete','receber-frete','aprovacoes-frete','vendas-despacho','perfil'],
    // FRT-C1+C2 (2026-05-09) — Amir/PY: Dashboard, Freteiros, Notas de Frete, Perfil
    freight_manager:['dashboard','freteiros','notas-frete','perfil'],
    // 'config' intencionalmente omitido — BC-002: endpoints /api/* não existem
  };

  // [v224.40 E 20260526] Filter dinâmico 'receber-frete' baseado em user.can_receive_frete
  // Estoquistas sem flag (Ibra/Karim) NÃO veem page · admin sempre vê (lista base).
  function getAllowedPages(user){
    if(!user || !user.role) return undefined;
    var base = ALLOWED_PAGES[user.role];
    if(!base) return base;
    if(user.role === 'estoquista' && user.can_receive_frete !== true){
      return base.filter(function(p){ return p !== 'receber-frete'; });
    }
    return base;
  }

  function renderPage(){
    // [SEC-001] Route guard — block unauthorized page access
    if(window.__ZNX_SERVER_ROLE_CHECK_ENABLED__){
      const allowedPages=getAllowedPages(currentUser);
      // allowedPages === null → admin (tudo liberado)
      // allowedPages === undefined → role desconhecido → só dashboard+perfil
      if(allowedPages===undefined&&page!=='dashboard'&&page!=='perfil'){
        setTimeout(()=>{setPage('dashboard');toast('⛔ Role não reconhecido — acesso restrito.','error');},0);
        return<div className="page-content"><p>Redirecionando...</p></div>;
      }
      if(allowedPages&&!allowedPages.includes(page)){
        setTimeout(()=>{setPage('dashboard');toast('⛔ Acesso restrito a esta página.','error');},0);
        return<div className="page-content"><p>Redirecionando...</p></div>;
      }
    }
    switch(page){
      case'dashboard':
        // [Onda 2026-05-07] 3 dashboards distintos por role:
        //   admin → DashboardV2 (visão executiva completa, equipe, metas, faturamento)
        //   financeiro → DashboardFinanceiro (cash flow, contas, inadimplência, métodos pagamento)
        //   vendedor/estoquista → Dashboard original (comissão, meta individual)
        if(currentUser.role==='admin') {
          return <DashboardV2 user={currentUser} data={data} metas={metas} setMetas={setMetas} metasBase={metasBase} setMetasBase={setMetasBase} metasEmpresa={metasEmpresa} setMetasEmpresa={setMetasEmpresa} extraUsers={extraUsers} setExtraUsers={setExtraUsers} allUsers={allUsers} discountRequests={discountRequests} cancelRequests={cancelRequests} onOpenDiscountModal={()=>setShowDiscountModal(true)} onOpenCancelModal={()=>setShowCancelModal(true)}/>;
        }
        if(currentUser.role==='financeiro') {
          return <DashboardFinanceiro user={currentUser} sales={sales} quotes={quotes} payables={payables} receivables={receivables} clients={clients}/>;
        }
        return <Dashboard user={currentUser} data={data} metas={metas} setMetas={setMetas} metasBase={metasBase} setMetasBase={setMetasBase} extraUsers={extraUsers} setExtraUsers={setExtraUsers} allUsers={allUsers} discountRequests={discountRequests} cancelRequests={cancelRequests} onOpenDiscountModal={()=>setShowDiscountModal(true)} onOpenCancelModal={()=>setShowCancelModal(true)} onNavigate={setPage}/>;
      case'produtos':return<Produtos user={currentUser} products={products} setProducts={setProducts} suppliers={suppliers} sales={sales} quotes={quotes} clients={clients}/>;
      case'entradas':return<Entradas user={currentUser} products={products} setProducts={setProducts} suppliers={suppliers} entries={entries} setEntries={setEntries} notasFreteiro={notasFreteiro} setNotasFreteiro={setNotasFreteiro} sales={sales} allUsers={allUsers}/>;
      case'vendas':return<Vendas user={currentUser} sales={sales} setSales={setSales} products={products} setProducts={setProducts} clients={clients} setReceivables={setReceivables} receivables={receivables} setPayables={setPayables} quotes={quotes} setQuotes={setQuotes} allUsers={allUsers} discountRequests={discountRequests} setDiscountRequests={setDiscountRequests} cancelRequests={cancelRequests} setCancelRequests={setCancelRequests}/>;
      case'orcamentos':return<Orcamentos user={currentUser} quotes={quotes} setQuotes={setQuotes} products={products} setProducts={setProducts} clients={clients} sales={sales} setSales={setSales} setReceivables={setReceivables} allUsers={allUsers} discountRequests={discountRequests} setDiscountRequests={setDiscountRequests}/>;
      case'clientes':return<Clientes clients={clients} setClients={setClients} sales={sales} quotes={quotes} products={products} receivables={receivables} setReceivables={setReceivables} payables={payables} setPayables={setPayables} user={currentUser} allUsers={allUsers}/>;
      case'fornecedores':return<Fornecedores suppliers={suppliers} setSuppliers={setSuppliers}/>;
      case'compras':return<Compras purchases={purchases} setPurchases={setPurchases} products={products} setProducts={setProducts} suppliers={suppliers} entries={entries} setEntries={setEntries} setPayables={setPayables}/>;
      case'devolucoes':return<Devolucoes devolucoes={devolucoes} setDevolucoes={setDevolucoes} sales={sales} setSales={setSales} products={products} setProducts={setProducts} clients={clients} receivables={receivables} setReceivables={setReceivables}/>;
      case'pagar':return<Pagar payables={payables} setPayables={setPayables} suppliers={suppliers}/>;
      case'receber':return<Receber receivables={receivables} setReceivables={setReceivables} clients={clients}/>;
      case'gastos':return<Gastos gastos={gastos} setGastos={setGastos} user={currentUser} allUsers={allUsers}/>;
      case'pagamentos':return<Pagamentos pagamentos={pagamentos} setPagamentos={setPagamentos}/>;
      case'relatorio':return<Relatorio sales={sales} products={products} payables={payables} receivables={receivables} clients={clients} allUsers={allUsers} quotes={quotes} metas={metas} user={currentUser} gastos={gastos}/>;
      // [REFACTOR 20260506] Atividade -> Usuarios. V2: meta + editar + novo + vendas mes + comissao + mini-grafico
      case'atividade':return <UsersPanel allUsers={allUsers} activityLog={activityLog} user={currentUser} isUserOnline={isUserOnline} formatLastSeen={formatLastSeen} sales={sales} clients={clients} metas={metas} setMetas={setMetas} metasBase={metasBase} setMetasBase={setMetasBase}/>;
      case'auditoria':return <AuditoriaAdmin user={currentUser} allUsers={allUsers}/>;
      case'historico':return<HistoricoVendedor sales={sales} products={products} clients={clients} user={currentUser} quotes={quotes} metas={metas} metasBase={metasBase} metasEmpresa={metasEmpresa} allUsers={allUsers} receivables={receivables}/>;
      case'regioes':return<RegioesAnalytics sales={sales} clients={clients} user={currentUser} products={products} allUsers={allUsers} quotes={quotes}/>;
      case'insights':return<Insights sales={sales} products={products} clients={clients} quotes={quotes} payables={payables} receivables={receivables} metas={metas} allUsers={allUsers} gastos={gastos}/>;
      case'catalogo':return<CatalogoVendedor products={products}/>;
      // [REMOVED v129 2026-05-09] case 'config' apontava pra Configuracoes.jsx (dead code, sem entry Sidebar)
      case'marcas':return<Marcas products={products} user={currentUser} sales={sales} clients={clients} purchases={purchases} suppliers={suppliers} allUsers={allUsers}/>;
      // FRT-C1 (2026-05-09) — sistema de freteiros PY→BR
      case'freteiros':return<Freteiros user={currentUser}/>;
      // FRT-C2 (2026-05-09) — notas de frete (visão Amir/admin)
      case'notas-frete':return<NotasFrete user={currentUser}/>;
      // FRT-C3 (2026-05-09) — receber frete (visão Abbes mobile-first)
      case'receber-frete':return<ReceberFrete user={currentUser}/>;
      // [v224.31 RECEBER-TRANSFERENCIA 20260526] Estoquista vê transferências pendentes em page dedicada
      case'receber-transferencia':return<ReceberTransferencia user={currentUser} products={products} allUsers={allUsers} onTransferReceived={function(){/* count refresh via Realtime */}}/>;
      // [v224.32 VENDAS-DESPACHO 20260526] Estoquista vê vendas a despachar + admin revisa reprints (mesma page · views diferentes via role)
      case'vendas-despacho':return<VendasDespacho user={currentUser} clients={clients} allUsers={allUsers} products={products}/>;
      // FRT-C4 (2026-05-09) — aprovações de close_requests (admin/financeiro)
      case'aprovacoes-frete':return<AprovacoesFrete user={currentUser}/>;
      case'perfil':return currentUser.role==='admin'?<PerfilAdmin user={currentUser} allUsers={allUsers} onSave={updatePerfil}/>:<PerfilPage user={currentUser} allUsers={allUsers} onTrocarSenha={trocarSenha} onBack={()=>setPage('dashboard')}/>;
      default:
        // [Onda 2026-05-07] mesma lógica do case dashboard — 3 dashboards distintos
        if(currentUser.role==='admin') {
          return <DashboardV2 user={currentUser} data={data} metas={metas} setMetas={setMetas} metasBase={metasBase} setMetasBase={setMetasBase} metasEmpresa={metasEmpresa} setMetasEmpresa={setMetasEmpresa} extraUsers={extraUsers} setExtraUsers={setExtraUsers} allUsers={allUsers} discountRequests={discountRequests} cancelRequests={cancelRequests} onOpenDiscountModal={()=>setShowDiscountModal(true)} onOpenCancelModal={()=>setShowCancelModal(true)}/>;
        }
        if(currentUser.role==='financeiro') {
          return <DashboardFinanceiro user={currentUser} sales={sales} quotes={quotes} payables={payables} receivables={receivables} clients={clients}/>;
        }
        return <Dashboard user={currentUser} data={data} metas={metas} setMetas={setMetas} metasBase={metasBase} setMetasBase={setMetasBase} extraUsers={extraUsers} setExtraUsers={setExtraUsers} allUsers={allUsers} discountRequests={discountRequests} cancelRequests={cancelRequests} onOpenDiscountModal={()=>setShowDiscountModal(true)} onOpenCancelModal={()=>setShowCancelModal(true)} onNavigate={setPage}/>;
    }
  }

  const showMetaBar=currentUser.role==='vendedor'&&page!=='dashboard';
  // [Wave 5 MEDIUM 2026-05-16] OfflineIndicatorComp movido pra dentro de TopBar.jsx

  return(
    <div>
      {/* [Wave 18 v223.45.1 HOTFIX 20260521] Banner heartbeat Realtime — visible se >5min sem evento (era 2min · ajuste falso positivo horários calmos) */}
      {rtBannerVisible && (
        <div style={{
          position: 'fixed', top: 0, left: '50%', transform: 'translateX(-50%)',
          background: '#FEF3C7', color: '#92400E', padding: '6px 16px',
          borderRadius: '0 0 8px 8px', fontSize: 12, fontWeight: 600,
          boxShadow: '0 2px 6px rgba(0,0,0,0.1)', zIndex: 9999
        }}>
          🔄 Sincronizando dados... <span style={{textDecoration:'underline',cursor:'pointer'}} onClick={()=>location.reload()}>atualizar página</span>
        </div>
      )}
      {/* [VERSIONING v104 20260507] Badge integrado ao chip Sincronizado no header (mais abaixo).
          Centralizado fixed (v103) ficava feio no meio da tela. Agora é chip dourado próprio
          ao lado do Sincronizado — equipe vê na hora, sem poluir, sem tapar nada. */}
      <Sidebar user={currentUser} active={page} onNav={setPage} onLogout={logout} onlineUsers={currentUser.role==='admin'?(allUsers||[]).filter(u=>isUserOnline(u.username).online).length:0} onClearData={clearAllData} onTrocarSenha={()=>setShowTrocarSenha(true)} badges={{pendingTransfersCount:pendingTransfersCount,pendingPrintCount:pendingPrintCount,pendingReprintCount:pendingReprintCount}}/>
      {showTrocarSenha&&<TrocarSenhaModal user={currentUser} onClose={()=>setShowTrocarSenha(false)} onSave={trocarSenha}/>}
      <div style={{marginLeft:220,minHeight:'100vh',display:'flex',flexDirection:'column'}}>
        {/* [Wave 5 MEDIUM 2026-05-16] TopBar extraído pra widgets/app/TopBar.jsx (RaceBarVendedor + ApprovalBell + sync chip + version chip + offline indicator + user info + Perfil/Logout) */}
        {TopBar && (
          <TopBar
            currentUser={currentUser}
            metas={metas}
            metasBase={metasBase}
            sales={sales}
            clients={clients}
            syncStatus={syncStatus}
            setPage={setPage}
            logout={logout}
          />
        )}
        <div style={{padding:28,flex:1}}>{renderPage()}</div>
      </div>
      <ZaynexChat user={currentUser} products={products} sales={sales} clients={clients}/>
      {/* [FEAT 20260504] Bell — drift radar (admin/financeiro) */}
      {['admin','financeiro'].includes(currentUser.role)&&driftSummary&&driftSummary.total_drift>0&&(
        <button onClick={async()=>{
          const r=await getDriftDetails();
          if(r.success)setDriftDetails(r);
          setShowDriftModal(true);
        }} title={`${driftSummary.total_drift} drift(s) detectado(s) — clique pra ver`}
          style={{position:'fixed',bottom:90,right:144,zIndex:900,width:52,height:52,borderRadius:'50%',
            background:'#1e293b',border:'2px solid #f59e0b',color:'#fff',fontSize:22,
            display:'flex',alignItems:'center',justifyContent:'center',
            boxShadow:'0 0 16px #f59e0b88',animation:'metaPulse 1.2s infinite'}}>
          🔍
          <span style={{position:'absolute',top:-4,right:-4,background:'#f59e0b',color:'#0A0A0A',
            borderRadius:'50%',width:20,height:20,fontSize:11,fontWeight:700,
            display:'flex',alignItems:'center',justifyContent:'center',border:'2px solid #0A0A0A'}}>
            {driftSummary.total_drift}
          </span>
        </button>
      )}
      {/* [Wave 5 MEDIUM 2026-05-16] DriftModal extraído pra widgets/app/DriftModal.jsx */}
      {showDriftModal && driftDetails && DriftModal && (
        <DriftModal
          showDriftModal={showDriftModal}
          setShowDriftModal={setShowDriftModal}
          driftDetails={driftDetails}
          driftSummary={driftSummary}
        />
      )}
      {/* Bell — desconto (admin sempre; financeiro após 5min) [v114] */}
      {showDiscountBell&&(
        <button onClick={()=>setShowDiscountModal(true)}
          title={currentUser.role==='financeiro'?'Descontos sem resposta há +5 min':'Descontos pendentes'}
          style={{position:'fixed',bottom:90,right:20,zIndex:900,width:52,height:52,borderRadius:'50%',
            background:'#991B1B',border:'2px solid #DC2626',color:'#374151',fontSize:22,
            display:'flex',alignItems:'center',justifyContent:'center',
            boxShadow:'0 0 16px #DC262688',animation:'metaPulse 1.2s infinite'}}>
          🔔
          <span style={{position:'absolute',top:-4,right:-4,background:'#DC2626',color:'#fff',
            borderRadius:'50%',width:20,height:20,fontSize:11,fontWeight:700,
            display:'flex',alignItems:'center',justifyContent:'center',border:'2px solid #0A0A0A'}}>
            {discountBellCount}
          </span>
        </button>
      )}
      {/* Bell — cancelamento (admin sempre; financeiro após 5min) */}
      {showCancelBell&&(
        <button onClick={()=>setShowCancelModal(true)}
          title={currentUser.role==='financeiro'?'Cancelamentos sem resposta há +5 min':'Cancelamentos pendentes'}
          style={{position:'fixed',bottom:90,right:82,zIndex:900,width:52,height:52,borderRadius:'50%',
            background:'#F0FDF4',border:'2px solid #EA580C',color:'#374151',fontSize:22,
            display:'flex',alignItems:'center',justifyContent:'center',
            boxShadow:'0 0 16px #EA580C88',animation:'metaPulse 1.2s infinite'}}>
          🚫
          <span style={{position:'absolute',top:-4,right:-4,background:'#EA580C',color:'#000',
            borderRadius:'50%',width:20,height:20,fontSize:11,fontWeight:700,
            display:'flex',alignItems:'center',justifyContent:'center',border:'2px solid #0A0A0A'}}>
            {cancelBellCount}
          </span>
        </button>
      )}
      {showDiscountModal&&(
        <DiscountApprovalModal
          requests={currentUser.role==='financeiro'
            ?discountRequests.filter(r=>{
                if(r.status!=='pending')return false;
                const ts=r.requestedAt||r.requested_at;
                if(!ts)return false;
                return nowRef.current-new Date(ts).getTime()>5*60*1000;
              })
            :discountRequests
          }
          products={products}
          inFlightIds={approvalInFlightIds}
          onApprove={req=>{approveDiscount(req);}}
          onDeny={reqId=>{denyDiscount(reqId);}}
          onClose={()=>setShowDiscountModal(false)}
        />
      )}
      {showCancelModal&&(
        <CancelApprovalModal
          requests={currentUser.role==='financeiro'?cancelRequests.filter(r=>r.status==='pending'&&nowRef.current-new Date(r.requestedAt).getTime()>5*60*1000):cancelRequests}
          products={products}
          clients={clients}
          inFlightIds={approvalInFlightIds}
          onApprove={req=>{approveCancelRequest(req);}}
          onDeny={reqId=>{denyCancelRequest(reqId);}}
          onClose={()=>setShowCancelModal(false)}
        />
      )}
    </div>
  );
}

  window.ZNX = window.ZNX || {};
  window.ZNX.components = window.ZNX.components || {};
  window.ZNX.components.App = App;
  window.App = App;

  window.ZNX.refactor_phase_6_loaded = window.ZNX.refactor_phase_6_loaded || {};
  window.ZNX.refactor_phase_6_loaded.App = true;


  // ── MOUNT ─────────────────────────────────────────────────────────────────
  // Executa após todos os scripts externos. ErrorBoundary definido no script inline.
  ReactDOM.createRoot(document.getElementById('root')).render(<ErrorBoundary><App/></ErrorBoundary>);
})();
