01Overview

Tela de revisão de erros — rota /#/revisao-planilha

Objetivo
Permitir ao vendedor revisar, corrigir inline e/ou ignorar produtos com erros importados via planilha, antes do envio final ao catálogo
Rota / Acesso
/#/revisao-planilha (HashRouter). Acessível via Textlink no ProcessingWidget após falhas de importação
Estado inicial
Skeleton 2000ms → Content. Tab "Todos" ativa, ordem original da planilha — nenhuma coluna destacada
Protótipo funcional GitHub
Como chegar nessa tela no protótipo
Adicionar ou gerenciar produtos › Com planilha › Enviar qualquer arquivo .xls ou .xlsx › Salvar produtos › Widget de sucesso parcial → clicar em "Revisar erros" › Tela de revisão online
Happy Path Ver no Figma
  1. Usuário chega à tela via ProcessingWidget, clicando em "Revisar e corrigir erros"
  2. Skeleton carrega (~2s) e exibe a lista de produtos com falha de importação
  3. Usuário navega pelas tabs (Todos / Com erro / Ignorados) para localizar os erros
  4. Edita as células com erro diretamente na tabela (inline) — o campo fica em foco com highlight de erro
  5. Opcionalmente ignora produtos que não serão corrigidos via overflow menu
  6. Clica em "Salvar produtos" na sticky bar → produtos corrigidos são enviados ao catálogo com snackbar de confirmação
Visão geral da tela no estado inicial
Visão geral da tela no estado inicial

02Entry points

Como o usuário chega a esta tela

OrigemTriggerComponente / Handler
Sucesso parcial — planilha modelo
Fluxo: vendedor baixa "planilha com todos os produtos" e reenvia. Widget exibe 4 linhas de resultado (adicionados / editados / não adicionados / não editados). Ativado quando failedEdit > 0 ou failedAdd > 0.
Valores de referência no protótipo: failedAdd: 41, failedEdit: 27 — total 68 produtos no review screen.
Clique em Textlink "Revisar e corrigir erros" no estado de sucesso parcial do ProcessingWidget navigate('/revisao-planilha')
SidesheetPlanilha.jsx
Link via e-mail
E-mail enviado ao vendedor após importação com falhas
Clique no CTA do corpo do e-mail de notificação de erros URL direta para /#/revisao-planilha — sem parâmetros adicionais na URL
Widget Envio concluído com link Revisar e corrigir erros
Entry point 1 — widget em estado de sucesso parcial
E-mail de notificação com botão Verificar erros como entry point
Entry point 2 — botão "Verificar erros" no e-mail de notificação

O contexto de "quais produtos têm erro" vem da importação anterior — esta tela não recebe parâmetros na URL, apenas carrega os produtos com erros pendentes do estado do servidor.

03Casos de uso

Todos os fluxos e cenários — happy path, erros e edge cases

Acesso via mobile

Tela de bloqueio exibida ao acessar via dispositivo móvel
Tela de bloqueio exibida em dispositivos móveis
Feature: Acesso à tela de revisão em dispositivo móvel

  Scenario: Exibir gate de bloqueio ao acessar via mobile
    Given o usuário abriu o link do e-mail em um dispositivo móvel
    When a rota /#/revisao-planilha é carregada
    Then o conteúdo da revisão NÃO é renderizado
    And exibe tela de bloqueio com ícone de laptop, título "Disponível apenas no computador",
        descrição orientando abrir pelo computador e botão "Voltar para Seus Produtos"

  Scenario: Fechar tela mobile via X
    Given a tela de bloqueio mobile está exibida
    When o usuário toca no IconButton X (canto superior direito)
    Then navega para "Seus Produtos"

  Scenario: Botão "Voltar para Seus Produtos"
    Given a tela de bloqueio mobile está exibida
    When o usuário toca em "Voltar para Seus Produtos"
    Then navega para "Seus Produtos"

Carregamento e skeleton

Skeleton shimmer enquanto dados carregam
Estado de skeleton enquanto dados carregam
Feature: Carregamento da página

  Scenario: Exibir skeleton enquanto dados carregam
    Given o usuário navega para /#/revisao-planilha
    Then o componente Skeleton é renderizado imediatamente
    And o skeleton exibe 5 linhas de tabela com células animadas (shimmer)
    And a toolbar exibe 4 pills de tab como skeleton
    After 2000ms:
    Then o Skeleton é desmontado
    And o componente Content é montado
    And a tab "Todos" está ativa por padrão
    And a peek animation horizontal inicia (ver seção Scroll horizontal)

Navegação por tabs

Tabs com contagem dinâmica
Tabs com contagem dinâmica
Feature: Filtro de produtos por status via tabs

  Background:
    Given o Content está carregado com produtos em múltiplos status

  Scenario: Tab "Todos" exibe todos os produtos
    Given activeTab = 'todos'
    Then visibleProducts inclui todos os produtos sem filtro de status
    And o contador mostra "Todos ([total])"

  Scenario: Tab "Com erros" filtra erros ativos
    When o usuário seleciona "Com erros"
    Then visibleProducts = apenas produtos com status = 'con_errores'
    And o label da tab mostra "Com erros ([N])"
    And a página reseta para 1

  Scenario: Tab "Corrigidos" filtra produtos já corrigidos
    When o usuário seleciona "Corrigidos"
    Then visibleProducts = apenas status = 'corrigido'

  Scenario: Tab "Ignorados" filtra produtos ignorados
    When o usuário seleciona "Ignorados"
    Then visibleProducts = apenas status = 'omitido'

  Scenario: Tab com count=0 — desabilitada e reposicionada
    Given tabCount('corrigido') === 0
    Then a TagChoice "Corrigidos (0)" tem disabled=true e onChange=undefined
    And a tab fica visualmente esmaecida (não clicável)
    And tabs com count > 0 aparecem antes das com count = 0 (reordenação com spring)
    And a animação de reordenação usa motion.div layout spring: damping:40, stiffness:260, mass:0.6

  Scenario: Trocar tab reseta paginação
    Given o usuário está na página 3 da tab "Todos"
    When o usuário muda para "Com erros"
    Then page volta a 1 (useEffect: setPage(1) ao mudar activeTab)

Toolbar — scroll hide/show

Toolbar escondendo ao scroll down e reaparecendo ao scroll up
Toolbar esconde/mostra baseada em scroll
Feature: Toolbar esconde e mostra baseada em scroll

  Background:
    Given o scroll container é #main-content (app-shell__main)
    And THRESHOLD = 8px

  Scenario: Toolbar se esconde ao rolar para baixo
    Given selected.size === 0
    And scrollTop > 4px
    When (scrollTop_novo - scrollTop_anterior) > 8
    Then toolbarHidden = true
    And rx__toolbar-clip recebe classe --hidden
    And a toolbar anima: height 40px → 0, translateY(-100%), 200ms cubic-bezier(0.2,0.8,0.2,1)
    And --rx-header-top muda para "0px" (header sticky sobe junto)

  Scenario: Toolbar reaparece ao rolar para cima
    When (scrollTop_novo - scrollTop_anterior) < -8
    Then toolbarHidden = false
    And rx__toolbar-clip remove classe --hidden
    And toolbar anima de volta: height 0 → 40px, translateY(0), 200ms
    And --rx-header-top muda para "40px"

  Scenario: Toolbar sempre visível no topo absoluto
    When scrollTop < 4px
    Then toolbarHidden = false (independente de qualquer seleção ou delta)

  Scenario: Toolbar NUNCA esconde com seleção ativa
    Given selected.size > 0
    When qualquer scroll ocorre
    Then o handler de scroll não chama setToolbarHidden(true)
    # selectedSizeRef.current é consultado no handler (evita stale closure)
    And toolbarHidden é forçado a false no useEffect de selected.size

Toolbar — swap ao selecionar linhas

Swap da toolbar ao selecionar linhas
Swap da toolbar ao selecionar linhas
Feature: Troca de contexto na toolbar ao selecionar produtos

  Background:
    Given selected.size === 0 (estado inicial)
    And Linha 1 (filtros) está visível: opacity:1, translateY(0)
    And Linha 2 (ações) está oculta: opacity:0, translateY(10px), pointer-events:none

  Scenario: Selecionar primeira linha — toolbar entra em modo bulk actions
    When o usuário seleciona qualquer produto (checkbox)
    Then rx__toolbar-row--filters recebe classe --exit
      # Efeito: opacity:0, transform:translateY(10px), pointer-events:none, 220ms ease
    And rx__toolbar-row--actions recebe classe --active
      # Efeito: opacity:1, transform:translateY(0), pointer-events:auto, 220ms ease
    And toolbarHidden é forçado a false (toolbar sempre visível com seleção)
    And a toolbar mostra:
      | Botão "Ignorar produtos" (ou "Não ignorar produtos")  |
      | Tabs (Todos/Com erros/Corrigidos/Ignorados) — ainda visíveis  |
      | Separador spacer  |
      | "[N] selecionados de [Total]"  |
      | Botão "Selecionar os [Total] produtos"  |
      | IconButton X (limpar seleção)  |

  Scenario: Desmarcar todos os produtos — toolbar volta ao modo filtros
    When selected.size volta a 0
    Then rx__toolbar-row--filters perde classe --exit → volta a opacity:1, translateY(0)
    And rx__toolbar-row--actions perde classe --active → volta a opacity:0, translateY(10px)

  Scenario: Contador de seleção
    Given selected = {id1, id2, id3}
    Then mostra "3 selecionados de [total_de_produtos]"
    # total_de_produtos = produtos.length (todos, não filtrado por tab)

  Scenario: Botão "Selecionar os N produtos" — cross-page e cross-tab
    When o usuário clica em "Selecionar os [N] produtos"
    Then handleSelectAll() → selected = new Set com IDs de TODOS os produtos
    # Seleciona todos, independente da tab ativa ou página atual

  Scenario: Toggle de seleção via checkbox do header
    Given allSelected = allVisibleIds.every(id => selected.has(id))
    When o usuário clica no Checkbox do header
    If allSelected = true → remove todos os IDs de allVisibleIds do selected
    If allSelected = false → adiciona todos os IDs de allVisibleIds ao selected
    # allVisibleIds = visibleProducts (tab ativa, todas as páginas)

  Scenario: Fechar seleção com X
    When o usuário clica no IconButton X
    Then handleClearSelection() → selected = new Set() (vazio)

Header sticky e scroll horizontal

Sticky header + scroll horizontal com colunas fixas
Sticky header + scroll horizontal com colunas fixas
Feature: Header de colunas fixo e scroll horizontal sincronizado

  Scenario: Peek animation ao montar (indicação de scroll horizontal)
    Given o componente Content acabou de montar
    And prefers-reduced-motion = false
    Then após 320ms: tableWrapperRef.scrollTo({left:160, behavior:'smooth'})
    And após mais 760ms: tableWrapperRef.scrollTo({left:0, behavior:'smooth'})
    # Indica ao usuário que há mais colunas à direita
    If prefers-reduced-motion = true → peek animation é pulada

  Scenario: Header sticky segue o scroll vertical
    Given rp__header-sticky: position:sticky, top:var(--rx-header-top)
    And --rx-header-top = "40px" (toolbar visível) ou "0px" (toolbar oculta)
    When o usuário rola a página para baixo
    Then o header de colunas permanece fixo no topo
    And a transição de top usa 200ms cubic-bezier(0.2,0.8,0.2,1)

  Scenario: Header sincroniza scroll horizontal com o corpo
    When o usuário faz scroll horizontal em rp__table-wrapper
    Then headerScrollRef.scrollLeft = tableWrapperRef.scrollLeft
    # Header é overflow:hidden, sincronizado por JS — não scroll nativo

  Scenario: Fades laterais indicam overflow horizontal
    When tableWrapperRef.scrollLeft > 4
    Then canScrollLeft = true → rp__scroll-fade--left recebe is-visible (opacity:1)
    And rp__sticky-shadow recebe is-visible (sombra na borda direita das colunas fixas)
    When scrollLeft + clientWidth < scrollWidth - 4
    Then canScrollRight = true → rp__scroll-fade--right recebe is-visible

  Scenario: Colunas fixas (sticky) nas primeiras 3 colunas
    Then Checkbox: position:sticky, left:0, z-index:2 (header: z-index:3)
    And Status: position:sticky, left:44px, z-index:2 (header: z-index:3)
    And Nome: position:sticky, left:164px, z-index:2 (header: z-index:3)
    # left do Nome = 44px (checkbox) + 120px (status) = 164px
    And Sticky shadow position: left:405px (44+120+240+1px border)

Ordenação de colunas

Header de coluna com indicador de ordenação ativo
Indicador de ordenação no header de coluna
Feature: Ordenação de colunas sortáveis

  Background:
    Given sortKey=null, sortDir=null (estado inicial — ordem original da planilha)
    # Nenhuma coluna fica destacada ao carregar. Sort só ocorre por ação explícita do usuário.

  Scenario: Ciclo de ordenação — 3 estados
    When o usuário clica em qualquer coluna sortável (1ª vez)
    Then sortKey=, sortDir='asc' — header mostra ArrowUp icon
    When clica novamente (2ª vez)
    Then sortKey=, sortDir='desc' — header mostra ArrowDown icon
    When clica uma 3ª vez (reset)
    Then sortKey=null — volta à ordem original da planilha, header mostra Sort icon (sem destaque)

  Scenario: Trocar de coluna reseta direção
    Given sortKey='nome', sortDir='desc'
    When o usuário clica em "Preço de venda"
    Then sortKey='preco', sortDir='asc'

  Scenario: Colunas sortáveis
    Then Estado, Nome do produto, Preço de venda, Custo, Estoque, Categorias são sortáveis
    And as demais colunas (SKU, EAN, NCM, CEST, Tipo origem, Origem, CSOSN) não têm sort

  Scenario: Ordenar por Estado
    Given sortKey='estado'
    Then usa STATUS_ORDER: {con_errores:0, corrigido:1, omitido:2}

  Scenario: Tiebreaker por prioridade de campo
    Given dois produtos com mesmo status
    Then o com erro de maior prioridade aparece primeiro
    And a prioridade de campos: nome(0) > preco(1) > custo(2) > estoque(3) > alertaMin(4) > categoria(5) > sku(6) > ean(7) > ncm(8) > cest(9) > tipoOrigem(10) > origem(11) > csosn(12)
    And getErroPriority retorna o menor código de prioridade dos erros do produto

  Scenario: pendingReorder afeta ordenação durante freeze
    Given produto foi corrigido e pendingReorder[id] = {status:'con_errores'}
    Then durante os 450ms de freeze, o sorting usa o status "congelado" (con_errores)
    And após os 450ms, o sorting usa o status real (corrigido) → linha se move

CellInput — edição inline de texto/número

CellInput — edição inline
CellInput — edição inline de texto/número
Feature: CellInput — componente de edição inline

  Background:
    Given uma célula editável na tabela (Nome, Preço, Custo, Estoque, SKU, EAN, NCM)

  Scenario: Estado idle (modo display)
    Then a célula exibe o valor atual como texto
    And um ícone Edit fica oculto (opacity:0) na célula
    And a célula tem role="button" e tabIndex=0

  Scenario: Hover na linha revela ícone de edição
    When o cursor passa sobre qualquer parte da linha (rx__row:hover)
    Then .cell-input__icon muda para opacity:1 (220ms CSS transition)
    And a borda da célula fica visível (andes-color-interactive-border-hover)
    And cursor: pointer

  Scenario: Ativar modo de edição
    When o usuário clica na célula (ou pressiona Enter no foco)
    Then setEditing(true)
    And um  nativo é renderizado com autoFocus
    And o valor atual é pré-preenchido no input
    And a célula recebe classe cell-input--editing

  Scenario: Salvar ao perder foco
    Given célula em modo editing
    When o usuário clica fora (blur) ou pressiona Enter
    Then onSave(inputVal) é chamado
    And setEditing(false)
    And savedVal é atualizado após 400ms (setTimeout) para evitar flicker
    # 400ms permite que a animação de reordenação aconteça antes de atualizar o display

  Scenario: Cancelar edição com Escape
    When o usuário pressiona Escape
    Then handleCancel() — inputVal volta para savedVal
    And setEditing(false)
    And nenhum onSave é chamado

  Scenario: Fechar com botão X dentro do input
    When o usuário clica no botão X dentro da célula editando
    Then handleCancel() via onMouseDown (antes do blur)
    # onMouseDown evita que blur dispare primeiro

  Scenario: Prefix e suffix (Preço e Custo)
    Then em modo idle: exibe "R$ 150,00 /un." como texto formatado
    And em modo editing: input fica entre prefix "R$" e suffix "/un."
    And formatValue=formatMoney é aplicado em tempo real no onChange

  Scenario: CellInput vazio mostra placeholder "Indefinido"
    Given produto.nome = '' ou prices[id] = ''
    Then a célula exibe "Indefinido" em cor secondary
    And renderDisplay customizado substitui o display padrão

  Scenario: modifier="caution" (erro não-bloqueante)
    Given o campo tem erro não-bloqueante
    Then CellInput recebe modifier="caution"
    And um BadgeIcon amarelo aparece inline à esquerda do valor
    And o comportamento de edição permanece o mesmo

SelectCell — dropdown com busca

SelectCell — dropdown com busca
SelectCell — dropdown com SearchBox e lista de opções
Feature: SelectCell — células com dropdown de opções

  Background:
    Given campos: Categoria, CEST, Tipo de origem, Origem, CSOSN

  Scenario: Estado idle (linha não hovered)
    Then .rp__select-idle está visível: exibe texto atual truncado com ellipsis
    And .rp__select-dropdown está display:none

  Scenario: Hover na linha revela dropdown component
    When o cursor passa sobre a linha (rx__row:hover)
    Then .rp__select-idle fica display:none
    And .rp__select-dropdown fica display:flex (sem abrir o menu ainda)
    And o DropdownStandalone fica visível mas menu fechado

  Scenario: Abrir menu de opções
    When o usuário clica no SelectCell
    Then open = true
    And DropdownStandalone abre com MenuSingleSelection width=240px
    And SearchBox aparece: placeholder "Busque por seção" (padrão) ou equivalente
    And as opções do campo aparecem filtradas em tempo real ao digitar

  Scenario: Selecionar opção
    When o usuário clica em uma opção do menu
    Then onChange(valor) é chamado
    And open = false
    And o valor selecionado é refletido no idle state

  Scenario: Fechar menu sem selecionar
    When o usuário clica fora do dropdown ou pressiona Escape
    Then open = false
    And o valor anterior é mantido

  Scenario: Campo com erro e dropdown
    Given hasError = true (ex: FISCAL_DATA_CSOSN_INVALID)
    And isBlocking = false (CSOSN não é campo bloqueante)
    Then rp__error-cell não tem --active (sem borda vermelha)
    And CellTooltip aparece no hover com a mensagem do erro

  Scenario: Opções de cada campo
    Categorias: Eletrônicos, Periféricos, Armazenamento, Áudio, Acessórios, Cabos, Informática (7 opções)
    CEST: 21.042.00, 21.052.00, 21.062.00, 21.072.00, 21.092.00 (5 opções)
    Tipo de origem: Produto nacional, Produto importado, + 4 variantes (6 opções)
    Origem: 0—Nacional até 8—Nacional processos específicos (9 opções)
    CSOSN: 101 até 900 (10 opções)

Células com erro — visual e tooltip

CellTooltip de erro sobre célula com borda vermelha
Tooltip de erro sobre célula com borda vermelha
Feature: Feedback visual de erros nas células

  Background:
    Given todos os erros têm a MESMA visualização: borda vermelha (#e8002a) + fundo #fff5f7
    And todos os erros bloqueiam o envio (handleSalvar retorna antes de submeter)

  Scenario: Erro em qualquer campo — visual padrão (vermelho)
    Given produto tem erro em qualquer campo (Nome, Preço, SKU, EAN, NCM, CSOSN...)
    And produto.status !== 'omitido'
    Then rp__error-cell recebe --active
    And borda da célula: #e8002a (vermelho)
    And background da célula: #fff5f7
    And no hover: CellTooltip aparece via createPortal com a mensagem do erro

  Scenario: Campo não-obrigatório — aceita envio como indefinido após apagar
    Given produto tem erro em campo não-obrigatório (ex: SKU, EAN, NCM, CSOSN...)
    And produto.status !== 'omitido'
    When o usuário apaga o valor da célula (deixa vazia)
    Then o campo é enviado como indefinido (sem valor)
    And o erro deixa de bloquear o envio
    And o CellTooltip indica esta opção ("Corrija ou apague para continuar")

  Scenario: Campo obrigatório — deve ser corrigido (Nome ou Preço)
    Given produto tem erro de Nome ou Preço
    Then o campo NÃO pode ser apagado para envio como indefinido
    And o CellTooltip indica que o campo é obrigatório
    And o produto bloqueia o envio independente de qualquer outra ação

  Scenario: Produto tem múltiplos erros num mesmo campo
    Given produto.erros = ['PRODUCT_TITLE_IS_REQUIRED', 'PRODUCT_TITLE_DUPLICATED']
    Then errorTooltip('nome') = "O nome do produto é obrigatório. Preencha o campo nome. · Já existe outro produto com este nome. Use um nome único."
    # Mensagens concatenadas com " · "

  Scenario: Produto omitido — sem erros visuais
    Given produto.status === 'omitido'
    Then hasErro(campo) retorna false para todos os campos
    And nenhuma borda vermelha ou indicação de erro
    And rx__row--ignorado aplicada (linha esmaecida)
    And CellTooltip nunca aparece

  Scenario: CellTooltip — posição e renderização
    When o usuário passa o cursor sobre uma célula com erro
    Then CellTooltip é renderizado via createPortal em document.body
    And posição: fixed, top=rect.top-8, left=rect.left+rect.width/2
    And transform: translate(-50%, -100%) — centralizado acima da célula
    And z-index: 99999
    And estilo: background #1a1a1a, cor #fff, padding 6px 10px, border-radius 6px, max-width 260px
    And seta apontando para baixo: border-top-color #1a1a1a
    And pointerEvents: none (não bloqueia interação)

  Scenario: CellTooltip em tablet (sem hover)
    Given o dispositivo é touch-only (sem ponteiro de hover)
    When o usuário toca em uma célula com erro
    Then o CellTooltip aparece imediatamente
    When o usuário começa a digitar no campo
    Then o CellTooltip desaparece no primeiro evento de input

Correção de produto — animação de reordenação

Animação de correção e reordenação de linha com flash verde
Animação de correção e reordenação de linha
Feature: Animação ao corrigir um produto com erro

  Background:
    Given produto tem status = 'con_errores' com erros ativos

  Scenario: Correção parcial — produto ainda tem outros erros
    When o usuário corrige um campo mas o produto ainda tem outros erros
    Then status permanece 'con_errores'
    And flashFixed NÃO é chamado
    And a célula corrigida perde o estilo de erro (isBlocking/caution removido)
    And a linha permanece na posição atual sem reordenação

  # ── Sem ordenação explícita (sortKey = null) ───────────────────

  Scenario: Correção completa — sem ordenação explícita ativa
    Given sortKey = null (ordenação padrão por status + prioridade de erro)
    When todos os erros do produto são resolvidos
    Then status muda para 'corrigido' imediatamente (BadgePill positive)
    And flash verde: newlyFixed.add(id) → rx__row--just-linked aplicado (fadeIn 300ms)
    And a linha NÃO se move — permanece na posição atual
    And rx__row--just-linked removido após 1200ms

  # ── Com ordenação explícita ativa (sortKey ≠ null) ─────────────

  Scenario: Correção completa — ordenação por "Estado" ativa (sortKey = 'estado')
    Given sortKey = 'estado'
    When todos os erros do produto são resolvidos
    Then FASE 1 — Freeze (0ms → 450ms):
      pendingReorder[id] = { status: 'con_errores', erros: oldErros }
      errorIds remove id (highlight de erro desaparece)
      linha PERMANECE na posição atual (sorting usa pendingReorder)
      produto no estado real já está status='corrigido'
    And FASE 2 — Reordenação (450ms):
      pendingReorder.delete(id)
      linha ANIMA para o grupo "Corrigidos" com spring: damping:38, stiffness:160, mass:0.7
      newlyFixed.add(id) → rx__row--just-linked aplicado
      BadgePill muda para "Corrigido" (positive)
    And FASE 3 — Fim do flash (450ms + 1200ms = 1650ms total):
      newlyFixed.delete(id)
      rx__row--just-linked removido

  Scenario: Correção completa — ordenação por coluna de valor ativa (Nome, Preço, Custo, Estoque, Categorias)
    Given sortKey = 'nome' | 'preco' | 'custo' | 'estoque' | 'categorias'
    When todos os erros do produto são resolvidos
    Then mesmas Fases 1, 2 e 3 do cenário anterior
    And na Fase 2 a linha anima para a posição correta segundo o valor da coluna ordenada

  # ── Validações locais de campo ─────────────────────────────────

  Scenario: Validação de Nome — campo vazio
    When o usuário salva Nome com valor vazio após trim
    Then adiciona PRODUCT_TITLE_IS_REQUIRED aos erros
    And borda vermelha na célula de Nome

  Scenario: Validação de Nome — excede 80 caracteres
    When o texto após trim.length > 80
    Then adiciona PRODUCT_TITLE_EXCEEDS_MAX_LENGTH

  Scenario: Validação de Nome — nome duplicado (case-insensitive)
    When outro produto já tem o mesmo nome (trim().toLowerCase() igual)
    Then adiciona PRODUCT_TITLE_DUPLICATED

  Scenario: Correção de Preço
    When o usuário insere preço com parseMoney(val) > 0
    Then erros de CAMPO_ERROS.preco são removidos
    And se o produto não tiver mais erros, flashFixed é chamado

  Scenario: Correção de Custo
    When o usuário insere custo com parseMoney(val) >= 0
    Then erros de CAMPO_ERROS.custo são removidos

  Scenario: Correção de Categoria, CEST, Tipo de origem, Origem, CSOSN (SelectCell)
    When o usuário seleciona uma opção válida no dropdown
    Then o erro do campo correspondente é removido de produto.erros
    And se todos os erros forem resolvidos, flashFixed é chamado

Ignorar e designorar produtos

Produto ignorado na tabela
Produto ignorado na tabela
Feature: Ignorar produtos selecionados

  Background:
    Given selected.size > 0

  # ── Sem ordenação explícita (sortKey = null) ───────────────────

  Scenario: Ignorar produtos — sem ordenação explícita ativa
    Given pelo menos um produto selecionado tem status != 'omitido'
    And sortKey = null
    When o usuário clica em "Ignorar produtos"
    Then handleIgnoreSelected() muda status para 'omitido' (exceto já ignorados)
    And as células perdem todos os estilos de erro
    And o BadgePill muda para "Ignorado" (neutral)
    And rx__row--ignorado aplicada (linha esmaecida)
    And as linhas NÃO se movem — permanecem nas posições atuais

  Scenario: Não ignorar (reativar) produtos — sem ordenação explícita ativa
    Given allSelectedIgnored = true
    And sortKey = null
    When o usuário clica em "Não ignorar produtos"
    Then handleUnignoreSelected() restaura status de cada produto:
      # erros.length === 0 → status='corrigido'
      # erros.length > 0  → status='con_errores'
      # selected = new Set()
    And o BadgePill volta para "Com erros" (negative) ou "Corrigido" (positive)
    And os estilos de erro voltam se o produto ainda tiver erros
    And as linhas NÃO se movem — permanecem nas posições atuais

  # ── Com ordenação explícita ativa (sortKey ≠ null) ─────────────

  Scenario: Ignorar produtos — com ordenação explícita ativa
    Given pelo menos um produto selecionado tem status != 'omitido'
    And sortKey ≠ null
    When o usuário clica em "Ignorar produtos"
    Then handleIgnoreSelected() executa:
      # 1. Salva status anterior de cada produto em pendingReorder (450ms freeze)
      # 2. Muda status para 'omitido' em TODOS os selecionados (exceto já ignorados)
      # 3. Limpa selected = new Set()
    And durante 450ms: linhas permanecem nas posições originais (pendingReorder)
    And após 450ms: linhas animam para posição de "Ignorados" com spring animation
    And as células perdem todos os estilos de erro
    And o BadgePill muda para "Ignorado" (neutral)
    And rx__row--ignorado aplicada (linha esmaecida)

  Scenario: Não ignorar (reativar) produtos — com ordenação explícita ativa
    Given allSelectedIgnored = true
    And sortKey ≠ null
    When o usuário clica em "Não ignorar produtos"
    Then handleUnignoreSelected() executa:
      # 1. Restaura status de cada produto omitido selecionado:
      #    - erros.length === 0 → status='corrigido'
      #    - erros.length > 0  → status='con_errores'
      # 2. pendingReorder freeze 450ms (linhas permanecem na posição de ignorados)
      # 3. Após 450ms: linhas animam para posição correta
      # 4. selected = new Set()
    And o BadgePill volta para "Com erros" (negative) ou "Corrigido" (positive)
    And os estilos de erro voltam se o produto ainda tiver erros

  # ── Casos gerais ───────────────────────────────────────────────

  Scenario: Ignorar produto já ignorado (sem efeito)
    Given um produto selecionado já tem status='omitido'
    Then handleIgnoreSelected() pula esse produto (filter: status !== 'omitido')
    And o produto permanece ignorado sem mudança visual

  Scenario: Todos selecionados já estão ignorados — botão muda
    Given allSelectedIgnored = [...selected].every(id => produto.status === 'omitido')
    Then o botão mostra "Não ignorar produtos" em vez de "Ignorar produtos"

  Scenario: Produto ignorado — células em readOnly
    Given produto.status === 'omitido' (isIgnorado = true)
    Then readOnly={true} aplicado nos 7 CellInputs da linha:
      # preço, custo, estoque, alertaMin, SKU, EAN e NCM
    And ícone de lápis não aparece ao hover em nenhuma dessas células
    And nenhuma célula abre modo de edição ao clique
    # Comportamento: CellInput.jsx já suportava o prop readOnly — foi adicionado
    # isIgnorado como prop em cada CellInput restante da ProdutoRow

Produtos variante — campos read-only

Tabela com produto variante: campos Nome, Categoria e fiscais em read-only
Produto variante: campos Nome, Categoria e dados fiscais são read-only mesmo com erros ativos
Feature: Campos read-only em produtos variante

  Background:
    Given o produto tem isVariante = true

  Scenario: Campos bloqueados em produto variante
    Then os seguintes campos ficam com readOnly={true} (mesmo comportamento visual de produto ignorado):
      Nome do produto, Categoria, NCM, CEST, Tipo de origem, Origem, CSOSN
    And ícone de lápis não aparece ao hover nessas células
    And nenhuma dessas células abre modo de edição ao clique
    # Motivo: esses atributos são herdados do produto-pai e não podem ser editados na variante

  Scenario: Campos editáveis em produto variante com erros
    Given o produto tem isVariante = true e possui erros corrigíveis
    Then os seguintes campos continuam editáveis normalmente:
      Preço de venda, Custo, Estoque, Alerta est. mín., SKU, EAN/GTIN
    And exibem indicadores de erro (borda vermelha ou caution icon) quando aplicável
    And podem ser corrigidos inline normalmente

Salvar produtos — validação e envio

Zero produtos prontos — mensagem inline e linhas destacadas
Zero prontos — mensagem inline + linhas destacadas
Modal de confirmação — Salvar apenas os produtos corrigidos?
Tem prontos — modal de confirmação
Feature: Salvar — validação ao clicar

  Background:
    Given o botão "Salvar produtos" está SEMPRE habilitado
    # Não há disabled — a validação só ocorre no clique

  Scenario: Salvar BLOQUEADO — zero produtos prontos (nenhum produto sem erro e não omitido)
    Given o usuário clica em "Salvar produtos"
    And sendableProducts.length === 0
      # Todos os produtos têm erros ativos ou estão omitidos
    Then handleSalvar() NÃO navega
    And setErrorIds(new Set(ids dos produtos com erro)) — linhas afetadas recebem rx__row--error (background #fff5f7)
    And exibe mensagem de erro inline no topo da lista
    And scroll suave para o topo: duplo requestAnimationFrame + scrollTo({top:0, behavior:'smooth'})
    # Tab permanece na que o usuário estava — sem mudança automática

  Scenario: Salvar com produtos prontos — abre modal de confirmação
    Given o usuário clica em "Salvar produtos"
    And sendableProducts.length > 0
    And existe pelo menos 1 produto com status !== 'omitido' AND erros.length > 0
      # Há prontos para salvar, mas também há com erros pendentes
    Then setErrorIds(new Set(ids dos produtos com erro)) é aplicado imediatamente
      # Linhas com erro recebem rx__row--error (background #fff5f7) antes do modal renderizar
    And a mensagem de erro inline é exibida no topo da lista simultaneamente
    And o modal de confirmação abre por cima — o highlight e a mensagem ficam visíveis por baixo

  Scenario: "Cancelar" no modal
    Given o modal de confirmação está aberto
    When o usuário clica em "Cancelar"
    Then o modal é fechado
    And mesmo comportamento do cenário "zero produtos prontos":
      # setErrorIds — linhas com erro recebem rx__row--error
      # mensagem inline exibida no topo
      # scroll suave para o topo
    # Tab permanece na que o usuário estava — sem mudança automática

  Scenario: "Salvar produtos" no modal
    Given o modal de confirmação está aberto
    When o usuário clica em "Salvar produtos"
    Then navega para '/' com state: { pumoffSync: { total, ignorados, categoriasNovas } }
    And produtos com erros que ficaram sem salvar são tratados como ignorados

  Scenario: Campo não-obrigatório — apagar o valor resolve o erro
    Given produto tem erro em campo não-obrigatório (ex: SKU, EAN, CSOSN...)
    When o usuário apaga o valor da célula
    Then o campo é enviado como indefinido
    And erros.length volta a 0 para este campo
    And o produto pode ser salvo normalmente
    # O usuário precisa realizar a ação de apagar — o erro não some sozinho

  Scenario: Dispensar mensagem de validação inline
    Given a Message de validação está visível
    When o usuário clica no X da Message
    Then validationMessage = null (Message some)

  Scenario: Salvar SUCESSO — todos os erros resolvidos ou ignorados
    Given não existe produto com status != 'omitido' E erros.length > 0
    Then navigate('/', { state: { pumoffSync: { total, ignorados, categoriasNovas } } })
    And pumoffSync.total = produtos sem omitido e sem erros (sendableProducts.length)
    And pumoffSync.ignorados = omitidos.length
    And pumoffSync.categoriasNovas = categorias únicas dos sendableProducts

  Scenario: Produto com erros omitOnly
    Given produto tem apenas erros com omitOnly:true (ex: INVALID_PRODUCT_ID)
    Then handleSalvar() BLOQUEIA (erros.length > 0 é o critério)
    # O usuário deve ignorar o produto para prosseguir

  Scenario: Todos os produtos ignorados — pode salvar
    Given todos os produtos têm status='omitido'
    Then sendableProducts.length = 0
    And handleSalvar() navega com total=0, ignorados=N

Sair da tela — modal de confirmação

Modal de confirmação ao tentar sair da tela de revisão
Modal exibido ao tentar sair da tela sem salvar
Feature: Confirmação de saída da tela de revisão

  Background:
    Given o usuário está na tela /#/revisao-planilha

  Scenario: Tentar sair via breadcrumb "Seus produtos"
    When o usuário clica no link "Seus produtos" na breadcrumb
    Then o modal "Descartar produtos?" é exibido

  Scenario: Tentar sair via botão voltar do browser
    When o usuário clica no botão voltar do browser
    Then o modal "Descartar produtos?" é exibido

  Scenario: Tentar sair via qualquer link de navegação da sidebar
    When o usuário clica em qualquer item de navegação que sairia da rota atual
    Then o modal "Descartar produtos?" é exibido

  Scenario: Confirmar saída — "Sim, descartar"
    Given o modal "Descartar produtos?" está aberto
    When o usuário clica em "Sim, descartar"
    Then o modal fecha
    And navega para a tela de destino sem salvar os produtos
    And quaisquer edições feitas inline são descartadas (estado volta ao original)
    But se o ProcessingWidget ainda estiver visível (não fechado pelo usuário),
        o link "Revisar e corrigir erros" permanece acessível
    And ao retornar via widget, a tela exibe os produtos no estado original (sem as edições descartadas)

  Scenario: Cancelar saída — "Cancelar"
    Given o modal "Descartar produtos?" está aberto
    When o usuário clica em "Cancelar"
    Then o modal fecha
    And o usuário permanece na tela de revisão

Baixar planilha

Snackbar de confirmação de download
Download de planilha + snackbar de confirmação
Ícone de download virando spinner durante o fetch
Botão — ícone troca para ProgressIndicatorCircular durante loading
Feature: Download da planilha de produtos

  Scenario: Download com sucesso
    When o usuário clica em "Baixar planilha" (toolbar, linha de filtros)
    Then downloadingAll = true
    And o botão exibe ProgressIndicatorCircular size=xsmall (loading)
    And o botão fica desabilitado (onClick undefined)
    After 2000ms:
    Then triggerDownloadFile() é chamado:
      # Gera arquivo TSV com colunas: Nome | SKU | Preço de venda | Custo | Estoque | Categoria
      # Content-type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
      # Filename: planilha-{total_produtos}-produtos.xlsx
    And downloadingAll = false
    And Snackbar success: "Sua planilha foi baixada com sucesso!" (delay 5000ms)

  Scenario: Erro no download (Shift+click simula no protótipo)
    Then downloadSnackbar = 'error'
    And Snackbar negative: "Não foi possível criar a sua planilha. Tente novamente mais tarde." (5000ms)

  Scenario: Auto-dismiss do snackbar
    After 5000ms: Snackbar some automaticamente (clearDownloadSnackbar)

Paginação

Componente de paginação — Página 1 de 3 páginas
Componente de paginação
Feature: Paginação de produtos

  Background:
    Given PER_PAGE = 25

  Scenario: Pagination aparece quando há mais de 25 produtos na tab ativa
    Given visibleProducts.length > 25
    Then totalPages = Math.ceil(visibleProducts.length / 25)
    And Pagination size="small" é renderizado
    And exibe "Total de produtos: [N]" à esquerda

  Scenario: Pagination some com 25 ou menos produtos
    Given visibleProducts.length <= 25
    Then totalPages = 1
    And Pagination não é renderizado

  Scenario: safePage protege contra página inválida
    Given totalPages = 3 e page = 5 (estado inválido após filtrar)
    Then safePage = Math.min(page, totalPages) = 3
    And a última página válida é exibida

  Scenario: Trocar de tab reseta para página 1
    Given page = 4 na tab "Todos"
    When o usuário muda para "Com erros"
    Then page = 1 (useEffect: [activeTab])

04Mapa de erros

Todos os erros têm a mesma visualização (borda vermelha #e8002a + fundo #fff5f7) e bloqueiam o envio. Obrigatório = Nome e Preço — devem ser corrigidos, não aceitam envio vazio. Aceita indefinido = demais campos — usuário pode apagar o valor para enviá-lo como indefinido e desbloquear o envio (o tooltip da célula indica essa opção). omitOnly = sem campo editável, produto só pode ser ignorado.

CampoCódigo do erroLabel (tooltip)Mensagem completaTipo
NomePRODUCT_TITLE_IS_REQUIREDFalta o nome do produtoO nome do produto é obrigatório. Preencha o campo nome.Obrigatório
PRODUCT_TITLE_EXCEEDS_MAX_LENGTHNome muito longoO nome excede 80 caracteres. Reduza o texto.Obrigatório
PRODUCT_TITLE_DUPLICATEDNome duplicadoJá existe outro produto com este nome. Use um nome único.Obrigatório
PreçoPRODUCT_PRICE_IS_REQUIREDFalta o preçoO preço de venda é obrigatório. Insira um valor maior que zero.Obrigatório
PRODUCT_PRICE_IS_INVALIDPreço inválidoO preço possui formato inválido. Insira um valor numérico válido.Obrigatório
PRODUCT_PRICE_EXCEEDS_MAX_VALUEPreço acima do limiteO preço excede o valor máximo permitido pela plataforma.Obrigatório
PRODUCT_PRICE_IS_NEGATIVEPreço negativoO preço não pode ser negativo. Insira um valor maior que zero.Obrigatório
SKUPRODUCT_SKU_DUPLICATEDSKU duplicadoEste SKU já está em uso. Corrija ou apague para continuar sem ele.Aceita indefinido
PRODUCT_SKU_EXCEEDS_MAX_LENGTHSKU muito longoO SKU excede 30 caracteres. Reduza o código ou apague para continuar.Aceita indefinido
EstoquePRODUCT_QUANTITY_IS_INVALIDQuantidade inválidaA quantidade informada não é válida. Corrija ou apague para continuar sem ela.Aceita indefinido
PRODUCT_STOCK_INVALID_FOR_UNIT_MEASUREEstoque inválido para a unidadeO estoque não é válido para a unidade de medida. Corrija ou apague para continuar.Aceita indefinido
PRODUCT_QUANTITY_EXCEEDS_MAX_VALUEEstoque acima do limiteA quantidade excede o valor máximo. Corrija ou apague para continuar.Aceita indefinido
PRODUCT_QUANTITY_EXCEEDS_MAX_VALUE_KGEstoque acima do limite (kg)A quantidade em kg excede o máximo.Aceita indefinido
PRODUCT_QUANTITY_EXCEEDS_MAX_VALUE_LEstoque acima do limite (L)A quantidade em litros excede o máximo.Aceita indefinido
PRODUCT_QUANTITY_EXCEEDS_MAX_VALUE_MEstoque acima do limite (m)A quantidade em metros excede o máximo.Aceita indefinido
Alerta mínimoPRODUCT_MINIMUM_QUANTITY_IS_INVALIDQtd. mínima inválidaA quantidade mínima não é válida. Corrija ou apague para continuar.Aceita indefinido
PRODUCT_MINIMUM_QUANTITY_EXCEEDS_MAX_VALUEQtd. mínima acima do limiteA quantidade mínima excede o máximo.Aceita indefinido
PRODUCT_MINIMUM_QUANTITY_EXCEEDS_MAX_VALUE_KGQtd. mínima acima do limite (kg)A quantidade mínima em kg excede o máximo.Aceita indefinido
PRODUCT_MINIMUM_QUANTITY_EXCEEDS_MAX_VALUE_LQtd. mínima acima do limite (L)A quantidade mínima em litros excede o máximo.Aceita indefinido
PRODUCT_MINIMUM_QUANTITY_EXCEEDS_MAX_VALUE_MQtd. mínima acima do limite (m)A quantidade mínima em metros excede o máximo.Aceita indefinido
PRODUCT_MINIMUM_QUANTITY_REQUIRES_STOCKQtd. mínima requer estoqueA quantidade mínima requer que haja estoque. Corrija ou apague para continuar.Aceita indefinido
UnidadePRODUCT_MEASURE_UNIT_INVALIDUnidade inválidaA unidade de medida não é reconhecida. Use un., kg, L ou similar.Aceita indefinido
PRODUCT_UNIT_MEASURE_NOT_MODIFIABLEUnidade não modificávelA unidade de medida deste produto não pode ser alterada.Aceita indefinido
EAN / GTINPRODUCT_GTIN_INVALIDGTIN inválidoO código de barras (GTIN/EAN) não é válido. Corrija ou apague para continuar sem ele.Aceita indefinido
PRODUCT_GTIN_EXCEEDS_MAX_LENGTHGTIN muito longoO código GTIN/EAN excede o máximo de caracteres.Aceita indefinido
PRODUCT_GTIN_TOO_SHORTGTIN muito curtoO código GTIN/EAN é muito curto.Aceita indefinido
INVALID_GTINGTIN inválidoO código de barras (GTIN) é inválido. Corrija ou apague para continuar sem ele.Aceita indefinido
INVALID_GTIN_BY_DOMAIN_CATALOGGTIN não corresponde à categoriaO GTIN não corresponde à categoria. Corrija ou apague para continuar.Aceita indefinido
FISCAL_DATA_INVALID_GTIN_BY_NCMGTIN incompatível com NCMO GTIN é inválido para o NCM informado. Corrija ou apague para continuar.Aceita indefinido
CustoPRODUCT_COST_IS_INVALIDCusto inválidoO custo possui formato inválido. Corrija ou apague para continuar sem ele.Aceita indefinido
PRODUCT_COST_EXCEEDS_MAX_VALUECusto acima do limiteO custo excede o valor máximo. Corrija ou apague para continuar.Aceita indefinido
CategoriaPRODUCT_SECTION_IS_INVALIDCategoria não encontradaA categoria informada não existe. O produto será publicado sem categoria.Aceita indefinido
PRODUCT_SECTION_EXCEEDS_MAX_LENGTHNome da categoria muito longoO nome da categoria excede o número máximo de caracteres.Aceita indefinido
MAX_SELLER_SECTIONS_REACHEDLimite de categorias atingidoVocê atingiu o limite máximo de categorias.omitOnly
MAX_ITEMS_EXCEEDED_ERRORLimite de produtos na categoriaO número máximo de produtos nesta categoria foi atingido.omitOnly
NCMFISCAL_DATA_NCM_INVALIDNCM inválidoO código NCM não é válido. Corrija ou apague para continuar sem ele.Aceita indefinido
FISCAL_DATA_NCM_SIZETamanho do NCM incorretoO código NCM não tem o número correto de dígitos.Aceita indefinido
CESTFISCAL_DATA_CEST_FORMAT_INVALIDFormato do CEST inválidoO código CEST possui formato inválido. Selecione uma opção válida para corrigir.Aceita indefinido
Tipo de origemFISCAL_DATA_ORIGIN_TYPE_INVALIDTipo de origem inválidoO tipo de origem não é válido. Selecione uma opção válida para corrigir.Aceita indefinido
OrigemFISCAL_DATA_ORIGIN_DETAIL_FORMAT_INVALIDFormato de origem inválidoO detalhe de origem possui formato inválido. Selecione uma opção válida.Aceita indefinido
FISCAL_DATA_ORIGIN_DETAIL_INCOMPATIBLEOrigem incompatívelA origem é incompatível com o tipo selecionado. Selecione uma opção válida.Aceita indefinido
CSOSNFISCAL_DATA_CSOSN_INVALIDCSOSN inválidoO código CSOSN não é válido. Selecione uma opção válida para corrigir.Aceita indefinido
SistemaINVALID_PRODUCT_IDID do produto inválidoO ID do produto é inválido.omitOnly
UNAUTHORIZED_ITEM_USERProduto não encontradoO produto não foi encontrado ou não pertence a esta conta.omitOnly
PRODUCT_ALREADY_DELETEDProduto já excluídoEste produto já foi excluído e não pode ser editado.omitOnly
NO_MAPPEDErro desconhecidoEste erro não foi identificado. Revise o produto manualmente ou ignore-o.omitOnly

omitOnly: erros sem campo editável na tabela. O produto deve ser ignorado para liberar o save. Não têm célula com borda de erro na tabela.

05Componentes Andes X

Componente@andes/reactHierarchy / VarianteProps e contexto de uso
Checkbox (linha)checkbox—srLabel="Selecionar produto", checked={isSelected}, onChange={onToggleSelect}
Checkbox (header)checkbox—srLabel="Selecionar todos", checked={allSelected}, onChange={toggleAll}
TagChoice (tab)tag—selected={activeTab===tab.id}, disabled={count===0}, label com contagem dinâmica
Button — Ignorarbuttonquiet, smallicon={{left: <FeedbackNegative />}} — alterna entre "Ignorar" e "Não ignorar"
Button — Selecionar todosbuttonmute, small"Selecionar os [N] produtos" — cross-page
Button — Baixar planilhabuttonmute, mediumicon={{left: downloadingAll ? <ProgressIndicator xsmall /> : <Download />}}
Button — Salvarbuttonloud, mediumonClick={handleSalvar} — footer da página
IconButton — fechar seleçãobuttonmute, smallsrLabel="Limpar seleção", icon=<Close size="xtiny" />
BadgePill — statusbadgequiet, mediumcolor="negative" (com erro), "positive" (corrigido), "neutral" (ignorado)
BadgeIcon — snackbarbadge—color="positive" ou color="negative" dentro do Snackbar
Snackbarsnackbar—defaultVisible, delay={5000}, onClose={clearDownloadSnackbar}
ProgressIndicatorCircularprogress-indicator-circular—size="xsmall" no botão Baixar planilha durante loading
Texttypographysmallcolor="secondary" em labels, color="primary" em valores de células
Message — validaçãomessagenegative, quietdescription={validationMessage}, dismissível com onDismiss
Paginationpaginationsmallpages={totalPages}, value={safePage}, onPageChange={setPage}
DropdownStandalonedropdownsmallDentro do SelectCell, open={open}
MenuSingleSelectionmenu—width={240} — lista de opções do SelectCell
SearchBoxsearch-box—Filtro de opções dentro do SelectCell dropdown

06Tokens e layout

Larguras de coluna

ColunaLarguraSticky?left (sticky)Tipo de célula
Checkbox44pxSim0Checkbox Andes
Estado120pxSim44pxBadgePill negative/positive/neutral
Nome do produto *240pxSim164pxCellInput (text, max 80 chars)
Preço de venda *192pxNão—CellInput (decimal, prefix R$, suffix /un.)
Custo192pxNão—CellInput (decimal, prefix R$, suffix /un.)
Estoque140pxNão—CellInput (number, min=0, suffix un.)
Alerta est. mínimo144pxNão—CellInput (number, min=0, suffix un.)
Categorias152pxNão—SelectCell (7 opções + busca)
SKU220pxNão—CellInput (text, max 30 chars)
EAN / GTIN240pxNão—CellInput (text)
NCM160pxNão—CellInput (text, 8 dígitos)
CEST140pxNão—SelectCell (5 opções fixas)
Tipo de origem180pxNão—SelectCell (6 opções)
Origem140pxNão—SelectCell (9 opções 0–8)
CSOSN140pxNão—SelectCell (10 opções)
Unidade de medida140pxNão—CellInput (text)

Largura mínima total da tabela: 2636px — scroll horizontal sempre necessário em telas menores que isso. Sticky shadow em left: 405px (44+120+240+1px border).

Thresholds e timings

ConstanteValorContexto
THRESHOLD (scroll)8pxDelta mínimo de scrollTop para toggle da toolbar (exclusivo: >8, não ≥8)
scrollTop mínimo4pxAbaixo de 4px sempre mostra toolbar (reset automático)
Peek — delay inicial320mssetTimeout antes do 1º scroll lateral
Peek — distância160pxscrollTo({left:160, behavior:'smooth'})
Peek — retorno760mssetTimeout antes do scroll de volta para 0
pendingReorder freeze450msLinha permanece na posição após correção ou ignore
newlyFixed flash1200msClasse rx__row--just-linked permanece após reordenação
Duração total correção1650ms450ms + 1200ms desde o momento da edição
Skeleton loading2000msTimeout antes de montar Content
CellInput save delay400mssetTimeout para atualizar savedVal após onSave
Download fake timeout2000msSimula latência de geração do arquivo
Snackbar delay5000msAuto-dismiss dos snackbars de download
PER_PAGE25Produtos por página na paginação
Row height68pxAltura fixa de cada linha da tabela
Toolbar height40pxUsado como valor de --rx-header-top quando visível

Parâmetros de animação (motion/react)

Elementotypedampingstiffnessmass
motion.tr — reordenação de linhasspring381600.7
motion.div — reordenação de tabsspring402600.6

Transitions CSS da toolbar

ElementoPropriedadeDuração / Easing
rx__toolbar-clipheight (40px ↔ 0)200ms cubic-bezier(0.2,0.8,0.2,1)
rx__toolbartransform (translateY 0 ↔ -100%)200ms cubic-bezier(0.2,0.8,0.2,1)
rx__toolbar-rowopacity + transform (translateY 10px)220ms ease
rp__header-stickytop (var(--rx-header-top))200ms cubic-bezier(0.2,0.8,0.2,1)
rp__scroll-fadeopacity (0 ↔ 1)220ms ease
rp__sticky-shadowopacity (0 ↔ 1)200ms ease

CSS var --rx-header-top

EstadoValorDefinido por
Toolbar visível40pxscrollEl.style.setProperty('--rx-header-top', '40px')
Toolbar oculta0pxscrollEl.style.setProperty('--rx-header-top', '0px')

07Nota de implementação

Atenção ao header sticky + scroll horizontal simultâneo. No protótipo, a única forma de manter o header de colunas fixo verticalmente e sincronizado com o scroll horizontal da tabela foi usar duas tabelas separadas: o header em um elemento fora do overflow-x: auto e o corpo dentro. O scroll horizontal do header é sincronizado via JS a cada evento de scroll do corpo.

Comportamento que qualquer implementação precisa garantir: ao rolar a tabela horizontalmente, o header de colunas acompanha exatamente. Ao rolar verticalmente (scroll do app), o header de colunas permanece fixo abaixo da toolbar. A toolbar some ao rolar para baixo e reaparece ao rolar para cima — o header de colunas sobe junto para compensar o espaço. Validar no protótipo em /#/revisao-planilha antes de fechar o comportamento visual.

08Textos e copy

Colunas fiscais variam por país — MLB: NCM, CEST, Tipo de origem, Origem, CSOSN · MLM: IVA, IEPS, Clave del producto · MLA: sem dados fiscais. Células em cinza indicam coluna inexistente naquele mercado.

Identificador Referência 🇧🇷 MLB 🇲🇽 MLM 🇦🇷 MLA
Tabs de filtro
Tab 1Navegação por tabs"Todos ([N])""Todos ([N])""Todos ([N])"
Tab 2Navegação por tabs"Com erros ([N])""Con errores ([N])""Con errores ([N])"
Tab 3Navegação por tabs"Corrigidos ([N])""Corregidos ([N])""Corregidos ([N])"
Tab 4Navegação por tabs"Ignorados ([N])""Ignorados ([N])""Ignorados ([N])"
Toolbar
DownloadBaixar planilha"Baixar planilha""Descargar planilla""Descargar planilla"
Ignorar seleçãoToolbar swap"Ignorar produtos""Ignorar productos""Ignorar productos"
Des-ignorar seleçãoToolbar swap"Não ignorar produtos""No ignorar productos""No ignorar productos"
Selecionar todosToolbar swap"Selecionar os [N] produtos""Seleccionar los [N] productos""Seleccionar los [N] productos"
Contador (plural)Toolbar swap"[N] selecionados de [Total]""[N] seleccionados de [Total]""[N] seleccionados de [Total]"
Contador (singular)Toolbar swap"1 selecionado de [Total]""1 seleccionado de [Total]""1 seleccionado de [Total]"
Cabeçalhos da tabela — comuns
EstadoTokens e layout"Estado""Estado""Estado"
Nome do produto *Tokens e layout"Nome do produto *""Nombre del producto *""Nombre del producto *"
Preço de venda *Tokens e layout"Preço de venda *""Precio de venta *""Precio de venta *"
CustoTokens e layout"Custo""Costo""Costo"
EstoqueTokens e layout"Estoque""Stock""Stock"
Alerta estoque mín.Tokens e layout"Alerta est. mín.""Alerta stock mín.""Alerta stock mín."
CategoriasTokens e layout"Categorias""Categorías""Categorías"
SKUTokens e layout"SKU""SKU""SKU"
EAN / GTINTokens e layout"EAN / GTIN""EAN / GTIN""EAN / GTIN"
Cabeçalhos da tabela — dados fiscais
NCMTokens e layout"NCM"——
CESTTokens e layout"CEST"——
Tipo de origemTokens e layout"Tipo de origem"——
OrigemTokens e layout"Origem"——
CSOSNTokens e layout"CSOSN"——
IVATokens e layout—"IVA"—
IEPSTokens e layout—"IEPS"—
Clave del productoTokens e layout—"Clave del producto"—
Status e células
Badge — com errosCorreção inline"Com erros""Con errores""Con errores"
Badge — corrigidoCorreção inline"Corrigido""Corregido""Corregido"
Badge — ignoradoIgnorar produto"Ignorado""Ignorado""Ignorado"
Placeholder — nomeCellInput"Nome do produto""Nombre del producto""Nombre del producto"
Valor vazioCellInput"Indefinido""Indefinido""Indefinido"
SelectCell — idleSelectCell"Indefinido""Indefinido""Indefinido"
SelectCell — buscaSelectCell"Busque por seção""Buscar por sección""Buscar por sección"
Rodapé e ações de salvar
Total paginaçãoPaginação"Total de produtos: [N]""Total de productos: [N]""Total de productos: [N]"
Botão salvarSalvar produtos"Salvar produtos""Guardar productos""Guardar productos"
Mensagem de validaçãoSalvar produtos"Corrija ou ignore todos os produtos com erros antes de continuar.""Corrige o ignora todos los productos con errores antes de continuar.""Corregí o ignorá todos los productos con errores antes de continuar."
Snackbar — download okBaixar planilha"Sua planilha foi baixada com sucesso!""Tu planilla se descargó con éxito.""Tu planilla se descargó con éxito."
Snackbar — download erroBaixar planilha"Não foi possível criar a sua planilha. Tente novamente mais tarde.""No fue posible crear tu planilla. Intenta de nuevo más tarde.""No fue posible crear tu planilla. Intentá de nuevo más tarde."
Modal — Detalhes do erro
TítuloCasos de uso"Detalhes do erro""Detalles del error""Detalles del error"
Campo — ProdutoCasos de uso"Produto""Producto""Producto"
Campo — SKUCasos de uso"SKU""SKU""SKU"
Campo — EAN/GTINCasos de uso"EAN/GTIN""EAN/GTIN""EAN/GTIN"
Campo — Erros encontradosCasos de uso"Erros encontrados""Errores encontrados""Errores encontrados"
Nome vazioCasos de uso"(sem nome)""(sin nombre)""(sin nombre)"
Modal — Descartar produtos
TítuloSair da tela"Descartar produtos?""¿Descartar productos?""¿Descartar productos?"
CorpoSair da tela"Ao sair dessa tela, esses produtos serão descartados e não serão salvos no seu catálogo.""Al salir de esta pantalla, estos productos se descartarán y no se guardarán en tu catálogo.""Al salir de esta pantalla, estos productos se van a descartar y no se van a guardar en tu catálogo."
ConfirmarSair da tela"Sim, descartar""Sí, descartar""Sí, descartar"
CancelarSair da tela"Cancelar""Cancelar""Cancelar"
Tela mobile
TítuloAcesso mobile"Disponível apenas no computador""Solo disponible en computadora""Solo disponible en computadora"
DescriçãoAcesso mobile"Para acessar essa tela, abra o link do e-mail ou acesse "Seus produtos" no seu computador.""Para acceder a esta pantalla, abre el link del correo electrónico o accede a "Tus productos" desde tu computadora.""Para acceder a esta pantalla, abrí el link del correo electrónico o accedé a "Tus productos" desde tu computadora."
BotãoAcesso mobile"Voltar para Seus Produtos""Volver a Tus productos""Volver a Tus productos"