01Overview
Tela de revisão de erros — rota /#/revisao-planilha
/#/revisao-planilha (HashRouter). Acessível via Textlink no ProcessingWidget após falhas de importação- Usuário chega à tela via ProcessingWidget, clicando em "Revisar e corrigir erros"
- Skeleton carrega (~2s) e exibe a lista de produtos com falha de importação
- Usuário navega pelas tabs (Todos / Com erro / Ignorados) para localizar os erros
- Edita as células com erro diretamente na tabela (inline) — o campo fica em foco com highlight de erro
- Opcionalmente ignora produtos que não serão corrigidos via overflow menu
- Clica em "Salvar produtos" na sticky bar → produtos corrigidos são enviados ao catálogo com snackbar de confirmação
02Entry points
Como o usuário chega a esta tela
| Origem | Trigger | Componente / 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 |
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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.
| Campo | Código do erro | Label (tooltip) | Mensagem completa | Tipo |
|---|---|---|---|---|
| Nome | PRODUCT_TITLE_IS_REQUIRED | Falta o nome do produto | O nome do produto é obrigatório. Preencha o campo nome. | Obrigatório |
PRODUCT_TITLE_EXCEEDS_MAX_LENGTH | Nome muito longo | O nome excede 80 caracteres. Reduza o texto. | Obrigatório | |
PRODUCT_TITLE_DUPLICATED | Nome duplicado | Já existe outro produto com este nome. Use um nome único. | Obrigatório | |
| Preço | PRODUCT_PRICE_IS_REQUIRED | Falta o preço | O preço de venda é obrigatório. Insira um valor maior que zero. | Obrigatório |
PRODUCT_PRICE_IS_INVALID | Preço inválido | O preço possui formato inválido. Insira um valor numérico válido. | Obrigatório | |
PRODUCT_PRICE_EXCEEDS_MAX_VALUE | Preço acima do limite | O preço excede o valor máximo permitido pela plataforma. | Obrigatório | |
PRODUCT_PRICE_IS_NEGATIVE | Preço negativo | O preço não pode ser negativo. Insira um valor maior que zero. | Obrigatório | |
| SKU | PRODUCT_SKU_DUPLICATED | SKU duplicado | Este SKU já está em uso. Corrija ou apague para continuar sem ele. | Aceita indefinido |
PRODUCT_SKU_EXCEEDS_MAX_LENGTH | SKU muito longo | O SKU excede 30 caracteres. Reduza o código ou apague para continuar. | Aceita indefinido | |
| Estoque | PRODUCT_QUANTITY_IS_INVALID | Quantidade inválida | A quantidade informada não é válida. Corrija ou apague para continuar sem ela. | Aceita indefinido |
PRODUCT_STOCK_INVALID_FOR_UNIT_MEASURE | Estoque inválido para a unidade | O estoque não é válido para a unidade de medida. Corrija ou apague para continuar. | Aceita indefinido | |
PRODUCT_QUANTITY_EXCEEDS_MAX_VALUE | Estoque acima do limite | A quantidade excede o valor máximo. Corrija ou apague para continuar. | Aceita indefinido | |
PRODUCT_QUANTITY_EXCEEDS_MAX_VALUE_KG | Estoque acima do limite (kg) | A quantidade em kg excede o máximo. | Aceita indefinido | |
PRODUCT_QUANTITY_EXCEEDS_MAX_VALUE_L | Estoque acima do limite (L) | A quantidade em litros excede o máximo. | Aceita indefinido | |
PRODUCT_QUANTITY_EXCEEDS_MAX_VALUE_M | Estoque acima do limite (m) | A quantidade em metros excede o máximo. | Aceita indefinido | |
| Alerta mínimo | PRODUCT_MINIMUM_QUANTITY_IS_INVALID | Qtd. mínima inválida | A quantidade mínima não é válida. Corrija ou apague para continuar. | Aceita indefinido |
PRODUCT_MINIMUM_QUANTITY_EXCEEDS_MAX_VALUE | Qtd. mínima acima do limite | A quantidade mínima excede o máximo. | Aceita indefinido | |
PRODUCT_MINIMUM_QUANTITY_EXCEEDS_MAX_VALUE_KG | Qtd. mínima acima do limite (kg) | A quantidade mínima em kg excede o máximo. | Aceita indefinido | |
PRODUCT_MINIMUM_QUANTITY_EXCEEDS_MAX_VALUE_L | Qtd. mínima acima do limite (L) | A quantidade mínima em litros excede o máximo. | Aceita indefinido | |
PRODUCT_MINIMUM_QUANTITY_EXCEEDS_MAX_VALUE_M | Qtd. mínima acima do limite (m) | A quantidade mínima em metros excede o máximo. | Aceita indefinido | |
PRODUCT_MINIMUM_QUANTITY_REQUIRES_STOCK | Qtd. mínima requer estoque | A quantidade mínima requer que haja estoque. Corrija ou apague para continuar. | Aceita indefinido | |
| Unidade | PRODUCT_MEASURE_UNIT_INVALID | Unidade inválida | A unidade de medida não é reconhecida. Use un., kg, L ou similar. | Aceita indefinido |
PRODUCT_UNIT_MEASURE_NOT_MODIFIABLE | Unidade não modificável | A unidade de medida deste produto não pode ser alterada. | Aceita indefinido | |
| EAN / GTIN | PRODUCT_GTIN_INVALID | GTIN inválido | O código de barras (GTIN/EAN) não é válido. Corrija ou apague para continuar sem ele. | Aceita indefinido |
PRODUCT_GTIN_EXCEEDS_MAX_LENGTH | GTIN muito longo | O código GTIN/EAN excede o máximo de caracteres. | Aceita indefinido | |
PRODUCT_GTIN_TOO_SHORT | GTIN muito curto | O código GTIN/EAN é muito curto. | Aceita indefinido | |
INVALID_GTIN | GTIN inválido | O código de barras (GTIN) é inválido. Corrija ou apague para continuar sem ele. | Aceita indefinido | |
INVALID_GTIN_BY_DOMAIN_CATALOG | GTIN não corresponde à categoria | O GTIN não corresponde à categoria. Corrija ou apague para continuar. | Aceita indefinido | |
FISCAL_DATA_INVALID_GTIN_BY_NCM | GTIN incompatível com NCM | O GTIN é inválido para o NCM informado. Corrija ou apague para continuar. | Aceita indefinido | |
| Custo | PRODUCT_COST_IS_INVALID | Custo inválido | O custo possui formato inválido. Corrija ou apague para continuar sem ele. | Aceita indefinido |
PRODUCT_COST_EXCEEDS_MAX_VALUE | Custo acima do limite | O custo excede o valor máximo. Corrija ou apague para continuar. | Aceita indefinido | |
| Categoria | PRODUCT_SECTION_IS_INVALID | Categoria não encontrada | A categoria informada não existe. O produto será publicado sem categoria. | Aceita indefinido |
PRODUCT_SECTION_EXCEEDS_MAX_LENGTH | Nome da categoria muito longo | O nome da categoria excede o número máximo de caracteres. | Aceita indefinido | |
MAX_SELLER_SECTIONS_REACHED | Limite de categorias atingido | Você atingiu o limite máximo de categorias. | omitOnly | |
MAX_ITEMS_EXCEEDED_ERROR | Limite de produtos na categoria | O número máximo de produtos nesta categoria foi atingido. | omitOnly | |
| NCM | FISCAL_DATA_NCM_INVALID | NCM inválido | O código NCM não é válido. Corrija ou apague para continuar sem ele. | Aceita indefinido |
FISCAL_DATA_NCM_SIZE | Tamanho do NCM incorreto | O código NCM não tem o número correto de dígitos. | Aceita indefinido | |
| CEST | FISCAL_DATA_CEST_FORMAT_INVALID | Formato do CEST inválido | O código CEST possui formato inválido. Selecione uma opção válida para corrigir. | Aceita indefinido |
| Tipo de origem | FISCAL_DATA_ORIGIN_TYPE_INVALID | Tipo de origem inválido | O tipo de origem não é válido. Selecione uma opção válida para corrigir. | Aceita indefinido |
| Origem | FISCAL_DATA_ORIGIN_DETAIL_FORMAT_INVALID | Formato de origem inválido | O detalhe de origem possui formato inválido. Selecione uma opção válida. | Aceita indefinido |
FISCAL_DATA_ORIGIN_DETAIL_INCOMPATIBLE | Origem incompatível | A origem é incompatível com o tipo selecionado. Selecione uma opção válida. | Aceita indefinido | |
| CSOSN | FISCAL_DATA_CSOSN_INVALID | CSOSN inválido | O código CSOSN não é válido. Selecione uma opção válida para corrigir. | Aceita indefinido |
| Sistema | INVALID_PRODUCT_ID | ID do produto inválido | O ID do produto é inválido. | omitOnly |
UNAUTHORIZED_ITEM_USER | Produto não encontrado | O produto não foi encontrado ou não pertence a esta conta. | omitOnly | |
PRODUCT_ALREADY_DELETED | Produto já excluído | Este produto já foi excluído e não pode ser editado. | omitOnly | |
NO_MAPPED | Erro desconhecido | Este 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/react | Hierarchy / Variante | Props 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 — Ignorar | button | quiet, small | icon={{left: <FeedbackNegative />}} — alterna entre "Ignorar" e "Não ignorar" |
| Button — Selecionar todos | button | mute, small | "Selecionar os [N] produtos" — cross-page |
| Button — Baixar planilha | button | mute, medium | icon={{left: downloadingAll ? <ProgressIndicator xsmall /> : <Download />}} |
| Button — Salvar | button | loud, medium | onClick={handleSalvar} — footer da página |
| IconButton — fechar seleção | button | mute, small | srLabel="Limpar seleção", icon=<Close size="xtiny" /> |
| BadgePill — status | badge | quiet, medium | color="negative" (com erro), "positive" (corrigido), "neutral" (ignorado) |
| BadgeIcon — snackbar | badge | — | color="positive" ou color="negative" dentro do Snackbar |
| Snackbar | snackbar | — | defaultVisible, delay={5000}, onClose={clearDownloadSnackbar} |
| ProgressIndicatorCircular | progress-indicator-circular | — | size="xsmall" no botão Baixar planilha durante loading |
| Text | typography | small | color="secondary" em labels, color="primary" em valores de células |
| Message — validação | message | negative, quiet | description={validationMessage}, dismissível com onDismiss |
| Pagination | pagination | small | pages={totalPages}, value={safePage}, onPageChange={setPage} |
| DropdownStandalone | dropdown | small | Dentro do SelectCell, open={open} |
| MenuSingleSelection | menu | — | width={240} — lista de opções do SelectCell |
| SearchBox | search-box | — | Filtro de opções dentro do SelectCell dropdown |
06Tokens e layout
Larguras de coluna
| Coluna | Largura | Sticky? | left (sticky) | Tipo de célula |
|---|---|---|---|---|
| Checkbox | 44px | Sim | 0 | Checkbox Andes |
| Estado | 120px | Sim | 44px | BadgePill negative/positive/neutral |
| Nome do produto * | 240px | Sim | 164px | CellInput (text, max 80 chars) |
| Preço de venda * | 192px | Não | — | CellInput (decimal, prefix R$, suffix /un.) |
| Custo | 192px | Não | — | CellInput (decimal, prefix R$, suffix /un.) |
| Estoque | 140px | Não | — | CellInput (number, min=0, suffix un.) |
| Alerta est. mínimo | 144px | Não | — | CellInput (number, min=0, suffix un.) |
| Categorias | 152px | Não | — | SelectCell (7 opções + busca) |
| SKU | 220px | Não | — | CellInput (text, max 30 chars) |
| EAN / GTIN | 240px | Não | — | CellInput (text) |
| NCM | 160px | Não | — | CellInput (text, 8 dígitos) |
| CEST | 140px | Não | — | SelectCell (5 opções fixas) |
| Tipo de origem | 180px | Não | — | SelectCell (6 opções) |
| Origem | 140px | Não | — | SelectCell (9 opções 0–8) |
| CSOSN | 140px | Não | — | SelectCell (10 opções) |
| Unidade de medida | 140px | Nã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
| Constante | Valor | Contexto |
|---|---|---|
THRESHOLD (scroll) | 8px | Delta mínimo de scrollTop para toggle da toolbar (exclusivo: >8, não ≥8) |
| scrollTop mínimo | 4px | Abaixo de 4px sempre mostra toolbar (reset automático) |
| Peek — delay inicial | 320ms | setTimeout antes do 1º scroll lateral |
| Peek — distância | 160px | scrollTo({left:160, behavior:'smooth'}) |
| Peek — retorno | 760ms | setTimeout antes do scroll de volta para 0 |
| pendingReorder freeze | 450ms | Linha permanece na posição após correção ou ignore |
| newlyFixed flash | 1200ms | Classe rx__row--just-linked permanece após reordenação |
| Duração total correção | 1650ms | 450ms + 1200ms desde o momento da edição |
| Skeleton loading | 2000ms | Timeout antes de montar Content |
| CellInput save delay | 400ms | setTimeout para atualizar savedVal após onSave |
| Download fake timeout | 2000ms | Simula latência de geração do arquivo |
| Snackbar delay | 5000ms | Auto-dismiss dos snackbars de download |
PER_PAGE | 25 | Produtos por página na paginação |
| Row height | 68px | Altura fixa de cada linha da tabela |
| Toolbar height | 40px | Usado como valor de --rx-header-top quando visível |
Parâmetros de animação (motion/react)
| Elemento | type | damping | stiffness | mass |
|---|---|---|---|---|
| motion.tr — reordenação de linhas | spring | 38 | 160 | 0.7 |
| motion.div — reordenação de tabs | spring | 40 | 260 | 0.6 |
Transitions CSS da toolbar
| Elemento | Propriedade | Duração / Easing |
|---|---|---|
rx__toolbar-clip | height (40px ↔ 0) | 200ms cubic-bezier(0.2,0.8,0.2,1) |
rx__toolbar | transform (translateY 0 ↔ -100%) | 200ms cubic-bezier(0.2,0.8,0.2,1) |
rx__toolbar-row | opacity + transform (translateY 10px) | 220ms ease |
rp__header-sticky | top (var(--rx-header-top)) | 200ms cubic-bezier(0.2,0.8,0.2,1) |
rp__scroll-fade | opacity (0 ↔ 1) | 220ms ease |
rp__sticky-shadow | opacity (0 ↔ 1) | 200ms ease |
CSS var --rx-header-top
| Estado | Valor | Definido por |
|---|---|---|
| Toolbar visível | 40px | scrollEl.style.setProperty('--rx-header-top', '40px') |
| Toolbar oculta | 0px | scrollEl.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 1 | Navegação por tabs | "Todos ([N])" | "Todos ([N])" | "Todos ([N])" |
| Tab 2 | Navegação por tabs | "Com erros ([N])" | "Con errores ([N])" | "Con errores ([N])" |
| Tab 3 | Navegação por tabs | "Corrigidos ([N])" | "Corregidos ([N])" | "Corregidos ([N])" |
| Tab 4 | Navegação por tabs | "Ignorados ([N])" | "Ignorados ([N])" | "Ignorados ([N])" |
| Toolbar | ||||
| Download | Baixar planilha | "Baixar planilha" | "Descargar planilla" | "Descargar planilla" |
| Ignorar seleção | Toolbar swap | "Ignorar produtos" | "Ignorar productos" | "Ignorar productos" |
| Des-ignorar seleção | Toolbar swap | "Não ignorar produtos" | "No ignorar productos" | "No ignorar productos" |
| Selecionar todos | Toolbar 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 | ||||
| Estado | Tokens 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 *" |
| Custo | Tokens e layout | "Custo" | "Costo" | "Costo" |
| Estoque | Tokens e layout | "Estoque" | "Stock" | "Stock" |
| Alerta estoque mín. | Tokens e layout | "Alerta est. mín." | "Alerta stock mín." | "Alerta stock mín." |
| Categorias | Tokens e layout | "Categorias" | "Categorías" | "Categorías" |
| SKU | Tokens e layout | "SKU" | "SKU" | "SKU" |
| EAN / GTIN | Tokens e layout | "EAN / GTIN" | "EAN / GTIN" | "EAN / GTIN" |
| Cabeçalhos da tabela — dados fiscais | ||||
| NCM | Tokens e layout | "NCM" | — | — |
| CEST | Tokens e layout | "CEST" | — | — |
| Tipo de origem | Tokens e layout | "Tipo de origem" | — | — |
| Origem | Tokens e layout | "Origem" | — | — |
| CSOSN | Tokens e layout | "CSOSN" | — | — |
| IVA | Tokens e layout | — | "IVA" | — |
| IEPS | Tokens e layout | — | "IEPS" | — |
| Clave del producto | Tokens e layout | — | "Clave del producto" | — |
| Status e células | ||||
| Badge — com erros | Correção inline | "Com erros" | "Con errores" | "Con errores" |
| Badge — corrigido | Correção inline | "Corrigido" | "Corregido" | "Corregido" |
| Badge — ignorado | Ignorar produto | "Ignorado" | "Ignorado" | "Ignorado" |
| Placeholder — nome | CellInput | "Nome do produto" | "Nombre del producto" | "Nombre del producto" |
| Valor vazio | CellInput | "Indefinido" | "Indefinido" | "Indefinido" |
| SelectCell — idle | SelectCell | "Indefinido" | "Indefinido" | "Indefinido" |
| SelectCell — busca | SelectCell | "Busque por seção" | "Buscar por sección" | "Buscar por sección" |
| Rodapé e ações de salvar | ||||
| Total paginação | Paginação | "Total de produtos: [N]" | "Total de productos: [N]" | "Total de productos: [N]" |
| Botão salvar | Salvar produtos | "Salvar produtos" | "Guardar productos" | "Guardar productos" |
| Mensagem de validação | Salvar 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 ok | Baixar planilha | "Sua planilha foi baixada com sucesso!" | "Tu planilla se descargó con éxito." | "Tu planilla se descargó con éxito." |
| Snackbar — download erro | Baixar 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ítulo | Casos de uso | "Detalhes do erro" | "Detalles del error" | "Detalles del error" |
| Campo — Produto | Casos de uso | "Produto" | "Producto" | "Producto" |
| Campo — SKU | Casos de uso | "SKU" | "SKU" | "SKU" |
| Campo — EAN/GTIN | Casos de uso | "EAN/GTIN" | "EAN/GTIN" | "EAN/GTIN" |
| Campo — Erros encontrados | Casos de uso | "Erros encontrados" | "Errores encontrados" | "Errores encontrados" |
| Nome vazio | Casos de uso | "(sem nome)" | "(sin nombre)" | "(sin nombre)" |
| Modal — Descartar produtos | ||||
| Título | Sair da tela | "Descartar produtos?" | "¿Descartar productos?" | "¿Descartar productos?" |
| Corpo | Sair 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." |
| Confirmar | Sair da tela | "Sim, descartar" | "Sí, descartar" | "Sí, descartar" |
| Cancelar | Sair da tela | "Cancelar" | "Cancelar" | "Cancelar" |
| Tela mobile | ||||
| Título | Acesso mobile | "Disponível apenas no computador" | "Solo disponible en computadora" | "Solo disponible en computadora" |
| Descrição | Acesso 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ão | Acesso mobile | "Voltar para Seus Produtos" | "Volver a Tus productos" | "Volver a Tus productos" |