// 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);const _usr=(p&&p.data&&p.data.username)?p.data:p;
      // [v224.245 E4] boot com sessao RESTAURADA tambem identifica no Sentry — antes
      // so o login por senha chamava setUser, e 100% dos eventos de sessao restaurada
      // (a maioria) chegavam sem user.username nem tag znx_sw_build (forensics 10/08).
      if(_usr&&window.ZNX_setSentryUser){try{window.ZNX_setSentryUser(_usr);}catch(_){}}
      return _usr;}catch{return null}
  });
  // [Opção B Fase 1] papel LIVE p/ o scoping (não o closure de mount) → admin nunca fica scoped após cold-login.
  const _opcaoB_userRef=useRef(currentUser); _opcaoB_userRef.current=currentUser;
  // A-020: efeitos automáticos não escrevem atividade de identidade apenas em cache.
  // Não limpa cache/fila nem força logout quando a rede está indisponível.
  async function _appHasCurrentSession(user){
    const sameUser=()=>{
      const live=_opcaoB_userRef.current;
      return !!(user&&user.id&&user.auth_user_id&&live&&live.id===user.id&&live.auth_user_id===user.auth_user_id);
    };
    if(!sameUser())return false;
    try{
      const api=window.ZNX&&window.ZNX.api;
      if(!api||typeof api.ensureFreshAuth!=='function'||!(await api.ensureFreshAuth()))return false;
      if(!sameUser())return false;
      const result=await window.sb.auth.getSession();
      return !!(sameUser()&&!result.error&&result.data&&result.data.session&&result.data.session.user&&result.data.session.user.id===user.auth_user_id);
    }catch(_){return false;}
  }
  // [opcao-b-rpc-orphan] snapshot pré-purge das 5 keys own-scoped → restaura se a RPC znx_my_* falhar
  // (RPC órfã/sem-grant). Sem isto o purge de boot apaga o local e a RPC falha → tela em branco silenciosa.
  const _opcaoB_bootBackupRef=useRef({});
  const[page,setPage]=useState('dashboard');

  // A-023: sync legado só acompanha a identidade validada; sucesso por chave não se repete.
  useEffect(()=>{
    if(!currentUser)return;
    let mounted=true,running=false,done=false;
    const completedKeys=new Set();
    const hasSession=async()=>mounted&&(await _appHasCurrentSession(currentUser))&&mounted;
    const run=async()=>{
      if(!mounted||running||done)return;
      const sync=window.ZNX&&window.ZNX.api&&window.ZNX.api.syncLegacyStoreCache;
      if(typeof sync!=='function')return;
      running=true;
      try{done=await sync(hasSession,completedKeys);}
      catch(error){znxLogWarn('[ZNX-SYNC] Sync inicial suspenso',error);}
      finally{running=false;}
    };
    const timer=setTimeout(run,3000);
    window.addEventListener('online',run);
    window.addEventListener('focus',run);
    return()=>{mounted=false;clearTimeout(timer);window.removeEventListener('online',run);window.removeEventListener('focus',run);};
  },[currentUser?.id,currentUser?.auth_user_id]);

  // [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');};

  // [v224.157] Delta-fetch: baixa SÓ keys com updated_at mais novo que o último aplicado.
  // Root: full-store ~13MB × N abas (pior caso: fallback 8s com realtime caído) = pressão → crash.
  // __znxStoreVer é populado DENTRO do applyRows (ponto único: boot/polling/realtime/delta).
  // Fallback: qualquer erro na fase-1 → full fetch (NUNCA deixar a tela stale por falha do delta).
  // [v224.161 boot-delta] Persistido em localStorage pra o BOOT poder fazer delta-fetch.
  // Sem isso o map zerava em todo reload → boot baixava ~45MB full → thundering herd (incidente 15/06).
  // SAFETY: só confia na versão de uma key se o DADO dela ainda existe no localStorage
  // (QuotaExceeded/clear parcial → força re-fetch dessa key, nunca deixa tela vazia).
  window.__znxStoreVer = window.__znxStoreVer || (function(){
    try{
      const v = JSON.parse(localStorage.getItem('znx_storeVer')||'{}') || {};
      if(v && typeof v==='object' && !Array.isArray(v)){
        Object.keys(v).forEach(function(k){
          const lk = (typeof window.toLocalKey==='function') ? window.toLocalKey(k) : ('znx_'+k);
          if(!localStorage.getItem(lk)) delete v[k];
        });
        return v;
      }
    }catch(_){}
    return {};
  })();
  // [Opção B Fase 1 2026-06-24] Não-admin (vendedor) NÃO lê dados sensíveis do blob `store` (que pula a RLS).
  //   - sales/quotes/clients/discountRequests/metas vêm dos RPCs RLS-scoped znx_my_* (mesma forma do blob — items embutidos + dual-case).
  //   - keys financeiras/empresa (incl. metasBase/metasEmpresa): NÃO carregadas.
  //   - products: continua no blob até products_pub (Camada 2). marcas/hiddenBrands/nextOrderNum/extraUsers: blob (neutras).
  // Admin/financeiro/estoquista/freight_manager: ZERO mudança (lê o blob inteiro). freight_manager = pendência Jamal (default blob).
  // Aditivo/backward-compat: o blob ainda está acessível; a Cowork trava a RLS do store (Fase 2 backend) DEPOIS.
  // [Opção B Fase 1] 5 keys own-scoped via RPC RLS-scoped (mesma forma do blob, paridade 100%; admin vê tudo):
  //   sales/quotes/clients/discountRequests = ARRAY · metas = OBJETO keyed-by-name (store.metas[user.name]).
  // ⚠️ id-space difere por RPC (a Cowork escopa internamente; o front NÃO filtra por id em lugar nenhum aqui):
  //   sales/quotes/clients filtram por app_user_id (seller_id); discount_requests filtra por auth.uid (requested_by_id).
  const OPCAOB_RPC = { sales: 'znx_my_sales', quotes: 'znx_my_quotes', clients: 'znx_my_clients', discountRequests: 'znx_my_discount_requests', metas: 'znx_my_metas' };
  // metas vem como OBJETO (keyed-by-name) — NÃO coage p/ array (zeraria metas[user.name]). Os outros 4 = array.
  const OPCAOB_RPC_OBJECT = ['metas'];
  // metasBase/metasEmpresa = nível-empresa → ficam em NOLOAD (vendedor não vê).
  const OPCAOB_NOLOAD = ['receivables','payables','gastos','suppliers','purchases','entries','metasEmpresa','notasFreteiro','pagamentos','cancelRequests','devolucoes','activityLog','metasBase'];
  const OPCAOB_BLOB_ROLES = ['admin','financeiro','estoquista','freight_manager'];
  // fail-closed: papel desconhecido/null → escopado (NUNCA libera o blob inteiro por dúvida).
  // lê o papel LIVE via ref (não o closure de mount) → admin nunca fica scoped após cold-login; vendedor sempre scoped.
  function _opcaoB_isScoped(){ const _u = _opcaoB_userRef.current; return !(_u && OPCAOB_BLOB_ROLES.indexOf(_u.role) !== -1); }
  async function _opcaoB_fetchRpcRows(keysFilter, _authGated){
    if(!_authGated && window.ZNX && window.ZNX.api && typeof window.ZNX.api.withFreshAuth==='function'){
      return (await window.ZNX.api.withFreshAuth(function(){return _opcaoB_fetchRpcRows(keysFilter,true);}))||[];
    }
    const out = [];
    // [FIX ERP-94/95/96/97/98 2026-06-30] znx_my_* sao SECDEF com EXECUTE so p/ authenticated (anon NAO,
    // por seguranca). Se a sessao expirou/ausente, chamar a RPC em estado anon estoura "permission denied
    // for function" 5x/ciclo (uma por funcao = ERP-94..98). Garante sessao fresca ANTES; se NAO ha sessao,
    // nao chama a RPC anon (NAO abrimos grant p/ anon — seria furo de seguranca) e usa o snapshot do
    // proprio vendedor ate o relogin (o handler SIGNED_OUT ja recarrega pra tela de login).
    let _authOk = true;
    try{ if(window.ZNX && window.ZNX.api && typeof window.ZNX.api.ensureFreshAuth==='function'){ _authOk = await window.ZNX.api.ensureFreshAuth(); } }catch(_){ _authOk = false; }
    for(const _k of Object.keys(OPCAOB_RPC)){
      if(Array.isArray(keysFilter) && keysFilter.indexOf(_k) === -1) continue; // respeita o keysFilter do caller
      if(!_authOk){
        // sem sessao valida: NAO chama znx_my_* em estado anon (evita permission denied). Usa snapshot do proprio vendedor.
        try{ const _snap=_opcaoB_bootBackupRef.current && _opcaoB_bootBackupRef.current[_k]; const _fb=(window.ZNXSyncHelpers && window.ZNXSyncHelpers.opcaoBFallbackRow)?window.ZNXSyncHelpers.opcaoBFallbackRow(_k,_snap,new Date().toISOString()):null; if(_fb) out.push(_fb); }catch(_){}
        continue;
      }
      try{
        // metas = OBJETO keyed-by-name (preserva a forma p/ metas[user.name]); os outros 4 = array.
        const _isObj = OPCAOB_RPC_OBJECT.indexOf(_k) !== -1;
        // [FIX rpc-timeout-listagem 2026-07-06 · Sentry ~879ev "Opção B"] A chamada era rpc(nome) SEM
        // paginação: a SECDEF devolvia TODO o histórico WORM do vendedor (items embutidos) numa
        // statement só → a materialização/detoast estoura o statement_timeout → 57014 "canceling
        // statement due to statement timeout" → o catch só re-semeava o snapshot (listagem stale/branca).
        // Agora as RPCs de ARRAY são drenadas em páginas KEYSET (p_after = id da última row da página
        // anterior, p_limit fixo) até esvaziar: cada statement fica abaixo do timeout e o concat das
        // páginas == full não-paginado (zero perda). O escopo por seller segue 100% DENTRO da SECDEF —
        // o front continua sem passar filtro de id/seller (só o cursor), zero furo cross-seller.
        // Backend ainda sem a assinatura paginada → PGRST202 "Could not find the function" → 1 retry
        // no caminho antigo (backward-compat: o front pode ir ao ar ANTES da migration das SECDEF).
        // metas fica FORA do keyset: é OBJETO keyed-by-name (só a entry do próprio vendedor, minúsculo).
        let data, error = null;
        if(_isObj){
          ({ data, error } = await window.sb.rpc(OPCAOB_RPC[_k]));
        }else{
          const _PAGE = 200;
          const _all = [];
          let _after = null;
          for(;;){
            let _res = await window.sb.rpc(OPCAOB_RPC[_k], { p_after: _after, p_limit: _PAGE });
            if(_res.error && (String(_res.error.code||'')==='PGRST202' || /Could not find the function/i.test(String(_res.error.message||'')))){
              // backend pré-migration (assinatura keyset ausente): 1 retry no caminho antigo e para —
              // NÃO volta pro loop (o retorno full não tem cursor; re-tentar paginado repetiria o PGRST202).
              _res = await window.sb.rpc(OPCAOB_RPC[_k]);
              if(!_res.error){ const _full = Array.isArray(_res.data) ? _res.data : []; for(const _r of _full) _all.push(_r); }
              error = _res.error || null;
              break;
            }
            if(_res.error){ error = _res.error; break; }
            const _page = Array.isArray(_res.data) ? _res.data : [];
            for(const _r of _page) _all.push(_r);
            if(_page.length < _PAGE) break; // página curta = acabou
            const _last = _page[_page.length-1];
            if(_last == null || _last.id == null) break; // sem cursor confiável → não loopa infinito
            _after = _last.id;
          }
          data = _all;
        }
        if(error) throw error;
        let _payload, _items;
        if(_isObj){
          _payload = (data && typeof data==='object' && !Array.isArray(data)) ? data : {};
          _items = Object.keys(_payload).map(function(kk){ return _payload[kk]; });
        }else{
          _payload = Array.isArray(data) ? data : [];
          _items = _payload;
        }
        // updated_at = max(updatedAt dos itens) → applyRows pula re-apply quando nada mudou (delta-friendly, sem re-render).
        let _ts = '';
        for(const it of _items){ const u = (it && (it.updatedAt || it.updated_at)) || ''; if(u > _ts) _ts = u; }
        if(!_ts) _ts = new Date().toISOString(); // vazio/sem updatedAt → aplica 1× (purga o que houver no local)
        out.push({ key: _k, data: _payload, updated_at: _ts });
      }catch(err){
        // falha_silenciosa_proibida: NÃO deixa a tela stale em silêncio. [item 3 ERP-8V..8T] erro de REDE
        // (Failed to fetch/aba-zumbi) = expected → breadcrumb (mata ~750 ev/dia de ruído); erro genuíno segue
        // captureMessage. O fallback opcaoBFallbackRow abaixo re-semeia o local (dado do próprio vendedor).
        if(window.ZNXSyncHelpers && window.ZNXSyncHelpers.opcaoBReportRpcError){
          window.ZNXSyncHelpers.opcaoBReportRpcError(OPCAOB_RPC[_k], err, { Sentry: window.Sentry, znxLogWarn: (typeof znxLogWarn==='function'?znxLogWarn:null), parseErrorCode: window.parseErrorCode });
        }else{
          if(typeof znxLogWarn==='function') znxLogWarn('[Opção B] RPC '+OPCAOB_RPC[_k]+' falhou', err);
          if(window.Sentry) window.Sentry.captureMessage('[Opção B] RPC '+OPCAOB_RPC[_k]+' falhou', { level:'warning', extra:{ errMsg:String(err && err.message||'') } });
        }
        // [opcao-b-rpc-orphan] RPC órfã/sem-grant: o purge de boot JÁ apagou o local desta key →
        // sem fallback o vendedor fica com a tela EM BRANCO. Restaura o snapshot pré-purge (dados do
        // PRÓPRIO vendedor; zero vazamento cross-seller) → applyRows re-semeia. Em ciclos pós-boot o
        // ts-guard do applyRows descarta o snapshot stale se o local já estiver mais novo.
        try{
          const _snap = _opcaoB_bootBackupRef.current && _opcaoB_bootBackupRef.current[_k];
          const _fb = (window.ZNXSyncHelpers && window.ZNXSyncHelpers.opcaoBFallbackRow)
            ? window.ZNXSyncHelpers.opcaoBFallbackRow(_k, _snap, new Date().toISOString()) : null;
          if(_fb) out.push(_fb); // não injeta row vazia: opcaoBFallbackRow devolve null se não havia snapshot
        }catch(_){}
      }
    }
    return out;
  }

  async function fetchStoreDelta(keysFilter, excludeFilter, _authGated){
    if(!_opcaoB_userRef.current)return []; // A-012: tela de login não tem dados para sincronizar.
    if(!_authGated && window.ZNX && window.ZNX.api && typeof window.ZNX.api.withFreshAuth==='function'){
      return (await window.ZNX.api.withFreshAuth(function(){return fetchStoreDelta(keysFilter,excludeFilter,true);}))||[];
    }
    // [FIX jwt-lifecycle 2026-07-02 · Sentry 89-delta 44ev] Refresh proativo ANTES de tocar `store`.
    // O path blob (admin/financeiro) nao passava por NENHUM ensureFreshAuth (so o scoped via
    // _opcaoB_fetchRpcRows, e DEPOIS do select) → aba em background acordava com JWT vencido e o
    // PostgREST devolvia PGRST301/401. Mesmo wrapper dos writes (rpc.js L223/327/448): fail-open
    // se indisponivel (offline) — a query segue e o erro real aparece no catch.
    try{ if(window.ZNX && window.ZNX.api && typeof window.ZNX.api.ensureFreshAuth==='function'){ await window.ZNX.api.ensureFreshAuth(); } }catch(_){}
    const _scoped = _opcaoB_isScoped();
    // [Opção B] não-admin: o blob NUNCA serve as keys financeiras nem as 3 que vêm de RPC (em TODOS os paths: boot/poll/focus/realtime).
    let _exclArr = (Array.isArray(excludeFilter) && excludeFilter.length) ? excludeFilter.slice() : [];
    if(_scoped) _exclArr = _exclArr.concat(OPCAOB_NOLOAD, Object.keys(OPCAOB_RPC));
    const _excl = _exclArr.length ? '(' + _exclArr.map(k=>`"${k}"`).join(',') + ')' : null;
    try{
      let head = window.sb.from('store').select('key,updated_at');
      if(Array.isArray(keysFilter)) head = head.in('key', keysFilter);
      if(_excl) head = head.not('key','in',_excl);
      const{data:heads,error:e1} = await head;
      if(e1 || !Array.isArray(heads)) throw (e1||new Error('heads vazio'));
      const stale = heads.filter(h => (window.__znxStoreVer[h.key]||'') !== h.updated_at).map(h=>h.key);
      // [Opção B] mesmo se o blob não mudou, o não-admin ainda busca os RPCs (a forma RLS-scoped).
      if(stale.length===0) return _scoped ? await _opcaoB_fetchRpcRows(keysFilter) : []; // nada mudou — ciclo de ~1KB
      // [FIX timeout-delta-fetch 2026-07-08 · Sentry 82 275ev] O delta tambem era UMA query
      // (SELECT ... WHERE key IN (stale)) sem lote: no path head-OK (JWT fresco, primeiro boot /
      // offline longo / __znxStoreVer limpo) `stale` traz TODAS as keys -> detoast de ~48MB numa
      // statement so -> "canceling statement due to statement timeout" (57014) em TODO ciclo antes
      // de cair no fallback (auto-DoS). O #115 so paginou o fallback; este ramo (head-OK) escapava.
      // Busca `stale` em lotes de _PAGE keys: cada query fica abaixo do statement_timeout; resultado
      // = mesmas rows do IN unico (chunks disjuntos cobrem stale). Erro re-throw -> fallback (igual).
      const _PAGE_D = 5;
      const _blob = [];
      for(let _i=0; _i<stale.length; _i+=_PAGE_D){
        const _chunk = stale.slice(_i, _i+_PAGE_D);
        const{data:rows,error:e2} = await window.sb.from('store').select('key,data,updated_at').in('key', _chunk);
        if(e2) throw e2;
        for(const _r of (rows||[])) _blob.push(_r);
      }
      return _scoped ? _blob.concat(await _opcaoB_fetchRpcRows(keysFilter)) : _blob;
    }catch(err){
      // [v224.158] silentToast — transição delta→full é interna/auto-recuperável (re-tenta no próximo ciclo);
      // toast técnico assustava equipe ("canceling statement due to statement timeout"). Console+Sentry mantidos.
      if(typeof znxLogWarn==='function') znxLogWarn('[v224.157 delta-fetch] fallback full:', err && err.message, null, {silentToast:true});
      // [FIX jwt-lifecycle] token pode ter vencido MID-FLIGHT (head ok, delta 401): re-garante antes
      // do full. No-op se o token esta fresco (gate de margem do ensureFreshAuth) — zero storm.
      try{ if(window.ZNX && window.ZNX.api && typeof window.ZNX.api.ensureFreshAuth==='function'){ await window.ZNX.api.ensureFreshAuth(); } }catch(_){}
      // [FIX delta-fallback-timeout 2026-07-02 · Sentry 273ev] O full era UMA query sem limite
      // (SELECT key,data,updated_at FROM store) → detoast de TODOS os blobs (~45MB) numa statement
      // só → "canceling statement due to statement timeout" em todo ciclo de polling (auto-DoS).
      // Pagina por `key` (PK → ordem estável, sem dup/skip entre páginas) em lotes pequenos: cada
      // query fica abaixo do timeout. keysFilter/_excl re-aplicados em CADA página; resultado
      // concatenado = mesmas rows do full não-paginado (zero perda de products/quotes/sales).
      const _PAGE = 5;
      const _blobFull = [];
      for(let _off = 0; ; _off += _PAGE){
        let full = window.sb.from('store').select('key,data,updated_at');
        if(Array.isArray(keysFilter)) full = full.in('key', keysFilter);
        if(_excl) full = full.not('key','in',_excl);
        full = full.order('key').range(_off, _off + _PAGE - 1);
        let rows, e3;
        // [FIX delta-fetch-degradado 2026-07-03 · Sentry 87 2ev] Body HTML/truncado (gateway 502/522)
        // faz o supabase-js REJEITAR com SyntaxError ("Unexpected token '<'...") — nao vem como {error:e3}.
        // Sem este catch o throw escapava do loop, rejeitava fetchStoreDelta inteira e descartava as
        // paginas ja acumuladas em _blobFull. Mesma semantica do break de e3: parcial retry-safe.
        try{
          ({data:rows,error:e3} = await full);
        }catch(eThrow){
          if(typeof znxLogWarn==='function') znxLogWarn('[v224.157 delta-fetch] full pagina com body nao-JSON (gateway?)', eThrow, null, {silentToast:true});
          break;
        }
        // [FIX jwt-lifecycle] e3 era DESCARTADO (const{data:rows}=await full) → [] silencioso mascarava
        // o culprit real (JWT expired). falha_silenciosa_proibida: loga; devolve o que já baixou
        // (rows completas → applyRows só registra versão de key efetivamente baixada).
        if(e3){
          if(typeof znxLogWarn==='function') znxLogWarn('[v224.157 delta-fetch] full tambem falhou', e3, null, {silentToast:true});
          break;
        }
        const _page = rows||[];
        for(const _r of _page) _blobFull.push(_r);
        if(_page.length < _PAGE) break;
      }
      return _scoped ? _blobFull.concat(await _opcaoB_fetchRpcRows(keysFilter)) : _blobFull;
    }
  }

  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=>{
      // [v224.157] registra a versão de TODA row processada (aplicada OU skipped pelos guards:
      // skip = "já tenho igual/mais novo" → não precisa rebaixar; mudança real vem com updated_at novo)
      window.__znxStoreVer[r.key]=r.updated_at||'';
      // 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]);
      });
    }
    // [v224.161 boot-delta] Persiste o version map pra o PRÓXIMO boot fazer delta (não full ~45MB)
    try{ localStorage.setItem('znx_storeVer', JSON.stringify(window.__znxStoreVer||{})); }catch(_){}
    // 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();
    // [realtime-channel-leak] Fallback de polling quando o circuit-breaker da lib degrada
    // (onDegraded) · parado ao reconectar (onConnect em SUBSCRIBED) e no cleanup do effect.
    var pollTimer = null;
    function stopPolling(){ if(pollTimer){ clearInterval(pollTimer); pollTimer = null; } }
    function startPolling(){ if(pollTimer) return; pollTimer = setInterval(async function(){ if(window.znxPollGate&&!(await window.znxPollGate()))return; fetchCount(); }, 60000); } // [v224.245 E3]
    // [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(); }, { onDegraded: startPolling, onConnect: stopPolling })
      : (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(){
      stopPolling();
      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;
    // [P98 v224.253] fonte única: js/lib/dias-uteis.js (era cópia byte a byte de VendasDespacho.jsx)
    function getWindowStartISO(){ return window.ZNX.diasUteis.janelaDespachoInicioISO(3); }
    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
      fetchStoreDelta(null).then(function(rows){
        if(!rows.length) return; // [v224.157] nada mudou — ciclo de ~1KB, sem apply nem breadcrumb falso
        applyRows(rows, false); // false = merge (não isInit replace)
        if(window.Sentry) window.Sentry.addBreadcrumb({
          category: 'polling-defesa-em-depth',
          level: 'info',
          message: 'v224.157 5min delta-fetch OK · ' + rows.length + ' keys'
        });
      }).catch(function(e){
        znxLogWarn('[ZNX v224.25 polling-defesa] re-fetch falhou', e);
      });
    }, REFETCH_INTERVAL_MS);
    return ()=>{ clearInterval(intervalId); };
  },[appReady]);

  // [v224.139 FRENTE B 20260606] Polling ADAPTATIVO 90s · só dispara refetch quando RT gap > 90s
  // ROOT CAUSE: 5min polling defesa-em-depth (v224.25) + 5min heartbeat banner (Wave 18 v223.45.1)
  // são TARDE DEMAIS pro ciclo vendedora (Tarek 06/06 08:33-39 criou 3× ORC R$149,99 em 6min).
  // Esta wave: aceita threshold de 90s sem evento RT → refetch SILENT focado em quotes+sales
  // (não tudo · não banner). Reusa applyRows existente (smart merge GUARD [A-03] + skip eco).
  // Blob store.key in ('quotes','sales') TEM items (cravado pre-flight) → zero strip de items.
  // Custo: 0 quando RT vivo (gap<90s · skip) · ~2 queries quando RT caído.
  // Disclosure honest: ainda não cobre offline 100% nem iOS PWA preso (waves futuros).
  useEffect(()=>{
    if(!appReady)return;
    const sb = window.sb;
    if(!sb || typeof sb.from !== 'function')return;
    const rt = window.ZNX && window.ZNX.lib && window.ZNX.lib.realtime;
    if(!rt || typeof rt.getLastEventAt !== 'function')return;
    const ADAPTIVE_CHECK_MS = 30 * 1000;     // checa a cada 30s
    const RT_GAP_THRESHOLD_MS = 90 * 1000;    // dispara refetch se gap > 90s
    let lastRefetchAt = 0;
    const COOLDOWN_MS = 60 * 1000;            // entre 2 refetch · evita rajada
    const intervalId = setInterval(function(){
      try {
        if(typeof navigator !== 'undefined' && navigator.onLine === false) return;
        if(typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
        const gap = Date.now() - rt.getLastEventAt();
        if(gap < RT_GAP_THRESHOLD_MS) return; // RT vivo · zero carga
        if(Date.now() - lastRefetchAt < COOLDOWN_MS) return; // cooldown anti-rajada
        lastRefetchAt = Date.now();
        // RT caído >90s · refetch FOCADO em quotes+sales (não tudo)
        fetchStoreDelta(['quotes','sales']).then(function(rows){
          if(!rows.length) return; // [v224.157] nada mudou
          applyRows(rows, false); // smart merge existente (false = merge, não isInit)
          if(window.Sentry) window.Sentry.addBreadcrumb({
            category: 'polling-adaptativo-v224.139',
            level: 'info',
            message: 'RT gap '+Math.round(gap/1000)+'s · delta quotes+sales OK',
            data: { keys: rows.length, gap_s: Math.round(gap/1000) }
          });
        }).catch(function(e){
          if(typeof znxLogWarn === 'function') znxLogWarn('[v224.139 polling-adaptativo] falhou', e);
          if(window.Sentry) window.Sentry.captureMessage('[v224.139] polling-adaptativo falhou', { level: 'warning', extra: { errMsg: String(e && e.message || '') } });
        });
      } catch(err) {
        if(window.Sentry) window.Sentry.captureException(err, { tags: { znx_v224139_polling_error: 'true' } });
      }
    }, ADAPTIVE_CHECK_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);
  // [P43 2026-09-02] Banner "decantes travados" — espelha zaynexpro_config.decant_freeze (flag do balanço
  // de estoque; o bloqueio REAL é o trigger trg_guard_decant_freeze em sale_items). Lê no boot e a cada
  // 5 min; flag false → banner some sozinho, sem deploy. Falha de leitura = sem banner (nunca trava tela).
  // Lê pela RPC znx_decant_freeze_v1 (SECDEF): zaynexpro_config tem FORCE RLS sem policy → select direto = 0 linhas
  // pra qualquer role (auditor 02/09), e a tabela tem pix_key/group_id que não precisam ir pro front.
  const [decantFreeze, setDecantFreeze] = useState(false);
  useEffect(()=>{
    if(!currentUser){setDecantFreeze(false);return;}
    if(!window.sb) return;
    let alive=true;
    async function readFlag(){
      try{
        const api=window.ZNX&&window.ZNX.api;
        const read=()=>window.sb.rpc('znx_decant_freeze_v1');
        const result=await (api&&typeof api.withFreshAuth==='function'?api.withFreshAuth(read):read());
        if(!alive||!result||result.error) return;
        setDecantFreeze(result.data===true);
      }catch(_e){ /* sem banner; o trigger do banco continua barrando */ }
    }
    readFlag();
    const t=setInterval(readFlag, 5*60*1000);
    return ()=>{ alive=false; clearInterval(t); };
  },[currentUser?.id]);
  // A-012: contador sincroniza ao login; resposta atrasada não altera uma sessão encerrada.
  useEffect(()=>{
    if(!appReady||!currentUser||!window.sb)return;
    let alive=true;
    (async()=>{
      try{
        const api=window.ZNX&&window.ZNX.api;
        const read=()=>window.sb.from('store').select('data').eq('key','nextOrderNum').single();
        const result=await (api&&typeof api.withFreshAuth==='function'?api.withFreshAuth(read):read());
        const row=result&&result.data;
        if(!alive||!row||row.data==null)return;
        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);}
    })();
    return ()=>{alive=false;};
  },[appReady,currentUser?.id]);
  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'];

    // [Opção B Fase 1] purge boot: limpa as 5 keys own-scoped (vazadas no localStorage) antes do escopo valer.
    // Deriva de OPCAOB_RPC → cobre sales/quotes/clients/discountRequests/metas (fica em sync se a lista mudar).
    // Safe no boot: nenhuma venda otimista pendente ainda (a UI não carregou).
    // Sem isso, o ts-guard do applyRows (L263)/GUARD A-03 bloqueariam o RPC row quando o blob local é mais novo.
    if(_opcaoB_isScoped()){
      _opcaoB_bootBackupRef.current = {};
      Object.keys(OPCAOB_RPC).forEach(function(k){
        try{
          // [opcao-b-rpc-orphan] guarda o snapshot ANTES de apagar → restaurável se a RPC znx_my_* falhar.
          const _raw = localStorage.getItem(toLocalKey(k));
          if(_raw != null) _opcaoB_bootBackupRef.current[k] = _raw;
          localStorage.removeItem(toLocalKey(k));
        }catch(_){}
      });
    }

    // [v224.161 boot-delta] Carga inicial ESSENCIAL (sem quotes+sales) via DELTA-FETCH:
    // baixa só as keys mudadas desde a última sessão (fallback full embutido no fetchStoreDelta).
    // rows=[] quando nada mudou → estado já vem semeado do localStorage (useStore: useState(load())).
    fetchStoreDelta(null, LAZY_BOOT_KEYS).then((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(()=>{
        fetchStoreDelta(LAZY_BOOT_KEYS).then((lazyRows)=>{
          // [v224.161 boot-delta] quotes+sales via delta: baixa os ~35MB só quando realmente mudaram
          // (reload sem mudança → []→ estado já semeado do localStorage). Fallback full no fetchStoreDelta.
          if (lazyRows && lazyRows.length > 0) {
            applyRows(lazyRows, false); // false = não é init, é merge
            console.log('[ZNX F10-01] Lazy load quotes+sales OK (delta):', 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');
      }
    }).catch(()=>{clearTimeout(safetyTimer);setAppReady(true);});
    // ── Polling fallback: só roda se Realtime desconectar ──
    let pollId=null;
    function startPoll(){
      if(pollId)return;
      pollId=setInterval(async()=>{
        if(window.znxPollGate&&!(await window.znxPollGate()))return; // [v224.245 E3] aba oculta/sessao vencida: pula
        fetchStoreDelta(null).then(rows=>{if(rows.length)applyRows(rows,false);}).catch(()=>{}); // [v224.157] delta — quebra o loop de morte (13MB→~1KB/ciclo)
      },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');}
      });
    // 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'){
        fetchStoreDelta(null).then(rows=>{
          if(rows.length){applyRows(rows,false);console.log('[zaynex] refresh on focus → '+rows.length+' key(s) atualizadas');}
        }).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);document.removeEventListener('visibilitychange',refreshOnFocus);window.removeEventListener('focus',refreshOnFocus);};
  },[]);

  // A-021: app_users inicial e Realtime acompanham a identidade autenticada.
  useEffect(()=>{
    if(!currentUser||!window.__ZNX_NEW_AUTH_ENABLED__)return;
    let mounted=true,channel=null,starting=false,readSequence=0;
    async function refresh(initial){
      if(!(await _appHasCurrentSession(currentUser))||!mounted)return;
      const sequence=++readSequence;
      try{
        const{data,error}=await 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,is_system,is_commissionable').eq('active',true);
        if(!(await _appHasCurrentSession(currentUser))||!mounted||sequence!==readSequence)return;
        if(error){console.warn('[ZNX] app_users fetch falhou:',error.message);return;}
        if(Array.isArray(data)&&(!initial||data.length>0))setAppUsersFromDB(data);
      }catch(e){if(mounted)znxLogWarn('[ZNX] app_users fetch falhou',e);}
    }
    async function start(){
      if(!mounted||starting)return;
      starting=true;
      try{
        if(!(await _appHasCurrentSession(currentUser))||!mounted)return;
        if(!channel){
          channel=sb.channel('znx-app-users-changes')
            .on('postgres_changes',{event:'*',schema:'public',table:'app_users'},()=>{refresh(false);})
            .subscribe();
          refresh(true);
        }else{
          refresh(false);
        }
      }catch(e){if(mounted)znxLogWarn('[ZNX] app_users subscribe falhou',e);}
      finally{starting=false;}
    }
    start();
    window.addEventListener('online',start);
    window.addEventListener('focus',start);
    return()=>{
      mounted=false;readSequence++;
      window.removeEventListener('online',start);
      window.removeEventListener('focus',start);
      if(channel)sb.removeChannel(channel);
    };
  },[currentUser?.id,currentUser?.auth_user_id]);

  // [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)
  // A-024: presença online consulta/aplica somente com sessão da identidade atual.
  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, running=false, failureCount=0, retryTimer=null;
    const hasSession=async()=>mounted&&(await _appHasCurrentSession(currentUser))&&mounted;
    async function refresh(retryAttempt=0){
      if(!mounted||running)return;
      running=true;
      try{
        if(!(await hasSession()))return;
        const r=await getUsersOnlineStatus();
        if(!(await hasSession()))return;
        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){
        if(!(await hasSession()))return;
        failureCount++;
        dbg('CATCH:', e?.message);
        if(typeof Sentry!=='undefined') Sentry.captureException(e,{tags:{flow:'online_status'},extra:{retryAttempt,failureCount}});
      }finally{running=false;}
    }
    const recover=()=>refresh();
    window.addEventListener('online',recover);
    window.addEventListener('focus',recover);
    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);window.removeEventListener('online',recover);window.removeEventListener('focus',recover);dbg('cleanup');};
  },[currentUser?.id,currentUser?.auth_user_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).
  // [P38 S32 24/08] Flags NÃO carregam só no login: a cada fetch de app_users (boot · Realtime · foco)
  // o currentUser é re-sincronizado com a linha do banco — inclusive flag=false (caso Abes 22/07:
  // permissão retirada continuava valendo até relogar). Compara valor a valor → sem loop.
  React.useEffect(function(){
    if(!currentUser || !Array.isArray(appUsersFromDB) || appUsersFromDB.length === 0) return;
    var fresh = appUsersFromDB.find(function(u){ return u.id === currentUser.id; });
    if(!fresh) return; // desativado/ausente: não desloga daqui (auth cuida)
    var FLAGS = ['role','can_manage_notes_freteiro','can_confirm_transfer_actions','can_receive_frete','is_on_duty','is_system','is_commissionable','name'];
    var diff = {};
    FLAGS.forEach(function(k){ if(fresh[k] !== undefined && fresh[k] !== currentUser[k]) diff[k] = fresh[k]; });
    if(Object.keys(diff).length === 0) return;
    var merged = Object.assign({}, currentUser, diff);
    setCurrentUser(merged);
    try{ localStorage.setItem('znx_user', JSON.stringify(merged)); }catch(_){}
    if(window.Sentry) window.Sentry.captureMessage('[P38] currentUser re-sincronizado com app_users', {level:'info', extra:{userId:currentUser.id, role:currentUser.role, diff:Object.keys(diff)}});
  }, [currentUser, 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;
    let mounted=true;
    (async()=>{
      if(!(await _appHasCurrentSession(currentUser))||!mounted)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)];
      });
    })().catch(e=>znxLogWarn('[ZNX] atividade inicial falhou',e));
    return()=>{mounted=false;};
  },[currentUser?.id,currentUser?.auth_user_id,currentUser?.username]);

  // Activity: heartbeat — atualiza lastSeen a cada 2 minutos
  // A-020: preserva a frequência e o caminho de presença; exige sessão da identidade atual.
  React.useEffect(()=>{
    if(!currentUser)return;
    let mounted=true;
    const updatePresence=async()=>{
      if(!(await _appHasCurrentSession(currentUser))||!mounted)return;
      // 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'){
          await window.updateMyPresence(page||null);
        }
      }catch(_e){}
    };
    const tick=()=>updatePresence().catch(e=>znxLogWarn('[ZNX] heartbeat falhou',e));
    tick();
    const hbId=setInterval(tick,120000);
    return()=>{mounted=false;clearInterval(hbId);};
  },[currentUser?.id,currentUser?.auth_user_id,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
    // [P151 2026-09-02] só no auth LEGADO: com Supabase Auth a senha mora em auth.users e o hash SHA-256
    // saiu de store.extraUsers (auditor 02/09) — sem este guard o salvar-perfil do admin morria em silêncio.
    if(!updated.password && !window.__ZNX_NEW_AUTH_ENABLED__){
      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)
    // [v224.151 AUTO-RECEIVE 20260610] -receber-transferencia: transfer_send credita destino direto
    // (status RECEBIDA na mesma transação) → página ficaria vazia pra sempre. Case do router mantido (rota morta reversível).
    // [v224.216 ENTRADA-DECANTES 20260728] +entrada-decantes · tela do Munir (depósito Alfonso fixo, sem R$)
    estoquista:['dashboard','produtos','entradas','entrada-decantes','receber-frete','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)
    // [v224.216 ENTRADA-DECANTES 20260728] +entrada-decantes (admin também lança/confere)
    admin:['dashboard','insights','relatorio','historico','regioes','produtos','marcas','entradas','entrada-decantes','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>;
      }
    }
    // [P103 GO2 v224.241] displays consomem o formato PLANO de sempre ({nome: valor});
    // so o editor (MetasAdmin/SetMetaModal) enxerga o cru aninhado via metasRaw.
    const metasFlat = window.znxMetasFlat ? window.znxMetasFlat(metas, allUsers) : (metas||{});
    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={metasFlat} metasRaw={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={metasFlat} metasRaw={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}/>;
      // [v224.216 ENTRADA-DECANTES 20260728] SÓ user como prop — a tela lê a view znx_decants_for_entry
      // direto. NÃO passar `products`: o cache global carrega avg_cost/sale_price e vazaria no DevTools.
      case'entrada-decantes':return<EntradaDecantes user={currentUser}/>;
      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 user={currentUser} 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={metasFlat} 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={metasFlat} 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={metasFlat} 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={metasFlat} metasRaw={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>
      {/* [P43 2026-09-02] Banner global do balanço de decantes (todos os roles) */}
      {decantFreeze && (
        <div style={{
          position: 'sticky', top: 0, width: '100%', background: '#7F1D1D', color: '#FEF2F2',
          padding: '8px 16px', fontSize: 13, fontWeight: 700, textAlign: 'center', zIndex: 9998,
          boxShadow: '0 2px 6px rgba(0,0,0,0.25)'
        }}>
          🔒 HOJE NÃO VENDEMOS DECANTES — balanço de estoque em andamento. Orçamentos com decante não serão convertidos até a liberação.
        </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={window.znxMetasFlat ? window.znxMetasFlat(metas, allUsers) : 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>);
})();
