---
id: SVC-EXCELIMPORTER-UI
tipo: servico
onda: 2
status: concluido
unidades: [SVC-EXCELIMPORTER]
fontes:
  - V2/Services/ExcelImporter/
depende_de: [SVC-EXCELIMPORTER, UI-CMS]
consumidores: []
criticidade: alta
dono: SvcExcel
---

# Contrato de UI — componente `excel-transpiler` (Poka-Yoke de importação)

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Renderiza dois modais Bootstrap (upload com drag & drop e mesa de validação editável) mais progresso/confirmação, e orquestra upload → validação → edição/ignorar → processamento. |
| Entrada | `@include('excel-importer::components.excel-transpiler', [...])` com `title`, `routePreview`, `routeProcess`, `routeTemplate?`, `uniqueColumns`; arquivo do usuário; relatório JSON do preview; JSON `rows` no processamento. |
| Saída | DOM de tabela editável, contadores, barra de progresso, modais; requisições `POST` para `routePreview` (multipart) e `routeProcess` (JSON, NDJSON como resposta). |
| Efeitos colaterais | Abre/fecha modais Bootstrap, cria `window.lotepImporterInstance`, recarrega a página (`location.reload()`) após sucesso, emite `alert()` em falhas de rede/init. |
| Particularidade central | A tabela de validação é **gerada inteiramente por JS a partir do relatório** e usa `contenteditable` por célula; não há `<input>` por campo nem formulário de processamento. |
| Como se integra | Namespace de view `excel-importer`; JS global `LotepImporter`, `ImporterUI`, `ImporterValidator`; jQuery + Bootstrap 5 + Bootstrap Icons. |
| Se removido, quebra | Qualquer tela de módulo que inclua o componente (nenhuma em produção hoje) e a importação guiada por planilha em geral. |
| Superfície pública | 4 views (`excel-transpiler`, `modals`, `scripts`, `styles`), classe `LotepImporter` com `init/upload/process/revalidateDuplicates/validateCellInstant/validateRowStatus`, `ImporterUI` e `ImporterValidator`. |

## 1. Efeito

### 1.1 Transformação que o componente realiza

`@include` produz, na página do módulo: um bloco `<style>` (`components.styles`), dois modais de trabalho (`#modalExcelTranspilerUpload`, `#modalImportExcelPokaYoke`), dois modais de apoio (`#modalImportExcelProgress`, `#modalImportExcelConfirm`), três `<script src>` e um bloco inline que instancia `new LotepImporter({...})` e liga os botões.

Fluxo observável para o operador: escolhe/arrasta o arquivo → "Enviar para Validação" → o modal de upload fecha e abre a mesa de validação com uma linha por registro da planilha, cabeçalhos com dica e destaque amarelo em campos obrigatórios, linhas inválidas em vermelho e duplicatas em laranja → pode destravar uma linha (cadeado), editar células, ignorar (lixeira) ou reativar linhas → "Processar e Importar" fica habilitado apenas quando não há linha inválida/desbloqueada e existe ao menos uma linha ativa → barra de progresso avança por lote → modal final "Sucesso!" com contagem e `location.reload()`.

### 1.2 Efeitos sobre dados e sistema

| Efeito | Recurso afetado | Ponto no código | Fonte |
|---|---|---|---|
| Fecha o modal de upload e abre o de validação | `bootstrap.Modal` | `upload()` → `init(report, groupId)` | `V2/Services/ExcelImporter/Assets/js/importer-class.js:97-113` |
| Substitui `thead`/`tbody` da tabela pelo relatório | DOM `#importExcelHeader`, `#importExcelBody` | `ImporterUI.renderTable` | `V2/Services/ExcelImporter/Assets/js/importer-ui.js:97-207` |
| Atualiza contadores e habilita/desabilita o processamento | `#totalRowsCount`, `#validRowsCount`, `#invalidRowsCount`, `#duplicateRowsCount`, `#btnProcessImportExcel` | `updateCounters` | `V2/Services/ExcelImporter/Assets/js/importer-ui.js:18-39` |
| Abre modal de progresso e envia JSON por `XMLHttpRequest` | `#modalImportExcelProgress`, `routeProcess` | `process()` | `V2/Services/ExcelImporter/Assets/js/importer-class.js:176-235` |
| Recarrega a página no fim | `window.location` | callback de `showAlert` | `V2/Services/ExcelImporter/Assets/js/importer-class.js:207-209` |
| Remove handlers anteriores de todos os elementos | `$(document)` namespace `.lotepImport` | `attachEvents` | `V2/Services/ExcelImporter/Assets/js/importer-ui.js:219` |

### 1.3 Contrato observável

- **Sem arquivo selecionado**, clicar em "Enviar para Validação" envia o form mesmo assim (o botão é `type="button"`; não há checagem de `files.length` no JS) — o servidor recebe `import_file` ausente e o consumidor decide o erro (`V2/Services/ExcelImporter/Views/components/excel-transpiler.blade.php:117`; `importer-class.js:74-79`).
- **Com arquivo**, o botão passa a `disabled` com spinner e texto `Processando...`; ao sucesso volta a `<i class="bi bi-check-lg me-1"></i> Enviar para Validação` (`importer-class.js:85,98`).
- **Falha no preview**: `alert(xhr.responseJSON.message || xhr.responseJSON.error || 'Erro ao processar arquivo.')` e o botão é reabilitado (`importer-class.js:118-124`).
- **Relatório com `rows: []`**: a mesa de validação abre **vazia**, com contadores zerados e processamento desabilitado; não há mensagem (`importer-ui.js:103-105`). É o sintoma de coluna obrigatória faltante.
- **Processamento**: sempre HTTP 200 em caso de linhas com erro; o resultado final é um modal `"{success} registros importados com êxito."` + `" Houve {n} erro(s) de persistência."` quando aplicável (`importer-class.js:203-206`).
- **Falha HTTP ≠ 200** no processamento: modal `Ocorreu um erro crítico durante o processamento.` (`importer-class.js:220-226`).

### 1.4 O que o componente deliberadamente NÃO faz

- **Não abre sozinho**: não há botão/gatilho no componente; o consumidor precisa criar algo como `data-bs-toggle="modal" data-bs-target="#modalExcelTranspilerUpload"`.
- **Não valida tipo/tamanho do arquivo**: `accept=".xlsx,.xls,.csv"` é só dica de seletor (`excel-transpiler.blade.php:45`).
- **Não renderiza `summary.missing_columns`, `summary.encoding_warning`, `file_url` nem `report.log_id`** — nenhum desses campos é lido pelo JS.
- **Não aplica permissão**: qualquer usuário que alcance a página vê o botão; a autorização é da rota/consumidor.
- **Não envia linhas ignoradas**: `tr:not(.row-ignored)` (`importer-class.js:136`).
- **Não bloqueia duplicatas**: duplicata não desabilita o processamento (só invalida/desbloqueia), e a linha duplicada é enviada se não for ignorada.
- **Não preserva estado** após reload: `location.reload()` descarta edições.

## 2. Abordagem

### 2.1 Estratégia e por quê

Separação em três camadas JS: `ImporterValidator` (funções puras: moeda, serial de data, CNPJ, hash), `ImporterUI` (renderização, modais, contadores, eventos delegados) e `LotepImporter` (estado + chamadas HTTP). A tabela é `contenteditable` porque o relatório é dinâmico (schema variável) e a edição é excepcional — evita gerar centenas de inputs e replicar o schema no Blade. Todos os eventos usam delegação em `document` com namespace `.lotepImport`, o que permite reconstruir a tabela sem religar handlers.

### 2.2 Estados e transições

```mermaid
stateDiagram-v2
  [*] --> Ocioso
  Ocioso --> Enviando: clique #btn-transpiler-preview
  Enviando --> Ocioso: erro HTTP (alert)
  Enviando --> Validando: 200 + report
  Validando --> MesaValidacao: init(report)
  MesaValidacao --> MesaValidacao: editar célula / destravar / ignorar
  MesaValidacao --> Processando: clique #btnProcessImportExcel (habilitado)
  Processando --> Processando: linha NDJSON (progresso)
  Processando --> Sucesso: is_final = true
  Processando --> ErroCritico: status != 200
  Sucesso --> [*]: location.reload()
```

Classes de estado no DOM: linha `row-valid` + `row-locked` (válida, travada), `row-invalid` + `row-unlocked` (inválida ou duplicata), `row-duplicate`, `row-ignored`; célula `cell-error`, `col-required`, `cell-duplicate-parameter`, `col-index-error`, `col-index-duplicate` (`importer-ui.js:129-205`).

### 2.3 Trechos de código pivô

```blade
{{-- V2/Services/ExcelImporter/Views/components/excel-transpiler.blade.php:4-10 — contrato de inclusão --}}
@include('excel-importer::components.excel-transpiler', [
    'title' => 'Importar Planilha',
    'routePreview' => route('v2.modulo.import.preview'),
    'routeProcess' => route('v2.modulo.import.process'),
    'routeTemplate' => route('v2.modulo.import.template'), // opcional
    'uniqueColumns' => ['titulo'] // Campos de chave única para destacar duplicidades
])
```

`$title` alimenta o título do modal de upload e o nome exibido em `#importExcelModuleTitle` (`:22,135`); `$uniqueColumns` é serializado em `previewOptions` para o construtor JS e precisa ser **a mesma lista** usada em `setUniqueColumns()` no servidor.

```js
// V2/Services/ExcelImporter/Assets/js/importer-class.js:17-44 — opções e mapa de seletores.
// Todo seletor pode ser sobrescrito por options.selectors (integração com layouts diferentes).
this.previewUrl = options.previewUrl;
this.processUrl = options.processUrl;
this.csrfToken = options.csrfToken;
this.uniqueColumns = options.uniqueColumns || [];

this.report = null;
this.groupId = null;
this.confirmCallback = null;

this.selectors = Object.assign({
    uploadForm: '#transpiler-upload-form',
    uploadModal: '#modalExcelTranspilerUpload',
    pokaYokeModal: '#modalImportExcelPokaYoke',
    progressModal: '#modalImportExcelProgress',
    confirmModal: '#modalImportExcelConfirm',
    btnSubmitUpload: '#btn-transpiler-preview',
    btnConfirmYes: '#btnConfirmModalYes',
    btnProcess: '#btnProcessImportExcel',
    thead: '#importExcelHeader',
    tbody: '#importExcelBody',
    // ... contadores, barra, #chkClearCurrentData
```

`options.selectors` é o único ponto de extensão para reaproveitar o widget com IDs diferentes; o mapa completo vai até `chkClearCurrent: '#chkClearCurrentData'` (`:45`).

```js
// V2/Services/ExcelImporter/Assets/js/importer-class.js:81-101 — upload multipart com CSRF,
// e transição upload → mesa de validação somente após o modal esconder.
const formData = new FormData(form);
const btn = $(this.selectors.btnSubmitUpload);
const groupId = $('#' + this.selectors.uploadForm.replace(/^#/, '') + ' #import_group_id').val() || '';

btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1" role="status"></span> Processando...');

$.ajax({
    url: this.previewUrl,
    type: 'POST',
    data: formData,
    processData: false,
    contentType: false,
    headers: {
        'X-CSRF-TOKEN': this.csrfToken,
        'Accept': 'application/json'
    },
    success: function (report) {
        btn.prop('disabled', false).html('<i class="bi bi-check-lg me-1"></i> Enviar para Validação');
        const myUploadModal = bootstrap.Modal.getInstance(document.getElementById('modalExcelTranspilerUpload'));
```

`#import_group_id` não existe no componente: sem um input injetado pelo consumidor, `groupId` é sempre `''` (`LG-EXCELIMPORTER-03`).

```js
// V2/Services/ExcelImporter/Assets/js/importer-ui.js:129-147 — cada linha nasce travada ou
// destravada e ganha os dois botões de ação (cadeado e lixeira).
instance.report.rows.forEach(function (row) {
    const isUnlocked = !row.is_valid || row.is_duplicate;
    const tr = $('<tr>').attr('data-line', row.line).addClass(isUnlocked ? 'row-invalid row-unlocked' : 'row-valid row-locked');

    if (row.is_duplicate) {
        tr.addClass('row-duplicate').attr('title', row.duplicate_info);
        tr.attr('data-original-duplicate-type', row.duplicate_type);
    }

    const lockIcon = isUnlocked ? 'bi-unlock-fill text-danger' : 'bi-lock-fill text-success';
    const ignoreIcon = row.is_ignored ? 'bi-arrow-counterclockwise text-warning' : 'bi-trash-fill text-danger';
    const ignoreTitle = row.is_ignored ? 'Reativar Linha' : 'Ignorar Linha';

    tr.append(
        '<td class="text-center col-actions">' +
            '<button type="button" class="btn-action-import js-btn-lock" title="Alternar Edição"><i class="bi ' + lockIcon + '"></i></button>' +
            '<button type="button" class="btn-action-import js-btn-ignore" title="' + ignoreTitle + '"><i class="bi ' + ignoreIcon + '"></i></button>' +
        '</td>'
    );
```

A linha só é editável quando `!row.is_valid || row.is_duplicate`; `row.is_ignored` nunca vem do servidor, então o ícone inicial é sempre a lixeira (`:139-140`).

```js
// V2/Services/ExcelImporter/Assets/js/importer-class.js:144-162 — serialização da linha:
// só normaliza o que a UI exibe; CNPJ perde máscara, data vira ISO, moeda vira número textual.
tr.find('.cell-editable').each(function () {
    const cell = $(this);
    const key = cell.data('key');
    const type = cell.data('type');
    let value = cell.text().trim();

    if (type === 'cnpj') {
        value = value.replace(/\D/g, '');
    } else if (type === 'currency') {
        value = ImporterValidator.cleanCurrency(value);
    } else if (type === 'date') {
        const parts = value.split('/');
        if (parts.length === 3) {
            value = parts[2] + '-' + parts[1].padStart(2, '0') + '-' + parts[0].padStart(2, '0');
        }
    }

    rowData.cells[key] = { normalized_value: value };
});
```

Também envia `line` e `is_valid` por linha (`:138-142`), mas o serviço só usa `line` em mensagens de erro (`ImportExcelService.php:145`).

```js
// V2/Services/ExcelImporter/Assets/js/importer-class.js:186-201 — leitura incremental do NDJSON:
// acumula responseText, processa linhas completas e ignora JSON inválido.
let lastIndex = 0;
xhr.onreadystatechange = function () {
    if (xhr.readyState === 3 || xhr.readyState === 4) {
        const responseText = xhr.responseText.substring(lastIndex);
        const lines = responseText.split('\n');
        lines.forEach(function (line) {
            if (line.trim()) {
                try {
                    const data = JSON.parse(line);
                    if (data.type === 'progress') {
                        ImporterUI.updateProgress(self, data.processed, data.total);
                        if (data.is_final) {
                            setTimeout(function () {
```

O parser é sensível a corte no meio de uma linha: `lastIndex` avança para o tamanho total mesmo quando o último fragmento não é JSON válido (`:218`), perdendo aquele evento de progresso (`LG-EXCELIMPORTER-08`).

```js
// V2/Services/ExcelImporter/Assets/js/importer-ui.js:219-238 — teardown global + delegação:
// qualquer instância anterior perde os handlers ao inicializar uma nova.
$(document).off('.lotepImport');

$(document).on('click.lotepImport', instance.selectors.btnConfirmYes, function () {
    const confirmM = bootstrap.Modal.getInstance(document.getElementById('modalImportExcelConfirm'));
    if (confirmM) {
        confirmM.hide();
    }
    if (typeof instance.confirmCallback === 'function') {
        instance.confirmCallback();
    }
});

$(document).on('click.lotepImport', '.js-btn-lock', function (e) {
    e.preventDefault();
    const tr = $(this).closest('tr');
    const icon = $(this).find('i');
    const line = tr.data('line');

    if (tr.hasClass('row-locked')) {
        askConfirm(instance, 'Editar Linha', 'Deseja abrir esta linha para edição manual?', function () {
```

`confirmCallback` é um único slot: `showAlert` e `askConfirm` sobrescrevem o callback anterior (`importer-ui.js:62-95`).

### 2.4 Custos e comportamento

- Renderização O(L·C) com criação de elementos jQuery por célula; cabeçalhos calculam largura por heurística de texto (`importer-ui.js:117-120`).
- `input` em cada célula dispara validação **sem debounce** (`importer-ui.js:305-307`), incluindo recontagem e varredura de duplicatas a cada `focusout` (`:309-323`).
- `revalidateDuplicates` percorre todas as linhas ativas duas vezes e recalcula hash por linha (`importer-class.js:244-291`); custo O(L²) no pior caso de cada edição.
- O XHR não usa `fetch`/streams: depende de `readyState 3` e de o servidor chamar `flush()`.
- Não há timeout, retry ou cancelamento; fechar o modal de progresso é impedido por `data-bs-backdrop="static"` (`modals.blade.php:2`).

## 3. Particularidades

### 3.1 O que é exclusivo deste widget

É o único componente V2 que edita dados **dentro de uma tabela** com `contenteditable` e estado por linha (travar/destravar/ignorar), reaproveitando o mesmo relatório para contadores e envio. Nenhum outro serviço de UI usa `contenteditable` como campo de formulário; `CONTENTRICHEDITOR` usa Tiptap e `GALLERY`/`MEDIA` usam uploads com `<input type="file">`.

### 3.2 Invariantes e regras não óbvias

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| RN-EXCELIMPORTER-16 | Processar só habilita se não houver linha `.row-invalid` nem `.row-unlocked` e houver linha ativa | duplicatas sozinhas não bloqueiam | `importer-ui.js:36-38` |
| RN-EXCELIMPORTER-17 | Linha inválida **ou** duplicata nasce destravada para edição | `isUnlocked = !row.is_valid \|\| row.is_duplicate` | `importer-ui.js:130-131` |
| RN-EXCELIMPORTER-18 | Edição é célula a célula: o `input` valida, o `focusout` atualiza o hash da linha | não há botão "salvar linha" | `importer-ui.js:305-323` |
| RN-EXCELIMPORTER-19 | Ignorar não pede confirmação; reativar pede | assimetria de UX | `importer-ui.js:266-303` |
| RN-EXCELIMPORTER-20 | Destravar pede confirmação; travar não | `askConfirm('Editar Linha', ...)` | `importer-ui.js:231-264` |
| RN-EXCELIMPORTER-21 | Contadores excluem linhas ignoradas do total | `total - ignored`, `activeRows` | `importer-ui.js:20-30` |
| RN-EXCELIMPORTER-22 | Duplicata ativa conta como "válida" no contador (não é inválida) | `Válidas = activeRows - invalidRows` | `importer-ui.js:28` |
| RN-EXCELIMPORTER-23 | Barra de progresso não atualiza `aria-valuenow` | leitor de tela só anuncia o texto visível | `importer-ui.js:48-52`; `modals.blade.php:13-15` |
| RN-EXCELIMPORTER-24 | `data-original-hash` é gravado no fim do render | usado para decidir se uma duplicata de banco deixou de ser duplicata após edição | `importer-ui.js:206`; `importer-class.js:249-259` |

### 3.3 Casos-limite

- **Relatório sem linhas**: tabela vazia, `#btnProcessImportExcel` desabilitado; nenhuma explicação. (coluna obrigatória ausente → `LG-EXCELIMPORTER-02`).
- **Todas as linhas ignoradas**: total 0, processamento desabilitado; o botão "Ignorar" continua disponível.
- **Duplicata com `uniqueColumns` vazio**: `calculateRowHash` devolve `null` (`importer-validator.js:89-91`); nenhuma linha é marcada ao vivo, mesmo que o servidor tenha marcado (o servidor só marca se `setUniqueColumns` existir).
- **`uniqueColumns` divergente entre Blade e schema**: o destaque de parâmetro duplicado usa `instance.uniqueColumns` (`importer-ui.js:197-199`), mas o hash do servidor usa as colunas do schema; listas diferentes produzem destaques inconsistentes.
- **Servidor devolve `duplicate_type='database'`** (não implementado hoje): a linha pode perder o estado de duplicata ao editar (`importer-class.js:250-259`).
- **Erro 422 do consumidor**: cai em `xhr.responseJSON.message` e é exibido em `alert`, fechando o modal de upload apenas se a chamada teve sucesso.
- **`bootstrap` ausente** (Bootstrap 4/jQuery plugin): `new bootstrap.Modal(...)` lança em `init` e o usuário vê `Erro ao processar dados da planilha: ...` (`importer-class.js:63-67`).
- **Duas instâncias na página**: a segunda chama `$(document).off('.lotepImport')` e passa a responder sozinha (`importer-ui.js:219`); a primeira fica inerte.
- **Dois blocos de init** (bug atual): `excel-transpiler.blade.php` inclui `components.scripts` e repete o mesmo código em `@push('scripts')` (`:225-341`), criando duas instâncias no mesmo `DOMContentLoaded` — a segunda sobrescreve `window.lotepImporterInstance` e os handlers.
- **Texto com HTML na dica do schema**: `th.append('<small class="import-excel-hint">' + hint + '</small>')` não escapa (`importer-ui.js:124`); valores de célula usam `.text()` e são seguros.
- **Célula vazia**: `:empty::before` mostra `data-placeholder` com `Ex: {hint}` (`styles.blade.php:198-208`).
- **Datas seriais**: a UI converte serial → `DD/MM/AAAA` para exibir (`ImporterValidator.excelDateToJS`), e o envio converte de volta para ISO; um valor já em ISO é exibido cru (não casa `\d{2}/\d{2}/\d{4}`) mas é reenviado como está.

### 3.4 Armadilhas de reimplementação

- **Ordem dos scripts importa**: `importer-validator.js` → `importer-ui.js` → `importer-class.js` (`scripts.blade.php:1-3`); a classe referencia os dois globais no momento da chamada, então a ordem só é fatal para o `new LotepImporter`.
- **Não há `defer`** nos `<script src>`; o inline de init depende de `DOMContentLoaded` e de jQuery já carregado pelo layout.
- **Eventos são globais** (`document`), não por instância; qualquer widget irmão com as mesmas classes (`.js-btn-lock`, `.cell-editable`) é capturado.
- **O botão de processamento é habilitado por classe**, não por validação do payload; editar o DOM manualmente (`contenteditable` via console) permite enviar qualquer valor — a validação server-side no processamento **não existe**, o servidor confia no `normalized_value`.
- **Não use `#transpiler_file` como seletor global**: ele é `visually-hidden` e `tabindex="-1"`; o clique deve ir para `#excel-drop-zone`.
- **Não espere `change` do input após remover o arquivo**: `$input.val('')` dispara `change` em alguns navegadores, mas o handler faz `if (file)` e ignora; o estado visual é atualizado manualmente (`excel-transpiler.blade.php:329-337`).
- **Não conte com `data-*` normalizados na célula**: `data-normalized` é gravado mas nunca lido (`importer-ui.js:187`).

### 3.5 Dívidas e divergências

- Bloco de inicialização **duplicado** (include + `@push`), já citado.
- `scripts.blade.php` e o `@push` de `excel-transpiler.blade.php` são cópias quase idênticas (var vs const) — duas fontes de verdade.
- `.preview-excel-icon .badge` é procurado pelo JS (`excel-transpiler.blade.php:319`; `scripts.blade.php:95`) mas o Blade usa `.preview-container` (`:65`), então o badge de extensão nunca é atualizado.
- `#modalImportExcelConfirm` não tem `aria-labelledby`/`aria-describedby`; o título é um `<h5>` sem `id` (`modals.blade.php:27-42`).
- `#importProgressBar` tem `role="progressbar"` e `aria-valuenow="0"` fixo (`modals.blade.php:13-15`); `updateProgress` só mexe em CSS/texto (`importer-ui.js:48-52`).
- Erros de célula são comunicados apenas por `title` e cor (`importer-ui.js:200-202`), sem `aria-invalid`/`aria-describedby`.
- O dropzone usa `role="button"` com `aria-expanded`, mas não controla um elemento expansível — semântica imprecisa (`excel-transpiler.blade.php:33-38`).
- Não há i18n: todas as strings são pt-BR hardcoded.

## 4. Integrabilidade

### 4.1 API pública

**Blade** — `excel-importer::components.excel-transpiler`

| Prop | Obrigatória | Tipo | Efeito |
|---|---|---|---|
| `title` | não (default `'Importar Planilha'`/`'Planilha'`) | string | título do modal de upload e `#importExcelModuleTitle` |
| `routePreview` | sim | URL | `previewUrl` do `LotepImporter` |
| `routeProcess` | sim | URL | `processUrl` |
| `routeTemplate` | não | URL | renderiza o card "Modelo Oficial"; ausente → card informativo |
| `uniqueColumns` | não (default `[]`) | array de chaves internas | `uniqueColumns` do construtor e destaque `cell-duplicate-parameter` |

**JS** — `new LotepImporter(options)` com `{previewUrl, processUrl, csrfToken, uniqueColumns, selectors?}`.

| Método | Assinatura | Efeito |
|---|---|---|
| `init(report, groupId)` | objeto de relatório + string | `attachEvents`, `renderTable`, abre a mesa de validação |
| `upload()` | — | POST multipart para `previewUrl` |
| `process()` | — | POST JSON para `processUrl`, abre progresso, consome NDJSON |
| `revalidateDuplicates()` | — | recalcula duplicatas entre linhas ativas e contadores |
| `validateCellInstant(td)` | jQuery td | valida uma célula e ajusta classes de célula/linha |
| `validateRowStatus(tr)` | jQuery tr | recomputa estado da linha e duplicatas |

**JS** — `ImporterUI.{updateCounters, updateProgress, askConfirm, showAlert, renderTable, attachEvents}`; `ImporterValidator.{cleanCurrency, excelDateToJS, formatCnpj, calculateRowHash}` (`importer-validator.js:111-116`).

### 4.2 Contrato de DOM

| ID / classe | Papel | Quem usa |
|---|---|---|
| `#modalExcelTranspilerUpload` | modal de upload | `upload()` (getInstance/hide) |
| `#transpiler-upload-form` | `<form enctype="multipart/form-data">` com `csrf_field()` | `upload()` |
| `#transpiler_file` (`name=import_file`, `accept=".xlsx,.xls,.csv"`, `required`, `aria-hidden="true"`) | input de arquivo oculto | dropzone |
| `#excel-drop-zone` (`role=button`, `tabindex=0`) | zona de clique/drag | init do dropzone |
| `.js-excel-zone-content` / `.js-excel-selected-content` / `.js-excel-file-name` / `.js-excel-remove-file` | estados do dropzone | init do dropzone |
| `#btn-transpiler-preview` | dispara `upload()` | inline init |
| `#modalImportExcelPokaYoke` (`data-bs-backdrop="static"`) | mesa de validação | `init()` |
| `#importExcelHeader` / `#importExcelBody` | `thead`/`tbody` gerados | `renderTable()` |
| `#tableImportExcelPreview` (`role=grid`, `aria-label`) | tabela | CSS/leitor de tela |
| `#totalRowsCount` / `#validRowsCount` / `#invalidRowsCount` / `#duplicateRowsCount` / `#labelDuplicateCount` | contadores | `updateCounters()` |
| `#btnNextError` / `#btnNextDuplicate` / `#btnNextUnlock` | navegação rápida | `attachEvents()` |
| `#btnProcessImportExcel` | inicia processamento; inicia `disabled` | inline init + `updateCounters()` |
| `#modalImportExcelProgress` / `#importProgressBar` / `#progressStatusLabel` | progresso | `updateProgress()` |
| `#modalImportExcelConfirm` / `#confirmModalTitle` / `#confirmModalMessage` / `#btnConfirmModalYes` | confirmação e alerta | `askConfirm`/`showAlert` |
| `tr[data-line]`, `.row-valid/.row-locked/.row-invalid/.row-unlocked/.row-ignored/.row-duplicate`, `data-original-hash`, `data-original-duplicate-type` | estado da linha | `process()`, contadores |
| `td.cell-editable[data-key][data-type][data-normalized][contenteditable][role=textbox][aria-label]` | célula editável | `process()`, validação instantânea |
| `.cell-error`, `.col-required`, `.cell-duplicate-parameter`, `.col-index-error`, `.col-index-duplicate` | semântica visual | CSS/`updateCounters` |

### 4.3 JS e ciclo de vida

- **Init**: `DOMContentLoaded` → `new LotepImporter({...})` → handlers dos botões → IIFE `initExcelDropzone`. Tudo em dois blocos idênticos (include + `@push`), ver §3.5.
- **Múltiplas instâncias**: suportadas apenas se `attachEvents` for adaptado; hoje a última instância "vence" (`importer-ui.js:219`).
- **Teardown**: não existe API pública; o único "teardown" é `$(document).off('.lotepImport')` na próxima inicialização. Modais Bootstrap criados por `new bootstrap.Modal(...)` ficam no DOM.
- **Estado**: `window.lotepImporterInstance.report` guarda o relatório original; edições atualizam `row.cells[key].normalized_value` apenas no `focusout` (`importer-ui.js:309-322`), mas o envio lê o **texto do DOM**, não esse objeto.
- **Storage**: nenhum (sem localStorage/sessionStorage).

### 4.4 Ordem de inicialização e registro

1. Layout CMS com jQuery, Bootstrap 5 bundle e Bootstrap Icons (`V2/layouts/cms/layouts/app.blade.php:101`).
2. `@include` do componente no corpo da view do módulo (ou dentro de `@push('modals')`).
3. O include emite CSS, modais, `components.modals`, e os três `<script src>`.
4. O bloco inline registra `DOMContentLoaded`; quando dispara, os três globais já existem.
5. O consumidor precisa fornecer o **gatilho** do modal de upload e as três rotas.
6. Stacks usados: `scripts` (`V2/layouts/cms/layouts/app.blade.php:140`); não usa `modals` nem `css`.

### 4.5 Receitas de uso

**Receita 1 — incluir e abrir a partir de um botão da tela.**

```blade
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#modalExcelTranspilerUpload">
    <i class="bi bi-upload"></i> Importar planilha
</button>

@include('excel-importer::components.excel-transpiler', [
    'title' => 'Importar Associados',
    'routePreview' => route('associados.import.preview'),
    'routeProcess' => route('associados.import.process'),
    'routeTemplate' => route('associados.import.template'),
    'uniqueColumns' => ['cnpj'],
])
```

Resultado: um botão abre `#modalExcelTranspilerUpload`; o envio vai para `associados.import.preview`.

**Receita 2 — resposta de preview esperada pelo widget.**

```php
return response()->json([
    'summary' => [
        'total_rows' => 3, 'valid_rows' => 2, 'invalid_rows' => 1,
        'duplicate_rows' => 1, 'has_errors' => true, 'missing_columns' => [],
    ],
    'rows' => [[
        'line' => 2, 'is_valid' => true, 'unique_key' => 'md5...', 'is_duplicate' => false,
        'cells' => [
            'titulo' => [
                'column_key' => 'titulo', 'column_label' => 'Título', 'column_type' => 'string',
                'original_value' => 'Primeiro Registro', 'normalized_value' => 'Primeiro Registro',
                'is_valid' => true, 'is_required' => true, 'errors' => [], 'hint' => 'Ex.: obra',
            ],
        ],
    ]],
    'file_url' => 'https://.../imports/2026/10/....xlsx',
]);
```

Sem `rows`, a tabela abre vazia; os campos `summary`, `file_url`, `unique_key` e `is_valid` da linha não são usados pelo render.

**Receita 3 — sobrescrever seletores para reaproveitar o widget fora dos IDs padrão.**

```blade
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function () {
    window.lotepImporterInstance = new LotepImporter({
        previewUrl: '{{ route('associados.import.preview') }}',
        processUrl: '{{ route('associados.import.process') }}',
        csrfToken: '{{ csrf_token() }}',
        uniqueColumns: ['cnpj'],
        selectors: { tbody: '#minhaTabelaBody', btnProcess: '#meuBotaoProcessar' }
    });
});
</script>
@endpush
```

Resultado: o widget passa a operar sobre o `tbody` e o botão informados; os IDs restantes continuam obrigatórios.

**Receita 4 — habilitar `clear_current`.**

```blade
<form id="transpiler-upload-form" enctype="multipart/form-data">
    @csrf
    <input type="hidden" id="import_group_id" value="{{ $grupoId }}">
    <label><input type="checkbox" id="chkClearCurrentData"> Limpar dados atuais antes de importar</label>
    {{-- dropzone e #transpiler_file --}}
</form>
```

Resultado: `groupId` deixa de ser vazio e o payload envia `clear_current: 1` — mas o serviço da unidade **ignora** esse campo; o consumidor precisa implementá-lo no `$persister`/controller.

### 4.6 Anti-uso

- **Incluir o componente duas vezes na mesma página** — IDs duplicados, duas instâncias e handlers globais trocados; só a última funciona (`importer-ui.js:219`).
- **Usar Bootstrap 4 ou `$.fn.modal`** — o código chama `bootstrap.Modal`, que não existe no Bootstrap 4; a mesa de validação nunca abre e o erro vai para `alert`.
- **Enviar valores sem editar o DOM** — `process()` lê `cell.text()`, então alterar `window.lotepImporterInstance.report` não muda o que é enviado (`importer-class.js:148`).
- **Omitir `uniqueColumns` no Blade esperando duplicidade do servidor** — o servidor marca duplicatas pelo schema (`setUniqueColumns`), mas o destaque de parâmetro e o recálculo ao vivo usam a lista do Blade; listas divergentes geram UI inconsistente (`importer-ui.js:197-199`).
- **Confiar no botão desabilitado como validação** — o processamento é `POST` público do consumidor; a validação de dados precisa ser refeita no servidor.

### 4.7 Custos e limites

| Item | Valor/limite | Fonte |
|---|---|---|
| Modal de validação | `85vw` × `96vh`; área de tabela `max-height: 55vh` | `styles.blade.php:5-12,82-88` |
| Colunas | largura calculada entre 160 e 550 px | `importer-ui.js:117-120` |
| Upload | sem limite no cliente; `accept` só xlsx/xls/csv | `excel-transpiler.blade.php:45` |
| Progresso | passo de 25 linhas (servidor) | `ImportExcelService.php:117` |
| Requisições | 1 POST preview + 1 POST process | `importer-class.js:87,182` |
| Reload | obrigatório ao final; perde filtros/edições | `importer-class.js:207-209` |

### 4.8 Acessibilidade

**Implementado**

- Dropzone focável (`tabindex="0"`), com `role="button"`, `title`, atalho Enter/Espaço que abre o seletor (`excel-transpiler.blade.php:33-38,260-266`).
- Input de arquivo oculto com `aria-hidden="true"` e `tabindex="-1"`, evitando dupla parada de foco.
- `#tableImportExcelPreview` com `role="grid"` e `aria-label="Dados para conferência da planilha"`; `thead` com `th` (sem `scope="col"`, mas dentro de `thead`).
- Células editáveis recebem `role="textbox"` e `aria-label="Célula de {label}, Linha {n}"`; ao travar, os atributos são removidos (`importer-ui.js:190-192,257`).
- Navegação por setas entre células e linhas via `keydown` (37–40) (`importer-ui.js:412-431`).
- Botões de ação com `title` ("Alternar Edição", "Ignorar Linha", "Reativar Linha") e ícones `aria-hidden="true"`.
- Barra de progresso com `role="progressbar"`, `aria-valuemin/max`; botões de navegação têm texto e ícone oculto.

**Lacunas**

- `aria-valuenow` nunca é atualizado; a porcentagem só existe como texto.
- Sem `aria-live` para contadores, erros de célula ou conclusão; erros só em `title` (não anunciados).
- Modal de confirmação sem `aria-labelledby`/`aria-describedby`.
- Linhas/tabela sem `<caption>`; a identificação vem do `aria-label`.
- Foco não é devolvido ao botão que abriu o modal manualmente (Bootstrap cuida apenas do próprio modal).
- Cores de erro/duplicata não têm alternativa textual persistente (apenas `title` no `td` e no `tr`).

### 4.9 Assets e build

- CSS embutido em `<style>` no include (`styles.blade.php:1-466`), sem arquivo externo, sem build.
- JS servido de `asset('assets/js/V2/services/excel-importer/*.js')` (`scripts.blade.php:1-3`); os fontes vivem em `V2/Services/ExcelImporter/Assets/js/` e **não há cópia publicada** em `public/assets/js/V2/` — `LG-EXCELIMPORTER-05`.
- Dependências de terceiros vêm do layout: jQuery, Bootstrap 5.3.2 e Bootstrap Icons (`01_arquitetura/11-satelites-build-config.md:234-235`). Não há import map/módulo ES; os três arquivos declaram `const`/`class` globais e colidem com `let`/`const` homônimos.

### 4.10 Comportamento sem JS

Não há fallback: o `<form>` não tem `action`/`method` (`excel-transpiler.blade.php:19`), o botão "Enviar para Validação" é `type="button"` sem submit, os modais só abrem via `bootstrap.Modal` e a tabela é 100% gerada por JS. Com JS desabilitado, o usuário vê os modais ocultos (`display:none` do Bootstrap) e nenhum caminho de importação — `LG-EXCELIMPORTER-04`.

## 5. Rotas e endpoints

| Método | URI | Consumidor | Corpo | Resposta usada pelo widget |
|---|---|---|---|---|
| POST | valor de `routePreview` | controlador do módulo → `ImportExcelService::previewImportExcelData` | `multipart/form-data` com `import_file` + `_token` | JSON `{summary, rows, file_url}` |
| POST | valor de `routeProcess` | controlador do módulo → `ImportExcelService::processImportExcelData` | `application/json` `{rows, log_id, group_id, clear_current}` | `application/x-ndjson`, 1 objeto por lote |
| GET | valor de `routeTemplate` | controlador do módulo → `ImportExcelTemplateGenerator::download` | — | XLSX (link direto no card) |

O widget **não** define rotas; ele recebe URLs prontas. CSRF é enviado por header nos dois POSTs e no form.

## 6. Dados e migrations

Não se aplica: o componente não cria tabela, model ou migration. O único estado é em memória (`window.lotepImporterInstance.report`) e no DOM.

### 6.1 Configuração e permissões

| Item | Valor | Efeito | Fonte |
|---|---|---|---|
| `title` | default `Importar Planilha` (upload) / `Planilha` (mesa) | rótulos | `excel-transpiler.blade.php:23,135` |
| `uniqueColumns` | `[]` | destaque e hash no cliente | `:235`; `importer-class.js:20,197-199` |
| `selectors` | mapa interno | override de IDs | `importer-class.js:26-45` |
| Permissão | nenhuma | visibilidade/ação dependem da rota e da tela | — |
| Feature flag | nenhuma | — | — |

### 6.2 Observabilidade e erros

- Todos os erros visíveis são modais (`showAlert`) ou `alert()`; não há log no cliente.
- Strings exatas: `Erro ao processar arquivo.`, `Erro ao processar dados da planilha: {msg}`, `Nenhuma linha válida selecionada para importar.`, `Ocorreu um erro crítico durante o processamento.`, `{n} registros importados com êxito.`, ` Houve {n} erro(s) de persistência.`, `Deseja abrir esta linha para edição manual?`, `Deseja realmente reativar esta linha?`.
- O console registra `LotepImporter: Inicializando com report:` e `LotepImporter Error no Init:` (`importer-class.js:55,66`).

## 7. Catálogo de arquivos

| Arquivo | Papel |
|---|---|
| `V2/Services/ExcelImporter/Views/components/excel-transpiler.blade.php` | componente raiz: CSS, dois modais, includes, init inline (duplicado) |
| `V2/Services/ExcelImporter/Views/components/modals.blade.php` | modal de progresso e mini-modal de confirmação/alerta |
| `V2/Services/ExcelImporter/Views/components/scripts.blade.php` | três `<script src>` + init canônico + dropzone |
| `V2/Services/ExcelImporter/Views/components/styles.blade.php` | todo o CSS do widget (466 linhas) |
| `V2/Services/ExcelImporter/Assets/js/importer-class.js` | `LotepImporter` |
| `V2/Services/ExcelImporter/Assets/js/importer-ui.js` | `ImporterUI` |
| `V2/Services/ExcelImporter/Assets/js/importer-validator.js` | `ImporterValidator` |

## 8. Consumidores (mapa de integração)

| Consumidor | O que usa | Como usa | Risco de quebra |
|---|---|---|---|
| Nenhum módulo de `V2/Modules` | — | busca por `excel-transpiler`/`LotepImporter` não retorna consumidores | — |
| Tela de módulo futura | include + rotas | ver §4.5 | alto: sem assets publicados e com init duplicado |

## 9. Lacunas

| ID | Lacuna | Impacto | Onde procurar |
|---|---|---|---|
| LG-EXCELIMPORTER-04 | Sem fallback sem JS | importação indisponível sem JavaScript | `excel-transpiler.blade.php:19,117` |
| LG-EXCELIMPORTER-05 | Assets JS não publicados | 404 e `LotepImporter is not defined` | `scripts.blade.php:1-3`; `public/assets/js/` |
| LG-EXCELIMPORTER-07 | Init duplicado no Blade | duas instâncias e handlers duplicados | `excel-transpiler.blade.php:225-341` |
| LG-EXCELIMPORTER-08 | Parser NDJSON pode perder o evento final quando a linha é cortada entre chunks | modal de progresso pode nunca fechar | `importer-class.js:189-218` |
| LG-EXCELIMPORTER-09 | `aria-valuenow` e `aria-live` ausentes | progresso/erros não anunciados | `modals.blade.php:13-15`; `importer-ui.js:48-52` |
| LG-EXCELIMPORTER-10 | Nenhum gatilho de abertura no componente | integração exige botão do consumidor | `excel-transpiler.blade.php:17` |

## 10. Checklist de paridade

- [ ] `PC-EXCELIMPORTER-15` — `@include` renderiza 4 modais com os IDs listados, um `<style>` e três `<script src>` na ordem `validator → ui → class`. Fonte: `excel-transpiler.blade.php:14,17,127,222,225`; `scripts.blade.php:1-3`.
- [ ] `PC-EXCELIMPORTER-16` — Dropzone: clique/Enter/Espaço abrem o seletor; `drop` copia `dataTransfer.files` para o input; remover volta ao estado inicial e foca a zona. Fonte: `excel-transpiler.blade.php:260-337`.
- [ ] `PC-EXCELIMPORTER-17` — Envio: `FormData` do form, `X-CSRF-TOKEN`, `Accept: application/json`, botão com spinner e reabilitação nos dois caminhos. Fonte: `importer-class.js:81-124`.
- [ ] `PC-EXCELIMPORTER-18` — Mesa: cabeçalho gerado com labels/hints, `th.col-required`, duas colunas fixas (`col-actions`, `col-index`), linhas com classes de estado e `data-line`. Fonte: `importer-ui.js:107-207`.
- [ ] `PC-EXCELIMPORTER-19` — Contadores: total sem ignoradas, válidas = ativas − inválidas, duplicatas ativas, botões de navegação visíveis só quando há ocorrências, processo desabilitado com inválida/desbloqueada. Fonte: `importer-ui.js:18-39`.
- [ ] `PC-EXCELIMPORTER-20` — Célula: `contenteditable` apenas em linha destravada/duplicada; `role=textbox` + `aria-label`; `data-key`/`data-type`/`data-placeholder`; `title` com erros. Fonte: `importer-ui.js:181-203`.
- [ ] `PC-EXCELIMPORTER-21` — Cadeado: destravar pede confirmação e trava sem confirmação; ignorar sem confirmação e reativar com confirmação; classes/ícones alternam. Fonte: `importer-ui.js:231-303`.
- [ ] `PC-EXCELIMPORTER-22` — Edição: `input` valida por tipo (obrigatório, CNPJ 14, data `DD/MM/AAAA`, moeda, inteiro) e propaga erro para a linha; `focusout` atualiza `normalized_value` e hash. Fonte: `importer-class.js:300-371`; `importer-ui.js:305-323`.
- [ ] `PC-EXCELIMPORTER-23` — Envio: exclui `.row-ignored`, converte CNPJ/moeda/data, envia `{rows, log_id, group_id, clear_current}` por XHR com CSRF. Fonte: `importer-class.js:132-235`.
- [ ] `PC-EXCELIMPORTER-24` — Progresso: cada linha NDJSON atualiza barra/label; `is_final` fecha o modal, mostra contagem e recarrega. Fonte: `importer-class.js:186-218`; `importer-ui.js:48-52`.
- [ ] `PC-EXCELIMPORTER-25` — Confirmação/alerta: um único `#modalImportExcelConfirm` com título/mensagem em `.text()`, botão "Sim, continuar"/"OK, entendi", "Não" oculto em alerta. Fonte: `importer-ui.js:62-95`; `modals.blade.php:27-42`.
- [ ] `PC-EXCELIMPORTER-26` — Acessibilidade mínima: dropzone focável com Enter/Espaço, `role=grid` + `aria-label` na tabela, `aria-label` por célula editável, `aria-hidden` nos ícones. Fonte: `excel-transpiler.blade.php:33-38,167`; `importer-ui.js:190-192`.

## 11. Evidências consultadas

| Arquivo | Faixas |
|---|---|
| `V2/Services/ExcelImporter/Views/components/excel-transpiler.blade.php` | 1-341 (integral) |
| `V2/Services/ExcelImporter/Views/components/modals.blade.php` | 1-42 (integral) |
| `V2/Services/ExcelImporter/Views/components/scripts.blade.php` | 1-116 (integral) |
| `V2/Services/ExcelImporter/Views/components/styles.blade.php` | 1-466 (integral) |
| `V2/Services/ExcelImporter/Assets/js/importer-class.js` | 1-388 (integral) |
| `V2/Services/ExcelImporter/Assets/js/importer-ui.js` | 1-442 (integral) |
| `V2/Services/ExcelImporter/Assets/js/importer-validator.js` | 1-117 (integral) |
| `V2/layouts/cms/layouts/app.blade.php` | 101,140-142 |
| `public/assets/js/` | listagem |
| `sucesu_analise/01_arquitetura/11-satelites-build-config.md` | 228-260 |

## 12. Checklist de extração (I)

| Item | Resposta |
|---|---|
| I1 | §4.1: props do include e opções do construtor. |
| I2 | §4.2: IDs, classes e `data-*`. |
| I3 | Eventos DOM: `click`, `input`, `focusout`, `keydown`, `dragover/dragenter/dragleave/dragend/drop`, `change`; todos delegados/nomeados; não emite eventos customizados. |
| I4 | Init em `DOMContentLoaded`; múltiplas instâncias conflitam; teardown só por `off('.lotepImport')`. |
| I5 | Estado em `report`/`groupId`/`confirmCallback` e no DOM; sem storage. |
| I6 | jQuery + Bootstrap 5 + ícones; ordem dos três scripts; sem fallback sem JS. |
| I7 | §4.8 (implementado e lacunas). |
| I8 | Strings pt-BR hardcoded em §6.2; sem i18n. |
