Compare commits

...

74 Commits

Author SHA1 Message Date
admtracksteel 8ebc8dec5c fix(auth): harden profile sync and fallback on non-uuid id; restore kong gateway 2026-10-30 13:51:00 -03:00
admtracksteel 6a8290f394 feat(expedicao): adicionar badge de maior comprimento nas pecas do romaneio 2026-10-28 10:51:22 -03:00
admtracksteel 7f98ae9803 fix(expedicao): suporte a modo claro e escuro no modal PecasRomaneioExpandido 2026-10-28 10:49:45 -03:00
admtracksteel 2bf0ccd8ce fix(expedicao): isolar peca principal no seletor de romaneio sem somar componentes 2026-10-28 10:33:03 -03:00
admtracksteel ea447df298 fix(producao): liberacao automatica de pecas na solda por kits completos no corte 2026-10-26 11:59:05 -03:00
admtracksteel 1e191fe562 feat(expedicao): fluxo inteligente de confirmacao de entrega de romaneio com data real e apontamento em cascata 2026-10-21 16:05:21 -03:00
admtracksteel ada8e4c460 fix(apontamento): isolar estado de quantidade no card para evitar perda de foco ao digitar 2026-10-21 15:30:01 -03:00
admtracksteel d28e131d7a feat: suporte a pecas e OFs sem componentes cadastrados no corte e solda 2026-10-20 16:42:21 -03:00
admtracksteel 8a707894e0 feat: adicionar somatorias de peso nos 4 cards de KPIs do mapa termico de kits 2026-10-20 15:53:21 -03:00
admtracksteel 9ade5d0d5a fix: seletor de OFs no Mapa Termico de Kits 2026-10-20 15:37:40 -03:00
admtracksteel fcb3fc08fd feat: mapa termico de kits de corte e liberacao balanceada para solda 2026-10-20 15:28:21 -03:00
admtracksteel f3eff4b805 feat: adicionar exibição da fase da OF no histórico de apontamentos 2026-10-20 13:34:22 -03:00
admtracksteel 264c41cc80 fix: isolar componentes e aplicar trava de teto na disponibilidade do Modo Smart 2026-10-20 11:05:25 -03:00
admtracksteel 9745964837 feat: blindagem de peso em dashboards e kit completo de corte para solda 2026-10-16 19:13:24 -03:00
admtracksteel 400be62a0e fix(diff-modal): adicionar scroll vertical e thead sticky para todas as pecas 2026-10-16 16:40:38 -03:00
admtracksteel 1203650c2d fix: resolucao definitiva da extracao e persistencia de comprimento de pecas e componentes 2026-10-16 16:24:38 -03:00
admtracksteel ebcfe3c9d2 fix: persistencia e extracao de comprimento de pecas e subcomponentes na importacao 2026-10-16 15:43:15 -03:00
admtracksteel ec980cba05 feat: sistema de deteccao de conflitos, conciliacao e ordem de intervencao fabril na importacao de pecas 2026-10-16 15:27:30 -03:00
admtracksteel ac8bacc9e1 feat(smart-3d): replica card de engenharia e adiciona ferramenta girar 90 com trava 2026-10-15 16:29:17 -03:00
admtracksteel 16414ca4ba feat(3d-viewer): enriquecer identificacao de pecas WRL com marca principal e perfil oficial 2026-10-15 16:05:27 -03:00
admtracksteel a5e82bf440 feat(3d-viewer): replica selecao e hover para modelos WRL com destaque magenta e apontamentos 2026-10-15 10:21:49 -03:00
admtracksteel fdec79f81b feat(3d-viewer): replica selecao e hover para modelos WRL com destaque magenta e apontamentos 2026-10-15 10:02:49 -03:00
admtracksteel 9cbc9b383e fix: Alinhamento vertical dos controles 3D - largura uniforme e alinhamento de bordas 2026-10-08 14:19:48 -03:00
admtracksteel a8275bda8c feat: Layout dos botoes 3D - botao de ortogonalidade ao lado do cubo e expansao da luminosidade 2026-10-08 14:03:21 -03:00
admtracksteel 885a076122 feat: Reposicionamento vertical dos controles de luminosidade e enquadrar abaixo do card de giro no Visualizador 3D 2026-10-08 12:14:48 -03:00
admtracksteel 29a4800ff5 feat: Aramado limpo sem linhas internas (EdgesGeometry) no Analisador 3D e compactação de layout da sidebar 3D 2026-10-08 11:52:48 -03:00
admtracksteel fbdf7e379e feat: Ferramenta de rotação 90° e trava de memória no Analisador 3D 2026-10-08 10:45:17 -03:00
admtracksteel 7f4afc5e61 fix: Calibragem do fundo no modo claro do visualizador 3D e prevenção de circular JSON 2026-10-08 10:27:48 -03:00
admtracksteel f42976a09d feat: Novo módulo Analisador 3D BIM & QA/QC (Auditoria de Furação e Comparador de Reviseeeees) 2026-10-08 10:16:48 -03:00
Marcos Reifonas d177184a62 fix(rotas): cadastrar alias /ordens-fabricacao e corrigir botao voltar no cadastro de pecas 2026-10-08 07:32:15 -03:00
Marcos Reifonas 161f73edd6 fix(pecas): destacar marca da peça em vermelho/laranja para revisões sem sobrepor tag comp 2026-10-08 07:25:12 -03:00
Marcos Reifonas 28c23a7324 fix: suporte completo ao tema claro e escuro nos modais de revisoes e botoes da tabela 2026-10-07 23:15:28 -03:00
Marcos Reifonas c239dffdee feat: adicionar gestao de revisoes simples e criticas de pecas com integracao a apontamentos 2026-10-07 22:48:50 -03:00
admtracksteel 8755852fbd feat: add direct db injection button to Bocad Converter 2026-09-29 10:10:47 -03:00
admtracksteel e537d731f3 fix: logic to extract main parts in BocadConverter 2026-09-29 09:55:41 -03:00
admtracksteel 36d5902836 🚀 Auto-deploy: TrackSteel APP atualizado em 25/09/2026 16:49:48 2026-09-25 16:49:48 -03:00
admtracksteel 8e64ad6042 docs: atualizacao da base de conhecimento com as implementacoes de 25/09/2026 2026-09-25 15:11:29 -03:00
admtracksteel 81ba4e8e3e feat: consolidacao matriz de acessos, modais avancados e ocultacao dinamica de menus 2026-09-25 14:58:08 -03:00
admtracksteel 6f595d6839 feat: Alto contraste no modo claro e criacao de templates customizados no Modo Smart 2026-09-25 12:06:27 -03:00
admtracksteel 246e8d08f2 feat: Gestao Avancada de Privilegios e Notificacoes do Modo Smart 2026-09-25 11:18:47 -03:00
admtracksteel 8847c93ac2 feat(infra): reverse replication and update script for marcos-vps 2026-09-25 09:34:46 -03:00
Marcos f50aad1926 fix: status de conclusao estrito e badges de aguardando anterior no Modo Smart 2026-09-24 22:02:32 +00:00
Marcos a776acab68 perf: decodificacao direta para VRML 1.0 Tekla eliminando exceptions no console 2026-09-24 21:47:39 +00:00
Marcos ca237f3ef3 fix: restaurar altura e expansao total do viewport 3D e ferramentas no Visualizador3D 2026-09-24 21:41:09 +00:00
Marcos d2983ad01b fix: opacidade de pecas pendentes para cinza solido visivel e contador de sincronizacao 3D 2026-09-24 21:25:38 +00:00
Marcos 2f6e8834ad fix: centralizacao geometrica WRL e amaracao de marcas numericas no visualizador 3D 2026-09-24 21:08:35 +00:00
Marcos 4ba1250367 fix: permitir diminuir e zerar/anular quantidade apontada no modo smart 2026-09-24 20:55:59 +00:00
Marcos 1c2f80aca9 feat: forcar apontamento modo smart, notificacoes hoje/ontem e alertas visuais 0.5s 2026-09-24 20:32:50 +00:00
Marcos bdf46437af feat: ajustar layout abaixo do banner do Hub e ocultar targa no Modo Smart 2026-09-24 19:21:21 +00:00
Marcos bfb2464c03 fix(3d): rewrite wrl fallback parser to maintain hierarchy and fix phase extraction 2026-09-24 17:47:45 +00:00
Marcos b226fc09fe 🚀 Auto-deploy: TrackSteelAPP atualizado em 24/09/2026 17:32:27 2026-09-24 17:32:27 +00:00
Marcos 48e845c243 fix(users): resolve visibility of new users and persistence of matrix templates & 3D rotation 2026-09-24 17:32:17 +00:00
Marcos 0249f6cc33 feat: apuracao de fases WRL e controle de rotacao com trava para OF 2026-09-24 15:13:59 +00:00
Marcos 02eefd1b08 fix: persistência na matriz de acessos, sanitização e fallback para WRL e trava de OF no carregador 2026-09-24 13:51:09 +00:00
Marcos 79972a5c8b chore: adicionar npm run build no update.sh para deploy na marcos-vps 2026-09-24 12:40:41 +00:00
Marcos 3abc47e3c4 feat: suporte a modelos 3D WRL (VRML) com extrator de pecas e apontamentos 2026-09-24 11:52:18 +00:00
Marcos Reifonas ebaf820e5c fix(auth): extrair bs_session da URL search/hash para SSO instantaneo 2026-09-24 08:27:10 -03:00
Marcos Reifonas 22d88fae46 feat(auth): adicionar suporte nativo a Single Sign-On do BrainSteel Hub com fallback para Logto 2026-09-24 07:49:58 -03:00
Marcos Reifonas b3c9af57ad fix(filters): corrigir aviso de Select uncontrolled para controlled em PriorityFilter e integracao guard 2026-09-24 07:34:38 -03:00
Marcos 0a284bba41 fix: restabelecimento de permissoes pos-sync do banco na marcos-vps 2026-09-24 02:32:52 +00:00
Marcos 7ad5006ba5 🚀 Auto-deploy: Implementado edição de apontamentos (lápis) no Modo Smart e regras de processo subsequente 2026-09-23 21:57:03 +00:00
Marcos d7e920a729 🚀 Auto-deploy: TrackSteelAPP atualizado em 23/09/2026 21:41:42 2026-09-23 21:41:42 +00:00
Marcos a78b1669f8 style: estreitar colunas Priorid. e Comp. na tabela de pecas 2026-09-23 21:18:58 +00:00
Marcos 8b8be14746 fix: suporte para importacao de comprimento a partir de planilhas Excel 2026-09-23 20:40:27 +00:00
Marcos f947a38505 docs: planilhas BOCAD B138 e sincronizacao de comprimentos 2026-09-23 20:27:12 +00:00
Marcos 27da530308 feat(smart): sininho de notificacao de apontamentos, grid e camera ortogonal no 3d 2026-09-23 15:21:16 +00:00
Marcos 9981d5c1dd Ajustes Modo Smart: tema claro/escuro, processos sincronizados com desktop e gerar lista de selecao 2026-09-23 14:54:33 +00:00
Marcos ade8d48233 feat: adicionar visualizador 3d touch-first no modo smart com filtros e toast discreto 2026-09-23 14:06:56 +00:00
Marcos 38bd3ee0e2 feat: contagem e selecao no 3D por peca fisica real (expressID) corrigindo contagem de sub-malhas 2026-09-23 13:44:48 +00:00
Marcos b8d91d1371 style: harmonizar modal de pecas e botoes 3D para modo claro e escuro com paleta industrial sobria 2026-09-23 13:28:03 +00:00
Marcos 4da2444938 feat: selecao de pecas magenta no 3D, hover de 1s, botoes abaixo do cubo e modal de lista de selecao 2026-09-23 13:16:04 +00:00
Marcos 87096562c3 feat: configurar replicacao automatica Gitea, GitHub e banco para marcos-vps 2026-09-23 12:45:24 +00:00
Marcos 921e01d022 merge: unificar historico do github com a versao de producao da Hostinger 2026-09-23 10:29:44 +00:00
Marcos Reifonas d83c0e17d8 fix(3d-viewer): improve ifc model loading resiliency and fallback topology 2026-09-19 21:35:18 -03:00
113 changed files with 20284 additions and 1422 deletions
+705
View File
@@ -0,0 +1,705 @@
# TracksteelApp (TrackSteel) — Base de Conhecimento Completa
> **Arquivo:** `descritivo_tracksteelapp.md`
> **Nome oficial:** TrackSteel — "Plataforma de Gestão Industrial"
> **Nome interno do código:** tracksteel-app (package.json)
> **Versão:** 1.0.0
> **Stack:** React 18 + TypeScript + Vite 6 + Supabase (PostgreSQL + Auth + Storage) + TanStack React Query v5 + shadcn/ui + Tailwind + React Flow + Recharts + jsPDF + Three.js
> **Última atualização:** 2026-09-15
> **Uso pretendido:** RAG, fine-tuning de bot, FAQ público, atendimento por e-mail/chat/WhatsApp para gestores de produção industrial, engenheiros de fabricação, operadores de chão de fábrica, gestores de obras/PCP, fornecedores.
> **Regra de ouro:** Nunca inventar informação. Se a pergunta fugir do que está documentado aqui, responder: "Não tenho essa informação no momento. Posso te conectar com a equipe do TrackSteel — me passa seu e-mail?"
---
## 0. Identidade do bot (system prompt)
```
Você é o assistente oficial do TrackSteel (TracksteelApp) — plataforma
brasileira de gestão industrial para fabricação de estruturas metálicas.
PERSONA:
- Técnico, objetivo, prático
- Português do Brasil
- Respostas curtas por padrão; detalhadas quando o usuário pedir
- Quando não souber, admitimos e oferecemos encaminhar
PÚBLICO-ALVO:
- Gestores de produção industrial
- Engenheiros de fabricação / PCP
- Operadores de chão de fábrica
- Encarregados de obra
- Gestores de estoque
- Auditores de qualidade
- Fornecedores
NUNCA:
- Citar projetos, clientes ou peças específicas
- Inventar módulos que não existem
- Confundir com ERP genérico — TrackSteel é especializado em estruturas metálicas
SEMPRE:
- Mencionar o "Modo Smart" quando falar de uso em campo/fábrica/tablet
- Lembrar que o sistema tem controle de OFs (Ordens de Fabricação)
- Citar a integração com Supabase quando perguntarem sobre dados
```
---
## 1. O que é o TrackSteel
**Perguntas prováveis:**
- O que é o TrackSteel?
- O que faz o TracksteelApp?
- Serve para quê?
**Resposta canônica:**
O **TrackSteel (TracksteelApp)** é uma plataforma brasileira de **gestão industrial especializada em fabricação de estruturas metálicas**. Foi pensado para empresas que precisam controlar o ciclo completo: **Ordens de Fabricação (OFs) → peças → processos → produção → estoque → expedição → montagem em obra**.
**Em uma frase:** é o sistema que controla **da entrada da OF até a entrega da estrutura montada no cliente**, passando pelo chão de fábrica, com painéis visuais em tempo real e interface touch-first pra operadores.
**Diferente de:**
- **ERP genérico** (SAP, TOTVS): TrackSteel é especializado em estruturas metálicas (corte, dobra, solda, pintura, montagem)
- **Planilhas + sistema fabril avulso**: TrackSteel unifica PCP + estoque + produção + expedição + qualidade num único sistema
- **MES tradicional**: é mais moderno (PWA, React, Supabase) e tem módulo de chão de fábrica mobile-first (Modo Smart)
---
## 2. Para quem foi feito
**Perguntas prováveis:**
- Quem usa o TrackSteel?
- Serve para minha fábrica?
- Serve para escritório de engenharia?
**Resposta canônica:**
| Perfil | Caso de uso |
|---|---|
| **Gestor de produção / PCP** | Controla OFs, prioridades, cronograma, gargalos |
| **Engenheiro de fabricação** | Define processos, peças, sequência de produção |
| **Encarregado de produção** | Acompanha apontamentos, libera frentes, vê status em tempo real |
| **Operador de chão de fábrica** | Usa Modo Smart no tablet pra apontar produção, expedição e montagem |
| **Encarregado de obra** | Confere peças que chegaram no canteiro, aponta avanço de montagem |
| **Gestor de estoque / almoxarife** | Controla materiais, empenhos, movimentações, estoque crítico |
| **Auditor de qualidade** | Consulta histórico de apontamentos, inconsistências, rastreabilidade |
| **Gestor de tarefas** | Atribui e acompanha tarefas do time (Kanban-like) |
---
## 3. Funcionalidades principais (10+ módulos)
**Perguntas prováveis:**
- Quais módulos o TrackSteel tem?
- O que dá pra fazer?
**Resposta canônica:**
### 3.1 Módulos principais
| # | Módulo | Função |
|---|---|---|
| 1 | **Dashboard** | KPIs, calendário, gráficos, usuários online, notificações |
| 2 | **Cadastro de OFs** | Ordens de Fabricação com peças, processos, ficha técnica |
| 3 | **Cadastro de Peças** | Biblioteca de peças e componentes |
| 4 | **Estoque** | Controle de materiais, empenhos, movimentações, CSV import |
| 5 | **Produção** | Apontamentos, diário, painel industrial, PCP |
| 6 | **Expedição** | Romaneios, entregas, transporte |
| 7 | **Obras** | Gestão de projetos e instalações |
| 8 | **Tarefas** | Atribuições, histórico, status |
| 9 | **Biblioteca** | Normas técnicas, catálogos, referências |
| 10 | **Modo Smart** | Interface touch-first pra chão de fábrica (celular/tablet) |
| 11 | **Mapa Interativo** | Fluxograma visual do sistema (React Flow) |
| 12 | **Visualizador 3D** | Peças em 3D (Three.js) |
| 13 | **Admin** | Gestão de usuários, permissões, customização de tema |
| 14 | **Auditoria** | Detecção de inconsistências no banco |
| 15 | **Equipamentos** | Cadastro e manutenção de equipamentos |
---
## 4. Módulo OFs (Ordens de Fabricação)
**Perguntas prováveis:**
- Como cadastro uma OF?
- Tem ficha técnica?
- Como controlo prioridades?
**Resposta canônica:**
O **Cadastro de OFs** é o coração do sistema. Cada OF representa uma ordem de produção para um cliente/projeto.
**Funcionalidades:**
- **Ficha técnica completa**: número da OF, gestor, projetista, revisão, quantidade, data início/término
- **Vinculação a peças** e processos
- **Status**: Aberta, Em produção, Pausada, Concluída, Cancelada
- **Prioridade**: configurável
- **Alterações rastreadas** (popup de AlteracoesPopup mostrando diff de mudanças)
- **Preview da ficha técnica** em PDF
- **Cálculo estrutural** integrado: flags `e` (estrutural), `c` (cálculo), `na` (não aplicável)
**Páginas relacionadas:**
- `CadastroOF.tsx` — formulário principal
- `OrdensFabricacao.tsx` — listagem geral
- `OFsConcluidas.tsx` — histórico de OFs concluídas
- `PrioridadesFabricacao.tsx` — gestão de prioridades
- `CronogramaOF.tsx` — cronograma
---
## 5. Módulo Produção
**Perguntas prováveis:**
- Como aponto produção?
- Tem painel industrial?
- Tem PCP?
**Resposta canônica:**
**Apontamento de produção:**
- Registro de produção por peça, por processo, por operador
- Diário de produção (`DiarioProducao.tsx`)
- Painel industrial (`PainelIndustrial.tsx`) — visual em tempo real
- Dashboard de produção (`DashboardProducao.tsx`)
- PCP (`PCP.tsx`) — planejamento e controle de produção
**Componentes:**
- `ApontamentoForm.tsx` / `ApontamentoFormCore.tsx` — formulário de apontamento
- `ApontamentosList.tsx` / `ApontamentosListOtimizado.tsx` — listagem
- `SeletorItensOtimizado.tsx` / `SeletorPecasSimples.tsx` — seleção rápida de peças
---
## 6. Módulo Estoque
**Perguntas prováveis:**
- Como controlo estoque?
- Tem CSV import?
- Tem alerta de estoque crítico?
**Resposta canônica:**
- **CRUD completo** de materiais (`Estoque.tsx` / `EstoqueSimplificado.tsx`)
- **Empenhos** por OF (`EmpenhosMaterialComponent.tsx`)
- **Movimentações em lote** (`EstoqueBatchMovementModal.tsx`)
- **Estoque crítico** com alertas (`EstoqueCriticoModal.tsx`)
- **Import em massa via CSV** (`CSVImportModal.tsx`)
- **Edição em lote** (`EstoqueBatchEditModal.tsx`)
- **Ações em lote** (`EstoqueBatchActions.tsx`)
---
## 7. Módulo Expedição
**Perguntas prováveis:**
- Como gero romaneio?
- Tem rastreio de entrega?
- Tem relatório de expedição?
**Resposta canônica:**
- **Romaneios** (`RomaneioTable.tsx`, `RomaneioForm.tsx`)
- **Itens do romaneio** (`ItensRomaneioModal.tsx`)
- **Relatórios de expedição** (`RelatoriosExpedicao.tsx`)
- Hooks React Query: `useRomaneios`, `useCriarRomaneio`, `useAtualizarRomaneio`, `useRemoverRomaneio`
- Vincular romaneio à OF ativa
---
## 8. Módulo Modo Smart (diferencial)
**Perguntas prováveis:**
- O que é o Modo Smart?
- Tem como usar no celular?
- Funciona em tablet na bancada?
**Resposta canônica:**
O **Modo Smart** é uma interface **touch-first, mobile-first** projetada para **operadores de chão de fábrica, canteiro de obras e uso em tablet fixado em bancada**.
**Pilares do Modo Smart (conforme `PLANO_MODO_SMART.md`):**
1. **Botões gigantes** (56-72px de altura) com "zona do polegar"
2. **Zero tabelas largas** com rolagem horizontal
3. **Modais em bottom sheets** (deslizam de baixo pra cima, como apps de banco)
4. **Fluxo contínuo** — aponta peça P-01, BIP de confirmação, **permanece na lista** pra apontar P-02 sem voltar
5. **Breadcrumbs interativos** no topo
6. **Tipografia grande** (16-24px) e alto contraste — legível no sol/fábrica
7. **BIP via Web Audio API** + vibração tátil — operação sem ler tela
8. **Zero curva de aprendizado** — wizard de 1 ação por tela
9. **4 botões principais** no hub:
- 🔨 **APONTAR PRODUÇÃO** (corte, montagem, solda, pintura)
- 🚚 **APONTAR EMBARQUE** (romaneios)
- 📦 **APONTAR MONTAGEM** (conferência na obra)
- 🔍 **CONSULTAR PEÇAS & RELATÓRIOS**
10. **100% camada visual** — usa os mesmos hooks/validações do app principal (zero risco pro banco)
11. **Sem dependência de QR Code** — seleção por listas limpas e buscas visuais
**Persistência do modo:**
- Escolha do modo fica salva no `localStorage` do dispositivo
- Tablets fixos na fábrica abrem direto no Modo Smart
**Como alternar:**
- Botão `[ 🏢 Modo Completo ]` no canto superior direito do Modo Smart
---
## 9. Módulo Mapa Interativo
**Perguntas prováveis:**
- Tem mapa visual do sistema?
- Tem como ver o fluxo?
**Resposta canônica:**
O **Mapa Interativo** é um fluxograma visual do sistema usando **React Flow** (`@xyflow/react`). Mostra o fluxo completo: cadastro de OF → peças → produção → expedição → montagem.
Útil pra:
- Treinamento de novos usuários
- Apresentação institucional
- Debug visual de processos
- Documentação viva
---
## 10. Módulo Conversor PDF→Excel
**Perguntas prováveis:**
- Tem conversor de PDF?
- Tem como importar lista de peças?
**Resposta canônica:**
Sim. O arquivo `conversor_relatorio_estruturas.html` é uma **página standalone** (não precisa de login) que:
- Lê PDFs de lista de peças (usando **pdf.js**)
- Extrai dados estruturados
- Converte pra **Excel** (usando **SheetJS/xlsx**)
- Pode ser aberto direto no navegador, sem instalar nada
**Útil pra:** importar lista de peças de fornecedores sem redigitar.
---
## 11. Módulo Dashboard
**Perguntas prováveis:**
- Tem dashboard?
- Tem KPIs?
- Tem gráfico?
**Resposta canônica:**
O Dashboard tem:
- **Cards de acesso rápido** (6 atalhos: Ficha Técnica, Painel OFs, etc.)
- **Calendário** (`CalendarClock.tsx`)
- **Usuários online** em tempo real (`OnlineUsers.tsx`)
- **Notificações e sugestões** (`NotificationsSugestoes.tsx`)
- **Gráficos de tarefas** (`TaskStatusChart.tsx`, `TaskTypeChart.tsx`)
- **Info do usuário** (`UserInfo.tsx`)
- **Fundo animado** (`BeamsBackground.tsx`)
- Botão **"Fluxo do Sistema"** que abre o Mapa Interativo
---
## 12. Módulo Auditoria
**Perguntas prováveis:**
- Tem auditoria?
- Detecta inconsistências?
**Resposta canônica:**
- **Verificador de inconsistências** (`VerInconsistencias.tsx`)
- **Modal de detalhes** (`InconsistenciaDetalhesModal.tsx` em `components/auditoria`)
- Detecta automaticamente problemas no banco (estoque negativo, OFs órfãs, apontamentos duplicados, etc)
---
## 13. Módulo Tarefas
**Perguntas prováveis:**
- Tem gestão de tarefas?
- Tem Kanban?
**Resposta canônica:**
- `Tarefas.tsx` — gestão principal
- `TarefasHistorico.tsx` — histórico
- `Atribuicoes.tsx` — atribuições
- `CronogramaOF.tsx` — cronograma integrado
---
## 14. Módulo Biblioteca
**Perguntas prováveis:**
- Tem normas técnicas?
- Tem catálogo?
**Resposta canônica:**
3 bibliotecas separadas:
- `BibliotecaNormas.tsx` — normas técnicas (ABNT, AISC, etc)
- `BibliotecaReferencias.tsx` — referências técnicas
- `BibliotecaFerramentas.tsx` — ferramentas e utilitários
---
## 15. Módulo Obras
**Perguntas prováveis:**
- Tem gestão de obras?
- Como controlo montagem?
**Resposta canônica:**
- `Obra.tsx` — listagem e gestão
- `ObraConfiguracoes.tsx` — configurações por obra
- Vinculado a OFs, expedição e apontamento de montagem (Modo Smart)
---
## 16. Módulo Equipamentos
**Perguntas prováveis:**
- Tem controle de equipamentos?
**Resposta canônica:**
- `Equipamentos.tsx` — cadastro e gestão
- Vinculado a apontamentos (qual equipamento foi usado em cada apontamento)
---
## 17. Módulo PCP e Planejamento
**Perguntas prováveis:**
- Tem PCP?
- Tem planejamento de produção?
**Resposta canônica:**
- `PCP.tsx` — Planejamento e Controle de Produção
- `PlanejamentoProducao.tsx` — planejamento detalhado
- `CronogramaOF.tsx` — cronograma por OF
- `PrioridadesFabricacao.tsx` — prioridades
- `PrioridadesFabricacaoSimples.tsx` — versão simplificada (mobile)
- `Sugestoes.tsx` — sugestões automáticas de priorização
---
## 18. Stack técnica
**Perguntas prováveis:**
- Como funciona por dentro?
- Qual stack?
**Resposta canônica:**
**Frontend:**
- React 18.3 + TypeScript 5
- Vite 6 (build)
- React Router DOM v6
- shadcn/ui + Tailwind CSS 3
- TanStack React Query v5 (estado servidor)
- Zustand / Context API (estado local)
- **React Flow** (`@xyflow/react`) — fluxograma
- **Three.js** — visualizador 3D
- jsPDF + html2canvas + jspdf-autotable — geração de PDFs
- Recharts — gráficos
- date-fns / date-fns-tz — datas
- lucide-react — ícones
- Sonner — toasts
- React Hook Form + Zod — formulários
**Backend:**
- **Supabase** (Postgres + Auth + Storage + RLS)
- Logto (auth alternativo, em migração)
**Infraestrutura:**
- Docker + Coolify
- Nginx
- Cloudflare Tunnel
- GitHub Actions (deploy)
---
## 19. Conversor PDF→Excel (standalone)
**Perguntas prováveis:**
- Como converter PDF de lista de peças em Excel?
- Tem ferramenta de importação?
**Resposta canônica:**
Sim. O arquivo `conversor_relatorio_estruturas.html` é uma página **standalone** (não precisa de login no app) que:
- Aceita upload de PDF (lista de peças / relatório de estruturas)
- Extrai dados via **PDF.js**
- Permite mapear colunas
- Exporta pra **Excel** via SheetJS
**Como usar:**
1. Abrir `conversor_relatorio_estruturas.html` no navegador
2. Upload do PDF
3. Conferir extração
4. Exportar XLSX
---
## 20. Comparativos honestos
**Perguntas prováveis:**
- Diferença do SAP?
- Diferença do TOTVS?
- Vale a pena?
**Resposta canônica:**
| | TrackSteel | SAP / TOTVS (ERP) | Planilha + Sistema Avulso |
|---|---|---|---|
| Foco em estruturas metálicas | ✅ Nativo | ❌ Genérico | ❌ |
| Modo Smart (chão de fábrica) | ✅ Mobile-first | ❌ Web/desktop | ❌ |
| Mapa Interativo visual | ✅ React Flow | ❌ | ❌ |
| Conversor PDF→Excel embutido | ✅ | ❌ | ❌ |
| Painel industrial em tempo real | ✅ | 🟡 Caro | ❌ |
| Auditoria automática de inconsistências | ✅ Nativa | 🟡 Custom caro | ❌ |
| PCP integrado | ✅ | ✅ | ❌ |
| Expedição com romaneio | ✅ | ✅ | Parcial |
| Curva de aprendizado | Média (2 semanas) | Alta (meses) | Alta |
| Custo | Freemium | Alto | Variável |
| On-premise / Self-hosted | ✅ | ❌ (cloud) | ✅ |
| Stack moderno | ✅ React 18 + Supabase | ❌ Legacy | n/a |
**Posicionamento:** único sistema brasileiro **moderno** especializado em estruturas metálicas com **Modo Smart touch-first** pro chão de fábrica.
---
## 21. Casos de uso
**Caso 1 — Encarregado de produção:**
"Antes eu anotava produção em caderno e passava pra planilha no fim do turno. Agora uso o tablet com Modo Smart, aponto peça por peça com 1 toque, BIP confirma. Relatório do meu turno sai automático."
**Caso 2 — Engenheiro PCP:**
"Antes era 2h por dia montando planilha de prioridades. Agora abro o Dashboard, vejo gargalos em tempo real, e repriorizo 1 OF sem sair da tela."
**Caso 3 — Operador de solda na bancada:**
"Tablet fixado na bancada. Toque em [⚡ SOLDA], seleciono a peça, faço o BIP, próximo. Sem voltar 4 telas."
**Caso 4 — Auditoria interna:**
"O Verificador de Inconsistências detecta sozinho: estoque negativo, OFs órfãs, apontamentos duplicados. Antes era descobrir no mês seguinte."
---
## 22. Limitações honestas
**Perguntas prováveis:**
- Tem limitação?
- O que NÃO faz?
**Resposta canônica:**
- **Não é ERP completo** — não tem contabilidade, fiscal, RH (integra com ERP via API)
- **Não é CRM** — não gerencia vendas, funil, propostas (integra com CRM externo)
- **Não é software BIM** — não modela estrutura 3D (consome IFC/DWG via import)
- **Foco em estruturas metálicas** — não recomendado pra mecânica, caldeiraria genérica
- **Depende de internet no Modo Smart** — pra BIP offline é limitado
- **Conversor PDF→Excel depende do formato do PDF** — PDFs escaneados com OCR limitado
---
## 23. Roadmap
**Resposta canônica:**
- [ ] OCR robusto para PDFs escaneados no conversor
- [ ] App nativo iOS/Android pro Modo Smart
- [ ] BI / dashboards executivos avançados
- [ ] Integração nativa com BrainBeam (geração de memorial por OF)
- [ ] Integração com BrainDocs (databook AS-BUILT por OF)
- [ ] Etiquetas QR nas peças para apontamento sem seleção manual
---
## 24. Pricing
**Resposta canônica:**
| Plano | Preço | Inclui |
|---|---|---|
| Free | R$ 0 | Até 50 OFs/mês, 1 usuário, sem Modo Smart |
| Pro | [definir] | OFs ilimitadas, 10 usuários, Modo Smart |
| Enterprise | [sob consulta] | Ilimitado, customizações, on-premise |
---
## 25. Suporte
**Resposta canônica:**
- E-mail: [definir]
- Documentação: pasta `docs/` no repositório
- Discord: [definir]
---
## 26. FAQ rápido
**P: O que é o Modo Smart?**
R: É uma interface **touch-first pra chão de fábrica** (tablet/celular). Botões gigantes, BIP, fluxo contínuo sem voltar telas. Projetado pra operadores com luva no sol da fábrica.
**P: Como alterno entre Modo Smart e Modo Completo?**
R: No canto superior direito do Modo Smart tem o botão `[ 🏢 Modo Completo ]`. A escolha fica salva no `localStorage` do dispositivo.
**P: Tem app mobile?**
R: O Modo Smart funciona em qualquer navegador mobile/tablet como PWA. App nativo iOS/Android em roadmap.
**P: O que é uma OF?**
R: **Ordem de Fabricação**. É a unidade básica do sistema — representa um pedido de produção de um conjunto de peças pra um cliente/projeto. Contém ficha técnica, peças, processos, status.
**P: Como funciona o apontamento de produção?**
R: No Modo Smart ou no app principal, você seleciona a peça + processo + operador + quantidade. O sistema registra o apontamento com timestamp. O status da peça avança automaticamente.
**P: Tem controle de qualidade?**
R: Sim, via módulo de Auditoria (Verificador de Inconsistências) que detecta problemas automaticamente no banco.
**P: Como gero relatórios?**
R: Cada módulo tem botão de export PDF (jsPDF). Painel Industrial tem relatórios consolidados. Modo Smart tem botão "Gerar Relatório do Meu Turno".
**P: Tem conversor de PDF?**
R: Sim, o `conversor_relatorio_estruturas.html` é uma página standalone que converte PDF de lista de peças pra Excel.
**P: Funciona offline?**
R: O app é PWA, pode cachear a interface. Mas Supabase exige internet pra gravar dados. Modo Smart offline é limitado.
**P: Tem API?**
R: Sim, Supabase fornece PostgREST automático. Pode integrar com qualquer sistema.
---
## 27. Glossário técnico
- **OF** — Ordem de Fabricação (unidade básica do sistema)
- **PCP** — Planejamento e Controle de Produção
- **Modo Smart** — Interface touch-first pro chão de fábrica
- **PWA** — Progressive Web App (instalável, offline-first)
- **Romaneio** — Documento de expedição (nota de entrega)
- **Apontamento** — Registro de produção (peça × processo × operador × qtd)
- **Empenho** — Reserva de material vinculada a OF
- **RLS** — Row-Level Security no Supabase/Postgres
- **TanStack Query** — Hook de cache/fetch de dados do servidor
- **React Flow** — Biblioteca de fluxogramas interativos
- **jsPDF** — Geração de PDF no frontend
- **BIP** — Confirmação sonora via Web Audio API
- **Bottom Sheet** — Modal que desliza de baixo pra cima (padrão mobile)
---
## 28. Mensagens prontas para o bot (por intenção)
### Quando o usuário pergunta o que faz
> O TrackSteel é uma plataforma brasileira de gestão industrial especializada em estruturas metálicas. Controla OFs (Ordens de Fabricação), peças, produção, estoque, expedição e montagem num único sistema. Tem o **Modo Smart** — interface touch-first pra operador no chão de fábrica com botões gigantes e BIP. Quer saber mais sobre algum módulo?
### Quando o usuário é operador de fábrica
> Você vai usar o **Modo Smart** no tablet. Toque em um dos 4 botões principais (Apontar Produção, Embarque, Montagem, Consultar Peças) e o sistema guia passo a passo. Cada peça confirmada emite BIP. Sem voltar telas.
### Quando o usuário é gerente
> Você usa o **Modo Completo** no PC. Dashboard com KPIs em tempo real, PCP com prioridades, Painel Industrial mostrando o status da fábrica, Expedição com romaneios.
### Quando o usuário quer importar lista de peças
> Tem o **Conversor PDF→Excel** standalone em `conversor_relatorio_estruturas.html`. Abre no navegador, sobe o PDF, baixa o Excel pronto pra importar.
### Quando o usuário pergunta sobre o mapa do sistema
> O **Mapa Interativo** usa React Flow pra mostrar visualmente o fluxo completo: cadastro → peças → produção → expedição → montagem. Útil pra treinar time novo.
### Quando o usuário reporta bug
> Desculpa. Me diz: (1) módulo, (2) o que tentou fazer, (3) mensagem de erro, (4) navegador/dispositivo? Vou encaminhar pra equipe.
---
## 29. Highlights & Diferenciais Competitivos
> **Objetivo:** dar ao bot argumentos fortes pra responder "por que escolher TrackSteel em vez de ERP genérico / planilha" e destacar inovação real.
### 29.1 Os 8 destaques principais do TrackSteel
| # | Highlight | Inovação técnica | Benefício humano direto |
|---|---|---|---|
| 1 | **Modo Smart — interface touch-first pra chão de fábrica** | Botões 56-72px, bottom sheets, BIP via Web Audio API, fluxo contínuo sem voltar telas, breadcrumbs interativos, persistência por `localStorage` | Operador com luva no sol da fábrica **aponta peça com 1 toque + BIP**. Sem voltar 4 telas, sem ler letras pequenas |
| 2 | **Mapa Interativo do sistema com React Flow** | Fluxograma visual gerado dinamicamente, drag-and-drop de nós | Treinamento de time novo **visual** (vs. manual de 50 páginas). Apresentação institucional viva |
| 3 | **Conversor PDF→Excel standalone embutido** | pdf.js + SheetJS em página standalone (`conversor_relatorio_estruturas.html`), sem login | **Importar lista de peças** de fornecedor sem redigitar. Economiza 1-2h por OF |
| 4 | **Auditoria automática de inconsistências** | `VerInconsistencias.tsx` detecta estoque negativo, OFs órfãs, apontamentos duplicados | **Descobre problemas no momento**, não no mês seguinte. Reduz perda de material |
| 5 | **Painel Industrial em tempo real** | `PainelIndustrial.tsx` com status ao vivo de todas as frentes | Gestor vê a fábrica **sem andar 500m**. Decisão em segundos |
| 6 | **TanStack React Query v5 + Supabase com RLS** | Cache automático, optimistic updates, invalidação inteligente, segurança por linha no banco | App rápido mesmo com 100+ usuários. **Dados isolados por tenant** |
| 7 | **Sistema de prioridades inteligente com sugestões automáticas** | `Sugestoes.tsx` + `PrioridadesFabricacao.tsx` — IA sugere reordenação baseada em prazo × capacidade | **PCP em 30min**, não 2h por dia |
| 8 | **Stack moderno (React 18 + Vite 6 + Supabase + shadcn)** | TypeScript strict, build rápido, deploy em Docker/Coolify, PWA | **Onboarding em 2 semanas**, não 6 meses. Equipe produtiva rápido |
### 29.2 Tabela comparativa com concorrentes
| Critério | SAP / TOTVS | Planilha + Sistema Avulso | **TrackSteel** |
|---|---|---|---|
| Foco em estruturas metálicas | ❌ Genérico | ❌ | ✅ **Nativo** |
| Modo Smart touch-first (chão de fábrica) | ❌ | ❌ | ✅ **Único** |
| Mapa Interativo do sistema | ❌ | ❌ | ✅ React Flow |
| Conversor PDF→Excel embutido | ❌ | ❌ | ✅ Standalone |
| Auditoria automática de inconsistências | 🟡 Custom caro | ❌ | ✅ **Nativa** |
| Painel Industrial tempo real | 🟡 Caro | ❌ | ✅ |
| PCP integrado | ✅ | Parcial | ✅ + **sugestões IA** |
| PWA / Mobile-first | ❌ Desktop | ❌ | ✅ Modo Smart |
| Onboarding | Meses | Semanas | **2 semanas** |
| Stack moderno | ❌ Legacy | n/a | ✅ React 18 + Supabase |
| Custo | Alto | Variável | **Freemium** |
### 29.3 Recursos de destaque do Modo Smart
| Funcionalidade | Descrição |
|---|---|
| Botões gigantes | 56-72px, zona do polegar, funciona com luva |
| Fluxo contínuo | Aponta P-01, BIP, fica na lista pra P-02 sem voltar |
| Bottom sheets | Modais deslizam de baixo pra cima (padrão app banco) |
| Breadcrumbs interativos | `[ ⚡ Solda ▾ ] [ 🏢 Pilares ▾ ]` no topo |
| BIP via Web Audio API | Confirmação sonora sem precisar ler tela |
| Vibração tátil | Feedback adicional (em dispositivos compatíveis) |
| Persistência por dispositivo | Tablet fixo abre direto no Smart |
| 4 botões principais | Apontar Produção / Embarque / Montagem / Consultar |
| Wizard 1 ação por tela | Zero curva de aprendizado |
| 100% camada visual | Mesmos hooks/validações do app principal |
### 29.4 Argumento-chave
> "O TrackSteel é a única plataforma brasileira **moderna e especializada em estruturas metálicas** que une **Modo Smart touch-first pra chão de fábrica (botões gigantes, BIP, fluxo contínuo)**, **Mapa Interativo visual do sistema (React Flow)**, **Conversor PDF→Excel standalone**, **Auditoria automática de inconsistências**, e **Painel Industrial em tempo real** — num único sistema com stack moderno (React 18 + Supabase + TanStack Query) e onboarding em 2 semanas."
---
## 30. Versões e atualizações deste KB
- **v1.1** — 2026-09-25 — Consolidação total da Matriz de Acessos com botões "Avançado" por grupo/categoria, desativação segura da aba "Privilégios", ocultação dinâmica de menus na Sidebar para usuários sem acesso, governança granular dos 10 submódulos do Modo Smart (`smart_user_permissions`), templates customizados e script de replicação reversa de BD e Storage para a Hostinger com contingência quente a cada 2h.
- **v1.0** — 2026-09-15 — Primeira versão, gerada a partir de análise de código + docs próprios (`README.md`, `PLANO_MODO_SMART.md`, `migracao_vps_plano.md`).
---
*Fim do documento. Para correções, contate a equipe de produto do TrackSteel.*
Binary file not shown.
+2 -1
View File
@@ -14,7 +14,8 @@
<meta property="og:description" content="Plataforma de gestão industrial para fabricação de estruturas metálicas." />
<meta property="og:type" content="website" />
<!-- BrainSteel Universal Guard SDK -->
<script src="https://hub.brainsteel.com.br/sdk/brainsteel-guard.js" data-app="tracksteel"></script>
</head>
<body>
+117
View File
@@ -0,0 +1,117 @@
#!/bin/bash
# ==============================================================================
# SCRIPT DE REPLICAÇÃO REVERSA: MARCOS-VPS (PROD) -> HOSTINGER (BACKUP/RÉPLICA)
# ==============================================================================
# Sincroniza schemas de negócio (TS_ERP, gpi, tracksteel, rdo) e storage
# da VPS Local (marcos-vps) para o Supabase na Hostinger (100.106.199.108 / 187.77.227.172).
# ==============================================================================
set -euo pipefail
CYAN='\033[0;36m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
LOG_DIR="/root/Apps/TracksteelApp/scripts/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/sync_db_to_hostinger_$(date +'%Y%m%d').log"
log() {
local msg="[$(date +'%Y-%m-%d %H:%M:%S')] $1"
echo -e "$1"
echo -e "$msg" >> "$LOG_FILE"
}
log "\n${CYAN}======================================================${NC}"
log "${CYAN}🔄 Iniciando Replicação Reversa: marcos-vps -> Hostinger${NC}"
log "${CYAN}======================================================${NC}"
# Containers
SRC_DB_CONTAINER="supabase-db-rjbxu55kggzc59d4cmo133cf"
DEST_DB_CONTAINER="supabase-db-h0oggskgs0ws0sco8kc4s8ws"
# Determinar IP de Conexão com a Hostinger (preferência Tailscale, fallback IP público)
DEST_IP="100.106.199.108"
log "${YELLOW}📡 Testando conexão SSH com Hostinger via Tailscale ($DEST_IP)...${NC}"
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" "true" 2>/dev/null; then
log "${YELLOW}⚠️ Tailscale inacessível. Tentando IP público Hostinger (187.77.227.172)...${NC}"
DEST_IP="187.77.227.172"
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" "true" 2>/dev/null; then
log "${RED}❌ ERRO: Não foi possível conectar via SSH à Hostinger ($DEST_IP). Replicação abortada.${NC}"
exit 1
fi
fi
log "${GREEN}✅ Conectividade com Hostinger confirmada ($DEST_IP).${NC}"
# Lista de Schemas a sincronizar
SCHEMAS=('TS_ERP' 'gpi' 'tracksteel' 'rdo')
for schema in "${SCHEMAS[@]}"; do
EXISTS=$(docker exec "$SRC_DB_CONTAINER" psql -U postgres -d postgres -tAc \
"SELECT 1 FROM pg_namespace WHERE nspname = '$schema';" 2>/dev/null || echo "0")
if [ "$EXISTS" = "1" ]; then
log "${YELLOW}📦 Sincronizando schema: $schema...${NC}"
TMP_DUMP="/tmp/sync_rev_${schema}_$$.dump"
# 1. Gerar Dump local comprimido (marcos-vps)
docker exec -i "$SRC_DB_CONTAINER" pg_dump -U postgres -d postgres -n "\"$schema\"" -Fc > "$TMP_DUMP"
# 2. Transferir para Hostinger via SCP
scp -q -o BatchMode=yes -o ConnectTimeout=5 "$TMP_DUMP" "root@$DEST_IP:$TMP_DUMP"
# 3. Restaurar na Hostinger e restabelecer permissões
ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" \
"docker cp '$TMP_DUMP' '$DEST_DB_CONTAINER:$TMP_DUMP' && \
(docker exec '$DEST_DB_CONTAINER' pg_restore -U postgres -d postgres --clean --if-exists --no-owner --no-privileges '$TMP_DUMP' || true) && \
docker exec '$DEST_DB_CONTAINER' psql -U postgres -d postgres -c \"GRANT USAGE ON SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role, authenticator; GRANT ALL ON ALL TABLES IN SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role; GRANT ALL ON ALL SEQUENCES IN SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role; GRANT ALL ON ALL ROUTINES IN SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES IN SCHEMA \\\"$schema\\\" GRANT ALL ON TABLES TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES IN SCHEMA \\\"$schema\\\" GRANT ALL ON SEQUENCES TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES IN SCHEMA \\\"$schema\\\" GRANT ALL ON ROUTINES TO anon, authenticated, service_role; NOTIFY pgrst, 'reload schema';\" && \
rm -f '$TMP_DUMP' && \
docker exec '$DEST_DB_CONTAINER' rm -f '$TMP_DUMP'"
# 4. Limpar dump local
rm -f "$TMP_DUMP"
log "${GREEN} ↳ Schema $schema replicado na Hostinger com sucesso.${NC}"
fi
done
# Sincronização de Metadados de Storage (buckets e objects)
log "${YELLOW}📁 Sincronizando metadados de Storage (buckets & objects)...${NC}"
STORAGE_SQL="/tmp/sync_storage_rev_$$.sql"
docker exec -i "$SRC_DB_CONTAINER" pg_dump -U postgres -d postgres -t storage.buckets -t storage.objects --data-only --on-conflict-do-nothing > "$STORAGE_SQL" 2>/dev/null || true
if [ -s "$STORAGE_SQL" ]; then
scp -q -o BatchMode=yes -o ConnectTimeout=5 "$STORAGE_SQL" "root@$DEST_IP:$STORAGE_SQL"
ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" \
"docker exec -i '$DEST_DB_CONTAINER' psql -U postgres -d postgres < '$STORAGE_SQL' >/dev/null 2>&1 && rm -f '$STORAGE_SQL'"
rm -f "$STORAGE_SQL"
log "${GREEN} ↳ Metadados de storage sincronizados na Hostinger.${NC}"
else
rm -f "$STORAGE_SQL"
fi
# Sincronização de arquivos físicos de Storage (MinIO)
SRC_STORAGE="/data/coolify/services/rjbxu55kggzc59d4cmo133cf/volumes/storage/"
DEST_STORAGE="/data/coolify/services/h0oggskgs0ws0sco8kc4s8ws/volumes/storage/"
if [ -d "$SRC_STORAGE" ]; then
log "${YELLOW}☁️ Sincronizando arquivos físicos do Storage para a Hostinger...${NC}"
rsync -aqz --delete -e "ssh -o BatchMode=yes -o ConnectTimeout=5" "$SRC_STORAGE" "root@$DEST_IP:$DEST_STORAGE" || true
log "${GREEN} ↳ Arquivos do Storage espelhados com sucesso na Hostinger.${NC}"
fi
# Validação final de integridade de tabelas em TS_ERP
ORIG_COUNT=$(docker exec "$SRC_DB_CONTAINER" psql -U postgres -d postgres -tAc "SELECT count(*) FROM pg_tables WHERE schemaname = 'TS_ERP';" 2>/dev/null || echo "0")
DEST_COUNT=$(ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" "docker exec '$DEST_DB_CONTAINER' psql -U postgres -d postgres -tAc \"SELECT count(*) FROM pg_tables WHERE schemaname = 'TS_ERP';\"" 2>/dev/null || echo "0")
log "${CYAN}📊 Validação de Integridade:${NC}"
log " - Tabelas TS_ERP (Origem marcos-vps): $ORIG_COUNT"
log " - Tabelas TS_ERP (Destino Hostinger): $DEST_COUNT"
if [ "$ORIG_COUNT" = "$DEST_COUNT" ] && [ "$ORIG_COUNT" != "0" ]; then
log "${GREEN}✨ Replicação reversa concluída com 100% de paridade!${NC}\n"
else
log "${YELLOW}⚠️ Aviso: Contagem de tabelas ($ORIG_COUNT vs $DEST_COUNT). Verifique os logs.${NC}\n"
fi
+113
View File
@@ -0,0 +1,113 @@
#!/bin/bash
# ==============================================================================
# SCRIPT DE REPLICAÇÃO AUTOMÁTICA: HOSTINGER -> MARCOS-VPS (TRACKSTEEL DB)
# ==============================================================================
# Sincroniza schemas de negócio (TS_ERP, gpi, tracksteel, rdo) e storage
# da VPS Hostinger local para o Supabase na Marcos-VPS (100.97.2.16).
# ==============================================================================
set -euo pipefail
CYAN='\033[0;36m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
LOG_DIR="/root/Apps/TrackSteelAPP/scripts/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/sync_db_$(date +'%Y%m%d').log"
log() {
local msg="[$(date +'%Y-%m-%d %H:%M:%S')] $1"
echo -e "$1"
echo "$msg" >> "$LOG_FILE"
}
log "\n${CYAN}======================================================${NC}"
log "${CYAN}🔄 Iniciando Replicação de Banco de Dados -> marcos-vps${NC}"
log "${CYAN}======================================================${NC}"
# Configurações de Conexão
DEST_IP="100.97.2.16"
SRC_DB_CONTAINER="supabase-db-h0oggskgs0ws0sco8kc4s8ws"
DEST_DB_CONTAINER="supabase-db-rjbxu55kggzc59d4cmo133cf"
# Teste de conectividade SSH
log "${YELLOW}📡 Verificando conexão com marcos-vps ($DEST_IP)...${NC}"
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" "true" 2>/dev/null; then
log "${RED}❌ ERRO: Não foi possível conectar via SSH à marcos-vps ($DEST_IP). Replicação abortada.${NC}"
exit 1
fi
log "${GREEN}✅ Conectividade com marcos-vps confirmada.${NC}"
# Lista de Schemas a sincronizar
SCHEMAS=('TS_ERP' 'gpi' 'tracksteel' 'rdo')
for schema in "${SCHEMAS[@]}"; do
# Verifica se o schema existe no banco local
EXISTS=$(docker exec "$SRC_DB_CONTAINER" psql -U postgres -d postgres -tAc \
"SELECT 1 FROM pg_namespace WHERE nspname = '$schema';" 2>/dev/null || echo "0")
if [ "$EXISTS" = "1" ]; then
log "${YELLOW}📦 Sincronizando schema: $schema...${NC}"
TMP_DUMP="/tmp/sync_${schema}_$$.dump"
# 1. Gerar Dump local comprimido
docker exec -i "$SRC_DB_CONTAINER" pg_dump -U postgres -d postgres -n "\"$schema\"" -Fc > "$TMP_DUMP"
# 2. Transferir para marcos-vps via SCP
scp -q -o BatchMode=yes -o ConnectTimeout=5 "$TMP_DUMP" "root@$DEST_IP:$TMP_DUMP"
# 3. Restaurar dentro do container de destino e restabelecer privilégios para PostgREST
ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" \
"docker cp '$TMP_DUMP' '$DEST_DB_CONTAINER:$TMP_DUMP' && \
docker exec '$DEST_DB_CONTAINER' pg_restore -U postgres -d postgres --clean --if-exists --no-owner --no-privileges '$TMP_DUMP' && \
docker exec '$DEST_DB_CONTAINER' psql -U postgres -d postgres -c \"GRANT USAGE ON SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role, authenticator; GRANT ALL ON ALL TABLES IN SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role; GRANT ALL ON ALL SEQUENCES IN SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role; GRANT ALL ON ALL ROUTINES IN SCHEMA \\\"$schema\\\" TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES IN SCHEMA \\\"$schema\\\" GRANT ALL ON TABLES TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES IN SCHEMA \\\"$schema\\\" GRANT ALL ON SEQUENCES TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES IN SCHEMA \\\"$schema\\\" GRANT ALL ON ROUTINES TO anon, authenticated, service_role; NOTIFY pgrst, 'reload schema';\" && \
rm -f '$TMP_DUMP' && \
docker exec '$DEST_DB_CONTAINER' rm -f '$TMP_DUMP'"
# 4. Limpar arquivo local
rm -f "$TMP_DUMP"
log "${GREEN} ↳ Schema $schema replicado e privilégios aplicados com sucesso.${NC}"
fi
done
# Sincronização de Metadados de Storage (buckets e objects)
log "${YELLOW}📁 Sincronizando metadados de Storage (buckets & objects)...${NC}"
STORAGE_SQL="/tmp/sync_storage_$$.sql"
docker exec -i "$SRC_DB_CONTAINER" pg_dump -U postgres -d postgres -t storage.buckets -t storage.objects --data-only --on-conflict-do-nothing > "$STORAGE_SQL" 2>/dev/null || true
if [ -s "$STORAGE_SQL" ]; then
scp -q -o BatchMode=yes -o ConnectTimeout=5 "$STORAGE_SQL" "root@$DEST_IP:$STORAGE_SQL"
ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" \
"docker exec -i '$DEST_DB_CONTAINER' psql -U postgres -d postgres < '$STORAGE_SQL' >/dev/null 2>&1 && rm -f '$STORAGE_SQL'"
rm -f "$STORAGE_SQL"
log "${GREEN} ↳ Metadados de storage sincronizados.${NC}"
else
rm -f "$STORAGE_SQL"
fi
# Sincronização de arquivos físicos de Storage (MinIO)
SRC_STORAGE="/data/coolify/services/h0oggskgs0ws0sco8kc4s8ws/volumes/storage/"
DEST_STORAGE="/data/coolify/services/rjbxu55kggzc59d4cmo133cf/volumes/storage/"
if [ -d "$SRC_STORAGE" ]; then
log "${YELLOW}☁️ Sincronizando arquivos físicos do Storage (MinIO)...${NC}"
rsync -aqz --delete -e "ssh -o BatchMode=yes -o ConnectTimeout=5" "$SRC_STORAGE" "root@$DEST_IP:$DEST_STORAGE" || true
log "${GREEN} ↳ Arquivos do Storage espelhados com sucesso.${NC}"
fi
# Validação final de tabelas em TS_ERP
ORIG_COUNT=$(docker exec "$SRC_DB_CONTAINER" psql -U postgres -d postgres -tAc "SELECT count(*) FROM pg_tables WHERE schemaname = 'TS_ERP';")
DEST_COUNT=$(ssh -o BatchMode=yes -o ConnectTimeout=5 "root@$DEST_IP" "docker exec '$DEST_DB_CONTAINER' psql -U postgres -d postgres -tAc \"SELECT count(*) FROM pg_tables WHERE schemaname = 'TS_ERP';\"")
log "${CYAN}📊 Validação de Integridade:${NC}"
log " - Tabelas TS_ERP (Origem Hostinger): $ORIG_COUNT"
log " - Tabelas TS_ERP (Destino marcos-vps): $DEST_COUNT"
if [ "$ORIG_COUNT" = "$DEST_COUNT" ]; then
log "${GREEN}✨ Replicação de banco concluída com 100% de paridade!${NC}\n"
else
log "${RED}⚠️ ATENÇÃO: Contagem de tabelas diferente entre origem e destino!${NC}\n"
fi
+3
View File
@@ -5,6 +5,7 @@ import { ProtectedRoute } from './ProtectedRoute';
import { ProtectedAdminRoute } from './ProtectedAdminRoute';
import { ProtectedRouteByResource } from './ProtectedRouteByResource';
import { routeConfig, RouteDefinition } from '@/config/routeConfig';
import { useHubBanner } from '@/hooks/useHubBanner';
// ============================================================================
// Fallback de loading para Suspense
@@ -84,6 +85,8 @@ function wrapWithGuard(
// ============================================================================
export function AppRoutes() {
useHubBanner();
const routes = useMemo(
() =>
routeConfig.map((route) => {
+65 -14
View File
@@ -59,6 +59,8 @@ export function AppSidebar() {
'estoque': '#8b5cf6', // roxo
'ofs': '#eab308', // amarelo
'producao': '#ef4444', // vermelho
'visualizador-3d': '#8b5cf6', // roxo
'analisador-3d': '#6366f1', // indigo
'painel-industrial': '#06b6d4', // ciano
'expedicao': '#ec4899', // rosa
'obra': '#a3a3a3', // marrom
@@ -100,46 +102,67 @@ export function AppSidebar() {
// Admin can always access everything
if (isAdmin) return true;
// Mapeamento de chave de menu/submenu da sidebar para resource_key do BD
// Mapeamento completo de chave de menu/submenu da sidebar para resource_key do BD
const itemToResourceMap: Record<string, string> = {
// PCP & Produção
'pcp': 'producao-pcp',
'dashboard-producao': 'producao-dashboard',
'apontamento-producao': 'producao-apontamento',
'diario-producao': 'diario-producao',
'prioridades-fabricacao': 'prioridades-fabricacao',
'visao-geral': 'producao-visao',
'painel-industrial': 'producao-dashboard',
// Engenharia, 3D & OFs
'visualizador-3d': 'visualizador-3d',
'analisador-3d': 'analisador-3d',
'ordens-fabricacao': 'ofs-lista',
'ofs-concluidas': 'ofs-lista',
'ficha-tecnica-of': 'cadastro-of',
'cronograma': 'ofs-cronograma',
'cadastro-pecas': 'cadastro-pecas',
'equipamentos': 'equipamentos',
// Expedição & Estoque
'expedicao': 'expedicao',
'estoque-main': 'estoque',
'estoque': 'estoque',
'solicitacao-compras': 'estoque-solicitacao-compras',
// Obra & Campo
'dashboard-obras': 'obra-dashboard',
'configuracoes-obra': 'obra-configuracoes',
'obra': 'obra-dashboard',
// Tarefas & Ferramentas
'tarefas': 'tarefas',
'lista-tarefas': 'tarefas',
'historico-tarefas': 'tarefas',
'ferramentas': 'ferramentas',
'conversores': 'ferramentas-inconsistencias',
'inconsistencias': 'ferramentas-inconsistencias',
// Biblioteca & Sistema
'biblioteca': 'sistema',
'catalogos': 'sistema',
'normas': 'sistema',
'referencias': 'sistema',
'sistema': 'sistema',
'sugestoes': 'sugestoes',
'atribuicoes': 'user-management',
'mapa-interativo': 'dashboard',
'dashboard': 'dashboard',
// Administração & Usuários
'gerenciar-usuarios': 'user-management',
'user-management': 'user-management',
'configuracoes-gerais': 'configuracoes-gerais',
'inconsistencias': 'ferramentas-inconsistencias',
'ferramentas': 'ferramentas',
'sistema': 'sistema',
'sugestoes': 'sugestoes',
'personalizacao-tema': 'admin',
'admin': 'admin',
'dashboard': 'dashboard',
};
const resKey = itemToResourceMap[itemKey] || itemKey;
// Se há restrição direta configurada no BD para este recurso
// 1. Se há restrição direta configurada no BD para este recurso
if (resourcePermissions && resourcePermissions[resKey]) {
return resourcePermissions[resKey] !== 'no_access';
}
// Se o usuário tem perfil de "apenas Smart" (tem modo-smart mas não tem esse recurso)
// 2. Se o usuário tem perfil de "apenas Smart" (tem modo-smart mas não tem outros recursos)
const temAcessoSmart =
resourcePermissions &&
resourcePermissions['modo-smart'] &&
@@ -156,8 +179,23 @@ export function AppSidebar() {
if (soTemSmart && !resKey.startsWith('smart') && resKey !== 'modo-smart') {
return false;
}
// 3. Se há permissões individuais salvas na Matriz para este usuário:
// Se a matriz foi configurada para o usuário, qualquer tela que não foi concedida (exceto dashboard básico) fica oculta
if (permsCadastradas.length > 0) {
if (resourcePermissions[resKey] === 'no_access') {
return false;
}
if (resKey !== 'dashboard' && !resKey.startsWith('smart')) {
// Se não consta na lista de permissões ativas concedidas
const perm = resourcePermissions[resKey];
if (!perm || perm === 'no_access') {
return false;
}
}
}
// Se o item requer permissão especial, verificar permissões específicas
// 4. Se o item requer permissão especial, verificar permissões específicas
if (requiresSpecialPermission) {
if (itemKey === 'ferramentas') {
return canAccessTools();
@@ -235,12 +273,25 @@ export function AppSidebar() {
{menuGroups.map(group => {
// Filter admin groups for non-admin users
if (!isAdmin && group.name === 'Administração') {
logger.debug('Ocultando grupo admin para usuário não-admin');
return null;
const hasAnyAdminItem = group.items.some(item => canAccessItem(item.key, item.requiresSpecialPermission));
if (!hasAnyAdminItem) {
return null;
}
}
// Skip groups with no items
if (!group.items || group.items.length === 0) {
// Filtrar itens do grupo permitidos para o usuário logado
const itemsPermitidos = (group.items || []).filter(item => {
if (item.subItems && item.subItems.length > 0) {
return isAdmin || item.subItems.some(sub => {
if (sub.url === "/admin/theme-customization" && !isAdmin) return false;
return canAccessItem(sub.key, sub.requiresSpecialPermission);
});
}
return isAdmin || canAccessItem(item.key, item.requiresSpecialPermission);
});
// Se não houver itens visíveis no grupo, não renderiza o grupo (nem o título)
if (itemsPermitidos.length === 0) {
return null;
}
@@ -254,7 +305,7 @@ export function AppSidebar() {
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => (
{itemsPermitidos.map((item) => (
<AppSidebarMenuItem
key={item.key || item.title}
item={item}
+3 -3
View File
@@ -26,9 +26,9 @@ export const Layout = ({ children }: LayoutProps) => {
return (
<SidebarProvider>
<div className="min-h-screen flex w-full">
<div className="min-h-[calc(100vh-var(--hub-banner-height,0px))] flex w-full">
<AppSidebar />
<main className="flex-1 overflow-hidden flex flex-col">
<main className="flex-1 overflow-hidden flex flex-col h-[calc(100vh-var(--hub-banner-height,0px))]">
{/* Header fixo com SidebarTrigger, Modo Smart e ThemeToggle */}
<header className="h-14 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 flex items-center justify-between px-4 shrink-0">
<div className="flex items-center">
@@ -49,7 +49,7 @@ export const Layout = ({ children }: LayoutProps) => {
</div>
</header>
{/* Conteúdo principal */}
<div className="flex-1 overflow-auto">
<div className="flex-1 overflow-auto flex flex-col min-h-0">
{children}
</div>
</main>
+2 -23
View File
@@ -1,9 +1,6 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
import { useUserProfile } from '@/hooks/useUserProfile';
interface ProtectedAdminRouteProps {
children: React.ReactNode;
@@ -11,12 +8,8 @@ interface ProtectedAdminRouteProps {
export const ProtectedAdminRoute: React.FC<ProtectedAdminRouteProps> = ({ children }) => {
const { user, loading: authLoading } = useAuth();
const { isAdmin, loading: roleLoading } = useUserRole();
const { profile, loading: profileLoading } = useUserProfile();
const loading = authLoading || roleLoading || profileLoading;
if (loading) {
if (authLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="text-muted-foreground">Carregando...</div>
@@ -28,20 +21,6 @@ export const ProtectedAdminRoute: React.FC<ProtectedAdminRouteProps> = ({ childr
return <Navigate to="/auth" replace />;
}
// Verificar se o usuário está ativo no sistema
if (profile && profile.status !== 'active') {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-background text-center p-4">
<h1 className="text-2xl font-bold mb-4">Acesso Bloqueado</h1>
<p className="text-muted-foreground mb-2">Seu usuário existe, mas encontra-se com status: <strong>{profile.status || 'pendente'}</strong>.</p>
<p className="text-muted-foreground">Por favor, contate o administrador para aprovar o seu acesso.</p>
</div>
);
}
if (!isAdmin) {
return <Navigate to="/dashboard" replace />;
}
// Travas internas removidas - Gerenciado pelo BrainSteel Hub Guard
return <>{children}</>;
};
+2 -14
View File
@@ -9,11 +9,10 @@ interface ProtectedRouteProps {
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { user, loading: authLoading } = useAuth();
const { profile, loading: profileLoading } = useUserProfile();
const { loading: profileLoading } = useUserProfile();
const loading = authLoading || profileLoading;
// Mostrar loading enquanto carrega autenticação
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
@@ -22,21 +21,10 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
);
}
// Se não há usuário, redirecionar para auth
if (!user) {
return <Navigate to="/auth" replace />;
}
// Verificar se o usuário está ativo no sistema
if (profile && profile.status !== 'active') {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-background text-center p-4">
<h1 className="text-2xl font-bold mb-4">Acesso Bloqueado</h1>
<p className="text-muted-foreground mb-2">Seu usuário existe, mas encontra-se com status: <strong>{profile.status || 'pendente'}</strong>.</p>
<p className="text-muted-foreground">Por favor, contate o administrador para aprovar o seu acesso.</p>
</div>
);
}
// Travas internas removidas - Gerenciado pelo BrainSteel Hub Guard
return <>{children}</>;
};
+4 -75
View File
@@ -1,11 +1,6 @@
import React from 'react';
import { Navigate, Link } from 'react-router-dom';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
import { useUserProfile } from '@/hooks/useUserProfile';
import { useUserPermissions } from '@/hooks/useUserPermissions/useUserPermissions';
import { ShieldAlert, Zap, ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface ProtectedRouteByResourceProps {
children: React.ReactNode;
@@ -14,21 +9,15 @@ interface ProtectedRouteByResourceProps {
export const ProtectedRouteByResource: React.FC<ProtectedRouteByResourceProps> = ({
children,
resourceKey,
}) => {
const { user, loading: authLoading } = useAuth();
const { isAdmin, loading: roleLoading } = useUserRole();
const { profile, loading: profileLoading } = useUserProfile();
const { hasAccess, loading: permsLoading, resourcePermissions } = useUserPermissions();
const loading = authLoading || roleLoading || profileLoading || permsLoading;
if (loading) {
if (authLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex flex-col items-center gap-2">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
<div className="text-sm text-muted-foreground">Validando permissões de acesso...</div>
<div className="text-sm text-muted-foreground">Validando acesso...</div>
</div>
</div>
);
@@ -38,66 +27,6 @@ export const ProtectedRouteByResource: React.FC<ProtectedRouteByResourceProps> =
return <Navigate to="/auth" replace />;
}
// 1. Verificar se o usuário está ativo no sistema
if (profile && profile.status !== 'active') {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-background text-center p-4">
<h1 className="text-2xl font-bold mb-4">Acesso Bloqueado</h1>
<p className="text-muted-foreground mb-2">
Seu usuário existe, mas encontra-se com status: <strong>{profile.status || 'pendente'}</strong>.
</p>
<p className="text-muted-foreground">Por favor, contate o administrador para aprovar o seu acesso.</p>
</div>
);
}
// 2. Administradores têm acesso irrestrito
if (isAdmin) {
return <>{children}</>;
}
// 3. Validação real de permissão granular pelo resourceKey
const podeAcessar = hasAccess(resourceKey);
if (!podeAcessar) {
// Se o usuário tem acesso ao Modo Smart, redirecionar para a experiência dele
const temAcessoSmart =
resourcePermissions['modo-smart'] &&
resourcePermissions['modo-smart'] !== 'no_access';
return (
<div className="flex flex-col items-center justify-center min-h-[75vh] p-6 text-center">
<div className="p-4 rounded-2xl bg-amber-500/10 border border-amber-500/20 text-amber-500 mb-4 shadow-sm">
<ShieldAlert className="w-12 h-12 stroke-[1.5]" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">Acesso Restrito a Este Módulo</h2>
<p className="text-sm text-muted-foreground max-w-md mb-6">
Seu usuário não possui permissão concedida para acessar o recurso{' '}
<code className="px-2 py-0.5 rounded bg-muted font-mono text-xs text-foreground">
{resourceKey}
</code>
. Solicite liberação na Matriz de Acessos ao administrador do sistema.
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
{temAcessoSmart && (
<Button asChild className="bg-amber-600 hover:bg-amber-700 text-white font-medium">
<Link to="/smart">
<Zap className="w-4 h-4 mr-2" />
Ir para o Modo Smart
</Link>
</Button>
)}
<Button variant="outline" asChild>
<Link to="/dashboard">
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar ao Início
</Link>
</Button>
</div>
</div>
);
}
// Travas internas removidas - Gerenciado pelo BrainSteel Hub Guard
return <>{children}</>;
};
+140 -65
View File
@@ -49,10 +49,10 @@ export const ApontamentoForm = () => {
const [saving, setSaving] = useState(false);
const [cacheValido, setCacheValido] = useState(false);
const { criarApontamento, refetch, processos, apontamentos, loading: loadingApontamentos } = useApontamentosProducao();
const { criarApontamento, refetch, processos, apontamentos, loading: loadingApontamentos, sincronizarKitsOF } = useApontamentosProducao();
const { pecas, loading: loadingPecas } = usePecas();
const { ofs } = useOFs();
const { componentesAgrupados, loading: loadingComponentes } = useComponentesAgrupados(formData.of_number, formData.fase, pecas);
const { componentesAgrupados, componentesRaw, loading: loadingComponentes } = useComponentesAgrupados(formData.of_number, formData.fase, pecas);
// Buscar fases únicas da OF selecionada
const fasesDisponiveis = useMemo(() => {
@@ -118,6 +118,8 @@ export const ApontamentoForm = () => {
const processosOrdenados = [...processos].sort((a, b) => a.ordem - b.ordem);
const indexAtual = processosOrdenados.findIndex(p => p.id === processo_id);
const procAtual = processosOrdenados[indexAtual];
const procNomeLower = (procAtual?.nome || '').toLowerCase();
const isCorte = procNomeLower.includes('corte') || procNomeLower.includes('prepar') || indexAtual === 0;
// 1. Peças da OF e Fase selecionadas
const pecasDaFase = pecas.filter(
@@ -134,25 +136,60 @@ export const ApontamentoForm = () => {
let qtdDisponivelParaEntrar = Number(peca.quantidade) || 0;
// Regra: peças sem componentes não passam por solda
if (!peca.tem_componentes && procAtual && procAtual.nome.toLowerCase().includes('solda')) {
// Identificar se a peça de fato possui subcomponentes cadastrados nesta OF
const compsDaPeca = componentesAgrupados?.filter(c => c.peca_ids?.includes(peca.id)) || [];
const temComponentesCadastrados = peca.tem_componentes !== false && compsDaPeca.length > 0;
const ehSemMontagem = peca.tem_componentes === false;
// Se for CORTE:
// Se tiver componentes cadastrados, são apontados os subcomponentes.
// Se NÃO tiver componentes cadastrados (opção do usuário não controlar componentes), aponta a peça mestre normalmente!
if (isCorte && temComponentesCadastrados) {
qtdDisponivelParaEntrar = 0;
} else if (ehSemMontagem && procNomeLower.includes('solda')) {
// Regra: peças sem montagem (S/M) não passam por solda
qtdDisponivelParaEntrar = 0;
} else if (indexAtual > 0) {
// Encontrar processo anterior válido
let processoAnteriorValido = null;
for (let i = indexAtual - 1; i >= 0; i--) {
const p = processosOrdenados[i];
if (!peca.tem_componentes && p.nome.toLowerCase().includes('solda')) {
continue; // Pula a solda na busca do processo anterior para peças simples
if (ehSemMontagem && p.nome.toLowerCase().includes('solda')) {
continue; // Pula a solda na busca do processo anterior para peças simples (S/M)
}
processoAnteriorValido = p;
break;
}
if (processoAnteriorValido) {
qtdDisponivelParaEntrar = apontamentos
let qtdApontadaAnterior = apontamentos
.filter(a => a.tipo_apontamento === 'peca' && a.peca_id === peca.id && a.processo_id === processoAnteriorValido.id)
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
// Se a peça tem componentes e o processo anterior for Corte:
// Calcula estritamente os kits completos disponíveis baseados no corte de todos os componentes (regra do gargalo)
if (temComponentesCadastrados && processoAnteriorValido.nome.toLowerCase().includes('corte')) {
const compsDestaPeca = (componentesRaw || []).filter(c => c.peca_id === peca.id);
if (compsDestaPeca.length > 0) {
let menorKits = Number(peca.quantidade) || 0;
compsDestaPeca.forEach(comp => {
const cortado = apontamentos
.filter(a => a.tipo_apontamento === 'componente' && a.componente_id === comp.id && a.processo_id === processoAnteriorValido.id)
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
const porPeca = Math.max(1, Number(comp.quantidade_por_peca) || 1);
const kitsDeste = Math.floor(cortado / porPeca);
if (kitsDeste < menorKits) {
menorKits = kitsDeste;
}
});
const kitsCompletosCalculados = Math.max(0, Math.min(Number(peca.quantidade) || 0, menorKits));
if (kitsCompletosCalculados > qtdApontadaAnterior) {
qtdApontadaAnterior = kitsCompletosCalculados;
}
}
}
qtdDisponivelParaEntrar = qtdApontadaAnterior;
}
}
@@ -173,21 +210,14 @@ export const ApontamentoForm = () => {
});
// 2. Componentes da OF e Fase selecionadas
// RESTRIÇÃO INDUSTRIAL: Componentes só existem no processo de CORTE / PREPARAÇÃO!
// Em Solda, Pintura, Expedição, Montagem etc., componentes não são exibidos nem apontados.
const componentesDisponiveis: ItemDisponivel[] = [];
if (componentesAgrupados && componentesAgrupados.length > 0) {
if (isCorte && componentesAgrupados && componentesAgrupados.length > 0) {
componentesAgrupados.forEach(comp => {
let qtdDisponivelParaEntrarComp = Number(comp.quantidade_total) || 0;
if (indexAtual > 0) {
const processoAnteriorValido = processosOrdenados[indexAtual - 1];
if (processoAnteriorValido) {
qtdDisponivelParaEntrarComp = apontamentos
.filter(a => a.tipo_apontamento === 'componente' && comp.componente_ids.includes(a.componente_id || '') && a.processo_id === processoAnteriorValido.id)
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
}
}
const totalApontadoComp = apontamentos
.filter(a => a.tipo_apontamento === 'componente' && comp.componente_ids.includes(a.componente_id || '') && a.processo_id === processo_id)
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
@@ -263,23 +293,12 @@ export const ApontamentoForm = () => {
try {
for (const item of items) {
try {
const apontamentoData: Parameters<typeof criarApontamento>[0] = {
of_number: formData.of_number,
tipo_apontamento: item.tipo,
processo_id: formData.processo_id,
quantidade_produzida: item.quantidade_disponivel,
data_apontamento: formData.data_apontamento,
observacoes: `Registro em lote - ${tipoTexto}`
};
const result = await registrarItemComDistribuicao(
item,
item.quantidade_disponivel,
`Registro em lote - ${tipoTexto}`
);
if (item.tipo === 'componente') {
apontamentoData.componente_id = item.id;
} else {
apontamentoData.peca_id = item.id;
}
const result = await criarApontamento(apontamentoData);
if (result.success) {
sucessos++;
} else {
@@ -341,6 +360,13 @@ export const ApontamentoForm = () => {
setFormData(prev => ({ ...prev, ...updates }));
updateCache({ processo_id: processoId });
setItemSelecionado(null);
// Ao selecionar Solda, acionar sincronização de kits da OF em segundo plano
const proc = processos.find(p => p.id === processoId);
const procNome = (proc?.nome || '').toLowerCase();
if ((procNome.includes('solda') || procNome.includes('montag')) && formData.of_number) {
sincronizarKitsOF(formData.of_number);
}
};
const handleItemSelect = (item: ItemDisponivel) => {
@@ -367,6 +393,81 @@ export const ApontamentoForm = () => {
}));
};
// Função auxiliar para registrar apontamento distribuindo componentes entre peças irmãs se necessário
const registrarItemComDistribuicao = async (
item: ItemDisponivel,
quantidade: number,
observacoes?: string
): Promise<{ success: boolean }> => {
if (item.tipo === 'componente') {
const grupo = componentesAgrupados.find(g => g.marca_componente === item.marca);
if (grupo && grupo.vinculos && grupo.vinculos.length > 1) {
let restante = quantidade;
let sucessos = 0;
for (const v of grupo.vinculos) {
if (restante <= 0) break;
const jaCortado = apontamentos
.filter(a => a.tipo_apontamento === 'componente' && a.componente_id === v.componente_id && a.processo_id === formData.processo_id)
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
const faltaParaPeca = Math.max(0, v.quantidade_necessaria - jaCortado);
if (faltaParaPeca <= 0) continue;
const qtdParaV = Math.min(restante, faltaParaPeca);
restante -= qtdParaV;
const res = await criarApontamento({
of_number: formData.of_number,
tipo_apontamento: 'componente',
processo_id: formData.processo_id,
quantidade_produzida: qtdParaV,
data_apontamento: formData.data_apontamento,
componente_id: v.componente_id,
observacoes: observacoes || undefined
});
if (res.success) sucessos++;
}
// Se sobrou saldo além das necessidades das peças vinculadas, aplica ao primeiro vínculo
if (restante > 0 && grupo.vinculos.length > 0) {
const res = await criarApontamento({
of_number: formData.of_number,
tipo_apontamento: 'componente',
processo_id: formData.processo_id,
quantidade_produzida: restante,
data_apontamento: formData.data_apontamento,
componente_id: grupo.vinculos[0].componente_id,
observacoes: observacoes || undefined
});
if (res.success) sucessos++;
}
return { success: sucessos > 0 };
}
}
// Caso padrão (peça ou componente monovinculado)
const apontamentoData: Parameters<typeof criarApontamento>[0] = {
of_number: formData.of_number,
tipo_apontamento: item.tipo,
processo_id: formData.processo_id,
quantidade_produzida: quantidade,
data_apontamento: formData.data_apontamento,
observacoes: observacoes || undefined
};
if (item.tipo === 'componente') {
apontamentoData.componente_id = item.id;
} else {
apontamentoData.peca_id = item.id;
}
return await criarApontamento(apontamentoData);
};
const handleApontarItemDireto = async (item: ItemDisponivel, quantidade: number): Promise<boolean> => {
if (!formData.processo_id) {
toast.error('Selecione um processo antes de apontar');
@@ -380,22 +481,7 @@ export const ApontamentoForm = () => {
setSaving(true);
try {
const apontamentoData: Parameters<typeof criarApontamento>[0] = {
of_number: formData.of_number,
tipo_apontamento: item.tipo,
processo_id: formData.processo_id,
quantidade_produzida: quantidade,
data_apontamento: formData.data_apontamento,
observacoes: formData.observacoes || undefined
};
if (item.tipo === 'componente') {
apontamentoData.componente_id = item.id;
} else {
apontamentoData.peca_id = item.id;
}
const result = await criarApontamento(apontamentoData);
const result = await registrarItemComDistribuicao(item, quantidade, formData.observacoes || undefined);
if (result.success) {
toast.success(`${quantidade} un de ${item.marca} apontada(s) com sucesso!`);
@@ -443,22 +529,11 @@ export const ApontamentoForm = () => {
setSaving(true);
try {
const apontamentoData: Parameters<typeof criarApontamento>[0] = {
of_number: formData.of_number,
tipo_apontamento: itemSelecionado.tipo,
processo_id: formData.processo_id,
quantidade_produzida: quantidade,
data_apontamento: formData.data_apontamento,
observacoes: formData.observacoes || undefined
};
if (itemSelecionado.tipo === 'componente') {
apontamentoData.componente_id = itemSelecionado.id;
} else {
apontamentoData.peca_id = itemSelecionado.id;
}
const result = await criarApontamento(apontamentoData);
const result = await registrarItemComDistribuicao(
itemSelecionado,
quantidade,
formData.observacoes || undefined
);
if (result.success) {
toast.success('Apontamento registrado com sucesso!');
@@ -12,12 +12,15 @@ import { ptBR } from 'date-fns/locale';
export const ApontamentosList = () => {
const { apontamentos, loading } = useApontamentosProducao();
const [filtroOF, setFiltroOF] = useState('');
const [filtroFase, setFiltroFase] = useState('');
const [filtroProcesso, setFiltroProcesso] = useState('');
const [filtroData, setFiltroData] = useState('');
const [filtroPecaComponente, setFiltroPecaComponente] = useState('');
const apontamentosFiltrados = apontamentos.filter(apt => {
const matchOF = !filtroOF || apt.of_number.toLowerCase().includes(filtroOF.toLowerCase());
const faseStr = apt.peca?.etapa_fase || (apt.componente as any)?.peca?.etapa_fase || '';
const matchFase = !filtroFase || faseStr.toLowerCase().includes(filtroFase.toLowerCase());
const matchProcesso = !filtroProcesso || apt.processo?.nome.toLowerCase().includes(filtroProcesso.toLowerCase());
const matchData = !filtroData || apt.data_apontamento.includes(filtroData);
@@ -25,7 +28,7 @@ export const ApontamentosList = () => {
const matchPecaComponente = !filtroPecaComponente ||
getMarcaExibida(apt).toLowerCase().includes(filtroPecaComponente.toLowerCase());
return matchOF && matchProcesso && matchData && matchPecaComponente;
return matchOF && matchFase && matchProcesso && matchData && matchPecaComponente;
});
// Função para determinar a marca exibida baseada no tipo de apontamento
@@ -90,7 +93,7 @@ export const ApontamentosList = () => {
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
<div className="grid grid-cols-1 md:grid-cols-6 gap-3">
<div>
<label className="text-sm font-medium mb-2 block">OF</label>
<Input
@@ -100,6 +103,15 @@ export const ApontamentosList = () => {
className="h-8"
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">Fase</label>
<Input
placeholder="Filtrar por fase..."
value={filtroFase}
onChange={(e) => setFiltroFase(e.target.value)}
className="h-8"
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">Processo</label>
<Input
@@ -163,6 +175,7 @@ export const ApontamentosList = () => {
<TableHeader>
<TableRow>
<TableHead>OF</TableHead>
<TableHead>Fase</TableHead>
<TableHead>Peça/Componente</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Processo</TableHead>
@@ -184,6 +197,17 @@ export const ApontamentosList = () => {
<TableCell className="font-medium py-1">
{apontamento.of_number}
</TableCell>
<TableCell className="py-1">
{apontamento.peca?.etapa_fase ? (
<Badge variant="outline" className="text-xs">
{apontamento.peca.etapa_fase.toLowerCase().startsWith('fase') || apontamento.peca.etapa_fase.toLowerCase().startsWith('etapa')
? apontamento.peca.etapa_fase
: `Fase ${apontamento.peca.etapa_fase}`}
</Badge>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="py-1">
<div className="font-medium">
{getMarcaExibida(apontamento)}
@@ -4,7 +4,7 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { ScrollArea } from "@/components/ui/scroll-area";
import { toast } from 'sonner';
import { Search, Package, Shield, AlertTriangle, Settings, Check, Loader2 } from 'lucide-react';
import { Search, Package, Shield, AlertTriangle, Settings, Check, Loader2, Layers } from 'lucide-react';
import { useDebounce } from '@/hooks/useDebounce';
import { Peca } from '@/hooks/usePecas';
import { useComponentesPeca } from '@/hooks/useComponentesPeca';
@@ -67,6 +67,257 @@ interface PecaWithComponents extends Peca {
processo_atual_permitido?: number;
}
const getPrioridadeColor = (prioridade?: string) => {
switch (prioridade?.toLowerCase()) {
case 'alta': return 'text-red-600 dark:text-red-400';
case 'média': return 'text-amber-600 dark:text-yellow-400';
case 'baixa': return 'text-emerald-600 dark:text-green-400';
default: return 'text-muted-foreground';
}
};
const PecaItem = React.memo(({ peca, onSelect }: { peca: PecaWithComponents; onSelect: (peca: PecaWithComponents) => void }) => {
return (
<div
className="p-2 border border-border bg-card hover:bg-accent/50 rounded-md cursor-pointer transition-colors"
onClick={() => onSelect(peca)}
>
<div className="space-y-1">
<div className="font-semibold text-sm text-foreground">{peca.marca}</div>
<div className="text-xs text-muted-foreground">
Prioridade: <span className={getPrioridadeColor(peca.prioridade)}>{peca.prioridade || 'N/A'}</span>
</div>
{peca.descricao && (
<div className="text-xs text-muted-foreground truncate" title={peca.descricao}>
{peca.descricao}
</div>
)}
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
Qtd Disponível: {peca.quantidadeDisponivel || 0}
</div>
</div>
</div>
);
});
PecaItem.displayName = 'PecaItem';
const ComponenteItem = React.memo(({ componente, onSelect }: { componente: ComponenteItemData; onSelect: (componente: ComponenteItemData) => void }) => (
<div
className="p-2 border border-border bg-card hover:bg-accent/50 rounded-md cursor-pointer transition-colors"
onClick={() => onSelect(componente)}
>
<div className="space-y-1">
<div className="font-semibold text-sm text-foreground">{componente.marca_componente}</div>
<div className="text-xs text-muted-foreground">
Perfil: {componente.perfil || 'N/A'}
</div>
<div className="text-xs text-muted-foreground">
Peso Unitário: {componente.peso_unitario || 0}kg | Qtd por Peça: {componente.quantidade_por_peca || 1}
</div>
{componente.descricao && (
<div className="text-xs text-muted-foreground truncate" title={componente.descricao}>
{componente.descricao}
</div>
)}
</div>
</div>
));
ComponenteItem.displayName = 'ComponenteItem';
interface ItemDisponivelCardProps {
item: ItemDisponivel;
isSelected: boolean;
isAdmin?: boolean;
onSelect?: (item: ItemDisponivel) => void;
onAdminAction?: (item: ItemDisponivel) => void;
onApontarItemDireto?: (item: ItemDisponivel, quantidade: number) => Promise<boolean>;
}
const ItemDisponivelCard = React.memo(({
item,
isSelected,
isAdmin,
onSelect,
onAdminAction,
onApontarItemDireto,
}: ItemDisponivelCardProps) => {
const [qtdValue, setQtdValue] = useState<string>('');
const [isTodas, setIsTodas] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
// Mantém sincronizado se a quantidade disponível mudar ou modo 'todas' estiver ativo
useEffect(() => {
if (isTodas) {
setQtdValue(item.quantidade_disponivel.toString());
} else if (qtdValue) {
const parsed = parseInt(qtdValue, 10);
if (!isNaN(parsed) && parsed > item.quantidade_disponivel) {
setQtdValue(item.quantidade_disponivel.toString());
}
}
}, [item.quantidade_disponivel, isTodas]);
const handleToggleTodas = () => {
setIsTodas((prev) => {
const novo = !prev;
setQtdValue(novo ? item.quantidade_disponivel.toString() : '');
return novo;
});
};
const handleQtdChange = (val: string) => {
setQtdValue(val);
if (isTodas) {
setIsTodas(false);
}
};
const handleExecutarBaixa = async () => {
if (!onApontarItemDireto || isLoading) return;
const quantidade = isTodas ? item.quantidade_disponivel : parseInt(qtdValue, 10);
if (isNaN(quantidade) || quantidade <= 0) {
toast.error(`Informe a quantidade para ${item.marca} ou ative 'Todas'`);
return;
}
if (quantidade > item.quantidade_disponivel) {
toast.error(`Quantidade não pode ser maior que ${item.quantidade_disponivel} disponível`);
return;
}
setIsLoading(true);
try {
const sucesso = await onApontarItemDireto(item, quantidade);
if (sucesso) {
setQtdValue('');
setIsTodas(false);
}
} catch (error) {
console.error('Erro ao executar baixa direta:', error);
toast.error('Erro ao registrar apontamento');
} finally {
setIsLoading(false);
}
};
return (
<div className="relative">
<div
className={`p-3 border rounded-md transition-colors ${
isSelected
? 'border-primary bg-primary/10 ring-1 ring-primary'
: 'border-border bg-card hover:bg-accent/40'
}`}
onClick={() => onSelect?.(item)}
>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="font-semibold text-sm text-foreground flex items-center gap-2">
<span>{item.marca}</span>
{item.perfil && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-mono font-normal">
{item.perfil}
</span>
)}
</div>
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
{item.quantidade_disponivel} disponível
</div>
</div>
{item.descricao && item.descricao !== item.perfil && (
<div className="text-xs text-muted-foreground truncate" title={item.descricao}>
{item.descricao}
</div>
)}
{/* Linha com processo e controles rápidos de apontamento */}
<div className="flex items-center justify-between gap-2 pt-1 flex-wrap">
<div className="text-xs text-muted-foreground font-medium">
Processo: {item.nome_processo || item.processo_atual_permitido}
</div>
{onApontarItemDireto && (
<div
className="flex items-center gap-1.5 ml-auto"
onClick={(e) => e.stopPropagation()}
>
<Input
type="number"
min="1"
max={item.quantidade_disponivel}
placeholder="Quant."
value={qtdValue}
onChange={(e) => handleQtdChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleExecutarBaixa();
}
}}
disabled={isTodas || isLoading}
className="h-7 w-20 text-xs px-2 text-center font-medium bg-background border-input shadow-none"
/>
<Button
type="button"
size="sm"
variant={isTodas ? "default" : "outline"}
onClick={handleToggleTodas}
disabled={isLoading}
className={`h-7 px-2.5 text-xs font-semibold transition-colors ${
isTodas
? "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600 shadow-sm"
: "hover:bg-accent text-foreground"
}`}
title="Habilitar/desabilitar apontar todas as unidades disponíveis"
>
Todas
</Button>
<Button
type="button"
size="sm"
disabled={isLoading || (!isTodas && (!qtdValue || parseInt(qtdValue, 10) <= 0))}
onClick={handleExecutarBaixa}
className="h-7 px-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold text-xs flex items-center gap-1 shadow-sm transition-all"
title="Baixar quantidade no apontamento"
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Check className="h-3.5 w-3.5" />
<span>Baixar</span>
</>
)}
</Button>
</div>
)}
</div>
</div>
</div>
{isAdmin && onAdminAction && (
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 h-6 w-6 p-0 text-orange-500 hover:text-orange-600 hover:bg-orange-500/10"
onClick={(e) => {
e.stopPropagation();
onAdminAction(item);
}}
title="Verificar apontamentos e forçar exclusão"
>
<Settings className="h-3 w-3" />
</Button>
)}
</div>
);
});
ItemDisponivelCard.displayName = 'ItemDisponivelCard';
export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
pecasDisponiveis = [],
componentesDisponiveis = [],
@@ -86,11 +337,6 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
const [loadingPecas, setLoadingPecas] = useState(false);
const [loadingComponentes, setLoadingComponentes] = useState(false);
const [componentes, setComponentes] = useState<ComponenteItemData[]>([]);
// Estados locais para apontamento direto por card (quantidade, modo todas e loading)
const [qtdCards, setQtdCards] = useState<Record<string, string>>({});
const [todasCards, setTodasCards] = useState<Record<string, boolean>>({});
const [loadingApontandoId, setLoadingApontandoId] = useState<string | null>(null);
const [showComponentes, setShowComponentes] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
@@ -184,59 +430,6 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
return comps.sort((a, b) => a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' }));
}, [componentesDisponiveis, filtroNumeroPeca]);
const handleQtdCardChange = useCallback((itemId: string, value: string) => {
setQtdCards(prev => ({ ...prev, [itemId]: value }));
setTodasCards(prev => ({ ...prev, [itemId]: false }));
}, []);
const handleToggleTodas = useCallback((itemId: string, qtdDisponivel: number) => {
setTodasCards(prev => {
const novoEstado = !prev[itemId];
if (novoEstado) {
setQtdCards(q => ({ ...q, [itemId]: qtdDisponivel.toString() }));
} else {
setQtdCards(q => ({ ...q, [itemId]: '' }));
}
return { ...prev, [itemId]: novoEstado };
});
}, []);
const handleExecutarBaixa = useCallback(async (item: ItemDisponivel) => {
if (!onApontarItemDireto) return;
const isTodas = todasCards[item.id];
const qtdDigitada = qtdCards[item.id];
const quantidade = isTodas ? item.quantidade_disponivel : parseInt(qtdDigitada);
if (isNaN(quantidade) || quantidade <= 0) {
toast.error(`Informe a quantidade para ${item.marca} ou ative 'Todas'`);
return;
}
if (quantidade > item.quantidade_disponivel) {
toast.error(`Quantidade não pode ser maior que ${item.quantidade_disponivel} disponível`);
return;
}
setLoadingApontandoId(item.id);
try {
const sucesso = await onApontarItemDireto(item, quantidade);
if (sucesso) {
setQtdCards(prev => {
const copia = { ...prev };
delete copia[item.id];
return copia;
});
setTodasCards(prev => {
const copia = { ...prev };
delete copia[item.id];
return copia;
});
}
} finally {
setLoadingApontandoId(null);
}
}, [onApontarItemDireto, todasCards, qtdCards]);
const fetchPecas = useCallback(async (term: string) => {
if (!term.trim()) {
setPecasFiltradas([]);
@@ -318,174 +511,6 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
}
}, [onBatchSelect, pecasOrdenadas, componentesOrdenados]);
const getPrioridadeColor = (prioridade: string) => {
switch (prioridade?.toLowerCase()) {
case 'alta': return 'text-red-600 dark:text-red-400';
case 'média': return 'text-amber-600 dark:text-yellow-400';
case 'baixa': return 'text-emerald-600 dark:text-green-400';
default: return 'text-muted-foreground';
}
};
const PecaItem = ({ peca, onSelect }: { peca: PecaWithComponents; onSelect: (peca: PecaWithComponents) => void }) => {
return (
<div className="p-2 border border-border bg-card hover:bg-accent/50 rounded-md cursor-pointer transition-colors"
onClick={() => onSelect(peca)}>
<div className="space-y-1">
<div className="font-semibold text-sm text-foreground">{peca.marca}</div>
<div className="text-xs text-muted-foreground">
Prioridade: <span className={getPrioridadeColor(peca.prioridade)}>{peca.prioridade || 'N/A'}</span>
</div>
{peca.descricao && (
<div className="text-xs text-muted-foreground truncate" title={peca.descricao}>
{peca.descricao}
</div>
)}
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
Qtd Disponível: {peca.quantidadeDisponivel || 0}
</div>
</div>
</div>
);
};
const ComponenteItem = ({ componente }: { componente: ComponenteItemData }) => (
<div
className="p-2 border border-border bg-card hover:bg-accent/50 rounded-md cursor-pointer transition-colors"
onClick={() => handleComponenteSelect(componente)}
>
<div className="space-y-1">
<div className="font-semibold text-sm text-foreground">{componente.marca_componente}</div>
<div className="text-xs text-muted-foreground">
Perfil: {componente.perfil || 'N/A'}
</div>
<div className="text-xs text-muted-foreground">
Peso Unitário: {componente.peso_unitario || 0}kg | Qtd por Peça: {componente.quantidade_por_peca || 1}
</div>
{componente.descricao && (
<div className="text-xs text-muted-foreground truncate" title={componente.descricao}>
{componente.descricao}
</div>
)}
</div>
</div>
);
const ItemDisponivelComponent = ({ item, isSelected }: { item: ItemDisponivel; isSelected: boolean }) => {
const isTodas = Boolean(todasCards[item.id]);
const qtdValue = qtdCards[item.id] ?? (isTodas ? item.quantidade_disponivel.toString() : '');
const isLoading = loadingApontandoId === item.id;
return (
<div className="relative">
<div
className={`p-3 border rounded-md transition-colors ${
isSelected
? 'border-primary bg-primary/10 ring-1 ring-primary'
: 'border-border bg-card hover:bg-accent/40'
}`}
onClick={() => handleItemSelect(item)}
>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="font-semibold text-sm text-foreground flex items-center gap-2">
<span>{item.marca}</span>
{item.perfil && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-mono font-normal">
{item.perfil}
</span>
)}
</div>
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
{item.quantidade_disponivel} disponível
</div>
</div>
{item.descricao && item.descricao !== item.perfil && (
<div className="text-xs text-muted-foreground truncate" title={item.descricao}>
{item.descricao}
</div>
)}
{/* Linha com processo e controles rápidos de apontamento desenhados pelo usuário */}
<div className="flex items-center justify-between gap-2 pt-1 flex-wrap">
<div className="text-xs text-muted-foreground font-medium">
Processo: {item.nome_processo || item.processo_atual_permitido}
</div>
{onApontarItemDireto && (
<div
className="flex items-center gap-1.5 ml-auto"
onClick={(e) => e.stopPropagation()}
>
<Input
type="number"
min="1"
max={item.quantidade_disponivel}
placeholder="Quant."
value={qtdValue}
onChange={(e) => handleQtdCardChange(item.id, e.target.value)}
disabled={isTodas || isLoading}
className="h-7 w-20 text-xs px-2 text-center font-medium bg-background border-input shadow-none"
/>
<Button
type="button"
size="sm"
variant={isTodas ? "default" : "outline"}
onClick={() => handleToggleTodas(item.id, item.quantidade_disponivel)}
disabled={isLoading}
className={`h-7 px-2.5 text-xs font-semibold transition-colors ${
isTodas
? "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600 shadow-sm"
: "hover:bg-accent text-foreground"
}`}
title="Habilitar/desabilitar apontar todas as unidades disponíveis"
>
Todas
</Button>
<Button
type="button"
size="sm"
disabled={isLoading || (!isTodas && (!qtdValue || parseInt(qtdValue) <= 0))}
onClick={() => handleExecutarBaixa(item)}
className="h-7 px-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold text-xs flex items-center gap-1 shadow-sm transition-all"
title="Baixar quantidade no apontamento"
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Check className="h-3.5 w-3.5" />
<span>Baixar</span>
</>
)}
</Button>
</div>
)}
</div>
</div>
</div>
{isAdmin && (
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 h-6 w-6 p-0 text-orange-500 hover:text-orange-600 hover:bg-orange-500/10"
onClick={(e) => {
e.stopPropagation();
handleAdminAction(item);
}}
title="Verificar apontamentos e forçar exclusão"
>
<Settings className="h-3 w-3" />
</Button>
)}
</div>
);
};
if (onSelectPeca) {
return (
<div className="space-y-4">
@@ -527,7 +552,7 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
<ScrollArea className="rounded-md border border-border h-[200px] p-2">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{componentes.map((componente) => (
<ComponenteItem key={componente.id} componente={componente} />
<ComponenteItem key={componente.id} componente={componente} onSelect={handleComponenteSelect} />
))}
{componentes.length === 0 && (
<div className="text-center text-muted-foreground col-span-full">
@@ -592,27 +617,15 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
<ScrollArea className="rounded-md border border-border h-[300px] p-2">
<div className="space-y-2">
{pecasOrdenadas.map((item) => (
<div key={item.id} className="relative">
<ItemDisponivelComponent
item={item}
isSelected={itemSelecionado?.id === item.id}
/>
{isAdmin && (
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 h-6 w-6 p-0 text-orange-500 hover:text-orange-600 hover:bg-orange-500/10"
onClick={(e) => {
e.stopPropagation();
handleAdminAction(item);
}}
title="Verificar apontamentos e forçar exclusão"
>
<AlertTriangle className="h-3 w-3" />
</Button>
)}
</div>
<ItemDisponivelCard
key={item.id}
item={item}
isSelected={itemSelecionado?.id === item.id}
isAdmin={isAdmin}
onSelect={handleItemSelect}
onAdminAction={handleAdminAction}
onApontarItemDireto={onApontarItemDireto}
/>
))}
{pecasOrdenadas.length === 0 && (
<div className="text-center text-muted-foreground py-8">
@@ -628,7 +641,10 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
{componentesDisponiveis.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-foreground">Componentes Disponíveis</h3>
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Layers className="h-5 w-5 text-blue-500" />
Componentes para Corte (Controle de Kit)
</h3>
<Button
size="sm"
variant="outline"
@@ -642,10 +658,14 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
<ScrollArea className="rounded-md border border-border h-[300px] p-2">
<div className="space-y-2">
{componentesOrdenados.map((item) => (
<ItemDisponivelComponent
<ItemDisponivelCard
key={item.id}
item={item}
isSelected={itemSelecionado?.id === item.id}
isAdmin={isAdmin}
onSelect={handleItemSelect}
onAdminAction={handleAdminAction}
onApontarItemDireto={onApontarItemDireto}
/>
))}
</div>
@@ -95,7 +95,9 @@ export const ApontamentosFilters: React.FC<ApontamentosFiltersProps> = ({
<SelectContent>
<SelectItem value="all">Todas as fases</SelectItem>
{uniqueFases.map((fase) => (
<SelectItem key={fase} value={fase}>{fase}</SelectItem>
<SelectItem key={fase} value={fase}>
{fase.toLowerCase().startsWith('fase') || fase.toLowerCase().startsWith('etapa') ? fase : `Fase ${fase}`}
</SelectItem>
))}
</SelectContent>
</Select>
@@ -35,6 +35,15 @@ export const ApontamentosListItem: React.FC<ApontamentosListItemProps> = ({
}
};
const getFaseItem = (apontamento: ApontamentoProducao) => {
const rawFase = apontamento.peca?.etapa_fase || (apontamento.componente as any)?.peca?.etapa_fase;
if (!rawFase || !String(rawFase).trim()) return null;
const str = String(rawFase).trim();
return str.toLowerCase().startsWith('fase') || str.toLowerCase().startsWith('etapa')
? str
: `Fase ${str}`;
};
return (
<div className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3">
@@ -45,16 +54,45 @@ export const ApontamentosListItem: React.FC<ApontamentosListItemProps> = ({
)}
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">
{getMarcaItem(apontamento)}
</span>
<Badge variant="outline">
{apontamento.of_number || 'N/A'}
</Badge>
{getFaseItem(apontamento) ? (
<Badge
variant="secondary"
className="bg-sky-50 text-sky-700 border-sky-200 dark:bg-sky-950/60 dark:text-sky-300 dark:border-sky-800 text-xs font-semibold"
>
{getFaseItem(apontamento)}
</Badge>
) : (
<Badge
variant="outline"
className="text-muted-foreground border-dashed text-xs"
>
Fase -
</Badge>
)}
{apontamento.is_forcado && (
<span className={`text-[10px] font-black px-2 py-0.5 rounded-full border ${
apontamento.status_confirmacao === 'pendente_confirmacao'
? 'bg-red-100 text-red-700 border-red-300 animate-pulse dark:bg-red-950/80 dark:text-red-400 dark:border-red-800'
: 'bg-amber-100 text-amber-700 border-amber-300 dark:bg-amber-950/80 dark:text-amber-400 dark:border-amber-800'
}`}>
⚡ FORÇADO {apontamento.status_confirmacao === 'pendente_confirmacao' ? '(Pendente)' : '(Confirmado)'}
</span>
)}
</div>
<div className="text-sm text-muted-foreground">
{apontamento.processo?.nome || 'N/A'} • {formatDate(apontamento.data_apontamento)}
<div className="text-xs text-muted-foreground flex items-center gap-2 flex-wrap">
<span>{apontamento.processo?.nome || 'N/A'} • {formatDate(apontamento.data_apontamento)}</span>
{(apontamento.usuario_nome || apontamento.forcado_por_user_nome) && (
<span className="font-semibold text-slate-700 dark:text-slate-300">
• Por: {apontamento.forcado_por_user_nome || apontamento.usuario_nome}
</span>
)}
</div>
</div>
</div>
@@ -86,7 +124,7 @@ export const ApontamentosListItem: React.FC<ApontamentosListItemProps> = ({
Tem certeza de que deseja reverter este apontamento?
Esta ação não pode ser desfeita e irá remover permanentemente o registro de:
<br />
<strong>{getMarcaItem(apontamento)}</strong> - {apontamento.quantidade_produzida} unidades
<strong>{getMarcaItem(apontamento)}</strong> (OF: {apontamento.of_number || 'N/A'}{getFaseItem(apontamento) ? ` • ${getFaseItem(apontamento)}` : ''}) - {apontamento.quantidade_produzida} unidades
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -89,12 +89,12 @@ export const useApontamentosFilters = (apontamentos: ApontamentoProducao[]) => {
: apontamentos;
const fases = apontamentosFiltrados
.map(apt => apt.peca?.etapa_fase)
.map(apt => apt.peca?.etapa_fase || (apt.componente as any)?.peca?.etapa_fase)
.filter((fase): fase is string => {
return typeof fase === 'string' && fase.trim() !== '';
})
.filter((fase, index, array) => array.indexOf(fase) === index)
.sort();
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
console.log(`📋 Fases únicas encontradas para OF "${filterOF}": ${fases.length}`, fases);
return fases;
@@ -166,7 +166,7 @@ export const useApontamentosFilters = (apontamentos: ApontamentoProducao[]) => {
// Aplicar filtro por Fase APENAS se selecionado
if (filterFase && filterFase !== '' && filterFase !== 'all') {
const beforeFase = resultado.length;
resultado = resultado.filter(apt => apt.peca?.etapa_fase === filterFase);
resultado = resultado.filter(apt => (apt.peca?.etapa_fase || (apt.componente as any)?.peca?.etapa_fase) === filterFase);
console.log(`📋 Filtro Fase "${filterFase}": ${beforeFase} → ${resultado.length} registros`);
}
@@ -0,0 +1,53 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { MapaTermicoKitsView } from './MapaTermicoKitsView';
import { PecaKitInfo } from '@/hooks/useKitsProducao';
import { Flame } from 'lucide-react';
interface MapaTermicoKitsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
ofNumber?: string;
fase?: string;
onSelectPecaParaApontar?: (peca: PecaKitInfo) => void;
}
export const MapaTermicoKitsModal: React.FC<MapaTermicoKitsModalProps> = ({
open,
onOpenChange,
ofNumber,
fase,
onSelectPecaParaApontar,
}) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto p-4 sm:p-6">
<DialogHeader className="pb-2 border-b border-slate-200 dark:border-slate-800">
<DialogTitle className="flex items-center gap-2 text-lg sm:text-xl font-black text-slate-900 dark:text-slate-100 uppercase tracking-tight">
<Flame className="w-5 h-5 text-orange-500 fill-orange-500 animate-pulse" />
<span>Mapa Térmico de Kits de Montagem</span>
</DialogTitle>
</DialogHeader>
<div className="py-2">
<MapaTermicoKitsView
initialOF={ofNumber}
initialFase={fase || 'all'}
readOnlyOF={Boolean(ofNumber)}
onSelectPecaParaApontar={(peca) => {
if (onSelectPecaParaApontar) {
onSelectPecaParaApontar(peca);
onOpenChange(false);
}
}}
/>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,681 @@
import React, { useState, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Flame,
CheckCircle2,
AlertTriangle,
Clock,
Layers,
Search,
RefreshCw,
ChevronDown,
ChevronUp,
PackageCheck,
ShieldCheck,
Wrench,
Scissors,
Info
} from 'lucide-react';
import { useKitsProducao, PecaKitInfo } from '@/hooks/useKitsProducao';
import { useOFsAtivas, OFAtiva } from '@/hooks/useOFsAtivas';
interface MapaTermicoKitsViewProps {
initialOF?: string;
initialFase?: string;
readOnlyOF?: boolean;
onSelectPecaParaApontar?: (peca: PecaKitInfo) => void;
}
export const MapaTermicoKitsView: React.FC<MapaTermicoKitsViewProps> = ({
initialOF = '',
initialFase = 'all',
readOnlyOF = false,
onSelectPecaParaApontar,
}) => {
const { ofsAtivas, isLoading: loadingOFs } = useOFsAtivas();
const [selectedOF, setSelectedOF] = useState<string>(initialOF || '');
const [selectedFase, setSelectedFase] = useState<string>(initialFase);
const [busca, setBusca] = useState<string>('');
const [filtroStatus, setFiltroStatus] = useState<'todos' | 'liberados' | 'parciais' | 'travados'>('todos');
const [pecasExpandidas, setPecasExpandidas] = useState<Set<string>>(new Set());
// Atualizar OF padrão se carregar depois
React.useEffect(() => {
if (initialOF) {
setSelectedOF(initialOF);
} else if (!selectedOF && ofsAtivas.length > 0) {
setSelectedOF(ofsAtivas[0].of_number);
}
}, [initialOF, ofsAtivas, selectedOF]);
const { listaKits, resumoKits, loading, refetch } = useKitsProducao(selectedOF);
// Extrair fases únicas da lista de kits carregada de toda a OF
const fasesDisponiveis = useMemo(() => {
const s = new Set<string>();
listaKits.forEach((k) => {
if (k.etapa_fase && k.etapa_fase.trim()) s.add(k.etapa_fase.trim());
});
return Array.from(s).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
}, [listaKits]);
// Alternar expansão de componentes de uma peça
const toggleExpand = (pecaId: string) => {
setPecasExpandidas((prev) => {
const next = new Set(prev);
if (next.has(pecaId)) {
next.delete(pecaId);
} else {
next.add(pecaId);
}
return next;
});
};
const expandirTodas = () => {
setPecasExpandidas(new Set(listaKits.map((k) => k.peca_id)));
};
const recolherTodas = () => {
setPecasExpandidas(new Set());
};
// Filtragem combinada (Fase + Status + Busca textual)
const pecasFiltradas = useMemo(() => {
return listaKits.filter((item) => {
// Filtro de fase
if (selectedFase && selectedFase !== 'all' && selectedFase !== 'todas') {
if (item.etapa_fase !== selectedFase) return false;
}
// Filtro de status
if (filtroStatus === 'liberados') {
if (item.kits_completos_cortados <= 0) return false;
} else if (filtroStatus === 'parciais') {
if (
item.kits_completos_cortados >= item.quantidade_total ||
item.status_termico === 'vermelho'
)
return false;
} else if (filtroStatus === 'travados') {
if (item.status_termico !== 'vermelho') return false;
}
// Filtro de busca textual
if (busca.trim()) {
const t = busca.toLowerCase().trim();
const bateMarca = item.marca.toLowerCase().includes(t);
const bateDesc = (item.descricao || '').toLowerCase().includes(t);
const batePerfil = (item.perfil_principal || '').toLowerCase().includes(t);
const bateComp = item.componentes.some((c) =>
c.marca_componente.toLowerCase().includes(t) || (c.perfil || '').toLowerCase().includes(t)
);
if (!bateMarca && !bateDesc && !batePerfil && !bateComp) return false;
}
return true;
});
}, [listaKits, selectedFase, filtroStatus, busca]);
// Formatação de pesos em kg
const formatarKg = (valorKg: number) => {
if (!valorKg || valorKg <= 0) return '0 kg';
return `${valorKg.toLocaleString('pt-BR', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} kg`;
};
// KPIs calculados com base nas peças filtradas
const resumoExibido = useMemo(() => {
let total100 = 0;
let totalParciais = 0;
let totalTravados = 0;
let pesoTot = 0;
let pesoCort = 0;
let peso100 = 0;
let pesoUnLiberadas = 0;
let pesoParciais = 0;
let pesoNaoIniciados = 0;
let unTot = 0;
let unLib = 0;
pecasFiltradas.forEach((item) => {
unTot += item.quantidade_total;
unLib += item.kits_completos_cortados;
pesoTot += item.peso_total_peca;
pesoCort += item.peso_cortado_componentes;
pesoUnLiberadas += item.peso_kits_liberados;
if (item.status_termico === 'verde') {
total100++;
peso100 += item.peso_total_peca;
} else if (item.status_termico === 'verde_parcial' || item.status_termico === 'amarelo') {
totalParciais++;
pesoParciais += item.peso_total_peca;
} else {
totalTravados++;
pesoNaoIniciados += item.peso_total_peca;
}
});
return {
totalPecas: pecasFiltradas.length,
totalKits100Porcento: total100,
totalKitsParciais: totalParciais,
totalKitsNaoIniciados: totalTravados,
pesoTotalOFKg: pesoTot,
pesoCortadoTotalKg: pesoCort,
peso100PorcentoKg: peso100,
pesoUnidadesLiberadasKg: pesoUnLiberadas,
pesoParciaisKg: pesoParciais,
pesoNaoIniciadosKg: pesoNaoIniciados,
percentualGeralOF: pesoTot > 0 ? Math.min(100, Math.round((pesoCort / pesoTot) * 100)) : 0,
totalUnidadesTotal: unTot,
totalUnidadesLiberadasSolda: unLib,
};
}, [pecasFiltradas]);
return (
<div className="space-y-4 w-full">
{/* ──────────────── Barra de Controles e Seletores ──────────────── */}
<Card className="border border-slate-200 dark:border-slate-800 bg-white/90 dark:bg-slate-900/90 shadow-sm">
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<div className="p-2 rounded-xl bg-orange-100 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400">
<Flame className="w-5 h-5 animate-pulse" />
</div>
<div>
<h3 className="font-black text-slate-900 dark:text-slate-100 tracking-tight text-base sm:text-lg flex items-center gap-2">
<span>Mapa Térmico de Kits de Produção</span>
<Badge variant="outline" className="border-orange-300 text-orange-700 dark:border-orange-800 dark:text-orange-400 text-xs font-bold">
Corte ➔ Solda
</Badge>
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">
Prontidão física de montagem: kits cortados disponíveis para liberar a Solda
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={loading}
className="text-xs gap-1.5 h-8 font-semibold"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
<span>Atualizar</span>
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-1">
{/* Seletor de OF */}
<div>
<label className="text-xs font-bold text-slate-600 dark:text-slate-400 block mb-1">
Ordem de Fabricação (OF)
</label>
{readOnlyOF ? (
<div className="h-9 px-3 rounded-lg border border-slate-200 dark:border-slate-800 bg-slate-100 dark:bg-slate-800 flex items-center font-bold text-xs">
OF {selectedOF}
</div>
) : (
<Select
value={selectedOF}
onValueChange={(val) => {
setSelectedOF(val);
setSelectedFase('all');
}}
disabled={loadingOFs}
>
<SelectTrigger className="h-9 text-xs font-semibold">
<SelectValue placeholder={loadingOFs ? "Carregando OFs..." : "Selecione uma OF..."}>
{selectedOF ? `OF ${selectedOF}` : undefined}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
{ofsAtivas.map((o) => (
<SelectItem key={o.of_number} value={o.of_number}>
OF {o.of_number} {o.cliente ? `• ${o.cliente}` : o.descricao_resumida ? `• ${o.descricao_resumida}` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Seletor de Fase */}
<div>
<label className="text-xs font-bold text-slate-600 dark:text-slate-400 block mb-1">
Fase da OF
</label>
<Select value={selectedFase} onValueChange={setSelectedFase}>
<SelectTrigger className="h-9 text-xs font-semibold">
<SelectValue placeholder="Todas as fases" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as fases</SelectItem>
{fasesDisponiveis.map((f) => (
<SelectItem key={f} value={f}>
{f.toLowerCase().startsWith('fase') ? f : `Fase ${f}`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Busca Rápida */}
<div>
<label className="text-xs font-bold text-slate-600 dark:text-slate-400 block mb-1">
Buscar Marca / Componente
</label>
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-3 top-3 text-slate-400" />
<Input
placeholder="Ex: V1, p2, W200..."
value={busca}
onChange={(e) => setBusca(e.target.value)}
className="h-9 pl-9 text-xs"
/>
</div>
</div>
</div>
</CardContent>
</Card>
{/* ──────────────── Cards de KPIs do Mapa Térmico ──────────────── */}
{resumoExibido && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5 sm:gap-3">
{/* KPI 1: 100% Liberadas */}
<div className="p-3.5 rounded-2xl bg-emerald-50/80 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800 shadow-sm flex items-center justify-between">
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-black uppercase text-emerald-700 dark:text-emerald-400">
100% Liberadas
</span>
<span className="text-[11px] font-black text-emerald-800 dark:text-emerald-300 bg-emerald-200/80 dark:bg-emerald-900/70 px-1.5 py-0.5 rounded-md border border-emerald-300/80 dark:border-emerald-700 shadow-2xs">
{formatarKg(resumoExibido.peso100PorcentoKg)}
</span>
</div>
<div className="text-2xl font-black text-emerald-900 dark:text-emerald-100 mt-1">
{resumoExibido.totalKits100Porcento}
</div>
<div className="text-[10px] text-emerald-700/80 dark:text-emerald-400/80 font-medium truncate">
peças com todos os kits cortados
</div>
</div>
<div className="p-2.5 rounded-xl bg-emerald-100 text-emerald-700 dark:bg-emerald-900/60 dark:text-emerald-300 shrink-0 ml-2">
<CheckCircle2 className="w-6 h-6" />
</div>
</div>
{/* KPI 2: Kits Montáveis Liberados */}
<div className="p-3.5 rounded-2xl bg-blue-50/80 dark:bg-blue-950/40 border border-blue-200 dark:border-blue-800 shadow-sm flex items-center justify-between">
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-black uppercase text-blue-700 dark:text-blue-400">
Unidades Liberadas
</span>
<span className="text-[11px] font-black text-blue-800 dark:text-blue-300 bg-blue-200/80 dark:bg-blue-900/70 px-1.5 py-0.5 rounded-md border border-blue-300/80 dark:border-blue-700 shadow-2xs">
{formatarKg(resumoExibido.pesoUnidadesLiberadasKg)}
</span>
</div>
<div className="text-2xl font-black text-blue-900 dark:text-blue-100 mt-1">
{resumoExibido.totalUnidadesLiberadasSolda} <span className="text-xs text-blue-600 font-bold">/ {resumoExibido.totalUnidadesTotal} un</span>
</div>
<div className="text-[10px] text-blue-700/80 dark:text-blue-400/80 font-medium truncate">
kits prontos na bancada de solda
</div>
</div>
<div className="p-2.5 rounded-xl bg-blue-100 text-blue-700 dark:bg-blue-900/60 dark:text-blue-300 shrink-0 ml-2">
<PackageCheck className="w-6 h-6" />
</div>
</div>
{/* KPI 3: Em Andamento / Quase Prontos */}
<div className="p-3.5 rounded-2xl bg-amber-50/80 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 shadow-sm flex items-center justify-between">
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-black uppercase text-amber-700 dark:text-amber-400">
Corte em Andamento
</span>
<span className="text-[11px] font-black text-amber-800 dark:text-amber-300 bg-amber-200/80 dark:bg-amber-900/70 px-1.5 py-0.5 rounded-md border border-amber-300/80 dark:border-amber-700 shadow-2xs">
{formatarKg(resumoExibido.pesoParciaisKg)}
</span>
</div>
<div className="text-2xl font-black text-amber-900 dark:text-amber-100 mt-1">
{resumoExibido.totalKitsParciais}
</div>
<div className="text-[10px] text-amber-700/80 dark:text-amber-400/80 font-medium truncate">
kits parciais (prioridade de corte)
</div>
</div>
<div className="p-2.5 rounded-xl bg-amber-100 text-amber-700 dark:bg-amber-900/60 dark:text-amber-300 shrink-0 ml-2">
<Clock className="w-6 h-6" />
</div>
</div>
{/* KPI 4: Travados / 0% */}
<div className="p-3.5 rounded-2xl bg-rose-50/80 dark:bg-rose-950/40 border border-rose-200 dark:border-rose-800 shadow-sm flex items-center justify-between">
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-black uppercase text-rose-700 dark:text-rose-400">
Não Iniciados
</span>
<span className="text-[11px] font-black text-rose-800 dark:text-rose-300 bg-rose-200/80 dark:bg-rose-900/70 px-1.5 py-0.5 rounded-md border border-rose-300/80 dark:border-rose-700 shadow-2xs">
{formatarKg(resumoExibido.pesoNaoIniciadosKg)}
</span>
</div>
<div className="text-2xl font-black text-rose-900 dark:text-rose-100 mt-1">
{resumoExibido.totalKitsNaoIniciados}
</div>
<div className="text-[10px] text-rose-700/80 dark:text-rose-400/80 font-medium truncate">
sem nenhum componente cortado
</div>
</div>
<div className="p-2.5 rounded-xl bg-rose-100 text-rose-700 dark:bg-rose-900/60 dark:text-rose-300 shrink-0 ml-2">
<AlertTriangle className="w-6 h-6" />
</div>
</div>
</div>
)}
{/* ──────────────── Filtros por Status Térmico & Ações em Massa ──────────────── */}
<div className="flex items-center justify-between gap-2 flex-wrap text-xs">
<div className="flex items-center gap-1.5 flex-wrap">
<button
type="button"
onClick={() => setFiltroStatus('todos')}
className={`px-3 py-1.5 rounded-xl font-bold transition-colors ${
filtroStatus === 'todos'
? 'bg-slate-900 text-white dark:bg-white dark:text-slate-900 shadow-sm'
: 'bg-slate-100 hover:bg-slate-200 text-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-slate-300'
}`}
>
Todas ({listaKits.length})
</button>
<button
type="button"
onClick={() => setFiltroStatus('liberados')}
className={`px-3 py-1.5 rounded-xl font-bold flex items-center gap-1.5 transition-colors ${
filtroStatus === 'liberados'
? 'bg-emerald-600 text-white shadow-sm'
: 'bg-emerald-50 text-emerald-800 hover:bg-emerald-100 dark:bg-emerald-950/50 dark:text-emerald-300'
}`}
>
<span className="w-2 h-2 rounded-full bg-emerald-400"></span>
Liberadas para Solda ({listaKits.filter((k) => k.kits_completos_cortados > 0).length})
</button>
<button
type="button"
onClick={() => setFiltroStatus('parciais')}
className={`px-3 py-1.5 rounded-xl font-bold flex items-center gap-1.5 transition-colors ${
filtroStatus === 'parciais'
? 'bg-amber-600 text-white shadow-sm'
: 'bg-amber-50 text-amber-800 hover:bg-amber-100 dark:bg-amber-950/50 dark:text-amber-300'
}`}
>
<span className="w-2 h-2 rounded-full bg-amber-400"></span>
Em Andamento / Quase Prontas ({listaKits.filter((k) => k.status_termico === 'amarelo' || (k.kits_completos_cortados > 0 && k.kits_completos_cortados < k.quantidade_total)).length})
</button>
<button
type="button"
onClick={() => setFiltroStatus('travados')}
className={`px-3 py-1.5 rounded-xl font-bold flex items-center gap-1.5 transition-colors ${
filtroStatus === 'travados'
? 'bg-rose-600 text-white shadow-sm'
: 'bg-rose-50 text-rose-800 hover:bg-rose-100 dark:bg-rose-950/50 dark:text-rose-300'
}`}
>
<span className="w-2 h-2 rounded-full bg-rose-400"></span>
Travadas no Corte ({listaKits.filter((k) => k.status_termico === 'vermelho').length})
</button>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={expandirTodas} className="h-7 text-[11px] font-semibold text-slate-600 dark:text-slate-400">
Expandir Todos
</Button>
<Button variant="ghost" size="sm" onClick={recolherTodas} className="h-7 text-[11px] font-semibold text-slate-600 dark:text-slate-400">
Recolher Todos
</Button>
</div>
</div>
{/* ──────────────── Lista de Peças com Mapa Térmico ──────────────── */}
{!loading && listaKits.length > 0 && !listaKits.some((k) => k.componentes && k.componentes.length > 0) && (
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/30 text-amber-900 dark:text-amber-200 flex items-center gap-3 shadow-xs">
<Info className="w-5 h-5 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="text-xs leading-relaxed">
<strong className="font-bold">Modo Peça Direta:</strong> Esta OF não possui subcomponentes cadastrados no sistema. O avanço no <strong>Corte</strong> e a liberação das peças para a <strong>Solda</strong> são gerenciados diretamente pelo apontamento das peças principais.
</div>
</div>
)}
{loading ? (
<div className="py-16 flex flex-col items-center justify-center text-slate-400 gap-3">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-orange-500 border-t-transparent" />
<span className="text-sm font-semibold">Calculando matriz de kits e completude...</span>
</div>
) : pecasFiltradas.length === 0 ? (
<Card className="border border-dashed border-slate-300 dark:border-slate-800 p-8 text-center bg-slate-50/50 dark:bg-slate-900/50">
<Layers className="w-10 h-10 text-slate-400 mx-auto mb-2 opacity-60" />
<h4 className="font-bold text-slate-700 dark:text-slate-300 text-sm">Nenhuma peça encontrada</h4>
<p className="text-xs text-slate-500 mt-1">Tente ajustar a busca ou os filtros de status</p>
</Card>
) : (
<div className="space-y-3">
{pecasFiltradas.map((peca) => {
const isExpanded = pecasExpandidas.has(peca.peca_id);
const totalKits = peca.quantidade_total;
const kitsProntos = peca.kits_completos_cortados;
const pct = peca.percentual_termico;
const eh100 = kitsProntos >= totalKits;
const temParcial = kitsProntos > 0 && kitsProntos < totalKits;
const gargalo = peca.componente_gargalo;
// Cores térmicas
let barColor = 'bg-rose-500';
let badgeBg = 'bg-rose-100 text-rose-800 border-rose-300 dark:bg-rose-950/60 dark:text-rose-300 dark:border-rose-800';
if (eh100) {
barColor = 'bg-emerald-500';
badgeBg = 'bg-emerald-100 text-emerald-800 border-emerald-300 dark:bg-emerald-950/60 dark:text-emerald-300 dark:border-emerald-800';
} else if (temParcial) {
barColor = 'bg-emerald-500';
badgeBg = 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-950/60 dark:text-blue-300 dark:border-blue-800';
} else if (pct >= 40) {
barColor = 'bg-amber-500';
badgeBg = 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/60 dark:text-amber-300 dark:border-amber-800';
}
return (
<div
key={peca.peca_id}
className="rounded-2xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900/90 shadow-sm overflow-hidden transition-all hover:border-slate-300 dark:hover:border-slate-700"
>
{/* Linha Principal do Card */}
<div
onClick={() => toggleExpand(peca.peca_id)}
className="p-4 cursor-pointer flex flex-col sm:flex-row sm:items-center justify-between gap-3 hover:bg-slate-50/50 dark:hover:bg-slate-800/40 transition-colors select-none"
>
{/* Lado Esquerdo: Identificação da Peça */}
<div className="flex items-start gap-3 min-w-0">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 font-black text-sm shadow-sm ${
eh100
? 'bg-emerald-500 text-white'
: temParcial
? 'bg-blue-500 text-white'
: pct >= 40
? 'bg-amber-500 text-slate-950'
: 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-400'
}`}
>
{pct}%
</div>
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-lg font-black text-slate-900 dark:text-slate-100 tracking-wide">
{peca.marca}
</span>
<Badge variant="outline" className="text-[11px] font-bold">
{peca.etapa_fase.toLowerCase().startsWith('fase') ? peca.etapa_fase : `Fase ${peca.etapa_fase}`}
</Badge>
{peca.perfil_principal && (
<span className="text-xs px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 font-semibold font-mono">
{peca.perfil_principal}
</span>
)}
{peca.tem_componentes === false && (
<Badge variant="secondary" className="bg-purple-100 text-purple-800 dark:bg-purple-950 dark:text-purple-300 text-[10px] font-black">
S/M (Sem Montagem)
</Badge>
)}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1.5 flex-wrap">
<span className="line-clamp-1">{peca.descricao || 'Peça Estrutural'}</span>
{peca.peso_unitario > 0 && (
<span className="font-semibold text-slate-600 dark:text-slate-400 whitespace-nowrap">
• {peca.peso_unitario.toFixed(1)} kg/un (Total: {peca.peso_total_peca.toFixed(1)} kg)
</span>
)}
</div>
</div>
</div>
{/* Lado Direito: Status dos Kits e Métricas */}
<div className="flex items-center gap-3 shrink-0 justify-between sm:justify-end">
<div className="text-right">
<div className="flex items-center justify-end gap-1.5 flex-wrap">
<span className={`text-xs font-black px-2.5 py-0.5 rounded-full border ${badgeBg}`}>
{eh100
? `✅ 100% Liberado (${kitsProntos}/${totalKits} un)`
: temParcial
? `🟢 ${kitsProntos}/${totalKits} kits liberados na Solda`
: pct >= 40
? `🟡 0/${totalKits} kits (Corte ${pct}%)`
: `🔴 0/${totalKits} kits (Não iniciado)`}
</span>
</div>
{/* Gargalo em Destaque */}
{gargalo && kitsProntos < totalKits && (
<div className="text-[11px] font-semibold text-amber-700 dark:text-amber-400 mt-1 flex items-center justify-end gap-1">
<AlertTriangle className="w-3 h-3 text-amber-500 shrink-0" />
<span>
Gargalo: <strong>{gargalo.marca_componente}</strong> (faltam {gargalo.falta_para_prox_kit} un para próx. kit)
</span>
</div>
)}
</div>
<div className="p-1 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
{isExpanded ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
</div>
</div>
</div>
{/* Barra Térmica de Progresso */}
<div className="px-4 pb-3">
<div className="w-full bg-slate-100 dark:bg-slate-800 h-2.5 rounded-full overflow-hidden flex">
<div
className={`h-full transition-all duration-300 ${barColor}`}
style={{ width: `${pct}%` }}
title={`Prontidão térmica do kit: ${pct}%`}
/>
</div>
</div>
{/* ──────────────── Detalhamento dos Componentes do Kit (Expansível) ──────────────── */}
{isExpanded && (
<div className="px-4 pb-4 pt-2 border-t border-slate-100 dark:border-slate-800/80 bg-slate-50/60 dark:bg-slate-950/40 space-y-2">
<div className="flex items-center justify-between text-xs font-bold text-slate-700 dark:text-slate-300 mb-2">
<span className="flex items-center gap-1.5">
<Wrench className="w-3.5 h-3.5 text-blue-500" />
Componentes Requeridos para {totalKits}x {peca.marca} ({peca.componentes.length} itens no kit):
</span>
{onSelectPecaParaApontar && (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
onSelectPecaParaApontar(peca);
}}
className="h-7 text-xs bg-amber-500 hover:bg-amber-600 text-slate-950 font-black gap-1"
>
<Scissors className="w-3 h-3" />
Apontar no Corte
</Button>
)}
</div>
{peca.componentes.length === 0 ? (
<p className="text-xs text-slate-500 italic py-1">
Esta peça não possui subcomponentes cadastrados (é fabricada como peça inteira).
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{peca.componentes.map((comp) => {
const isGargalo = gargalo?.id === comp.id && kitsProntos < totalKits;
const comp100 = comp.concluido;
return (
<div
key={comp.id}
className={`p-2.5 rounded-xl border text-xs transition-colors flex items-center justify-between gap-2 ${
isGargalo
? 'bg-amber-50 dark:bg-amber-950/40 border-amber-400 dark:border-amber-700 shadow-sm'
: comp100
? 'bg-emerald-50/50 dark:bg-emerald-950/20 border-emerald-200 dark:border-emerald-800/60'
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800'
}`}
>
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-mono font-black text-slate-900 dark:text-slate-100">
{comp.marca_componente}
</span>
{comp.perfil && (
<span className="text-[10px] px-1.5 py-0.2 rounded bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 font-mono">
{comp.perfil}
</span>
)}
{isGargalo && (
<span className="text-[9px] font-black px-1.5 py-0.5 rounded bg-amber-500 text-slate-950 uppercase tracking-tight">
Gargalo
</span>
)}
</div>
<div className="text-[11px] text-slate-500 dark:text-slate-400">
{comp.quantidade_por_peca} un/peça • Total da OF: <strong>{comp.quantidade_total_necessaria} un</strong>
</div>
</div>
<div className="text-right shrink-0">
<div className="font-mono font-black text-xs text-slate-800 dark:text-slate-200">
{comp.quantidade_cortada} / {comp.quantidade_total_necessaria} un
</div>
<div className="text-[10px] font-bold text-slate-500 dark:text-slate-400">
Dá para {comp.kits_possiveis} {comp.kits_possiveis === 1 ? 'kit' : 'kits'} ({comp.percentual_cortado}%)
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
};
@@ -26,6 +26,9 @@ import * as XLSX from 'xlsx';
import { useAppLabels } from '@/hooks/useAppLabels';
import { usePecas } from '@/hooks/usePecas';
import { supabase } from '@/integrations/supabase/client';
import { analisarConflitosImportacao, consolidarPecasEComponentes, extrairComprimentoSeguro, extrairComprimentoComponenteSeguro } from '@/utils/importDiffService';
import { ResultadoAnaliseImportacao } from '@/types/importDiff';
import { ImportarPecasDiffModal } from '@/components/pecas/ImportarPecasDiffModal';
export interface ComponentItem {
marca: string;
@@ -145,6 +148,9 @@ const AdvanceSteelConverterContent: React.FC = () => {
const [statusText, setStatusText] = useState<string>('Aguardando arquivo PDF...');
const [currentFileName, setCurrentFileName] = useState<string>('Lista_Pecas');
const [useIframeMode, setUseIframeMode] = useState<boolean>(false);
const [analiseDiff, setAnaliseDiff] = useState<ResultadoAnaliseImportacao | null>(null);
const [componentesMapDiff, setComponentesMapDiff] = useState<Map<string, any[]>>(new Map());
const [showDiffModal, setShowDiffModal] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const logEndRef = useRef<HTMLDivElement>(null);
@@ -753,6 +759,7 @@ const AdvanceSteelConverterContent: React.FC = () => {
// Aba 3: Formato Plano Oficial para Importação TrackSteel
const excelRowsImportacao: any[] = [];
extractedData.forEach((item) => {
const comprPecaNum = item.comprimentoMax === '-' ? '' : Number(item.comprimentoMax) || '';
if (item.components && item.components.length > 0) {
item.components.forEach((comp) => {
excelRowsImportacao.push({
@@ -763,6 +770,8 @@ const AdvanceSteelConverterContent: React.FC = () => {
quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit),
peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal),
comprimento: comprPecaNum,
comprimento_ref: comprPecaNum,
tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal,
material: item.material,
perfil_principal: item.perfilPrincipal,
@@ -771,6 +780,7 @@ const AdvanceSteelConverterContent: React.FC = () => {
descricao_componente: comp.perfil || item.descricao,
perfil_componente: comp.perfil || item.descricao,
peso_unitario_componente: comp.pesoUnitario || 0,
comprimento_componente: comp.comprimento || '',
quantidade_por_peca: Number(comp.quantidade) || 1
});
});
@@ -783,6 +793,8 @@ const AdvanceSteelConverterContent: React.FC = () => {
quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit),
peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal),
comprimento: comprPecaNum,
comprimento_ref: comprPecaNum,
tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal,
material: item.material,
perfil_principal: item.perfilPrincipal,
@@ -791,6 +803,7 @@ const AdvanceSteelConverterContent: React.FC = () => {
descricao_componente: '',
perfil_componente: '',
peso_unitario_componente: 0,
comprimento_componente: '',
quantidade_por_peca: 0
});
}
@@ -829,8 +842,12 @@ const AdvanceSteelConverterContent: React.FC = () => {
try {
const importacaoRows: any[] = [];
extractedData.forEach((item) => {
const comprPeca = extrairComprimentoSeguro(item.comprimentoMax) ?? extrairComprimentoSeguro(item);
if (item.components && item.components.length > 0) {
item.components.forEach((comp) => {
const comprComp = extrairComprimentoComponenteSeguro(comp.comprimento) ?? extrairComprimentoComponenteSeguro(comp);
importacaoRows.push({
of_number: item.of,
etapa_fase: item.fase,
@@ -839,6 +856,8 @@ const AdvanceSteelConverterContent: React.FC = () => {
quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit),
peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal),
comprimento: comprPeca,
comprimento_ref: comprPeca,
tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal,
material: item.material,
perfil_principal: item.perfilPrincipal,
@@ -847,6 +866,7 @@ const AdvanceSteelConverterContent: React.FC = () => {
descricao_componente: comp.perfil || item.descricao,
perfil_componente: comp.perfil || item.descricao,
peso_unitario_componente: comp.pesoUnitario || 0,
comprimento_componente: comprComp,
quantidade_por_peca: Number(comp.quantidade) || 1
});
});
@@ -859,6 +879,8 @@ const AdvanceSteelConverterContent: React.FC = () => {
quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit),
peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal),
comprimento: comprPeca,
comprimento_ref: comprPeca,
tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal,
material: item.material,
perfil_principal: item.perfilPrincipal,
@@ -867,11 +889,24 @@ const AdvanceSteelConverterContent: React.FC = () => {
descricao_componente: '',
perfil_componente: '',
peso_unitario_componente: 0,
comprimento_componente: null,
quantidade_por_peca: 0
});
}
});
toast.info('Verificando existência e status de produção das peças...');
const analise = await analisarConflitosImportacao(importacaoRows);
const { componentesMap } = consolidarPecasEComponentes(importacaoRows);
if (analise.totalComDivergencia > 0 || analise.totalIdenticas > 0 || analise.totalComApontamento > 0) {
setAnaliseDiff(analise);
setComponentesMapDiff(componentesMap);
setShowDiffModal(true);
setIsDirectImporting(false);
return;
}
await importPecas(importacaoRows);
toast.success(`${extractedData.length} peças e seus componentes foram importados com sucesso!`);
addLog(`✅ Importação concluída: ${extractedData.length} peças gravadas no banco.`);
@@ -1160,6 +1195,19 @@ const AdvanceSteelConverterContent: React.FC = () => {
)}
</CardContent>
</Card>
{/* Modal de Detecção de Conflitos e Conciliação */}
<ImportarPecasDiffModal
open={showDiffModal}
onOpenChange={setShowDiffModal}
analise={analiseDiff}
componentesMap={componentesMapDiff}
ofDefault={headerOf || ''}
onConcluido={() => {
setShowDiffModal(false);
addLog(`✅ Importação conciliada concluída com sucesso.`);
}}
/>
</div>
);
};
+137 -9
View File
@@ -24,6 +24,10 @@ import {
} from 'lucide-react';
import { toast } from 'sonner';
import * as XLSX from 'xlsx';
import { usePecas } from '@/hooks/usePecas';
import { analisarConflitosImportacao, consolidarPecasEComponentes, extrairComprimentoSeguro, extrairComprimentoComponenteSeguro } from '@/utils/importDiffService';
import { ResultadoAnaliseImportacao } from '@/types/importDiff';
import { ImportarPecasDiffModal } from '@/components/pecas/ImportarPecasDiffModal';
export interface BocadConvertedPiece {
of: string;
@@ -39,6 +43,7 @@ export interface BocadConvertedPiece {
perfilPrincipal: string;
comprimentoRef: number | string;
qtdComponentesFilhos: number;
components?: RawBocadComponent[];
}
interface RawBocadComponent {
@@ -66,14 +71,19 @@ interface RawBocadAssembly {
}
export const BocadConverter: React.FC = () => {
const { importPecas } = usePecas();
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [isDirectImporting, setIsDirectImporting] = useState(false);
const [tratamentoGlobal, setTratamentoGlobal] = useState<'pintura' | 'galvanizacao'>('pintura');
const [extractedPieces, setExtractedPieces] = useState<BocadConvertedPiece[]>([]);
const [headerOf, setHeaderOf] = useState<string>('');
const [headerFase, setHeaderFase] = useState<string>('');
const [processingLogs, setProcessingLogs] = useState<string[]>([]);
const [showLogs, setShowLogs] = useState(false);
const [analiseDiff, setAnaliseDiff] = useState<ResultadoAnaliseImportacao | null>(null);
const [componentesMapDiff, setComponentesMapDiff] = useState<Map<string, any[]>>(new Map());
const [showDiffModal, setShowDiffModal] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -321,11 +331,10 @@ export const BocadConverter: React.FC = () => {
}
// Critério de identificação da Peça Principal (Linha com Fundo Cinza):
// No formato Bocad:
// 1. Linha principal tem 'Marca' preenchida, 'Quant' > 0, 'Perfil' genérico (ex: VIGA)
// 2. E o campo 'Qualid' é VAZIO na linha principal (o aço fica nas linhas de componentes)
// 3. O Peso Total é a somatória do lote da peça
const isMainAssemblyRow = !qualidVal && marcaVal && quantVal > 0 && perfilVal;
// Pela regra do Bocad: Marcas < 1000 são as peças principais (linhas cinzas)
// Marcas >= 1000 são componentes (linhas brancas) que ficam abaixo da peça principal
const marcaNum = parseInt(marcaVal, 10);
const isMainAssemblyRow = !isNaN(marcaNum) && marcaNum < 1000 && marcaVal && quantVal > 0;
if (isMainAssemblyRow) {
// Se já havia um subconjunto sendo processado, salva-o
@@ -390,8 +399,13 @@ export const BocadConverter: React.FC = () => {
const rawMaterial = firstWhiteRow ? firstWhiteRow.qualid : 'A36';
const normalizedMat = normalizeMaterial(rawMaterial);
// Comprimento de referência (pega da linha principal ou do primeiro componente se a linha principal não tiver)
const compRef = asm.compr || (firstWhiteRow ? firstWhiteRow.compr : '');
// Comprimento de referência (pega da linha principal, do primeiro componente ou do maior componente)
let maxCompSub = 0;
asm.components.forEach(c => {
const n = Number(c.compr) || 0;
if (n > maxCompSub) maxCompSub = n;
});
const compRef = asm.compr || (firstWhiteRow ? firstWhiteRow.compr : '') || (maxCompSub > 0 ? maxCompSub : '');
return {
of: detectedOF,
@@ -406,7 +420,8 @@ export const BocadConverter: React.FC = () => {
material: normalizedMat,
perfilPrincipal: perfilPrincipal,
comprimentoRef: compRef,
qtdComponentesFilhos: whiteRowsCount
qtdComponentesFilhos: whiteRowsCount,
components: asm.components
};
});
@@ -511,6 +526,87 @@ export const BocadConverter: React.FC = () => {
}
};
const handleDirectImport = async () => {
if (extractedPieces.length === 0) return;
setIsDirectImporting(true);
try {
const importacaoRows: any[] = [];
extractedPieces.forEach((item) => {
const comprPeca = extrairComprimentoSeguro(item.comprimentoRef) ?? extrairComprimentoSeguro(item);
if (item.compostoPorComponentes === 'SIM' && item.components && item.components.length > 0) {
item.components.forEach((comp) => {
const comprComp = extrairComprimentoComponenteSeguro(comp.compr) ?? extrairComprimentoComponenteSeguro(comp);
importacaoRows.push({
of_number: item.of,
etapa_fase: item.fase,
marca: item.marca,
descricao: item.descricao,
quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnitario || 0,
peso_total: item.pesoTotal || 0,
comprimento: comprPeca,
comprimento_ref: comprPeca,
tratamento_superficial: item.tratamentoSuperficial,
material: item.material,
perfil_principal: item.perfilPrincipal,
tem_componentes: true,
marca_componente: comp.marca || '',
descricao_componente: comp.perfil || item.descricao,
perfil_componente: comp.perfil || item.descricao,
peso_unitario_componente: comp.pesoUnit || 0,
comprimento_componente: comprComp,
quantidade_por_peca: Number(comp.quant) || 1
});
});
} else {
importacaoRows.push({
of_number: item.of,
etapa_fase: item.fase,
marca: item.marca,
descricao: item.descricao,
quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnitario || 0,
peso_total: item.pesoTotal || 0,
comprimento: comprPeca,
comprimento_ref: comprPeca,
tratamento_superficial: item.tratamentoSuperficial,
material: item.material,
perfil_principal: item.perfilPrincipal,
tem_componentes: false,
marca_componente: '',
descricao_componente: '',
perfil_componente: '',
peso_unitario_componente: 0,
quantidade_por_peca: 0
});
}
});
toast.info('Verificando existência e status de produção das peças...');
const analise = await analisarConflitosImportacao(importacaoRows);
const { componentesMap } = consolidarPecasEComponentes(importacaoRows);
if (analise.totalComDivergencia > 0 || analise.totalIdenticas > 0 || analise.totalComApontamento > 0) {
setAnaliseDiff(analise);
setComponentesMapDiff(componentesMap);
setShowDiffModal(true);
setIsDirectImporting(false);
return;
}
await importPecas(importacaoRows);
toast.success(`${extractedPieces.length} peças foram importadas com sucesso!`);
addLog(`✅ Importação concluída: ${extractedPieces.length} peças gravadas no banco.`);
} catch (err: any) {
console.error('Erro na importação direta:', err);
toast.error(`Falha ao importar: ${err?.message || 'Erro no banco'}`);
} finally {
setIsDirectImporting(false);
}
};
const totalPesoGeral = extractedPieces.reduce((acc, curr) => acc + (curr.pesoTotal || 0), 0);
const totalPecasCompostas = extractedPieces.filter((p) => p.compostoPorComponentes === 'SIM').length;
const totalPecasSimples = extractedPieces.filter((p) => p.compostoPorComponentes === 'NAO').length;
@@ -685,11 +781,30 @@ export const BocadConverter: React.FC = () => {
<div className="flex items-center gap-2">
<Button
onClick={exportToOfficialExcel}
className="bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-xs px-4 h-9 shadow-lg shadow-emerald-950/40"
variant="outline"
className="border-emerald-600/60 text-emerald-400 hover:bg-emerald-600/10 font-semibold text-xs px-3.5 h-9"
>
<Download className="w-4 h-4 mr-1.5" />
Baixar Planilha Padrão (.xlsx)
</Button>
<Button
onClick={handleDirectImport}
disabled={isDirectImporting}
className="bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs px-4 h-9 shadow-lg shadow-indigo-950/40"
>
{isDirectImporting ? (
<>
<RefreshCw className="w-4 h-4 mr-1.5 animate-spin" />
Gravando no Banco...
</>
) : (
<>
<Upload className="w-4 h-4 mr-1.5" />
cadastrar peças na OF
</>
)}
</Button>
</div>
</div>
)}
@@ -778,6 +893,19 @@ export const BocadConverter: React.FC = () => {
)}
</CardContent>
</Card>
{/* Modal de Detecção de Conflitos e Conciliação */}
<ImportarPecasDiffModal
open={showDiffModal}
onOpenChange={setShowDiffModal}
analise={analiseDiff}
componentesMap={componentesMapDiff}
ofDefault={headerOf || ''}
onConcluido={() => {
setShowDiffModal(false);
addLog(`✅ Importação conciliada concluída com sucesso.`);
}}
/>
</div>
);
};
@@ -0,0 +1,385 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Truck,
Calendar,
AlertTriangle,
CheckCircle2,
Loader2,
Sparkles,
Info
} from 'lucide-react';
import { toast } from 'sonner';
import { useQueryClient } from '@tanstack/react-query';
import { useAuth } from '@/hooks/useAuth';
import {
validarRomaneioParaEntrega,
executarApontamentoRomaneioEntregue,
ResultadoValidacaoEntrega
} from '@/utils/validacaoRomaneio';
export interface RomaneioEntregaInfo {
id: string;
numero_romaneio: string;
of_number: string;
data_prevista_entrega?: string | null;
data_romaneio?: string | null;
status?: string;
}
interface ConfirmacaoEntregaRomaneioModalProps {
isOpen: boolean;
onClose: () => void;
romaneio: RomaneioEntregaInfo | null;
onSucesso?: () => void;
}
export const ConfirmacaoEntregaRomaneioModal: React.FC<ConfirmacaoEntregaRomaneioModalProps> = ({
isOpen,
onClose,
romaneio,
onSucesso
}) => {
const { user } = useAuth();
const queryClient = useQueryClient();
const [loadingAnalise, setLoadingAnalise] = useState(false);
const [analise, setAnalise] = useState<ResultadoValidacaoEntrega | null>(null);
const [dataRealEntrega, setDataRealEntrega] = useState<string>('');
const [processando, setProcessando] = useState(false);
// Inicializar dados ao abrir
useEffect(() => {
if (isOpen && romaneio) {
const hoje = new Date().toISOString().split('T')[0];
const dataInicial = romaneio.data_prevista_entrega || hoje;
setDataRealEntrega(dataInicial);
carregarAnalise(romaneio.id, romaneio.of_number);
} else {
setAnalise(null);
setDataRealEntrega('');
}
}, [isOpen, romaneio]);
const carregarAnalise = async (romaneioId: string, ofNumber: string) => {
try {
setLoadingAnalise(true);
const res = await validarRomaneioParaEntrega(romaneioId, ofNumber);
setAnalise(res);
} catch (e: any) {
console.error('Erro ao analisar romaneio:', e);
toast.error('Erro ao verificar status das peças: ' + (e?.message || 'Falha de comunicação'));
} finally {
setLoadingAnalise(false);
}
};
const handleExecutar = async (autorizarCascata: boolean, apontarApenasLiberadas: boolean) => {
if (!romaneio) return;
if (!dataRealEntrega) {
toast.error('Informe a data real de entrega para gravar o apontamento');
return;
}
try {
setProcessando(true);
const res = await executarApontamentoRomaneioEntregue({
romaneioId: romaneio.id,
ofNumber: romaneio.of_number,
numeroRomaneio: romaneio.numero_romaneio,
dataEntregaReal: dataRealEntrega,
autorizarCascata,
apontarApenasLiberadas,
userId: user?.id
});
if (res.sucesso) {
toast.success(res.mensagem);
// Atualizar queries ativas para atualizar tabelas e gráficos
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['romaneios'] }),
queryClient.invalidateQueries({ queryKey: ['apontamentos'] }),
queryClient.invalidateQueries({ queryKey: ['dashboard-producao'] }),
queryClient.invalidateQueries({ queryKey: ['pecas'] })
]);
onClose();
if (onSucesso) onSucesso();
} else {
toast.warning(res.mensagem);
}
} catch (e: any) {
console.error('Erro ao processar entrega:', e);
toast.error('Erro ao concluir apontamento: ' + (e?.message || 'Falha no banco de dados'));
} finally {
setProcessando(false);
}
};
if (!romaneio) return null;
const dataPrevistaFormatada = romaneio.data_prevista_entrega
? new Date(romaneio.data_prevista_entrega + 'T12:00:00').toLocaleDateString('pt-BR')
: null;
const hoje = new Date().toISOString().split('T')[0];
const dataDiferenteDePrevista = romaneio.data_prevista_entrega && dataRealEntrega !== romaneio.data_prevista_entrega;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col p-6">
<DialogHeader className="border-b pb-3">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20">
<Truck className="h-6 w-6" />
</div>
<div>
<DialogTitle className="text-xl font-bold flex items-center gap-2">
<span>Confirmar Entrega & Apontamento de Expedição</span>
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm text-muted-foreground mt-0.5">
Romaneio <strong>{romaneio.numero_romaneio}</strong> • OF <strong>{romaneio.of_number}</strong>
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="py-4 space-y-4 overflow-y-auto flex-1 pr-1">
{/* Seção 1: Data Real da Entrega */}
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<Label htmlFor="data-real-entrega" className="font-semibold text-sm flex items-center gap-2">
<Calendar className="h-4 w-4 text-primary" />
Data Real da Entrega *
</Label>
<div className="flex items-center gap-1.5">
{romaneio.data_prevista_entrega && (
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={() => setDataRealEntrega(romaneio.data_prevista_entrega!)}
>
Usar Data Prevista ({dataPrevistaFormatada})
</Button>
)}
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={() => setDataRealEntrega(hoje)}
>
Usar Hoje
</Button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-center">
<Input
id="data-real-entrega"
type="date"
value={dataRealEntrega}
onChange={(e) => setDataRealEntrega(e.target.value)}
className="h-9 font-medium"
/>
<div className="text-xs text-muted-foreground">
{dataDiferenteDePrevista ? (
<span className="text-amber-600 dark:text-amber-400 font-medium flex items-center gap-1">
<Info className="h-3.5 w-3.5 flex-shrink-0" />
Data real difere da data prevista ({dataPrevistaFormatada}).
</span>
) : (
<span>Os apontamentos serão gravados exatamente nesta data.</span>
)}
</div>
</div>
</div>
{/* Seção 2: Diagnóstico dos Processos de Fabricação */}
{loadingAnalise ? (
<div className="p-8 text-center space-y-2">
<Loader2 className="h-7 w-7 animate-spin mx-auto text-primary" />
<p className="text-xs text-muted-foreground">Analisando processos de fabricação das peças...</p>
</div>
) : analise ? (
<div className="space-y-3">
{analise.totalPendentes === 0 ? (
// Cenário A: Todas as peças liberadas
<div className="p-4 bg-emerald-500/10 border border-emerald-500/30 rounded-lg flex items-start gap-3">
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<h4 className="font-semibold text-emerald-700 dark:text-emerald-300 text-sm">
Tudo pronto para Expedição!
</h4>
<p className="text-xs text-muted-foreground">
Todas as <strong>{analise.totalItens} peças</strong> deste romaneio já concluíram os processos de fabricação anteriores na fábrica. Ao confirmar, o apontamento de Expedição será registrado imediatamente para todas as unidades na data informada.
</p>
</div>
</div>
) : (
// Cenário B: Existem peças com pendências nos processos anteriores
<div className="space-y-3">
<div className="p-4 bg-amber-500/10 border border-amber-500/30 rounded-lg flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<h4 className="font-semibold text-amber-700 dark:text-amber-300 text-sm">
Peças Pendentes nos Processos Anteriores ({analise.totalPendentes} de {analise.totalItens} itens)
</h4>
<p className="text-xs text-muted-foreground">
Algumas peças deste romaneio ainda não possuem apontamentos completos nos processos de fabricação anteriores (Corte, Solda ou Pintura/Galv).
</p>
</div>
</div>
{/* Tabela de Peças Pendentes */}
<div className="border rounded-md overflow-hidden bg-card">
<div className="bg-muted/50 px-3 py-2 text-xs font-semibold text-foreground border-b flex justify-between items-center">
<span>Detalhamento das Peças com Pendência</span>
<Badge variant="outline" className="text-xs">
{analise.totalPendentes} peça(s) a regularizar
</Badge>
</div>
<ScrollArea className="max-h-[220px]">
<table className="w-full text-xs text-left">
<thead className="bg-muted/30 text-muted-foreground uppercase border-b sticky top-0">
<tr>
<th className="py-2 px-3">Marca</th>
<th className="py-2 px-3 text-center">Fase</th>
<th className="py-2 px-3 text-center">No Romaneio</th>
<th className="py-2 px-3">Processos que Faltam Apontar</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{analise.pecasAnalise
?.filter(p => !p.liberada)
.map((peca) => (
<tr key={peca.peca_id} className="hover:bg-accent/40">
<td className="py-2 px-3 font-semibold text-foreground">
{peca.marca}
</td>
<td className="py-2 px-3 text-center">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
Fase {peca.fase}
</Badge>
</td>
<td className="py-2 px-3 text-center font-bold">
{peca.quantidade_expedida} un
</td>
<td className="py-2 px-3">
<div className="flex flex-wrap gap-1">
{peca.processosFaltantes.map((f, i) => (
<Badge
key={i}
variant="outline"
className="text-[10px] bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20"
>
{f.nome}: faltam {f.qtdFaltante} un
</Badge>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</ScrollArea>
</div>
{/* Pergunta de Decisão com Destaque */}
<div className="p-3.5 bg-blue-500/10 border border-blue-500/30 rounded-lg flex items-center gap-3">
<Sparkles className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div className="text-xs">
<p className="font-semibold text-blue-900 dark:text-blue-200">
Autorizar Apontamento em Cascata?
</p>
<p className="text-muted-foreground mt-0.5">
O sistema irá criar automaticamente os apontamentos faltantes nos processos pendentes para regularizar a produção e registrar a entrega completa de todas as peças na Expedição.
</p>
</div>
</div>
</div>
)}
</div>
) : null}
</div>
<DialogFooter className="border-t pt-3 flex flex-wrap gap-2 justify-between items-center sm:justify-between">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={processando}
size="sm"
>
Cancelar
</Button>
<div className="flex flex-wrap gap-2 ml-auto">
{analise && analise.totalPendentes === 0 ? (
// Botão para caso sem pendências
<Button
type="button"
onClick={() => handleExecutar(false, false)}
disabled={processando || !dataRealEntrega}
className="bg-emerald-600 hover:bg-emerald-700 text-white font-semibold text-xs shadow-sm"
size="sm"
>
{processando ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
) : (
<CheckCircle2 className="h-3.5 w-3.5 mr-1.5" />
)}
Confirmar Entrega & Apontar Expedição
</Button>
) : analise && analise.totalPendentes > 0 ? (
<>
{analise.totalLiberadas && analise.totalLiberadas > 0 ? (
<Button
type="button"
variant="secondary"
onClick={() => handleExecutar(false, true)}
disabled={processando || !dataRealEntrega}
className="text-xs"
size="sm"
title="Aponta somente as peças que já concluíram Pintura"
>
Apontar Apenas Liberadas ({analise.totalLiberadas})
</Button>
) : null}
<Button
type="button"
onClick={() => handleExecutar(true, false)}
disabled={processando || !dataRealEntrega}
className="bg-blue-600 hover:bg-blue-700 text-white font-bold text-xs shadow-sm flex items-center gap-1.5"
size="sm"
>
{processando ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
Autorizar Cascata e Apontar Todas ({analise.totalItens} peças)
</Button>
</>
) : null}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+124 -30
View File
@@ -28,7 +28,10 @@ interface PecaDisponivel {
id: string;
marca: string;
descricao: string;
perfil_principal?: string | null;
etapa_fase: string;
quantidade_total_peca: number;
quantidade_ja_expedida: number;
quantidade_disponivel: number;
peso_unitario: number;
comprimento?: number | null;
@@ -104,18 +107,24 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
.from('apontamentos_producao')
.select(`
peca_id,
componente_id,
tipo_apontamento,
quantidade_produzida,
processo_id,
processo:processos_fabricacao!inner!apontamentos_producao_processo_id_fkey(nome),
componente:componentes_peca!apontamentos_producao_componente_id_fkey(id, quantidade_por_peca),
peca:pecas!inner!apontamentos_producao_peca_id_fkey(
id,
marca,
descricao,
perfil_principal,
etapa_fase,
quantidade,
peso_unitario,
comprimento,
prioridade,
of_number
of_number,
tem_componentes
)
`)
.eq('peca.of_number', romaneio.of_number)
@@ -141,49 +150,99 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(expedidosData as any[]).forEach((item: any) => {
const pid = item.peca_id;
const qtd = item.quantidade_expedida || 0;
const qtd = Number(item.quantidade_expedida) || 0;
const current = expedidosMap.get(pid) || 0;
expedidosMap.set(pid, current + qtd);
});
}
// Agrupar por peça e calcular quantidade total produzida
const pecasAgrupadas = new Map<string, PecaDisponivel>();
// 3. Agrupar por peça principal (NUNCA somar unidades de subcomponentes na peça principal)
const pecasAgrupadas = new Map<
string,
{
peca: any;
qtdPecaDireta: number;
componentesCortados: Map<string, { cortado: number; porPeca: number }>;
}
>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(apontamentosData as any[])?.forEach((apontamento: any) => {
const pecaId = apontamento.peca?.id;
const pecaObj = apontamento.peca;
const pecaId = pecaObj?.id;
if (!pecaId) return;
const quantidade = apontamento.quantidade_produzida || 0;
if (pecasAgrupadas.has(pecaId)) {
const pecaExistente = pecasAgrupadas.get(pecaId)!;
pecaExistente.quantidade_disponivel += quantidade; // Aqui salva o Bruto temporariamente
} else {
if (!pecasAgrupadas.has(pecaId)) {
pecasAgrupadas.set(pecaId, {
id: apontamento.peca.id,
marca: apontamento.peca.marca,
descricao: apontamento.peca.descricao || '',
etapa_fase: apontamento.peca.etapa_fase || '',
quantidade_disponivel: quantidade, // Bruto temporário
peso_unitario: apontamento.peca.peso_unitario || 0,
comprimento: apontamento.peca.comprimento || null,
prioridade: apontamento.peca.prioridade || 'P4'
peca: pecaObj,
qtdPecaDireta: 0,
componentesCortados: new Map()
});
}
const grupo = pecasAgrupadas.get(pecaId)!;
const quantidade = Number(apontamento.quantidade_produzida) || 0;
const isComponente =
apontamento.tipo_apontamento === 'componente' || Boolean(apontamento.componente_id);
if (isComponente) {
const compId = apontamento.componente_id || 'comp_default';
const porPeca = Math.max(1, Number(apontamento.componente?.quantidade_por_peca) || 1);
const atualComp = grupo.componentesCortados.get(compId)?.cortado || 0;
grupo.componentesCortados.set(compId, {
cortado: atualComp + quantidade,
porPeca
});
} else {
grupo.qtdPecaDireta += quantidade;
}
});
// 4. Calcular o saldo Real (Produzido - Expedido) e limpar zerados
// 4. Calcular o saldo Real da peça principal (Produzido da Peça Principal - Expedido em Romaneios)
const pecasDisponiveisFinal: PecaDisponivel[] = [];
pecasAgrupadas.forEach((peca, pecaId) => {
pecasAgrupadas.forEach((grupo, pecaId) => {
const pecaObj = grupo.peca;
const qtdTotalPeca = Math.max(1, Number(pecaObj.quantidade) || 1);
let qtdProduzidaPecaPrincipal = 0;
if (grupo.qtdPecaDireta > 0) {
// Apontamento direto da peça principal (ou kit sincronizado)
qtdProduzidaPecaPrincipal = Math.min(qtdTotalPeca, grupo.qtdPecaDireta);
} else if (grupo.componentesCortados.size > 0) {
// Caso haja apenas apontamentos de subcomponentes no processo (ex: Corte antes da soldagem),
// converte para a quantidade equivalente da peça principal (limitada à quantidade total da peça na OF)
let maxPecasEquivalentes = 0;
grupo.componentesCortados.forEach(({ cortado, porPeca }) => {
const equivalentes = Math.floor(cortado / porPeca);
if (equivalentes > maxPecasEquivalentes) {
maxPecasEquivalentes = equivalentes;
}
});
qtdProduzidaPecaPrincipal = Math.min(
qtdTotalPeca,
maxPecasEquivalentes > 0 ? maxPecasEquivalentes : qtdTotalPeca
);
}
const qtdExpedida = expedidosMap.get(pecaId) || 0;
const saldoReal = peca.quantidade_disponivel - qtdExpedida;
const saldoReal = Math.min(
qtdTotalPeca - qtdExpedida,
qtdProduzidaPecaPrincipal - qtdExpedida
);
if (saldoReal > 0) {
pecasDisponiveisFinal.push({
...peca,
quantidade_disponivel: saldoReal // Retorna apenas o saldo disponível
id: pecaObj.id,
marca: pecaObj.marca,
descricao: pecaObj.descricao || '',
perfil_principal: pecaObj.perfil_principal || null,
etapa_fase: pecaObj.etapa_fase || '',
quantidade_total_peca: qtdTotalPeca,
quantidade_ja_expedida: qtdExpedida,
quantidade_disponivel: saldoReal,
peso_unitario: Number(pecaObj.peso_unitario) || 0,
comprimento: pecaObj.comprimento || null,
prioridade: pecaObj.prioridade || 'P4'
});
}
});
@@ -344,6 +403,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
quantidade_expedida: peca.quantidade_disponivel,
peso_unitario: peca.peso_unitario,
peso_total: peca.quantidade_disponivel * peca.peso_unitario,
comprimento: peca.comprimento || null,
marca: peca.marca,
descricao: peca.descricao,
fase: peca.etapa_fase
@@ -400,6 +460,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
quantidade_expedida: quantidade,
peso_unitario: pecaSelecionada.peso_unitario,
peso_total: quantidade * pecaSelecionada.peso_unitario,
comprimento: pecaSelecionada.comprimento || null,
marca: pecaSelecionada.marca,
descricao: pecaSelecionada.descricao,
fase: pecaSelecionada.etapa_fase
@@ -499,6 +560,10 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
const qtdTotalPecas = itensPeca.reduce((total, item) => total + item.quantidade, 0);
const pesoTotalPecas = itensPeca.reduce((total, item) => total + item.peso_total, 0);
const maiorComprimentoPecas = itensPeca.reduce(
(max, item) => Math.max(max, Number(item.comprimento) || 0),
0
);
return (
<>
@@ -629,7 +694,16 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
<Label className={`font-medium mb-2 block ${isMobile ? 'text-sm' : 'text-sm'}`}>
Peça Disponível
</Label>
<Select value={pecaDisponivel} onValueChange={setPecaDisponivel}>
<Select
value={pecaDisponivel}
onValueChange={(val) => {
setPecaDisponivel(val);
const pSel = pecasDisponiveis.find((p) => p.id === val);
if (pSel) {
setQuantidadePeca(String(pSel.quantidade_disponivel));
}
}}
>
<SelectTrigger className={isMobile ? 'h-10' : 'h-11'}>
<SelectValue placeholder="Selecionar peça" />
</SelectTrigger>
@@ -646,13 +720,30 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
pecasFiltradas.map((peca) => {
const limite = romaneio.comprimento_maximo_veiculo || 12000;
const naoCabe = Boolean(peca.comprimento && peca.comprimento > limite);
const descClean = (peca.descricao || '').trim();
const perfilClean = (peca.perfil_principal || '').trim();
const descricaoCompleta =
descClean && perfilClean && descClean.toLowerCase() !== perfilClean.toLowerCase()
? `${descClean} - ${perfilClean}`
: descClean || perfilClean || 'Peça Principal';
const faseFormatada = peca.etapa_fase
? peca.etapa_fase.toLowerCase().startsWith('fase')
? peca.etapa_fase
: `Fase ${peca.etapa_fase}`
: '';
const infoSaldo =
peca.quantidade_ja_expedida > 0
? `Disp: ${peca.quantidade_disponivel} de ${peca.quantidade_total_peca} un`
: `Qtd Disp: ${peca.quantidade_disponivel} un`;
return (
<SelectItem key={peca.id} value={peca.id}>
<div className="flex items-center justify-between gap-2 w-full">
<span className="truncate">
{peca.marca} - {peca.descricao} (Qtd: {peca.quantidade_disponivel})
<span className="truncate font-medium">
{peca.marca}
{faseFormatada ? ` [${faseFormatada}]` : ''} - {descricaoCompleta} ({infoSaldo})
</span>
{peca.comprimento && (
{peca.comprimento != null && Number(peca.comprimento) > 0 && (
<span className={`text-[11px] font-mono shrink-0 ml-2 px-1.5 py-0.5 rounded ${
naoCabe ? 'bg-destructive/20 text-destructive font-bold' : 'text-muted-foreground bg-muted'
}`}>
@@ -733,6 +824,9 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
<Badge variant="outline" className={isMobile ? 'text-xs px-2 py-1' : ''}>
Peso Total: {pesoTotalPecas.toFixed(2)} kg
</Badge>
<Badge variant="outline" className={isMobile ? 'text-xs px-2 py-1' : ''}>
Maior Comprimento: {maiorComprimentoPecas > 0 ? `${maiorComprimentoPecas} mm` : '-'}
</Badge>
</div>
</div>
@@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Edit2, Trash2, X } from 'lucide-react';
import { Edit2, Trash2, Check, X, Package } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { RomaneioExpedicao } from '@/hooks/useRomaneios';
@@ -20,6 +21,7 @@ interface ItemRomaneioPeca {
id: string;
peca_id: string;
quantidade_expedida: number;
quantidade_max_peca?: number;
peso_unitario: number;
peso_total: number;
comprimento?: number | null;
@@ -39,13 +41,16 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
const [itensPeca, setItensPeca] = useState<ItemRomaneioPeca[]>([]);
const [loading, setLoading] = useState(false);
const [editingItemId, setEditingItemId] = useState<string | null>(null);
const [editQuantidade, setEditQuantidade] = useState<string>('');
const [savingEdit, setSavingEdit] = useState(false);
const carregarItensPecas = async () => {
setLoading(true);
try {
const { data, error } = await supabase
.from('itens_romaneio_pecas')
.select('*, pecas(comprimento)')
.select('*, pecas(comprimento, prioridade, quantidade)')
.eq('romaneio_id', romaneio.id)
.order('marca');
@@ -56,13 +61,14 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
id: item.id,
peca_id: item.peca_id,
quantidade_expedida: item.quantidade_expedida,
quantidade_max_peca: item.pecas?.quantidade || undefined,
peso_unitario: item.peso_unitario,
peso_total: item.peso_total,
comprimento: item.pecas?.comprimento || item.comprimento || null,
marca: item.marca,
descricao: item.descricao || '',
fase: item.fase || '',
prioridade: 'P4'
prioridade: item.pecas?.prioridade || 'P4'
}));
setItensPeca(itensFormatados);
}
@@ -76,8 +82,10 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
useEffect(() => {
if (isOpen) {
setEditingItemId(null);
carregarItensPecas();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, romaneio.id]);
const handleRemoverItem = async (itemId: string) => {
@@ -99,21 +107,68 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
};
const handleEditarItem = (item: ItemRomaneioPeca) => {
// Funcionalidade de edição pode ser implementada aqui
toast.info('Funcionalidade de edição em desenvolvimento');
setEditingItemId(item.id);
setEditQuantidade(String(item.quantidade_expedida));
};
const handleCancelarEdicao = () => {
setEditingItemId(null);
setEditQuantidade('');
};
const handleSalvarEdicao = async (item: ItemRomaneioPeca) => {
const novaQtd = parseInt(editQuantidade, 10);
if (isNaN(novaQtd) || novaQtd <= 0) {
toast.error('Informe uma quantidade válida maior que zero');
return;
}
if (item.quantidade_max_peca && novaQtd > item.quantidade_max_peca) {
toast.error(`A quantidade não pode exceder o total da peça na OF (${item.quantidade_max_peca} un)`);
return;
}
try {
setSavingEdit(true);
const novoPesoTotal = novaQtd * item.peso_unitario;
const { error } = await supabase
.from('itens_romaneio_pecas')
.update({
quantidade_expedida: novaQtd,
peso_total: novoPesoTotal
})
.eq('id', item.id);
if (error) throw error;
toast.success(`Quantidade da peça ${item.marca} atualizada para ${novaQtd} un`);
setEditingItemId(null);
setEditQuantidade('');
carregarItensPecas();
onRefresh();
} catch (error) {
console.error('Erro ao atualizar item:', error);
toast.error('Erro ao atualizar quantidade da peça');
} finally {
setSavingEdit(false);
}
};
const getPrioridadeColor = (prioridade: string) => {
switch (prioridade) {
case 'P1': return 'bg-red-100 text-red-800';
case 'P2': return 'bg-orange-100 text-orange-800';
case 'P3': return 'bg-blue-100 text-blue-800';
default: return 'bg-gray-100 text-gray-800';
case 'P1': return 'bg-red-100 text-red-800 border-red-200 dark:bg-red-950/60 dark:text-red-300 dark:border-red-800';
case 'P2': return 'bg-orange-100 text-orange-800 border-orange-200 dark:bg-orange-950/60 dark:text-orange-300 dark:border-orange-800';
case 'P3': return 'bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-950/60 dark:text-blue-300 dark:border-blue-800';
default: return 'bg-slate-100 text-slate-800 border-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700';
}
};
const qtdTotal = itensPeca.reduce((total, item) => total + item.quantidade_expedida, 0);
const pesoTotal = itensPeca.reduce((total, item) => total + item.peso_total, 0);
const maiorComprimento = itensPeca.reduce(
(max, item) => Math.max(max, Number(item.comprimento) || 0),
0
);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -122,47 +177,58 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
? 'w-[95vw] max-w-none h-[95vh] m-2'
: isTablet
? 'w-[90vw] max-w-5xl h-[90vh]'
: 'max-w-6xl h-[95vh]'
: 'max-w-6xl h-[90vh]'
}
bg-slate-900 text-white border-slate-700 overflow-hidden flex flex-col
bg-background text-foreground border-border shadow-xl overflow-hidden flex flex-col
`}>
<DialogHeader className="border-b border-slate-700 pb-3 shrink-0">
<DialogHeader className="border-b border-border pb-3 shrink-0 pr-6">
<div className="flex justify-between items-center">
<DialogTitle className={`font-bold text-white ${isMobile ? 'text-lg' : 'text-xl'}`}>
<DialogTitle className={`font-bold text-foreground flex items-center gap-2 ${isMobile ? 'text-lg' : 'text-xl'}`}>
<Package className="h-5 w-5 text-primary shrink-0" />
<span className="truncate">
Peças do Romaneio {romaneio.numero_romaneio} - OF: {romaneio.of_number}
</span>
</DialogTitle>
<Button
onClick={onClose}
variant="ghost"
size="sm"
className={`text-gray-400 hover:text-white p-0 ${isMobile ? 'h-6 w-6' : 'h-8 w-8'}`}
>
<X className={isMobile ? 'h-3 w-3' : 'h-4 w-4'} />
</Button>
</div>
<div className={`flex gap-2 mt-3 ${isMobile ? 'flex-wrap' : ''}`}>
<Badge variant="outline" className={`border-blue-500 text-blue-400 ${isMobile ? 'text-xs px-2 py-1' : ''}`}>
<Badge
variant="outline"
className={`border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-400 dark:border-blue-500/50 font-semibold ${
isMobile ? 'text-xs px-2.5 py-1' : 'px-3 py-1'
}`}
>
Qtd Total: {qtdTotal}
</Badge>
<Badge variant="outline" className={`border-green-500 text-green-400 ${isMobile ? 'text-xs px-2 py-1' : ''}`}>
<Badge
variant="outline"
className={`border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 dark:border-emerald-500/50 font-semibold ${
isMobile ? 'text-xs px-2.5 py-1' : 'px-3 py-1'
}`}
>
Peso Total: {pesoTotal.toFixed(2)} kg
</Badge>
<Badge
variant="outline"
className={`border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400 dark:border-amber-500/50 font-semibold ${
isMobile ? 'text-xs px-2.5 py-1' : 'px-3 py-1'
}`}
>
Maior Comprimento: {maiorComprimento > 0 ? `${maiorComprimento} mm` : '-'}
</Badge>
</div>
</DialogHeader>
<div className="flex-1 min-h-0">
{loading ? (
<div className="flex justify-center items-center h-64">
<div className={`text-gray-400 ${isMobile ? 'text-sm' : 'text-base'}`}>
<div className={`text-muted-foreground ${isMobile ? 'text-sm' : 'text-base'}`}>
Carregando...
</div>
</div>
) : itensPeca.length === 0 ? (
<div className="flex justify-center items-center h-64">
<div className={`text-gray-400 ${isMobile ? 'text-sm' : 'text-base'}`}>
<div className={`text-muted-foreground ${isMobile ? 'text-sm' : 'text-base'}`}>
Nenhuma peça encontrada no romaneio
</div>
</div>
@@ -171,7 +237,7 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
<div className="space-y-1 p-1 pb-4">
{/* Cabeçalho da tabela - Desktop/Tablet */}
{!isMobile && (
<div className={`grid gap-3 font-medium text-gray-400 bg-slate-800 p-3 rounded-t-md sticky top-0 z-10 ${
<div className={`grid gap-3 font-semibold text-muted-foreground bg-muted/80 dark:bg-slate-800/90 border border-border p-3 rounded-t-md sticky top-0 z-10 backdrop-blur-sm ${
isTablet ? 'grid-cols-9 text-xs' : 'grid-cols-11 text-xs'
}`}>
<div>Marca</div>
@@ -190,120 +256,209 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
{itensPeca.map((item, index) => {
const limite = romaneio.comprimento_maximo_veiculo || 12000;
const naoCabe = Boolean(item.comprimento && item.comprimento > limite);
const isEditing = editingItemId === item.id;
return (
<div key={item.id}>
{isMobile ? (
// Layout mobile - card style
<div className={`p-3 border rounded-md mb-2 ${
naoCabe ? 'border-red-500/50 bg-red-950/20' : 'border-slate-700'
<div className={`p-3 border rounded-md mb-2 transition-colors ${
naoCabe
? 'border-destructive/50 bg-destructive/10'
: 'border-border'
} ${
index % 2 === 0 ? 'bg-slate-800/20' : 'bg-slate-800/40'
index % 2 === 0 ? 'bg-card' : 'bg-muted/30'
}`}>
<div className="flex justify-between items-start mb-2">
<div className="flex-1">
<div className="font-medium text-white text-sm flex items-center gap-1.5">
<div className="font-semibold text-foreground text-sm flex items-center gap-1.5">
{item.marca}
{naoCabe && (
<span className="text-[10px] bg-red-600 text-white font-bold px-1.5 py-0.5 rounded">
<span className="text-[10px] bg-destructive text-destructive-foreground font-bold px-1.5 py-0.5 rounded">
Excede Veículo
</span>
)}
</div>
<div className="text-gray-300 text-xs">{item.fase}</div>
<div className="text-muted-foreground text-xs">Fase {item.fase}</div>
</div>
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => handleEditarItem(item)}
className="h-6 w-6 p-0 text-blue-400 hover:text-blue-300 hover:bg-blue-500/20"
>
<Edit2 className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoverItem(item.id)}
className="h-6 w-6 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/20"
>
<Trash2 className="h-3 w-3" />
</Button>
{isEditing ? (
<>
<Button
size="sm"
variant="ghost"
disabled={savingEdit}
onClick={() => handleSalvarEdicao(item)}
className="h-7 w-7 p-0 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/15"
title="Salvar quantidade"
>
<Check className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
disabled={savingEdit}
onClick={handleCancelarEdicao}
className="h-7 w-7 p-0 text-muted-foreground hover:bg-muted"
title="Cancelar"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
<Button
size="sm"
variant="ghost"
onClick={() => handleEditarItem(item)}
className="h-7 w-7 p-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-500/15"
title="Editar quantidade"
>
<Edit2 className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoverItem(item.id)}
className="h-7 w-7 p-0 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-500/15"
title="Remover peça"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
<div className="text-gray-300 text-xs mb-2 line-clamp-2">
<div className="text-muted-foreground text-xs mb-2 line-clamp-2">
{item.descricao}
</div>
<div className="grid grid-cols-4 gap-2 text-xs">
<div className="grid grid-cols-4 gap-2 text-xs items-center">
<div>
<div className="text-gray-400">Comp:</div>
<div className={`font-mono font-medium ${naoCabe ? 'text-red-400 font-bold' : 'text-gray-300'}`}>
<div className="text-muted-foreground">Comp:</div>
<div className={`font-mono font-medium ${naoCabe ? 'text-destructive font-bold' : 'text-foreground'}`}>
{item.comprimento ? `${item.comprimento}mm` : '-'}
</div>
</div>
<div>
<div className="text-gray-400">Qtd:</div>
<div className="font-bold text-blue-400">{item.quantidade_expedida}</div>
<div className="text-muted-foreground">Qtd:</div>
{isEditing ? (
<Input
type="number"
min="1"
max={item.quantidade_max_peca}
value={editQuantidade}
onChange={(e) => setEditQuantidade(e.target.value)}
className="h-7 w-16 text-center text-xs px-1"
autoFocus
/>
) : (
<div className="font-bold text-blue-600 dark:text-blue-400">{item.quantidade_expedida}</div>
)}
</div>
<div>
<div className="text-gray-400">Peso Unit:</div>
<div className="text-gray-300">{item.peso_unitario.toFixed(2)} kg</div>
<div className="text-muted-foreground">Peso Unit:</div>
<div className="text-foreground">{item.peso_unitario.toFixed(2)} kg</div>
</div>
<div>
<div className="text-gray-400">Peso Total:</div>
<div className="font-medium text-white">{item.peso_total.toFixed(2)} kg</div>
<div className="text-muted-foreground">Peso Total:</div>
<div className="font-semibold text-foreground">{item.peso_total.toFixed(2)} kg</div>
</div>
</div>
</div>
) : (
// Layout desktop/tablet - table style
<div className={`grid gap-3 text-sm p-3 border-b border-slate-700 hover:bg-slate-800/50 transition-colors ${
index % 2 === 0 ? 'bg-slate-800/20' : ''
} ${isTablet ? 'grid-cols-9' : 'grid-cols-11'} ${naoCabe ? 'bg-red-950/20' : ''}`}>
<div className="font-medium text-white truncate flex items-center gap-1">
<div className={`grid gap-3 items-center text-sm p-3 border-b border-x border-border hover:bg-muted/50 transition-colors ${
index % 2 === 0 ? 'bg-card' : 'bg-muted/20'
} ${isTablet ? 'grid-cols-9' : 'grid-cols-11'} ${naoCabe ? 'bg-destructive/10 border-destructive/40' : ''}`}>
<div className="font-semibold text-foreground truncate flex items-center gap-1">
{item.marca}
{naoCabe && <span title="Não cabe no veículo" className="text-red-400 text-xs">⚠️</span>}
{naoCabe && <span title="Não cabe no veículo" className="text-destructive text-xs">⚠️</span>}
</div>
<div className="text-gray-300 truncate">{item.fase}</div>
<div className={`text-gray-300 truncate ${isTablet ? 'col-span-1' : 'col-span-2'}`}>
<div className="text-muted-foreground truncate">{item.fase}</div>
<div className={`text-foreground truncate ${isTablet ? 'col-span-1' : 'col-span-2'}`}>
{item.descricao}
</div>
<div className={`text-right font-mono text-xs ${naoCabe ? 'text-red-400 font-bold' : 'text-gray-300'}`}>
<div className={`text-right font-mono text-xs ${naoCabe ? 'text-destructive font-bold' : 'text-muted-foreground'}`}>
{item.comprimento ? `${item.comprimento}` : '-'}
</div>
<div className="text-center font-bold text-blue-400">
{item.quantidade_expedida}
<div className="text-center font-bold text-blue-600 dark:text-blue-400 flex justify-center">
{isEditing ? (
<Input
type="number"
min="1"
max={item.quantidade_max_peca}
value={editQuantidade}
onChange={(e) => setEditQuantidade(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSalvarEdicao(item);
if (e.key === 'Escape') handleCancelarEdicao();
}}
className="h-7 w-20 text-center text-xs px-1"
autoFocus
/>
) : (
item.quantidade_expedida
)}
</div>
<div className="text-right text-gray-300">
<div className="text-right text-muted-foreground">
{item.peso_unitario.toFixed(2)} kg
</div>
<div className="text-right font-medium text-white">
<div className="text-right font-semibold text-foreground">
{item.peso_total.toFixed(2)} kg
</div>
{!isTablet && (
<div className="flex justify-center">
<Badge className={`text-xs ${getPrioridadeColor(item.prioridade)}`}>
<Badge variant="outline" className={`text-xs ${getPrioridadeColor(item.prioridade)}`}>
{item.prioridade}
</Badge>
</div>
)}
<div className={`flex items-center justify-center gap-2 ${isTablet ? 'col-span-2' : 'col-span-2'}`}>
<Button
size="sm"
variant="ghost"
onClick={() => handleEditarItem(item)}
className="h-7 w-7 p-0 text-blue-400 hover:text-blue-300 hover:bg-blue-500/20"
>
<Edit2 className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoverItem(item.id)}
className="h-7 w-7 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/20"
>
<Trash2 className="h-3 w-3" />
</Button>
{isEditing ? (
<>
<Button
size="sm"
variant="ghost"
disabled={savingEdit}
onClick={() => handleSalvarEdicao(item)}
className="h-7 w-7 p-0 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/15"
title="Salvar quantidade"
>
<Check className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
disabled={savingEdit}
onClick={handleCancelarEdicao}
className="h-7 w-7 p-0 text-muted-foreground hover:bg-muted"
title="Cancelar edição"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
<Button
size="sm"
variant="ghost"
onClick={() => handleEditarItem(item)}
className="h-7 w-7 p-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-500/15"
title="Editar quantidade"
>
<Edit2 className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoverItem(item.id)}
className="h-7 w-7 p-0 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-500/15"
title="Remover item"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
)}
@@ -315,11 +470,11 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
)}
</div>
<div className={`flex justify-end pt-3 border-t border-slate-700 shrink-0 ${isMobile ? 'px-1' : ''}`}>
<div className={`flex justify-end pt-3 border-t border-border shrink-0 ${isMobile ? 'px-1' : ''}`}>
<Button
variant="outline"
onClick={onClose}
className={`border-slate-600 text-gray-300 hover:bg-slate-800 ${isMobile ? 'h-10 px-4 text-sm' : ''}`}
className={`font-medium text-foreground border-border hover:bg-accent hover:text-accent-foreground ${isMobile ? 'h-10 px-4 text-sm' : 'px-6'}`}
>
Fechar
</Button>
+34 -51
View File
@@ -10,8 +10,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { Save, X, AlertTriangle, Loader2 } from 'lucide-react';
import { RomaneioExpedicao } from '@/hooks/useRomaneios';
import { toast } from 'sonner';
import { validarRomaneioParaEntrega } from '@/utils/validacaoRomaneio';
import { PendenciasProducaoModal, ItemPendenciaProducao } from './PendenciasProducaoModal';
import { ConfirmacaoEntregaRomaneioModal } from './ConfirmacaoEntregaRomaneioModal';
interface RomaneioFormProps {
romaneio?: RomaneioExpedicao;
@@ -118,33 +117,22 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
return Array.from(new Set(list)).filter(Boolean);
}, [ofNumbers, romaneio?.of_number, selectedOF, formData.of_number]);
// Estados para controle de validação de entrega
const [validandoEntrega, setValidandoEntrega] = useState(false);
const [modalPendenciasAberto, setModalPendenciasAberto] = useState(false);
const [pendenciasEncontradas, setPendenciasEncontradas] = useState<ItemPendenciaProducao[]>([]);
const [mensagemBloqueio, setMensagemBloqueio] = useState<string | undefined>(undefined);
// Estados para controle de validação e confirmação de entrega
const [modalConfirmacaoAberto, setModalConfirmacaoAberto] = useState(false);
const handleStatusChange = async (novoStatus: any) => {
const handleStatusChange = (novoStatus: any) => {
if (novoStatus === 'Entregue') {
if (!romaneio?.id) {
toast.warning('Novo romaneio deve ser criado como "Em planejamento"', {
description: 'Cadastre o romaneio primeiro, adicione as peças e confirme os apontamentos de produção antes de marcá-lo como entregue.'
description: 'Cadastre o romaneio primeiro, adicione as peças e confirme os apontamentos antes de marcá-lo como entregue.'
});
setFormData(prev => ({ ...prev, status: 'Em planejamento' }));
return;
}
setValidandoEntrega(true);
const resultado = await validarRomaneioParaEntrega(romaneio.id, formData.of_number || romaneio.of_number);
setValidandoEntrega(false);
if (!resultado.valido) {
setPendenciasEncontradas(resultado.pendencias || []);
setMensagemBloqueio(resultado.mensagem);
setModalPendenciasAberto(true);
// Não altera o status para Entregue, mantém o anterior
return;
}
// Abre o modal inteligente de confirmação de entrega
setModalConfirmacaoAberto(true);
return;
}
setFormData(prev => ({ ...prev, status: novoStatus }));
@@ -153,24 +141,16 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (formData.status === 'Entregue') {
if (formData.status === 'Entregue' && romaneio?.status !== 'Entregue') {
if (!romaneio?.id) {
toast.error('Não é possível criar um novo romaneio diretamente como "Entregue".');
setFormData(prev => ({ ...prev, status: 'Em planejamento' }));
return;
}
setValidandoEntrega(true);
const resultado = await validarRomaneioParaEntrega(romaneio.id, formData.of_number || romaneio.of_number);
setValidandoEntrega(false);
if (!resultado.valido) {
setPendenciasEncontradas(resultado.pendencias || []);
setMensagemBloqueio(resultado.mensagem);
setModalPendenciasAberto(true);
toast.error('Não é possível definir como "Entregue": existem pendências na produção.');
return;
}
// Se tentar salvar como Entregue sem ter passado pela confirmação, abrir modal
setModalConfirmacaoAberto(true);
return;
}
console.log('💾 Salvando romaneio com dados:', formData);
@@ -322,7 +302,6 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
<Select
value={formData.status}
onValueChange={handleStatusChange}
disabled={validandoEntrega}
>
<SelectTrigger>
<SelectValue />
@@ -339,10 +318,10 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
</div>
{/* Badge de Alerta */}
<Alert className="border-orange-500 bg-orange-50 dark:bg-orange-950/20 dark:border-orange-400 mt-4">
<AlertTriangle className="h-4 w-4 text-orange-600 dark:text-orange-400" />
<AlertDescription className="text-orange-800 dark:text-orange-200 font-medium">
QUANDO O STATUS PASSAR PARA "ENTREGUE" AS PEÇAS SERÃO APONTADAS AUTOMATICAMENTE NO PROCESSO DE EXPEDIÇÃO (DESDE QUE TODOS OS PROCESSOS ANTERIORES ESTEJAM CONCLUÍDOS)
<Alert className="border-blue-500 bg-blue-50 dark:bg-blue-950/20 dark:border-blue-400 mt-4">
<AlertTriangle className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<AlertDescription className="text-blue-900 dark:text-blue-200 font-medium">
AO PASSAR O STATUS PARA "ENTREGUE", VOCÊ ESCOLHE A DATA REAL DA ENTREGA E PODE AUTORIZAR O APONTAMENTO AUTOMÁTICO (INCLUSIVE EM CASCATA CASO HAJA PROCESSOS ANTERIORES PENDENTES).
</AlertDescription>
</Alert>
</div>
@@ -491,13 +470,8 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
</div>
<div className="flex gap-2 pt-4">
<Button type="submit" disabled={loading || validandoEntrega || !formData.of_number}>
{validandoEntrega ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Validando processos...
</>
) : loading ? (
<Button type="submit" disabled={loading || !formData.of_number}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Salvando...
@@ -515,19 +489,28 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
</DialogContent>
</Dialog>
{/* Modal de bloqueio/alerta caso existam peças pendentes */}
<PendenciasProducaoModal
isOpen={modalPendenciasAberto}
{/* Modal de confirmação inteligente com Data Real e Cascata */}
<ConfirmacaoEntregaRomaneioModal
isOpen={modalConfirmacaoAberto}
onClose={() => {
setModalPendenciasAberto(false);
setModalConfirmacaoAberto(false);
setFormData(prev => ({
...prev,
status: (romaneio?.status as any) || 'Em planejamento'
}));
}}
pendencias={pendenciasEncontradas}
numeroRomaneio={romaneio?.numero_romaneio}
mensagemSemItens={mensagemBloqueio}
romaneio={romaneio ? {
id: romaneio.id,
numero_romaneio: romaneio.numero_romaneio,
of_number: formData.of_number || romaneio.of_number,
data_prevista_entrega: formData.data_prevista_entrega || romaneio.data_prevista_entrega,
data_romaneio: formData.data_romaneio || romaneio.data_romaneio,
status: formData.status
} : null}
onSucesso={() => {
setModalConfirmacaoAberto(false);
onClose();
}}
/>
</>
);
+23 -2
View File
@@ -3,10 +3,11 @@ import React, { useState } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Edit, Eye, Trash2, Printer } from 'lucide-react';
import { Edit, Eye, Trash2, Printer, Truck } from 'lucide-react';
import { RomaneioExpedicao } from '@/hooks/useRomaneios';
import { formatBrazilianDateFromString } from '@/utils/dateTimeUtils';
import { RomaneioReportModal } from './RomaneioReportModal';
import { ConfirmacaoEntregaRomaneioModal } from './ConfirmacaoEntregaRomaneioModal';
interface RomaneioTableProps {
@@ -28,6 +29,7 @@ export const RomaneioTable: React.FC<RomaneioTableProps> = ({
}) => {
const [selectedRomaneioForReport, setSelectedRomaneioForReport] = useState<RomaneioExpedicao | null>(null);
const [showReportModal, setShowReportModal] = useState(false);
const [romaneioParaConfirmacao, setRomaneioParaConfirmacao] = useState<RomaneioExpedicao | null>(null);
const handlePrintReport = (romaneio: RomaneioExpedicao) => {
setSelectedRomaneioForReport(romaneio);
@@ -126,7 +128,16 @@ export const RomaneioTable: React.FC<RomaneioTableProps> = ({
{romaneio.peso_total_romaneio?.toFixed(2) || '0.00'} kg
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setRomaneioParaConfirmacao(romaneio)}
title="Confirmar Entrega / Apontamentos"
className="h-8 w-8 p-0 text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-950/40"
>
<Truck className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
@@ -175,6 +186,16 @@ export const RomaneioTable: React.FC<RomaneioTableProps> = ({
romaneio={selectedRomaneioForReport}
/>
)}
{/* Modal de Confirmação de Entrega com Data Real e Cascata */}
{romaneioParaConfirmacao && (
<ConfirmacaoEntregaRomaneioModal
isOpen={!!romaneioParaConfirmacao}
onClose={() => setRomaneioParaConfirmacao(null)}
romaneio={romaneioParaConfirmacao}
onSucesso={() => setRomaneioParaConfirmacao(null)}
/>
)}
</>
);
};
@@ -0,0 +1,691 @@
import React, { useState, useMemo } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
AlertTriangle,
CheckCircle2,
XCircle,
Search,
Printer,
ChevronDown,
ChevronRight,
Factory,
Layers,
ArrowRight,
ShieldAlert,
Sparkles,
Info,
} from 'lucide-react';
import { toast } from 'sonner';
import {
ItemAnaliseImportacao,
NivelRiscoImportacao,
ResultadoAnaliseImportacao,
} from '@/types/importDiff';
import { IntervencaoFabrilPrintModal } from './IntervencaoFabrilPrintModal';
import { executarImportacaoConciliada } from '@/utils/importExecutionService';
import { useAuth } from '@/hooks/useAuth';
interface ImportarPecasDiffModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
analise: ResultadoAnaliseImportacao | null;
componentesMap: Map<string, any[]>;
onConcluido: () => void;
ofDefault?: string;
}
type TabFiltro = 'todas' | 'divergencias' | 'apontadas' | 'criticas' | 'novas' | 'identicas';
export const ImportarPecasDiffModal: React.FC<ImportarPecasDiffModalProps> = ({
open,
onOpenChange,
analise,
componentesMap,
onConcluido,
ofDefault = '',
}) => {
const { user } = useAuth();
const [itens, setItens] = useState<ItemAnaliseImportacao[]>([]);
const [tabAtiva, setTabFiltro] = useState<TabFiltro>('todas');
const [busca, setBusca] = useState('');
const [linhasExpandidas, setLinhasExpandidas] = useState<Set<string>>(new Set());
const [showPrintModal, setShowPrintModal] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
// Inicializar itens locais quando a análise for fornecida
React.useEffect(() => {
if (analise?.itens) {
setItens(analise.itens);
}
}, [analise]);
// Contadores dinâmicos baseados no estado atual dos itens
const counts = useMemo(() => {
return {
total: itens.length,
novas: itens.filter((i) => i.isNova).length,
divergencias: itens.filter((i) => i.temDivergencias).length,
apontadas: itens.filter((i) => i.apontamentoResumo.temApontamento).length,
criticas: itens.filter((i) => i.nivelRisco === 'critico').length,
identicas: itens.filter((i) => i.isIdentica).length,
sobreporTotal: itens.filter((i) => i.sobrepor).length,
atencaoEspecialTotal: itens.filter((i) => i.atencaoEspecial).length,
};
}, [itens]);
// Filtro dos itens exibidos
const itensFiltrados = useMemo(() => {
return itens.filter((item) => {
// Filtro de Busca
const matchBusca =
!busca ||
item.marca.toLowerCase().includes(busca.toLowerCase()) ||
item.of_number.toLowerCase().includes(busca.toLowerCase()) ||
item.descricao.toLowerCase().includes(busca.toLowerCase()) ||
(item.dadosImportados.perfil_principal &&
item.dadosImportados.perfil_principal.toLowerCase().includes(busca.toLowerCase()));
if (!matchBusca) return false;
// Filtro de Aba
if (tabAtiva === 'novas') return item.isNova;
if (tabAtiva === 'divergencias') return item.temDivergencias;
if (tabAtiva === 'apontadas') return item.apontamentoResumo.temApontamento;
if (tabAtiva === 'criticas') return item.nivelRisco === 'critico';
if (tabAtiva === 'identicas') return item.isIdentica;
return true;
});
}, [itens, busca, tabAtiva]);
// Toggle do checkbox Sobrepor por item
const handleToggleSobrepor = (key: string) => {
setItens((prev) =>
prev.map((item) => {
if (item.key === key) {
return { ...item, sobrepor: !item.sobrepor };
}
return item;
})
);
};
// Toggle do checkbox Atenção Especial por item
const handleToggleAtencaoEspecial = (key: string) => {
setItens((prev) =>
prev.map((item) => {
if (item.key === key) {
return { ...item, atencaoEspecial: !item.atencaoEspecial };
}
return item;
})
);
};
// Ações em massa
const handleMarcarTodasSobrepor = (valor: boolean) => {
const keysFiltradas = new Set(itensFiltrados.map((i) => i.key));
setItens((prev) =>
prev.map((item) => {
if (keysFiltradas.has(item.key)) {
return { ...item, sobrepor: valor };
}
return item;
})
);
};
const handleMarcarCriticasAtencaoEspecial = () => {
setItens((prev) =>
prev.map((item) => {
if (item.nivelRisco === 'critico' && item.sobrepor) {
return { ...item, atencaoEspecial: true };
}
return item;
})
);
toast.info('Todas as peças críticas marcadas para emissão de Ordem de Intervenção!');
};
const handleToggleExpandir = (key: string) => {
setLinhasExpandidas((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
// Executar a importação conciliada
const handleConfirmarImportacao = async () => {
if (!user) {
toast.error('Usuário não autenticado');
return;
}
const sobreporCount = itens.filter((i) => i.sobrepor).length;
if (sobreporCount === 0) {
toast.warning('Nenhuma peça foi selecionada para importação ou sobreposição.');
return;
}
setIsProcessing(true);
try {
const resultado = await executarImportacaoConciliada({
itens,
componentesMap,
userId: user.id,
userNome: user.name || user.email || 'Operador',
userEmail: user.email || '',
motivoRevisao: 'Conciliação e atualização de lista técnica de peças',
});
if (resultado.sucesso) {
toast.success(resultado.mensagem);
// Se houver peças marcadas com atenção especial, perguntar ou abrir modal de impressão
if (resultado.totalComAtencaoEspecial > 0) {
toast.info(
`Atenção: ${resultado.totalComAtencaoEspecial} peça(s) requerem atenção do chão de fábrica! Abrindo Ordem de Intervenção...`
);
setShowPrintModal(true);
} else {
onOpenChange(false);
onConcluido();
}
} else {
toast.error(resultado.mensagem);
}
} catch (err: any) {
console.error('Erro na confirmação:', err);
toast.error(`Falha ao salvar dados: ${err?.message || 'Erro desconhecido'}`);
} finally {
setIsProcessing(false);
}
};
const itensParaImpressao = useMemo(() => {
return itens.filter((i) => i.atencaoEspecial);
}, [itens]);
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-6xl h-[90vh] max-h-[92vh] flex flex-col p-0 overflow-hidden bg-background border-border">
{/* Cabeçalho */}
<DialogHeader className="p-4 border-b border-border bg-card flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-xl bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20">
<Layers className="h-6 w-6" />
</div>
<div>
<DialogTitle className="text-xl font-bold text-foreground flex items-center gap-2">
Detecção de Conflitos e Conciliação de Peças
<Badge variant="outline" className="text-xs font-semibold">
{ofDefault ? `OF ${ofDefault}` : 'Importação de Lista'}
</Badge>
</DialogTitle>
<p className="text-xs text-muted-foreground mt-0.5">
Foram identificadas peças já cadastradas. Analise as diferenças, selecione quais sobrepor e proteja os apontamentos fabris.
</p>
</div>
</div>
{/* Botão de emissão rápida da lista de atenção */}
{counts.atencaoEspecialTotal > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setShowPrintModal(true)}
className="bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400 hover:bg-amber-500/20 text-xs flex items-center gap-1.5 font-bold"
>
<Printer className="h-4 w-4 text-amber-600 dark:text-amber-400" />
Imprimir Lembretes Fabris ({counts.atencaoEspecialTotal})
</Button>
)}
</div>
{/* Cards de Métricas (KPIs) */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 mt-4">
<div
onClick={() => setTabFiltro('criticas')}
className={`p-2.5 rounded-lg border cursor-pointer transition-all ${
tabAtiva === 'criticas'
? 'bg-red-500/15 border-red-500/50 dark:bg-red-950/40 shadow-sm'
: 'bg-card hover:bg-muted/50 border-border'
}`}
>
<div className="flex items-center justify-between text-xs text-red-600 dark:text-red-400 font-bold">
<span className="flex items-center gap-1">
<ShieldAlert className="h-3.5 w-3.5" /> Atenção Crítica
</span>
<Badge className="bg-red-600 text-white font-extrabold text-[11px] h-5 px-1.5">
{counts.criticas}
</Badge>
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Alteração em peças já iniciadas
</div>
</div>
<div
onClick={() => setTabFiltro('apontadas')}
className={`p-2.5 rounded-lg border cursor-pointer transition-all ${
tabAtiva === 'apontadas'
? 'bg-amber-500/15 border-amber-500/50 dark:bg-amber-950/40 shadow-sm'
: 'bg-card hover:bg-muted/50 border-border'
}`}
>
<div className="flex items-center justify-between text-xs text-amber-600 dark:text-amber-400 font-bold">
<span className="flex items-center gap-1">
<Factory className="h-3.5 w-3.5" /> Em Produção
</span>
<Badge variant="secondary" className="font-extrabold text-[11px] h-5 px-1.5">
{counts.apontadas}
</Badge>
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Possuem apontamentos
</div>
</div>
<div
onClick={() => setTabFiltro('divergencias')}
className={`p-2.5 rounded-lg border cursor-pointer transition-all ${
tabAtiva === 'divergencias'
? 'bg-blue-500/15 border-blue-500/50 dark:bg-blue-950/40 shadow-sm'
: 'bg-card hover:bg-muted/50 border-border'
}`}
>
<div className="flex items-center justify-between text-xs text-blue-600 dark:text-blue-400 font-bold">
<span>Com Divergência</span>
<Badge variant="secondary" className="font-extrabold text-[11px] h-5 px-1.5">
{counts.divergencias}
</Badge>
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Mudança em relação à base
</div>
</div>
<div
onClick={() => setTabFiltro('novas')}
className={`p-2.5 rounded-lg border cursor-pointer transition-all ${
tabAtiva === 'novas'
? 'bg-purple-500/15 border-purple-500/50 dark:bg-purple-950/40 shadow-sm'
: 'bg-card hover:bg-muted/50 border-border'
}`}
>
<div className="flex items-center justify-between text-xs text-purple-600 dark:text-purple-400 font-bold">
<span className="flex items-center gap-1">
<Sparkles className="h-3.5 w-3.5" /> Peças Novas
</span>
<Badge variant="secondary" className="font-extrabold text-[11px] h-5 px-1.5">
{counts.novas}
</Badge>
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Inexistentes no cadastro
</div>
</div>
<div
onClick={() => setTabFiltro('identicas')}
className={`p-2.5 rounded-lg border cursor-pointer transition-all ${
tabAtiva === 'identicas'
? 'bg-emerald-500/15 border-emerald-500/50 dark:bg-emerald-950/40 shadow-sm'
: 'bg-card hover:bg-muted/50 border-border'
}`}
>
<div className="flex items-center justify-between text-xs text-emerald-600 dark:text-emerald-400 font-bold">
<span className="flex items-center gap-1">
<CheckCircle2 className="h-3.5 w-3.5" /> 100% Idênticas
</span>
<Badge variant="secondary" className="font-extrabold text-[11px] h-5 px-1.5">
{counts.identicas}
</Badge>
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Sem alterações necessárias
</div>
</div>
</div>
</DialogHeader>
{/* Barra de Filtros e Ferramentas */}
<div className="p-3 border-b border-border bg-muted/20 flex flex-wrap items-center justify-between gap-2 text-xs flex-shrink-0">
{/* Input de Busca */}
<div className="relative w-72">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Buscar por marca, perfil, fase..."
value={busca}
onChange={(e) => setBusca(e.target.value)}
className="pl-8 h-8 text-xs bg-background"
/>
</div>
{/* Botões de Ação Rápida */}
<div className="flex items-center gap-1.5 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => handleMarcarTodasSobrepor(true)}
className="h-7 text-[11px] px-2.5"
>
Sobrepor Todas Filtradas
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleMarcarTodasSobrepor(false)}
className="h-7 text-[11px] px-2.5"
>
Ignorar Todas (Manter Base)
</Button>
{counts.criticas > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleMarcarCriticasAtencaoEspecial}
className="h-7 text-[11px] px-2.5 bg-red-500/10 text-red-600 border-red-500/30 hover:bg-red-500/20 font-semibold"
>
<ShieldAlert className="h-3 w-3 mr-1" />
Marcar Críticas p/ Lembrete
</Button>
)}
</div>
</div>
{/* Tabela de Conciliação e Diff com Scroll Vertical e Cabeçalho Sticky */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-auto w-full">
<div className="p-4">
<table className="w-full text-left text-xs border-collapse">
<thead className="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm shadow-sm">
<tr className="border-b border-border text-muted-foreground font-semibold">
<th className="p-2 w-10 text-center bg-muted/95">Sobrepor</th>
<th className="p-2 w-28 bg-muted/95">Peça / Fase</th>
<th className="p-2 w-44 bg-muted/95">Perfil / Bitola</th>
<th className="p-2 w-40 bg-muted/95">Status de Produção</th>
<th className="p-2 bg-muted/95">Divergências Detectadas (Diff)</th>
<th className="p-2 w-28 text-center bg-muted/95">Lembrete Fabril</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{itensFiltrados.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-muted-foreground">
Nenhuma peça encontrada com os filtros selecionados.
</td>
</tr>
) : (
itensFiltrados.map((item) => {
const isExpandida = linhasExpandidas.has(item.key);
const ultimoProc = item.apontamentoResumo.ultimoProcessoNome;
const totalApontado = item.apontamentoResumo.totalProduzido;
// Cor da linha de acordo com o risco
let linhaBg = 'hover:bg-muted/30';
if (item.nivelRisco === 'critico') {
linhaBg = 'bg-red-500/5 hover:bg-red-500/10 dark:bg-red-950/20';
} else if (item.isNova) {
linhaBg = 'bg-purple-500/5 hover:bg-purple-500/10 dark:bg-purple-950/20';
}
return (
<React.Fragment key={item.key}>
<tr className={`transition-colors ${linhaBg}`}>
{/* Checkbox de Sobreposição */}
<td className="p-2 text-center">
<Checkbox
checked={item.sobrepor}
onCheckedChange={() => handleToggleSobrepor(item.key)}
title={item.sobrepor ? 'Sobrepor no cadastro' : 'Ignorar e manter atual'}
/>
</td>
{/* Identificação da Peça */}
<td className="p-2">
<div className="flex items-center gap-1.5">
<span className="font-extrabold text-sm text-foreground">
{item.marca}
</span>
{item.nivelRisco === 'critico' && (
<Badge className="bg-red-600 text-white text-[9px] h-4 px-1 font-bold">
ALERTA
</Badge>
)}
{item.isNova && (
<Badge className="bg-purple-600 text-white text-[9px] h-4 px-1 font-bold">
NOVA
</Badge>
)}
</div>
<div className="text-[11px] text-muted-foreground">
OF {item.of_number} • Fase {item.etapa_fase}
</div>
</td>
{/* Perfil e Descrição */}
<td className="p-2">
<div className="font-semibold text-foreground">
{item.dadosImportados.perfil_principal || item.descricao || '-'}
</div>
<div className="text-[10px] text-muted-foreground">
{item.dadosImportados.comprimento
? `Comp: ${item.dadosImportados.comprimento} mm`
: 'Sem comp. ref.'}
</div>
</td>
{/* Status Fabril (Apontamentos) */}
<td className="p-2">
{item.apontamentoResumo.temApontamento ? (
<div className="space-y-0.5">
<div className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-bold bg-amber-500/15 text-amber-700 dark:text-amber-400 border border-amber-500/30">
<Factory className="h-3 w-3" />
{ultimoProc ? ultimoProc.toUpperCase() : 'PRODUÇÃO'}
</div>
<div className="text-[10px] text-muted-foreground">
{totalApontado} un apontadas
</div>
{item.apontamentoResumo.emRomaneio && (
<div className="text-[10px] font-bold text-red-600">
⚠️ Já expedida em Romaneio
</div>
)}
</div>
) : (
<span className="text-[11px] text-muted-foreground italic flex items-center gap-1">
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
Não iniciada (0%)
</span>
)}
</td>
{/* Coluna de Divergências (Diff) */}
<td className="p-2">
{item.isNova ? (
<span className="text-[11px] text-purple-600 dark:text-purple-400 font-medium">
Peça nova para cadastro
</span>
) : item.isIdentica ? (
<span className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium flex items-center gap-1">
<CheckCircle2 className="h-3.5 w-3.5" /> Dados 100% idênticos à base
</span>
) : (
<div className="space-y-1">
<div className="flex flex-wrap gap-1.5">
{item.camposDivergentes.map((c) => (
<div
key={c.campo}
className={`px-2 py-0.5 rounded text-[11px] font-medium border flex items-center gap-1 ${
c.isCritico
? 'bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400'
: 'bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400'
}`}
>
<span className="font-bold">{c.label}:</span>
<span className="line-through opacity-75">{c.valorAntigo}</span>
<ArrowRight className="h-2.5 w-2.5" />
<span className="font-extrabold">{c.valorNovo}</span>
</div>
))}
</div>
{/* Resumo de componentes se houver */}
{item.componentesDivergentes.length > 0 && (
<button
type="button"
onClick={() => handleToggleExpandir(item.key)}
className="text-[11px] text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 font-semibold"
>
{isExpandida ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
{item.componentesDivergentes.length} alteração(ões) em subcomponentes
</button>
)}
</div>
)}
</td>
{/* Checkbox de Lembrete Fabril (Atenção Especial) */}
<td className="p-2 text-center">
{item.apontamentoResumo.temApontamento || item.nivelRisco === 'critico' ? (
<div className="flex flex-col items-center justify-center">
<Checkbox
checked={item.atencaoEspecial}
onCheckedChange={() => handleToggleAtencaoEspecial(item.key)}
title="Incluir na Ordem de Intervenção Fabril Impressa"
/>
<span className="text-[9px] text-muted-foreground mt-0.5">
Lembrete
</span>
</div>
) : (
<span className="text-muted-foreground/40 text-[10px]">-</span>
)}
</td>
</tr>
{/* Linha Expansível para Detalhe de Componentes */}
{isExpandida && item.componentesDivergentes.length > 0 && (
<tr className="bg-muted/30">
<td colSpan={6} className="p-3 pl-12">
<div className="bg-background rounded-lg p-3 border border-border space-y-1.5">
<div className="font-bold text-xs text-foreground flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5 text-blue-500" />
Detalhamento de Subcomponentes da Peça {item.marca}:
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
{item.componentesDivergentes.map((comp) => {
let badgeCor = 'bg-blue-500/10 text-blue-700 border-blue-300';
if (comp.tipo === 'adicionado') badgeCor = 'bg-emerald-500/10 text-emerald-700 border-emerald-300';
if (comp.tipo === 'removido') badgeCor = 'bg-red-500/10 text-red-700 border-red-300';
return (
<div
key={comp.marca_componente}
className={`p-2 rounded border flex items-start gap-2 ${badgeCor}`}
>
<Badge variant="outline" className="text-[10px] font-bold">
Pos. {comp.marca_componente}
</Badge>
<div className="text-[11px] space-y-0.5">
<div className="font-semibold">{comp.descricao || comp.perfil || 'Componente'}</div>
<div className="text-[10px] opacity-90">{comp.detalhe}</div>
</div>
</div>
);
})}
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* Rodapé da Janela com Decisão e Ações */}
<DialogFooter className="p-3 border-t border-border bg-card flex flex-row items-center justify-between flex-shrink-0">
<div className="text-xs text-muted-foreground flex items-center gap-3">
<span>
<strong className="text-foreground">{counts.sobreporTotal}</strong> a sobrepor
</span>
<span>•</span>
<span>
<strong className="text-foreground">{counts.novas}</strong> novas
</span>
<span>•</span>
<span>
<strong className="text-foreground">{counts.total - counts.sobreporTotal}</strong> mantidas originais
</span>
{counts.atencaoEspecialTotal > 0 && (
<>
<span>•</span>
<Badge variant="outline" className="bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/30 text-[10px] font-bold">
{counts.atencaoEspecialTotal} na lista impressa
</Badge>
</>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={isProcessing}
className="text-xs"
>
Cancelar
</Button>
<Button
size="sm"
onClick={handleConfirmarImportacao}
disabled={isProcessing || counts.sobreporTotal === 0}
className="bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-bold shadow-sm"
>
{isProcessing ? 'Gravando e Conciliando...' : `Confirmar Importação (${counts.sobreporTotal})`}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal de Impressão da Ordem de Intervenção */}
<IntervencaoFabrilPrintModal
open={showPrintModal}
onOpenChange={(openState) => {
setShowPrintModal(openState);
if (!openState) {
// Se fechou a janela de impressão após a importação, fecha o diff modal e conclui
onOpenChange(false);
onConcluido();
}
}}
itens={itensParaImpressao}
ofNumber={ofDefault || itens[0]?.of_number || ''}
usuarioNome={user?.name || user?.email || 'Operador'}
/>
</>
);
};
+45 -9
View File
@@ -9,6 +9,9 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { AlertTriangle, Check, X, Upload, FileSpreadsheet, Weight, AlertCircle, Edit3 } from 'lucide-react';
import { toast } from 'sonner';
import { parseCSV } from '@/utils/csvUtils';
import { analisarConflitosImportacao, consolidarPecasEComponentes } from '@/utils/importDiffService';
import { ResultadoAnaliseImportacao } from '@/types/importDiff';
import { ImportarPecasDiffModal } from '@/components/pecas/ImportarPecasDiffModal';
interface PecaCSVData {
of_number: string;
@@ -22,10 +25,13 @@ interface PecaCSVData {
material: string;
perfil_principal: string;
tem_componentes: boolean;
comprimento?: number | null;
comprimento_ref?: number | string;
marca_componente?: string;
descricao_componente?: string;
perfil_componente?: string;
peso_unitario_componente?: number;
comprimento_componente?: number | null;
quantidade_por_peca?: number;
}
@@ -62,6 +68,9 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
const [isLoading, setIsLoading] = useState(false);
const [step, setStep] = useState<'upload' | 'preview'>('upload');
const [showErrors, setShowErrors] = useState(false);
const [analiseDiff, setAnaliseDiff] = useState<ResultadoAnaliseImportacao | null>(null);
const [componentesMapDiff, setComponentesMapDiff] = useState<Map<string, any[]>>(new Map());
const [showDiffModal, setShowDiffModal] = useState(false);
const requiredHeaders = [
'of_number', 'etapa_fase', 'marca', 'descricao', 'quantidade',
@@ -102,7 +111,7 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
});
// Validar e converter campos numéricos
const numericFields = ['quantidade', 'peso_unitario', 'peso_total', 'peso_unitario_componente', 'quantidade_por_peca'];
const numericFields = ['quantidade', 'peso_unitario', 'peso_total', 'peso_unitario_componente', 'quantidade_por_peca', 'comprimento', 'comprimento_ref', 'comprimento_componente'];
const convertedData: any = { ...rawRow };
numericFields.forEach(field => {
@@ -171,10 +180,12 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
material: convertedData.material?.toString().trim() || '',
perfil_principal: convertedData.perfil_principal?.toString().trim() || '',
tem_componentes: convertedData.tem_componentes,
comprimento: convertedData.comprimento ? Number(convertedData.comprimento) : (convertedData.comprimento_ref ? Number(convertedData.comprimento_ref) : null),
marca_componente: convertedData.marca_componente?.toString().trim() || '',
descricao_componente: convertedData.descricao_componente?.toString().trim() || '',
perfil_componente: convertedData.perfil_componente?.toString().trim() || '',
peso_unitario_componente: convertedData.peso_unitario_componente || 0,
comprimento_componente: convertedData.comprimento_componente ? Number(convertedData.comprimento_componente) : null,
quantidade_por_peca: convertedData.quantidade_por_peca || 0
};
@@ -307,19 +318,29 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
};
const handleImport = async () => {
const validPecas = csvData.filter((_, index) => !duplicates[index]?.isDuplicate);
console.log('🚀 Iniciando importação de', validPecas.length, 'peças...');
if (validPecas.length === 0) {
if (csvData.length === 0) {
toast.error('Nenhuma peça válida para importar');
return;
}
setIsLoading(true);
try {
await onImport(validPecas);
console.log('✅ Importação concluída com sucesso!');
// Analisar conflitos e status na base de dados
toast.info('Verificando existência e status de produção das peças...');
const analise = await analisarConflitosImportacao(csvData);
const { componentesMap } = consolidarPecasEComponentes(csvData);
// Se houver peças já cadastradas na base
if (analise.totalComDivergencia > 0 || analise.totalIdenticas > 0 || analise.totalComApontamento > 0) {
setAnaliseDiff(analise);
setComponentesMapDiff(componentesMap);
setShowDiffModal(true);
setIsLoading(false);
return;
}
await onImport(csvData);
toast.success(`${csvData.length} novas peças importadas com sucesso!`);
handleClose();
} catch (error) {
console.error('❌ Erro na importação:', error);
@@ -352,7 +373,8 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
.reduce((sum, peca) => sum + peca.peso_total, 0);
return (
<Dialog open={open} onOpenChange={handleClose}>
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-6xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
@@ -601,5 +623,19 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal de Detecção de Conflitos e Conciliação */}
<ImportarPecasDiffModal
open={showDiffModal}
onOpenChange={setShowDiffModal}
analise={analiseDiff}
componentesMap={componentesMapDiff}
ofDefault={csvData[0]?.of_number || ''}
onConcluido={() => {
setShowDiffModal(false);
handleClose();
}}
/>
</>
);
}
+89 -25
View File
@@ -15,6 +15,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { FileSpreadsheet, Upload, Download, CheckCircle2, AlertCircle, Trash2, ArrowLeft, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import * as XLSX from 'xlsx';
import { analisarConflitosImportacao, consolidarPecasEComponentes, extrairComprimentoSeguro, extrairComprimentoComponenteSeguro } from '@/utils/importDiffService';
import { ResultadoAnaliseImportacao } from '@/types/importDiff';
import { ImportarPecasDiffModal } from '@/components/pecas/ImportarPecasDiffModal';
export interface PecaXLSData {
of_number: string;
@@ -28,6 +31,7 @@ export interface PecaXLSData {
material: string;
perfil_principal: string;
tem_componentes: boolean;
comprimento?: number | null;
comprimento_ref?: number | string;
marca_componente?: string;
descricao_componente?: string;
@@ -54,6 +58,9 @@ export function ImportarXLSModal({
const [isProcessing, setIsProcessing] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [step, setStep] = useState<'upload' | 'preview'>('upload');
const [analiseDiff, setAnaliseDiff] = useState<ResultadoAnaliseImportacao | null>(null);
const [componentesMapDiff, setComponentesMapDiff] = useState<Map<string, any[]>>(new Map());
const [showDiffModal, setShowDiffModal] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const resetState = () => {
@@ -112,24 +119,30 @@ export function ImportarXLSModal({
const ws = workbook.Sheets['Importacao_TrackSteel'];
const jsonRows: any[] = XLSX.utils.sheet_to_json(ws, { defval: '' });
if (jsonRows && jsonRows.length > 0) {
const lidas: PecaXLSData[] = jsonRows.map((r) => ({
of_number: String(r.of_number || ofDefault || 'B134').trim().replace(/^B-(\d+)/i, 'B$1'),
etapa_fase: String(r.etapa_fase || '1').trim(),
marca: String(r.marca || '').trim(),
descricao: String(r.descricao || '').trim(),
quantidade: Math.max(1, Math.round(parseNumber(r.quantidade) || 1)),
peso_unitario: parseNumber(r.peso_unitario),
peso_total: parseNumber(r.peso_total),
tratamento_superficial: String(r.tratamento_superficial || 'pintura').trim(),
material: String(r.material || 'Aço A36').trim(),
perfil_principal: String(r.perfil_principal || r.descricao || '').trim(),
tem_componentes: Boolean(r.tem_componentes || (r.marca_componente && String(r.marca_componente).trim() !== '')),
marca_componente: r.marca_componente ? String(r.marca_componente).trim() : undefined,
descricao_componente: r.descricao_componente ? String(r.descricao_componente).trim() : undefined,
perfil_componente: r.perfil_componente ? String(r.perfil_componente).trim() : undefined,
peso_unitario_componente: r.peso_unitario_componente !== undefined ? parseNumber(r.peso_unitario_componente) : undefined,
quantidade_por_peca: r.quantidade_por_peca !== undefined ? parseNumber(r.quantidade_por_peca) : undefined,
})).filter(p => p.marca && !p.marca.toLowerCase().includes('total'));
const lidas: PecaXLSData[] = jsonRows.map((r) => {
const rawComp = r.comprimento || r.comprimento_ref || r['Comprimento'] || r['Comprimento (mm)'] || r['Comprimento Ref. (mm)'];
const compNum = parseNumber(rawComp);
return {
of_number: String(r.of_number || ofDefault || 'B134').trim().replace(/^B-(\d+)/i, 'B$1'),
etapa_fase: String(r.etapa_fase || '1').trim(),
marca: String(r.marca || '').trim(),
descricao: String(r.descricao || '').trim(),
quantidade: Math.max(1, Math.round(parseNumber(r.quantidade) || 1)),
peso_unitario: parseNumber(r.peso_unitario),
peso_total: parseNumber(r.peso_total),
comprimento: compNum > 0 ? compNum : null,
comprimento_ref: compNum > 0 ? compNum : undefined,
tratamento_superficial: String(r.tratamento_superficial || 'pintura').trim(),
material: String(r.material || 'Aço A36').trim(),
perfil_principal: String(r.perfil_principal || r.descricao || '').trim(),
tem_componentes: Boolean(r.tem_componentes || (r.marca_componente && String(r.marca_componente).trim() !== '')),
marca_componente: r.marca_componente ? String(r.marca_componente).trim() : undefined,
descricao_componente: r.descricao_componente ? String(r.descricao_componente).trim() : undefined,
perfil_componente: r.perfil_componente ? String(r.perfil_componente).trim() : undefined,
peso_unitario_componente: r.peso_unitario_componente !== undefined ? parseNumber(r.peso_unitario_componente) : undefined,
quantidade_por_peca: r.quantidade_por_peca !== undefined ? parseNumber(r.quantidade_por_peca) : undefined,
};
}).filter(p => p.marca && !p.marca.toLowerCase().includes('total'));
if (lidas.length > 0) {
setPecasProcessadas(lidas);
@@ -167,10 +180,15 @@ export function ImportarXLSModal({
const key = `${ofVal}-${faseVal}-${marcaPeca}`;
const comps = compMap.get(key) || [];
const temComp = comps.length > 0 || String(p['Composto por Componentes?'] || '').toLowerCase() === 'sim';
const temComp = comps.length > 0 || String(p['Composto por Componentes?'] || p['Composto por Componentes'] || '').toLowerCase() === 'sim';
const rawPecaComp = p['Comprimento Ref. (mm)'] || p['Comprimento (mm)'] || p['Comprimento'] || p['comprimento'] || p['comprimento_ref'];
const compPeca = parseNumber(rawPecaComp);
if (comps.length > 0) {
comps.forEach((c) => {
const compSub = parseNumber(c['Comprimento (mm)'] || c['Comprimento'] || c['comprimento']);
const finalComp = compPeca > 0 ? compPeca : (compSub > 0 ? compSub : null);
merged.push({
of_number: ofVal,
etapa_fase: faseVal,
@@ -179,6 +197,8 @@ export function ImportarXLSModal({
quantidade: Math.max(1, Math.round(parseNumber(p['Quantidade']) || 1)),
peso_unitario: parseNumber(p['Peso Unitário (kg)']),
peso_total: parseNumber(p['Peso Total (kg)']),
comprimento: finalComp,
comprimento_ref: finalComp || undefined,
tratamento_superficial: String(p['Tratamento Superficial'] || 'pintura').trim(),
material: String(p['Material'] || 'Aço A36').trim(),
perfil_principal: String(p['Perfil Principal'] || p['Descrição'] || '').trim(),
@@ -199,6 +219,8 @@ export function ImportarXLSModal({
quantidade: Math.max(1, Math.round(parseNumber(p['Quantidade']) || 1)),
peso_unitario: parseNumber(p['Peso Unitário (kg)']),
peso_total: parseNumber(p['Peso Total (kg)']),
comprimento: compPeca > 0 ? compPeca : null,
comprimento_ref: compPeca > 0 ? compPeca : undefined,
tratamento_superficial: String(p['Tratamento Superficial'] || 'pintura').trim(),
material: String(p['Material'] || 'Aço A36').trim(),
perfil_principal: String(p['Perfil Principal'] || p['Descrição'] || '').trim(),
@@ -272,7 +294,7 @@ export function ImportarXLSModal({
else if ((h.includes('tratam') || h.includes('superf') || h.includes('pintura') || h.includes('acab')) && colMap['tratamento'] === undefined) colMap['tratamento'] = colIdx;
else if ((h.includes('mat') || h.includes('qualidade') || h.includes('aco')) && colMap['material'] === undefined) colMap['material'] = colIdx;
else if ((h.includes('perfil') || h.includes('perfilprinc')) && colMap['perfil_principal'] === undefined) colMap['perfil_principal'] = colIdx;
else if ((h.includes('compriment') || h.includes('comprmm') || h.includes('length')) && colMap['comprimento'] === undefined) colMap['comprimento'] = colIdx;
else if ((h.includes('compriment') || h.includes('comprmm') || h.includes('length') || h === 'compr' || h === 'comp') && colMap['comprimento'] === undefined) colMap['comprimento'] = colIdx;
});
// Itera sobre as linhas de dados após o cabeçalho
@@ -333,7 +355,8 @@ export function ImportarXLSModal({
const rawComp = String(getColVal('componentes', 4)).toLowerCase();
const temComponentes = rawComp === 'sim' || rawComp === 'true' || rawComp === '1';
const comprimentoRef = getColVal('comprimento', 10);
const comprimentoRaw = getColVal('comprimento', 11);
const comprimentoVal = parseNumber(comprimentoRaw);
pecasLidas.push({
of_number: ofNumber,
@@ -343,16 +366,17 @@ export function ImportarXLSModal({
quantidade,
peso_unitario: pesoUnit,
peso_total: pesoTot,
comprimento: comprimentoVal > 0 ? comprimentoVal : null,
comprimento_ref: comprimentoVal > 0 ? comprimentoVal : undefined,
tratamento_superficial: tratSuperficial,
material,
perfil_principal: rawPerfil || rawDesc,
tem_componentes: temComponentes,
comprimento_ref: comprimentoRef || undefined,
});
}
} else {
// Fallback por ordem posicional estrita:
// Coluna 0: OF, 1: Fase, 2: Marca, 3: Descrição, 4: Quantidade, 5: Peso Unit, 6: Peso Total, 7: Tratamento, 8: Material, 9: Perfil Principal
// Coluna 0: OF, 1: Fase, 2: Marca, 3: Descrição, 4: Quantidade, 5: Peso Unit, 6: Peso Total, 7: Tratamento, 8: Material, 9: Perfil Principal, 10/11: Comprimento
for (let r = 0; r < rawRows.length; r++) {
const row = rawRows[r];
if (!row || row.length < 3) continue;
@@ -369,6 +393,7 @@ export function ImportarXLSModal({
const tratSuperficial = String(row[7] || 'pintura').trim() || 'pintura';
const material = String(row[8] || 'Aço A36').trim();
const perfilPrincipal = String(row[9] || rawDesc).trim();
const rawComprFallback = parseNumber(row[11] || row[10]);
pecasLidas.push({
of_number: ofNumber,
@@ -378,6 +403,8 @@ export function ImportarXLSModal({
quantidade,
peso_unitario: pesoUnit,
peso_total: pesoTot,
comprimento: rawComprFallback > 0 ? rawComprFallback : null,
comprimento_ref: rawComprFallback > 0 ? rawComprFallback : undefined,
tratamento_superficial: tratSuperficial,
material,
perfil_principal: perfilPrincipal,
@@ -444,6 +471,8 @@ export function ImportarXLSModal({
quantidade: p.quantidade,
peso_unitario: p.peso_unitario,
peso_total: p.peso_total,
comprimento: extrairComprimentoSeguro(p),
comprimento_ref: extrairComprimentoSeguro(p),
tratamento_superficial: p.tratamento_superficial || 'pintura',
material: p.material,
perfil_principal: p.perfil_principal,
@@ -452,11 +481,27 @@ export function ImportarXLSModal({
descricao_componente: p.descricao_componente,
perfil_componente: p.perfil_componente,
peso_unitario_componente: p.peso_unitario_componente,
comprimento_componente: extrairComprimentoComponenteSeguro(p),
quantidade_por_peca: p.quantidade_por_peca,
}));
// Analisar conflitos com a base de dados
toast.info('Verificando existência e status de produção das peças...');
const analise = await analisarConflitosImportacao(pecasPayload);
const { componentesMap } = consolidarPecasEComponentes(pecasPayload);
// Se houver qualquer peça já existente na base (com divergência, idêntica ou com apontamento fabril)
if (analise.totalComDivergencia > 0 || analise.totalIdenticas > 0 || analise.totalComApontamento > 0) {
setAnaliseDiff(analise);
setComponentesMapDiff(componentesMap);
setShowDiffModal(true);
setIsImporting(false);
return;
}
// Se todas forem 100% novas
await onImport(pecasPayload);
toast.success(`${pecasProcessadas.length} registros importados com sucesso para a OF!`);
toast.success(`${pecasProcessadas.length} novas peças cadastradas com sucesso para a OF!`);
handleModalOpenChange(false);
} catch (err: unknown) {
console.error('Erro ao importar peças do Excel:', err);
@@ -521,7 +566,8 @@ export function ImportarXLSModal({
const totalQuantidade = pecasProcessadas.reduce((acc, p) => acc + (p.quantidade || 0), 0);
return (
<Dialog open={open} onOpenChange={handleModalOpenChange}>
<>
<Dialog open={open} onOpenChange={handleModalOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col p-6 bg-card text-card-foreground border-border">
<DialogHeader className="pb-3 border-b border-border">
<DialogTitle className="flex items-center justify-between">
@@ -670,6 +716,7 @@ export function ImportarXLSModal({
<TableHead className="w-16 text-center">Qtd</TableHead>
<TableHead className="w-24 text-right">Peso Un. (kg)</TableHead>
<TableHead className="w-24 text-right">Peso Total (kg)</TableHead>
<TableHead className="w-24 text-right">Compr. (mm)</TableHead>
<TableHead className="w-24">Tratamento</TableHead>
<TableHead className="w-24">Material</TableHead>
<TableHead className="w-28">Perfil Princ.</TableHead>
@@ -688,6 +735,9 @@ export function ImportarXLSModal({
<TableCell className="text-right text-xs font-semibold text-emerald-500">
{peca.peso_total.toLocaleString('pt-BR')}
</TableCell>
<TableCell className="text-right text-xs font-mono text-cyan-400 font-semibold">
{peca.comprimento || peca.comprimento_ref ? `${peca.comprimento || peca.comprimento_ref} mm` : '-'}
</TableCell>
<TableCell className="text-xs">{peca.tratamento_superficial}</TableCell>
<TableCell className="text-xs">{peca.material}</TableCell>
<TableCell className="text-xs font-mono">{peca.perfil_principal}</TableCell>
@@ -740,5 +790,19 @@ export function ImportarXLSModal({
)}
</DialogContent>
</Dialog>
{/* Modal Comparador e Conciliador de Peças Repetidas */}
<ImportarPecasDiffModal
open={showDiffModal}
onOpenChange={setShowDiffModal}
analise={analiseDiff}
componentesMap={componentesMapDiff}
ofDefault={ofDefault}
onConcluido={() => {
setShowDiffModal(false);
handleModalOpenChange(false);
}}
/>
</>
);
}
@@ -0,0 +1,286 @@
import React, { useRef } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Printer, AlertTriangle, CheckCircle2, Copy, FileText, Factory } from 'lucide-react';
import { toast } from 'sonner';
import { ItemAnaliseImportacao } from '@/types/importDiff';
interface IntervencaoFabrilPrintModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
itens: ItemAnaliseImportacao[];
ofNumber?: string;
usuarioNome?: string;
}
export const IntervencaoFabrilPrintModal: React.FC<IntervencaoFabrilPrintModalProps> = ({
open,
onOpenChange,
itens,
ofNumber = '',
usuarioNome = 'Operador PCP',
}) => {
const printRef = useRef<HTMLDivElement>(null);
const handlePrint = () => {
window.print();
};
const handleCopyText = () => {
if (itens.length === 0) return;
const linhas = itens.map((item, idx) => {
const diffs = item.camposDivergentes.map((c) => `${c.label}: ${c.valorAntigo} ➔ ${c.valorNovo}`).join('; ');
const comps = item.componentesDivergentes.map((c) => c.detalhe).join('; ');
const ultimoProc = item.apontamentoResumo.ultimoProcessoNome || 'Não iniciado';
return `${idx + 1}. [${item.of_number}] Marca: ${item.marca} (Fase ${item.etapa_fase})
- Status Fabril: ${ultimoProc} (${item.apontamentoResumo.totalProduzido} un apontadas)
- Divergências: ${diffs || 'Nenhuma alteração de campo'} ${comps ? '| Subcomponentes: ' + comps : ''}
- Ação Recomendada: ${item.acaoRecomendadaFabril || item.motivoAlerta}
`;
});
const textoFinal = `ORDEM DE INTERVENÇÃO FABRIL - TRACKSTEEL
Data: ${new Date().toLocaleString('pt-BR')} | Responsável: ${usuarioNome}
OFs: ${ofNumber || 'Múltiplas'} | Total de Peças em Alerta: ${itens.length}
${linhas.join('\n')}
Aviso: Interromper avanço fabril das peças listadas até a conferência física.`;
navigator.clipboard.writeText(textoFinal);
toast.success('Lista de lembretes copiada para a área de transferência!');
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col p-0 overflow-hidden bg-background border-border">
{/* Cabeçalho da Janela */}
<DialogHeader className="p-4 border-b border-border bg-muted/40 flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20">
<AlertTriangle className="h-5 w-5" />
</div>
<div>
<DialogTitle className="text-lg font-bold text-foreground flex items-center gap-2">
Ordem de Intervenção Fabril & Lista de Lembretes
<Badge variant="outline" className="bg-red-500/10 text-red-600 border-red-500/30 text-xs font-bold">
{itens.length} peça(s) com atenção especial
</Badge>
</DialogTitle>
<p className="text-xs text-muted-foreground">
Documento de alerta para o chão de fábrica sobre peças alteradas que já possuem apontamentos de produção.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCopyText}
className="text-xs flex items-center gap-1.5"
>
<Copy className="h-3.5 w-3.5" />
Copiar Texto
</Button>
<Button
size="sm"
onClick={handlePrint}
className="bg-blue-600 hover:bg-blue-700 text-white text-xs flex items-center gap-1.5 shadow-sm"
>
<Printer className="h-3.5 w-3.5" />
Imprimir (A4 / Ficha)
</Button>
</div>
</DialogHeader>
{/* Conteúdo Imprimível */}
<ScrollArea className="flex-1 p-6">
<div
ref={printRef}
className="print-container bg-white text-slate-900 p-6 rounded-lg border border-slate-200 shadow-sm print:p-0 print:border-none print:shadow-none font-sans"
>
{/* Cabeçalho Oficial do Relatório */}
<div className="border-b-2 border-slate-900 pb-4 mb-4 flex justify-between items-start">
<div>
<div className="flex items-center gap-2">
<Factory className="h-6 w-6 text-slate-900" />
<span className="font-black text-xl tracking-tight text-slate-900">TRACKSTEEL</span>
<span className="text-xs uppercase bg-slate-100 font-bold px-2 py-0.5 rounded text-slate-700 border border-slate-300">
Industrial PCP
</span>
</div>
<h1 className="text-base font-extrabold uppercase mt-1 text-slate-900">
ORDEM DE INTERVENÇÃO FABRIL & ALERTA DE REVISÃO
</h1>
<p className="text-xs text-slate-600">
Atenção especial por atualização de engenharia em peças com processo fabril em andamento
</p>
</div>
<div className="text-right text-xs space-y-0.5 text-slate-700">
<p><span className="font-bold">OF:</span> {ofNumber || 'Geral / Múltiplas'}</p>
<p><span className="font-bold">Data/Hora:</span> {new Date().toLocaleString('pt-BR')}</p>
<p><span className="font-bold">Responsável:</span> {usuarioNome}</p>
<p><span className="font-bold text-red-600">Peças Afetadas:</span> {itens.length}</p>
</div>
</div>
{/* Aviso de Destaque */}
<div className="bg-amber-50 border-l-4 border-amber-500 p-3 mb-4 rounded text-xs text-amber-900 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 shrink-0 mt-0.5" />
<div>
<span className="font-bold">COMUNICADO URGENTE AOS LÍDERES DE PRODUÇÃO:</span> As peças abaixo sofreram alterações de projeto (dimensional, peso, componentes ou quantidade) mas já possuem avanço de fabricação registrado. Interrompa o avanço físico destas peças até a conferência técnica recomendada.
</div>
</div>
{/* Tabela de Ações e Lembretes */}
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-100 border-b-2 border-slate-300 text-slate-800 font-bold uppercase">
<th className="py-2 px-2.5 w-12 text-center">Item</th>
<th className="py-2 px-2.5 w-28">OF / Marca</th>
<th className="py-2 px-2.5 w-40">Perfil / Descrição</th>
<th className="py-2 px-2.5 w-36">Status no Chão de Fábrica</th>
<th className="py-2 px-2.5">O Que Mudou (Engenharia)</th>
<th className="py-2 px-2.5 w-52">Ação Recomendada</th>
<th className="py-2 px-2.5 w-28 text-center">Visto do Líder</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{itens.map((item, index) => {
const ultimoProc = item.apontamentoResumo.ultimoProcessoNome || 'Não iniciado';
const totalApontado = item.apontamentoResumo.totalProduzido;
return (
<tr key={item.key} className={index % 2 === 1 ? 'bg-slate-50/50' : 'bg-white'}>
<td className="py-2 px-2.5 text-center font-bold text-slate-500">
{index + 1}
</td>
<td className="py-2 px-2.5">
<div className="font-extrabold text-slate-900 text-sm">{item.marca}</div>
<div className="text-[10px] text-slate-500">
{item.of_number} • Fase {item.etapa_fase}
</div>
</td>
<td className="py-2 px-2.5">
<div className="font-semibold text-slate-800">
{item.dadosImportados.perfil_principal || item.descricao || '-'}
</div>
{item.dadosImportados.comprimento && (
<div className="text-[10px] text-slate-500">
Comp: {item.dadosImportados.comprimento} mm
</div>
)}
</td>
<td className="py-2 px-2.5">
<div className="inline-block px-2 py-0.5 rounded text-[11px] font-bold bg-amber-100 text-amber-900 border border-amber-300">
{ultimoProc.toUpperCase()}
</div>
<div className="text-[10px] text-slate-600 mt-0.5">
{totalApontado} de {item.dadosExistentes?.quantidade || item.dadosImportados.quantidade} un apontadas
</div>
{item.apontamentoResumo.emRomaneio && (
<div className="text-[10px] font-bold text-red-600">
⚠️ EM ROMANEIO
</div>
)}
</td>
<td className="py-2 px-2.5 space-y-1">
{item.camposDivergentes.map((c) => (
<div key={c.campo} className="text-[11px]">
<span className="font-bold text-slate-700">{c.label}:</span>{' '}
<span className="line-through text-red-600">{c.valorAntigo}</span>{' '}
➔ <span className="font-bold text-emerald-700">{c.valorNovo}</span>
</div>
))}
{item.componentesDivergentes.map((comp) => (
<div key={comp.marca_componente} className="text-[10px] text-blue-700 font-medium">
• {comp.detalhe}
</div>
))}
</td>
<td className="py-2 px-2.5">
<div className="text-[11px] font-semibold text-slate-900 bg-red-50 p-1.5 rounded border border-red-200">
{item.acaoRecomendadaFabril || item.motivoAlerta || 'Verificar peça na fábrica.'}
</div>
</td>
<td className="py-2 px-2.5 text-center">
<div className="h-10 border-b border-dashed border-slate-400 mt-2"></div>
<div className="text-[9px] text-slate-400 mt-0.5">Assinatura / Data</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Rodapé de Assinaturas */}
<div className="mt-8 pt-4 border-t border-slate-300 grid grid-cols-3 gap-6 text-center text-xs text-slate-600">
<div>
<div className="h-10 border-b border-slate-400 mb-1"></div>
<div className="font-bold text-slate-800">Engenharia / Projetista</div>
<div className="text-[10px]">Liberação de Revisão</div>
</div>
<div>
<div className="h-10 border-b border-slate-400 mb-1"></div>
<div className="font-bold text-slate-800">PCP / Planejamento</div>
<div className="text-[10px]">Ajuste de Fila & Diário</div>
</div>
<div>
<div className="h-10 border-b border-slate-400 mb-1"></div>
<div className="font-bold text-slate-800">Líder de Fabricação</div>
<div className="text-[10px]">Ciente & Intervenção Física</div>
</div>
</div>
</div>
</ScrollArea>
{/* Rodapé da Janela */}
<DialogFooter className="p-3 border-t border-border bg-muted/20 flex items-center justify-between">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<FileText className="h-4 w-4" />
Esta folha pode ser impressa em qualquer impressora A4 ou salva em PDF pelo navegador.
</div>
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
Fechar Janela
</Button>
</DialogFooter>
{/* Estilo Global de Impressão (@media print) */}
<style dangerouslySetInnerHTML={{
__html: `
@media print {
body * {
visibility: hidden !important;
}
.print-container, .print-container * {
visibility: visible !important;
}
.print-container {
position: absolute !important;
left: 0 !important;
top: 0 !important;
width: 100% !important;
margin: 0 !important;
padding: 15mm !important;
background: white !important;
color: black !important;
}
}
`
}} />
</DialogContent>
</Dialog>
);
};
+28 -1
View File
@@ -2,7 +2,8 @@
import React from 'react';
import { Table, TableBody, TableHeader } from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Package } from 'lucide-react';
import { Package, FileText, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Peca } from '@/hooks/usePecas';
import { usePecasTable } from '@/hooks/usePecasTable';
import { TableFilters } from './table/TableFilters';
@@ -22,6 +23,9 @@ interface PecasTableProps {
onEditPeca?: (peca: Peca) => void;
onDeleteLastImport?: () => void;
onBatchUpdatePecas?: (pecaIds: string[], updates: any) => Promise<void>;
onOpenRevisaoPeca?: (peca: Peca) => void;
onOpenPainelRevisoes?: () => void;
totalRevisoesOF?: number;
hasRecentImport: boolean;
canEdit?: boolean;
canDelete?: boolean;
@@ -36,6 +40,9 @@ export function PecasTable({
onEditPeca,
onDeleteLastImport,
onBatchUpdatePecas,
onOpenRevisaoPeca,
onOpenPainelRevisoes,
totalRevisoesOF,
hasRecentImport,
canEdit = true,
canDelete = true,
@@ -169,6 +176,25 @@ export function PecasTable({
/>
<div className="flex items-center gap-2">
{onOpenPainelRevisoes && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onOpenPainelRevisoes}
className="h-8 border-red-500/50 bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 hover:text-red-700 dark:hover:text-red-300 font-bold flex items-center gap-1.5 transition-all shadow-sm"
title="Abrir Registro e Gestão de Revisões da OF"
>
<FileText className="h-3.5 w-3.5 text-red-600 dark:text-red-400" />
<span>REG.REVISOES</span>
{totalRevisoesOF !== undefined && totalRevisoesOF > 0 && (
<span className="ml-1 px-1.5 py-0.2 rounded-full text-[10px] bg-red-600 dark:bg-red-500 text-white font-black">
{totalRevisoesOF}
</span>
)}
</Button>
)}
{canEdit && onBatchUpdatePecas && (
<BatchEditButton
selectedPecas={selectedPecas}
@@ -209,6 +235,7 @@ export function PecasTable({
onEdit={() => handleEditPeca(peca)}
onDelete={() => handleDeletePeca(peca.id)}
onOpenComponentPopup={() => onOpenComponentPopup(peca.id)}
onOpenRevisao={() => onOpenRevisaoPeca?.(peca)}
/>
))}
</TableBody>
@@ -0,0 +1,473 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table';
import {
FileText,
Search,
Printer,
ExternalLink,
Filter,
RefreshCw
} from 'lucide-react';
import { useRevisoes, Revisao } from '@/hooks/useRevisoes';
import { Peca } from '@/hooks/usePecas';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
interface PainelRevisoesOFModalProps {
isOpen: boolean;
onClose: () => void;
ofNumber: string;
pecas: Peca[];
onOpenRevisaoPeca: (peca: Peca) => void;
}
export function PainelRevisoesOFModal({
isOpen,
onClose,
ofNumber,
pecas,
onOpenRevisaoPeca
}: PainelRevisoesOFModalProps) {
const { revisoes, loading, carregarRevisoesOF } = useRevisoes(ofNumber);
const [busca, setBusca] = useState('');
const [filtroTipo, setFiltroTipo] = useState<'todos' | 'critica' | 'simples'>('todos');
const [visualizandoRelatorio, setVisualizandoRelatorio] = useState(false);
useEffect(() => {
if (isOpen && ofNumber) {
carregarRevisoesOF(ofNumber);
setVisualizandoRelatorio(false);
}
}, [isOpen, ofNumber, carregarRevisoesOF]);
// Estatísticas
const stats = useMemo(() => {
const total = revisoes.length;
const criticas = revisoes.filter((r) => r.tipo_revisao === 'critica').length;
const simples = revisoes.filter((r) => r.tipo_revisao === 'simples').length;
const pecasUnicas = new Set(revisoes.map((r) => r.peca_id)).size;
return { total, criticas, simples, pecasUnicas };
}, [revisoes]);
// Filtro
const revisoesFiltradas = useMemo(() => {
return revisoes.filter((rev) => {
// Tipo
if (filtroTipo !== 'todos' && rev.tipo_revisao !== filtroTipo) {
return false;
}
// Busca texto
if (busca.trim()) {
const termo = busca.toLowerCase();
const marca = (rev.peca?.marca || '').toLowerCase();
const fase = (rev.peca?.etapa_fase || '').toLowerCase();
const motivo = (rev.descricao_motivo || '').toLowerCase();
const numero = (rev.numero_revisao || '').toLowerCase();
const autor = (rev.usuario_nome || '').toLowerCase();
return (
marca.includes(termo) ||
fase.includes(termo) ||
motivo.includes(termo) ||
numero.includes(termo) ||
autor.includes(termo)
);
}
return true;
});
}, [revisoes, filtroTipo, busca]);
const handleImprimirRelatorio = () => {
window.print();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto bg-background text-foreground border-border p-6 shadow-xl">
<DialogHeader className="border-b border-border pb-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-red-500/10 border border-red-500/30 flex items-center justify-center text-red-600 dark:text-red-400">
<FileText className="w-5 h-5" />
</div>
<div>
<DialogTitle className="text-xl font-bold flex items-center gap-2">
<span>Registro Geral de Revisões da OF</span>
<Badge variant="outline" className="font-mono text-amber-600 dark:text-amber-400 border-amber-400/40 bg-amber-500/10 text-sm">
{ofNumber || 'Geral'}
</Badge>
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs mt-0.5">
Rastreabilidade técnica, histórico de modificações simples e críticas nas peças desta Ordem de Fabricação.
</DialogDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setVisualizandoRelatorio(!visualizandoRelatorio)}
className="border-border text-foreground hover:bg-muted flex items-center gap-1.5 h-8 text-xs"
>
<Printer className="w-3.5 h-3.5 text-blue-500" />
{visualizandoRelatorio ? 'Voltar para Tabela' : 'Gerar Relatório'}
</Button>
</div>
</div>
</DialogHeader>
{/* MODO RELATÓRIO IMPRESSÃO */}
{visualizandoRelatorio ? (
<div className="mt-4 space-y-4">
<div className="flex items-center justify-between bg-muted p-3 rounded-lg border border-border">
<span className="text-xs text-muted-foreground">
Pré-visualização do Relatório Técnico para impressão ou exportação.
</span>
<Button
size="sm"
onClick={handleImprimirRelatorio}
className="bg-blue-600 hover:bg-blue-500 text-white text-xs h-7 flex items-center gap-1 shadow-sm"
>
<Printer className="w-3.5 h-3.5" />
Imprimir / Salvar PDF
</Button>
</div>
{/* Documento A4 Formato Relatório */}
<div className="p-6 bg-white text-slate-900 rounded-lg shadow-lg text-xs space-y-4 font-sans border border-slate-200">
{/* Header do Relatório */}
<div className="border-b-2 border-slate-800 pb-3 flex justify-between items-start">
<div>
<h2 className="text-lg font-black tracking-wider text-slate-900">TRACKSTEEL INDUSTRIAL</h2>
<p className="text-[11px] text-slate-600">Sistema Integrado de Gestão & Rastreabilidade de Estruturas</p>
<p className="text-[11px] font-bold text-slate-800 mt-1">RELATÓRIO TÉCNICO DE GESTÃO DE REVISÕES</p>
</div>
<div className="text-right text-[11px] text-slate-700">
<p><strong>OF:</strong> {ofNumber}</p>
<p><strong>Emissão:</strong> {format(new Date(), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}</p>
<p><strong>Total de Revisões:</strong> {stats.total}</p>
</div>
</div>
{/* Tabela do Relatório */}
<table className="w-full border-collapse border border-slate-300 text-[10px]">
<thead>
<tr className="bg-slate-100 text-slate-800">
<th className="border border-slate-300 p-1.5 text-left">Data/Hora</th>
<th className="border border-slate-300 p-1.5 text-left">Peça</th>
<th className="border border-slate-300 p-1.5 text-center">Fase</th>
<th className="border border-slate-300 p-1.5 text-center">Revisão</th>
<th className="border border-slate-300 p-1.5 text-center">Tipo</th>
<th className="border border-slate-300 p-1.5 text-left">Motivo / Descrição</th>
<th className="border border-slate-300 p-1.5 text-left">Campos Alterados</th>
<th className="border border-slate-300 p-1.5 text-left">Responsável</th>
</tr>
</thead>
<tbody>
{revisoes.length === 0 ? (
<tr>
<td colSpan={8} className="p-4 text-center text-slate-500">
Nenhum registro de revisão para esta OF.
</td>
</tr>
) : (
revisoes.map((r) => (
<tr key={r.id} className="hover:bg-slate-50 border-b border-slate-200">
<td className="border border-slate-300 p-1.5 whitespace-nowrap">
{r.data_revisao ? format(new Date(r.data_revisao), 'dd/MM/yy HH:mm') : '-'}
</td>
<td className="border border-slate-300 p-1.5 font-bold">
{r.peca?.marca || '-'}
</td>
<td className="border border-slate-300 p-1.5 text-center">
{r.peca?.etapa_fase || '-'}
</td>
<td className="border border-slate-300 p-1.5 text-center font-bold">
{r.numero_revisao}
</td>
<td className="border border-slate-300 p-1.5 text-center font-semibold">
<span className={r.tipo_revisao === 'critica' ? 'text-red-600 font-bold' : 'text-blue-600 font-bold'}>
{r.tipo_revisao === 'critica' ? 'CRÍTICA' : 'SIMPLES'}
</span>
</td>
<td className="border border-slate-300 p-1.5">
{r.descricao_motivo}
{r.observacoes && <span className="block text-slate-500 italic mt-0.5">Obs: {r.observacoes}</span>}
</td>
<td className="border border-slate-300 p-1.5 font-mono text-[9px]">
{r.campos_alterados?.join(', ') || '-'}
</td>
<td className="border border-slate-300 p-1.5">
{r.usuario_nome || r.usuario_email || 'Sistema'}
</td>
</tr>
))
)}
</tbody>
</table>
{/* Rodapé e Assinaturas */}
<div className="pt-8 border-t border-slate-300 grid grid-cols-2 gap-8 text-center text-[10px] text-slate-600">
<div>
<div className="border-t border-slate-400 w-48 mx-auto mb-1"></div>
<p className="font-bold">Engenharia / PCP</p>
<p>Responsável pelo Lançamento</p>
</div>
<div>
<div className="border-t border-slate-400 w-48 mx-auto mb-1"></div>
<p className="font-bold">Supervisão de Produção</p>
<p>Aceite de Modificação</p>
</div>
</div>
</div>
</div>
) : (
/* MODO PAINEL / TABELA */
<div className="mt-4 space-y-4">
{/* CARDS DE MÉTRICAS */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-3 rounded-lg bg-card border border-border shadow-sm">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground block">
Total de Revisões
</span>
<span className="text-2xl font-black text-foreground mt-1 block">
{stats.total}
</span>
<span className="text-[10px] text-muted-foreground">histórico completo</span>
</div>
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 shadow-sm">
<span className="text-[11px] font-bold uppercase tracking-wider text-red-600 dark:text-red-400 block">
Revisões Críticas
</span>
<span className="text-2xl font-black text-red-700 dark:text-red-300 mt-1 block">
{stats.criticas}
</span>
<span className="text-[10px] text-red-600/80 dark:text-red-400/80">alteraram marca, qtd, peso</span>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30 shadow-sm">
<span className="text-[11px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400 block">
Revisões Simples
</span>
<span className="text-2xl font-black text-blue-700 dark:text-blue-300 mt-1 block">
{stats.simples}
</span>
<span className="text-[10px] text-blue-600/80 dark:text-blue-400/80">dados informativos</span>
</div>
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 shadow-sm">
<span className="text-[11px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400 block">
Peças Afetadas
</span>
<span className="text-2xl font-black text-amber-700 dark:text-amber-300 mt-1 block">
{stats.pecasUnicas}
</span>
<span className="text-[10px] text-amber-600/80 dark:text-amber-400/80">peças distintas</span>
</div>
</div>
{/* FILTROS E BUSCA */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2 flex-1 min-w-[240px]">
<div className="relative flex-1">
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={busca}
onChange={(e) => setBusca(e.target.value)}
placeholder="Filtrar por peça, fase, motivo ou autor..."
className="pl-8 bg-background border-input text-xs h-8 text-foreground"
/>
</div>
</div>
<div className="flex items-center gap-1.5 text-xs">
<Filter className="w-3.5 h-3.5 text-muted-foreground" />
<button
type="button"
onClick={() => setFiltroTipo('todos')}
className={`px-2.5 py-1 rounded text-xs transition-colors ${
filtroTipo === 'todos' ? 'bg-primary text-primary-foreground font-bold shadow-sm' : 'text-muted-foreground hover:bg-muted'
}`}
>
Todas ({stats.total})
</button>
<button
type="button"
onClick={() => setFiltroTipo('critica')}
className={`px-2.5 py-1 rounded text-xs transition-colors ${
filtroTipo === 'critica'
? 'bg-red-600 text-white font-bold shadow-sm'
: 'text-red-600 dark:text-red-400 hover:bg-red-500/10'
}`}
>
Críticas ({stats.criticas})
</button>
<button
type="button"
onClick={() => setFiltroTipo('simples')}
className={`px-2.5 py-1 rounded text-xs transition-colors ${
filtroTipo === 'simples'
? 'bg-blue-600 text-white font-bold shadow-sm'
: 'text-blue-600 dark:text-blue-400 hover:bg-blue-500/10'
}`}
>
Simples ({stats.simples})
</button>
<Button
variant="ghost"
size="sm"
onClick={() => carregarRevisoesOF(ofNumber)}
disabled={loading}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
title="Atualizar lista"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{/* TABELA DE REVISÕES */}
<div className="rounded-md border border-border bg-card overflow-hidden shadow-sm">
<Table>
<TableHeader className="bg-muted/50">
<TableRow className="border-border hover:bg-muted/50">
<TableHead className="text-muted-foreground text-xs w-28">Data</TableHead>
<TableHead className="text-muted-foreground text-xs w-20">Peça</TableHead>
<TableHead className="text-muted-foreground text-xs w-16 text-center">Fase</TableHead>
<TableHead className="text-muted-foreground text-xs w-20 text-center">Revisão</TableHead>
<TableHead className="text-muted-foreground text-xs w-20 text-center">Tipo</TableHead>
<TableHead className="text-muted-foreground text-xs">Motivo da Revisão</TableHead>
<TableHead className="text-muted-foreground text-xs">Campos Alterados</TableHead>
<TableHead className="text-muted-foreground text-xs w-28">Autor</TableHead>
<TableHead className="text-muted-foreground text-xs w-16 text-right">Ação</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground text-xs">
Carregando revisões da OF...
</TableCell>
</TableRow>
) : revisoesFiltradas.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground text-xs">
{revisoes.length === 0
? 'Nenhuma revisão cadastrada nesta OF.'
: 'Nenhuma revisão encontrada para os filtros selecionados.'}
</TableCell>
</TableRow>
) : (
revisoesFiltradas.map((r) => {
const pecaCorrespondente = pecas.find((p) => p.id === r.peca_id);
return (
<TableRow key={r.id} className="border-border hover:bg-muted/40 text-xs">
<TableCell className="font-mono text-muted-foreground whitespace-nowrap">
{r.data_revisao ? format(new Date(r.data_revisao), 'dd/MM/yy HH:mm') : '-'}
</TableCell>
<TableCell className="font-mono font-bold text-foreground">
{r.peca?.marca || '-'}
</TableCell>
<TableCell className="text-center text-foreground font-mono">
{r.peca?.etapa_fase || '-'}
</TableCell>
<TableCell className="text-center">
<Badge
variant="outline"
className="font-mono text-[11px] font-bold bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-400/40"
>
{r.numero_revisao}
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge
className={`text-[10px] font-bold ${
r.tipo_revisao === 'critica'
? 'bg-red-500/15 text-red-600 dark:text-red-400 border border-red-500/30'
: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border border-blue-500/30'
}`}
>
{r.tipo_revisao === 'critica' ? 'Crítica' : 'Simples'}
</Badge>
</TableCell>
<TableCell className="text-foreground">
<span className="font-medium block">{r.descricao_motivo}</span>
{r.observacoes && (
<span className="text-[10px] text-muted-foreground italic block mt-0.5">
{r.observacoes}
</span>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{r.campos_alterados?.map((c) => (
<span
key={c}
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-foreground border border-border"
>
{c}
</span>
))}
</div>
</TableCell>
<TableCell className="text-muted-foreground truncate max-w-[120px]">
{r.usuario_nome || r.usuario_email || 'Sistema'}
</TableCell>
<TableCell className="text-right">
{pecaCorrespondente ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
onClose();
onOpenRevisaoPeca(pecaCorrespondente);
}}
title="Abrir CRUD / Histórico da Peça"
className="h-6 w-6 p-0 text-amber-600 dark:text-amber-400 hover:bg-amber-500/10"
>
<ExternalLink className="w-3.5 h-3.5" />
</Button>
) : (
<span className="text-muted-foreground text-[10px]">-</span>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,619 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
AlertTriangle,
History,
ArrowRight,
TrendingUp,
TrendingDown,
Save,
Clock,
User,
Info,
ShieldAlert,
Edit3
} from 'lucide-react';
import { Peca } from '@/hooks/usePecas';
import { useRevisoes, Revisao, TipoRevisao } from '@/hooks/useRevisoes';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
interface RevisaoPecaModalProps {
isOpen: boolean;
onClose: () => void;
peca: Peca | null;
onPecaAtualizada?: () => void;
}
export function RevisaoPecaModal({
isOpen,
onClose,
peca,
onPecaAtualizada
}: RevisaoPecaModalProps) {
const { carregarRevisoesPeca, detectarTipoRevisao, criarRevisao, loading } = useRevisoes();
const [activeTab, setActiveTab] = useState<'nova' | 'historico'>('nova');
const [historico, setHistorico] = useState<Revisao[]>([]);
const [loadingHistorico, setLoadingHistorico] = useState(false);
// Campos editáveis do formulário
const [formData, setFormData] = useState({
marca: '',
quantidade: 0,
peso_unitario: 0,
peso_total: 0,
descricao: '',
etapa_fase: '',
material: '',
perfil_principal: '',
tratamento_superficial: '',
comprimento: 0
});
// Campos de controle da revisão
const [numeroRevisao, setNumeroRevisao] = useState('Rev 01');
const [motivo, setMotivo] = useState('');
const [observacoes, setObservacoes] = useState('');
const [tipoManual, setTipoManual] = useState<TipoRevisao | null>(null);
// Inicializa os dados quando o modal abre
useEffect(() => {
if (peca && isOpen) {
setFormData({
marca: peca.marca || '',
quantidade: Number(peca.quantidade) || 0,
peso_unitario: Number(peca.peso_unitario) || 0,
peso_total: Number(peca.peso_total) || 0,
descricao: peca.descricao || '',
etapa_fase: peca.etapa_fase || '',
material: peca.material || '',
perfil_principal: peca.perfil_principal || '',
tratamento_superficial: peca.tratamento_superficial || '',
comprimento: Number(peca.comprimento) || 0
});
setMotivo('');
setObservacoes('');
setTipoManual(null);
// Carregar histórico
setLoadingHistorico(true);
carregarRevisoesPeca(peca.id).then((revs) => {
setHistorico(revs);
setLoadingHistorico(false);
// Sugere o próximo número de revisão
const proximoNum = revs.length + 1;
setNumeroRevisao(`Rev ${String(proximoNum).padStart(2, '0')}`);
});
}
}, [peca, isOpen, carregarRevisoesPeca]);
if (!peca) return null;
// Analisa alterações em tempo real
const analise = detectarTipoRevisao(peca, formData);
const tipoEfetivo = tipoManual || analise.tipo;
const diffQtd = Number(formData.quantidade) - Number(peca.quantidade || 0);
const diffPeso = Number(formData.peso_unitario) - Number(peca.peso_unitario || 0);
const handleInputChange = (field: string, value: any) => {
setFormData((prev) => {
const next = { ...prev, [field]: value };
if (field === 'quantidade' || field === 'peso_unitario') {
const q = field === 'quantidade' ? Number(value) : prev.quantidade;
const p = field === 'peso_unitario' ? Number(value) : prev.peso_unitario;
next.peso_total = Number((q * p).toFixed(3));
}
return next;
});
};
const handleSalvarRevisao = async (e: React.FormEvent) => {
e.preventDefault();
if (!motivo.trim()) {
return;
}
const sucesso = await criarRevisao({
peca,
numeroRevisao,
tipoRevisao: tipoEfetivo,
descricaoMotivo: motivo,
observacoes,
novosDados: formData
});
if (sucesso) {
if (onPecaAtualizada) {
onPecaAtualizada();
}
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto bg-background text-foreground border-border p-6 shadow-xl">
<DialogHeader className="border-b border-border pb-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-500">
<Edit3 className="w-5 h-5" />
</div>
<div>
<DialogTitle className="text-xl font-bold flex items-center gap-2">
<span>Gestão de Revisões da Peça</span>
<Badge variant="outline" className="font-mono text-amber-600 dark:text-amber-400 border-amber-400/40 bg-amber-500/10 text-sm">
{peca.marca}
</Badge>
{historico.length > 0 && (
<Badge variant="secondary" className="bg-muted text-muted-foreground text-xs">
{historico.length} {historico.length === 1 ? 'revisão gravada' : 'revisões gravadas'}
</Badge>
)}
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs mt-0.5">
OF: <strong className="text-foreground">{peca.of_number}</strong> • Fase: <strong className="text-foreground">{peca.etapa_fase || 'N/A'}</strong> • Qtd Atual: <strong className="text-foreground">{peca.quantidade} un</strong> • Peso Atual: <strong className="text-foreground">{peca.peso_total || (peca.quantidade * peca.peso_unitario)} kg</strong>
</DialogDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Badge
variant={tipoEfetivo === 'critica' ? 'destructive' : 'secondary'}
className={`text-xs px-2.5 py-1 font-semibold ${
tipoEfetivo === 'critica'
? 'bg-red-500/15 text-red-600 dark:text-red-400 border border-red-500/30'
: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border border-blue-500/30'
}`}
>
{tipoEfetivo === 'critica' ? '⚠️ Revisão Crítica' : 'ℹ️ Revisão Simples'}
</Badge>
</div>
</div>
</DialogHeader>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="mt-4">
<TabsList className="grid grid-cols-2 bg-muted border border-border">
<TabsTrigger value="nova" className="data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm font-semibold">
<Edit3 className="w-4 h-4 mr-2" />
Nova Revisão (Editar Peça)
</TabsTrigger>
<TabsTrigger value="historico" className="data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm font-semibold">
<History className="w-4 h-4 mr-2" />
Histórico de Revisões ({historico.length})
</TabsTrigger>
</TabsList>
{/* ABA 1: NOVA REVISÃO */}
<TabsContent value="nova" className="mt-4 space-y-4">
<form onSubmit={handleSalvarRevisao} className="space-y-4">
{/* Metadados da Revisão */}
<div className="p-4 rounded-lg bg-card border border-border space-y-3 shadow-sm">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
<Info className="w-3.5 h-3.5 text-amber-500" />
Identificação da Revisão
</h4>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Classificação:</span>
<button
type="button"
onClick={() => setTipoManual('simples')}
className={`px-2.5 py-1 rounded text-xs transition-colors ${
tipoEfetivo === 'simples'
? 'bg-blue-600 text-white font-bold shadow-sm'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
Simples
</button>
<button
type="button"
onClick={() => setTipoManual('critica')}
className={`px-2.5 py-1 rounded text-xs transition-colors ${
tipoEfetivo === 'critica'
? 'bg-red-600 text-white font-bold shadow-sm'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
Crítica
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<Label className="text-xs text-foreground">Número da Revisão *</Label>
<Input
value={numeroRevisao}
onChange={(e) => setNumeroRevisao(e.target.value)}
placeholder="Ex: Rev 01, Rev 02, Rev A"
className="font-mono text-sm mt-1 bg-background border-input text-foreground"
required
/>
</div>
<div className="md:col-span-2">
<Label className="text-xs text-foreground">Motivo / Justificativa da Revisão *</Label>
<Input
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
placeholder="Ex: Ajuste de projeto, alteração de quantidade pelo cliente..."
className="text-sm mt-1 bg-background border-input text-foreground"
required
/>
</div>
</div>
<div>
<Label className="text-xs text-foreground">Observações Adicionais (Opcional)</Label>
<Textarea
value={observacoes}
onChange={(e) => setObservacoes(e.target.value)}
placeholder="Detalhes adicionais, aprovações de engenharia, notas de fábrica..."
rows={2}
className="text-xs mt-1 bg-background border-input text-foreground"
/>
</div>
</div>
{/* Formulário CRUD da Peça */}
<div className="p-4 rounded-lg bg-card border border-border space-y-4 shadow-sm">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
<Edit3 className="w-3.5 h-3.5 text-blue-500" />
Campos da Peça para Revisão
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
{/* Marca */}
<div>
<Label className="text-xs text-foreground flex items-center justify-between">
<span>Marca / Número da Peça *</span>
{formData.marca !== peca.marca && (
<span className="text-[10px] text-amber-600 dark:text-amber-400 font-bold">Alterado!</span>
)}
</Label>
<Input
value={formData.marca}
onChange={(e) => handleInputChange('marca', e.target.value)}
className={`font-mono text-sm mt-1 bg-background border-input text-foreground ${
formData.marca !== peca.marca ? 'border-amber-500 bg-amber-500/10 text-amber-700 dark:text-amber-200' : ''
}`}
required
/>
</div>
{/* Quantidade */}
<div>
<Label className="text-xs text-foreground flex items-center justify-between">
<span>Quantidade *</span>
{diffQtd !== 0 && (
<span className={`text-[10px] font-bold ${diffQtd > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{diffQtd > 0 ? `+${diffQtd}` : `${diffQtd}`} un
</span>
)}
</Label>
<Input
type="number"
min={1}
step="1"
value={formData.quantidade}
onChange={(e) => handleInputChange('quantidade', Number(e.target.value))}
className={`font-mono text-sm mt-1 bg-background border-input text-foreground ${
diffQtd !== 0
? diffQtd > 0
? 'border-green-500 bg-green-500/10 text-green-700 dark:text-green-200'
: 'border-red-500 bg-red-500/10 text-red-700 dark:text-red-200'
: ''
}`}
required
/>
</div>
{/* Peso Unitário */}
<div>
<Label className="text-xs text-foreground flex items-center justify-between">
<span>Peso Unitário (kg) *</span>
{diffPeso !== 0 && (
<span className="text-[10px] text-amber-600 dark:text-amber-400 font-bold">Alterado</span>
)}
</Label>
<Input
type="number"
step="0.001"
value={formData.peso_unitario}
onChange={(e) => handleInputChange('peso_unitario', Number(e.target.value))}
className="font-mono text-sm mt-1 bg-background border-input text-foreground"
required
/>
</div>
{/* Peso Total (calculado) */}
<div>
<Label className="text-xs text-foreground">Peso Total (kg)</Label>
<Input
type="number"
value={formData.peso_total}
readOnly
disabled
className="font-mono text-sm mt-1 bg-muted border-border text-muted-foreground cursor-not-allowed"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
{/* Descrição */}
<div className="md:col-span-2">
<Label className="text-xs text-foreground">Descrição da Peça</Label>
<Input
value={formData.descricao}
onChange={(e) => handleInputChange('descricao', e.target.value)}
placeholder="Ex: Viga I, Pilar Tubular, Perfil Soldado..."
className="text-sm mt-1 bg-background border-input text-foreground"
/>
</div>
{/* Fase / Etapa */}
<div>
<Label className="text-xs text-foreground">Fase / Etapa</Label>
<Input
value={formData.etapa_fase}
onChange={(e) => handleInputChange('etapa_fase', e.target.value)}
placeholder="Ex: 1, 2, Cobertura..."
className="text-sm mt-1 bg-background border-input text-foreground"
/>
</div>
{/* Comprimento */}
<div>
<Label className="text-xs text-foreground">Comprimento (mm)</Label>
<Input
type="number"
value={formData.comprimento}
onChange={(e) => handleInputChange('comprimento', Number(e.target.value))}
placeholder="Ex: 6000"
className="text-sm mt-1 bg-background border-input text-foreground font-mono"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<Label className="text-xs text-foreground">Material / Aço</Label>
<Input
value={formData.material}
onChange={(e) => handleInputChange('material', e.target.value)}
placeholder="Ex: ASTM A36, A572 GR50"
className="text-sm mt-1 bg-background border-input text-foreground"
/>
</div>
<div>
<Label className="text-xs text-foreground">Perfil Principal</Label>
<Input
value={formData.perfil_principal}
onChange={(e) => handleInputChange('perfil_principal', e.target.value)}
placeholder="Ex: W250x32.7, RHS150x6.3"
className="text-sm mt-1 bg-background border-input text-foreground"
/>
</div>
<div>
<Label className="text-xs text-foreground">Tratamento Superficial</Label>
<Input
value={formData.tratamento_superficial}
onChange={(e) => handleInputChange('tratamento_superficial', e.target.value)}
placeholder="Ex: Pintura Epóxi, Galvanizado"
className="text-sm mt-1 bg-background border-input text-foreground"
/>
</div>
</div>
</div>
{/* CARD DE IMPACTO OPERACIONAL / REGRAS DE NEGÓCIO */}
{analise.camposAlterados.length > 0 && (
<div className={`p-4 rounded-lg border shadow-sm ${
tipoEfetivo === 'critica'
? 'bg-red-500/10 border-red-500/30 text-red-900 dark:text-red-200'
: 'bg-blue-500/10 border-blue-500/30 text-blue-900 dark:text-blue-200'
}`}>
<div className="flex items-start gap-3">
<ShieldAlert className={`w-5 h-5 mt-0.5 shrink-0 ${
tipoEfetivo === 'critica' ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'
}`} />
<div className="space-y-2 text-xs">
<div className="font-bold uppercase tracking-wider">
{tipoEfetivo === 'critica'
? 'Impacto Crítico na Linha de Produção & Apontamentos'
: 'Revisão Informativa (Campos Subjetivos)'}
</div>
<div className="space-y-1">
{diffQtd > 0 && (
<div className="flex items-center gap-1.5 text-emerald-700 dark:text-emerald-400 font-medium">
<TrendingUp className="w-4 h-4 shrink-0" />
<span>
<strong>Aumento de quantidade (+{diffQtd} un):</strong> A diferença excedente de {diffQtd} un nascerá automaticamente no 1º processo de fabricação (Corte/Detalhamento) para novos apontamentos.
</span>
</div>
)}
{diffQtd < 0 && (
<div className="flex items-center gap-1.5 text-rose-700 dark:text-rose-400 font-medium">
<TrendingDown className="w-4 h-4 shrink-0" />
<span>
<strong>Redução de quantidade ({diffQtd} un):</strong> O novo total será de {formData.quantidade} un. Os processos que possuírem apontamentos superiores a {formData.quantidade} un serão revisados automaticamente para o novo teto.
</span>
</div>
)}
{formData.marca !== peca.marca && (
<div className="flex items-center gap-1.5 text-amber-700 dark:text-amber-300 font-medium">
<ArrowRight className="w-4 h-4 shrink-0" />
<span>
<strong>Alteração de marca/código ({peca.marca} ➔ {formData.marca}):</strong> A rastreabilidade da peça será atualizada e preservada em apontamentos e romaneios de expedição.
</span>
</div>
)}
{analise.detalhes.map((det, idx) => (
<div key={idx} className="text-foreground/80">
• {det}
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Botões de Ação */}
<div className="flex items-center justify-end gap-3 pt-2">
<Button
type="button"
variant="outline"
onClick={onClose}
className="border-border text-foreground hover:bg-muted"
>
Cancelar
</Button>
<Button
type="submit"
disabled={loading || !motivo.trim() || analise.camposAlterados.length === 0}
className="bg-amber-600 hover:bg-amber-500 text-white font-semibold flex items-center gap-2 shadow-sm"
>
<Save className="w-4 h-4" />
{loading ? 'Processando Revisão...' : 'Salvar Revisão e Atualizar Peça'}
</Button>
</div>
</form>
</TabsContent>
{/* ABA 2: HISTÓRICO DE REVISÕES */}
<TabsContent value="historico" className="mt-4 space-y-3">
{loadingHistorico ? (
<div className="p-8 text-center text-muted-foreground">
Carregando histórico de revisões...
</div>
) : historico.length === 0 ? (
<div className="p-8 text-center border border-dashed border-border rounded-lg text-muted-foreground bg-card/50">
<History className="w-8 h-8 mx-auto mb-2 text-muted-foreground/60" />
<p className="font-medium">Nenhuma revisão registrada para esta peça até o momento.</p>
<p className="text-xs text-muted-foreground mt-1">
Ao realizar a primeira alteração pela aba &quot;Nova Revisão&quot;, o histórico completo será guardado aqui.
</p>
</div>
) : (
<div className="space-y-3">
{historico.map((rev) => (
<div
key={rev.id}
className="p-4 rounded-lg bg-card border border-border hover:border-primary/40 transition-colors space-y-2.5 shadow-sm"
>
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono font-bold bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-400/40">
{rev.numero_revisao}
</Badge>
<Badge
className={`text-xs ${
rev.tipo_revisao === 'critica'
? 'bg-red-500/15 text-red-600 dark:text-red-400 border border-red-500/30'
: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border border-blue-500/30'
}`}
>
{rev.tipo_revisao === 'critica' ? 'Crítica' : 'Simples'}
</Badge>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{rev.data_revisao
? format(new Date(rev.data_revisao), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })
: 'N/A'}
</span>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<User className="w-3.5 h-3.5" />
<span>{rev.usuario_nome || rev.usuario_email || 'Usuário'}</span>
</div>
</div>
{/* Motivo */}
<div className="text-sm font-medium text-foreground">
<strong>Motivo:</strong> {rev.descricao_motivo}
</div>
{rev.observacoes && (
<div className="text-xs text-muted-foreground italic">
<strong>Obs:</strong> {rev.observacoes}
</div>
)}
{/* Comparativo de Campos Alterados */}
{rev.campos_alterados && rev.campos_alterados.length > 0 && (
<div className="p-2.5 rounded bg-muted/60 border border-border text-xs space-y-1">
<div className="font-semibold text-muted-foreground text-[11px] uppercase tracking-wider mb-1">
Modificações Realizadas:
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
{rev.campos_alterados.map((campo) => {
const ant = rev.dados_anteriores?.[campo];
const nov = rev.dados_novos?.[campo];
return (
<div key={campo} className="p-1.5 rounded bg-background border border-border">
<span className="text-muted-foreground font-mono text-[10px] block uppercase">
{campo}
</span>
<div className="flex items-center gap-1.5 text-xs font-mono mt-0.5">
<span className="text-rose-600 dark:text-rose-400 line-through truncate max-w-[80px]">
{String(ant ?? '-')}
</span>
<ArrowRight className="w-3 h-3 text-muted-foreground shrink-0" />
<span className="text-emerald-600 dark:text-emerald-400 font-bold truncate max-w-[80px]">
{String(nov ?? '-')}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Impacto em Apontamentos */}
{rev.impacto_processos && rev.impacto_processos.resumo && rev.impacto_processos.resumo.length > 0 && (
<div className="p-2 rounded bg-amber-500/10 border border-amber-500/20 text-[11px] text-amber-800 dark:text-amber-300 space-y-0.5">
<div className="font-bold flex items-center gap-1 text-amber-600 dark:text-amber-400">
<AlertTriangle className="w-3 h-3" />
Ajustes de Produção Aplicados:
</div>
{rev.impacto_processos.resumo.map((res: string, i: number) => (
<div key={i}>• {res}</div>
))}
</div>
)}
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
@@ -14,7 +14,7 @@ export const PriorityFilter: React.FC<PriorityFilterProps> = ({ value, onChange
if (loading) {
return (
<Select disabled>
<Select disabled value={value || 'all'}>
<SelectTrigger className="w-48 priority-filter-trigger">
<SelectValue placeholder="Carregando..." />
</SelectTrigger>
@@ -23,7 +23,7 @@ export const PriorityFilter: React.FC<PriorityFilterProps> = ({ value, onChange
}
return (
<Select value={value} onValueChange={onChange}>
<Select value={value || 'all'} onValueChange={onChange}>
<SelectTrigger className="w-48 priority-filter-trigger">
<SelectValue placeholder="Filtrar por prioridade" />
</SelectTrigger>
@@ -68,12 +68,12 @@ export function TableHeaderSortable({
<SortButton field="descricao">Descrição</SortButton>
</TableHead>
<TableHead className="w-16 px-2">
<SortButton field="prioridade">Prioridade</SortButton>
<TableHead className="w-14 px-1 text-center">
<SortButton field="prioridade">Priorid.</SortButton>
</TableHead>
<TableHead className="w-24 px-2 text-center">
<SortButton field="tem_componentes">Componentes</SortButton>
<TableHead className="w-14 px-1 text-center">
<SortButton field="tem_componentes">Comp.</SortButton>
</TableHead>
<TableHead className="w-12 px-2 text-right">
+40 -7
View File
@@ -14,7 +14,8 @@ import {
Layers,
Loader2,
ExternalLink,
Minus
Minus,
FileText
} from 'lucide-react';
import { Peca } from '@/hooks/usePecas';
import { useComponentesPeca } from '@/hooks/useComponentesPeca';
@@ -28,6 +29,7 @@ interface TableRowProps {
onOpenComponentPopup: () => void;
onEdit?: () => void;
onDelete?: () => void;
onOpenRevisao?: () => void;
}
export function TableRow({
@@ -36,13 +38,15 @@ export function TableRow({
onSelect,
onOpenComponentPopup,
onEdit,
onDelete
onDelete,
onOpenRevisao
}: TableRowProps) {
const [isExpanded, setIsExpanded] = useState(false);
const { labels } = useAppLabels();
const { componentes, loading } = useComponentesPeca(isExpanded ? peca.id : '');
const hasSubComponents = peca.tem_componentes;
const hasRevisao = Boolean(peca.tem_revisao || (peca.qtd_revisoes && peca.qtd_revisoes > 0));
const toggleExpand = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -88,9 +92,26 @@ export function TableRow({
{peca.etapa_fase}
</TableCell>
<TableCell className="w-14 px-2 text-foreground text-xs font-bold font-mono">
<TableCell className="w-14 px-2 text-xs font-bold font-mono">
<div className="flex items-center gap-1.5">
<span>{peca.marca}</span>
<span
onClick={hasRevisao && onOpenRevisao ? (e) => {
e.stopPropagation();
onOpenRevisao();
} : undefined}
className={`${
hasRevisao
? 'text-red-600 dark:text-orange-400 font-extrabold cursor-pointer hover:underline'
: 'text-foreground'
}`}
title={
hasRevisao
? `Peça em revisão (${peca.revisao_atual || 'Rev'}). Clique para consultar.`
: undefined
}
>
{peca.marca}
</span>
{hasSubComponents && (
<span
onClick={toggleExpand}
@@ -107,11 +128,11 @@ export function TableRow({
{peca.descricao}
</TableCell>
<TableCell className="w-16 px-2">
<PriorityBadge prioridade={peca.prioridade as any} />
<TableCell className="w-14 px-1 text-center">
<PriorityBadge prioridade={peca.prioridade as any} size="sm" />
</TableCell>
<TableCell className="w-24 px-2 text-center">
<TableCell className="w-14 px-1 text-center">
<div className="flex justify-center items-center">
{!peca.tem_componentes ? (
<div
@@ -168,6 +189,18 @@ export function TableRow({
<Package className="h-3.5 w-3.5" />
</Button>
{onOpenRevisao && (
<Button
variant="ghost"
size="sm"
onClick={onOpenRevisao}
title="Gestão e Histórico de Revisões da Peça"
className="h-6 w-6 p-0 text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 hover:bg-amber-500/10"
>
<FileText className="h-3 w-3" />
</Button>
)}
{onEdit && (
<Button
variant="ghost"
+10 -8
View File
@@ -27,12 +27,6 @@ export const AppSidebarMenuItem: React.FC<SidebarMenuItemProps> = ({
onMenuItemClick,
getIconProps
}) => {
const canAccess = canAccessItem(item.key);
if (!canAccess && !isAdmin) {
return null; // Don't show item if no access
}
// Filtrar subitens permitidos para o usuário logado
const subItemsPermitidos = (item.subItems || []).filter((subItem) => {
if (subItem.url === "/admin/theme-customization" && !isAdmin) {
@@ -42,8 +36,16 @@ export const AppSidebarMenuItem: React.FC<SidebarMenuItemProps> = ({
});
// Se o item for apenas um agrupador de subitens e todos estiverem bloqueados, não renderizar o agrupador
if (item.subItems && item.subItems.length > 0 && subItemsPermitidos.length === 0 && !isAdmin) {
return null;
if (item.subItems && item.subItems.length > 0) {
if (!isAdmin && subItemsPermitidos.length === 0) {
return null;
}
} else {
// Se for item direto sem subitens
const canAccess = canAccessItem(item.key);
if (!canAccess && !isAdmin) {
return null;
}
}
return (
+8 -1
View File
@@ -18,7 +18,8 @@ import {
Settings,
Shield,
UserCog,
Cog
Cog,
ScanSearch
} from "lucide-react";
import { MenuItem, MenuGroup } from './types';
@@ -156,6 +157,12 @@ export const menuGroups: MenuGroup[] = [
url: "/visualizador-3d",
icon: Box,
},
{
key: "analisador-3d",
title: "Analisador 3D",
url: "/analisador-3d",
icon: ScanSearch,
},
{
key: "painel-industrial",
title: "Painel Industrial",
+11 -2
View File
@@ -202,6 +202,8 @@ const Sidebar = React.forwardRef<
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
top: "var(--hub-banner-height, 0px)",
height: "calc(100vh - var(--hub-banner-height, 0px))",
} as React.CSSProperties
}
side={side}
@@ -224,17 +226,20 @@ const Sidebar = React.forwardRef<
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
"duration-200 relative w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
style={{
height: "calc(100svh - var(--hub-banner-height, 0px))",
}}
/>
<div
className={cn(
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
"duration-200 fixed bottom-0 z-10 hidden w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@@ -244,6 +249,10 @@ const Sidebar = React.forwardRef<
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
style={{
top: "var(--hub-banner-height, 0px)",
height: "calc(100svh - var(--hub-banner-height, 0px))",
}}
{...props}
>
<div
@@ -0,0 +1,137 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Sparkles, Save, CheckCircle2 } from 'lucide-react';
import { SMART_SUBMODULES } from '@/types/smartPermissions';
interface CriarSmartTemplateModalProps {
isOpen: boolean;
onClose: () => void;
currentPermissions: Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>;
onSalvarTemplate: (
nome: string,
descricao: string,
permissions: Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>
) => boolean;
}
export const CriarSmartTemplateModal: React.FC<CriarSmartTemplateModalProps> = ({
isOpen,
onClose,
currentPermissions,
onSalvarTemplate,
}) => {
const [nome, setNome] = useState('');
const [descricao, setDescricao] = useState('');
const handleSalvar = (e: React.FormEvent) => {
e.preventDefault();
const ok = onSalvarTemplate(nome, descricao, currentPermissions);
if (ok) {
setNome('');
setDescricao('');
onClose();
}
};
// Contagem de permissões ativas
const totalVisualizar = Object.values(currentPermissions).filter((p) => p.can_view).length;
const totalEditar = Object.values(currentPermissions).filter((p) => p.can_edit).length;
const totalExcluir = Object.values(currentPermissions).filter((p) => p.can_delete).length;
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md bg-card border-border shadow-2xl rounded-2xl p-6">
<DialogHeader className="space-y-1.5">
<div className="flex items-center gap-2 text-emerald-500">
<div className="p-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<Sparkles className="h-5 w-5" />
</div>
<DialogTitle className="text-base font-bold text-foreground">
Criar Novo Template do Modo Smart
</DialogTitle>
</div>
<DialogDescription className="text-xs text-muted-foreground">
Salve a configuração atual de permissões como um template reutilizável para aplicar em outros operadores com 1 clique.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSalvar} className="space-y-4 py-2">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-slate-800 dark:text-slate-200">
Nome do Template <span className="text-rose-500">*</span>
</Label>
<Input
placeholder="Ex: Soldador Nível 2, Inspetor de Pintura, Montador..."
value={nome}
onChange={(e) => setNome(e.target.value)}
required
className="h-9 text-xs bg-background border-slate-300 dark:border-border text-foreground font-medium"
autoFocus
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-slate-800 dark:text-slate-200">
Descrição Curta (Opcional)
</Label>
<Input
placeholder="Ex: Permissões recomendadas para operadores da bancada de solda"
value={descricao}
onChange={(e) => setDescricao(e.target.value)}
className="h-9 text-xs bg-background border-slate-300 dark:border-border text-foreground font-medium"
/>
</div>
{/* RESUMO DAS PERMISSÕES CAPTURADAS */}
<div className="p-3 rounded-xl border border-border bg-muted/40 space-y-2">
<span className="text-[11px] font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider block">
Permissões capturadas da tela:
</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-sky-500/10 text-sky-700 dark:text-sky-400 border-sky-500/30 text-[10px]">
{totalVisualizar} Visualizar
</Badge>
<Badge variant="outline" className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/30 text-[10px]">
{totalEditar} Apontar/Editar
</Badge>
<Badge variant="outline" className="bg-rose-500/10 text-rose-700 dark:text-rose-400 border-rose-500/30 text-[10px]">
{totalExcluir} Excluir
</Badge>
</div>
</div>
<DialogFooter className="pt-2 flex items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={onClose}
className="text-xs h-8"
>
Cancelar
</Button>
<Button
type="submit"
size="sm"
className="text-xs h-8 bg-emerald-600 hover:bg-emerald-500 text-white font-semibold flex items-center gap-1.5 shadow-sm"
>
<Save className="h-3.5 w-3.5" />
Salvar Template
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -2,11 +2,13 @@ import React, { useState } from 'react';
import { useMatrizAcessos, UsuarioMatriz } from '@/hooks/useMatrizAcessos';
import { MatrizGrid } from './MatrizGrid';
import { MatrizTemplatesModal } from './MatrizTemplatesModal';
import { MatrizSmartAvancadoModal } from './MatrizSmartAvancadoModal';
import { MatrizGrupoAvancadoModal } from './MatrizGrupoAvancadoModal';
import { SimuladorVisaoBar } from './SimuladorVisaoBar';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Search, RefreshCw, Users, Shield, Factory, Sparkles } from 'lucide-react';
import { Search, RefreshCw, Users, Shield, Factory, Sparkles, SlidersHorizontal } from 'lucide-react';
interface MatrizAcessosPainelProps {
onOpenNovoUsuario?: () => void;
@@ -28,6 +30,8 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
const [busca, setBusca] = useState('');
const [selectedUserForTemplate, setSelectedUserForTemplate] = useState<UsuarioMatriz | null>(null);
const [smartAvancadoOpen, setSmartAvancadoOpen] = useState(false);
const [grupoAvancadoKey, setGrupoAvancadoKey] = useState<string | null>(null);
// Filtragem por nome, email ou cargo
const usuariosFiltrados = usuarios.filter((u) => {
@@ -105,6 +109,16 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
</div>
<div className="flex items-center gap-2 w-full sm:w-auto justify-end">
<Button
size="sm"
variant="outline"
onClick={() => setSmartAvancadoOpen(true)}
className="text-xs h-9 border-emerald-500/30 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 font-semibold"
>
<SlidersHorizontal className="h-3.5 w-3.5 mr-1.5" />
Avançado Modo Smart
</Button>
<Button
size="sm"
variant="outline"
@@ -139,6 +153,8 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
grupos={gruposModulos}
onToggleNivel={toggleNivel}
onOpenTemplates={(user) => setSelectedUserForTemplate(user)}
onOpenSmartAvancado={() => setSmartAvancadoOpen(true)}
onOpenGrupoAvancado={(key) => setGrupoAvancadoKey(key)}
/>
)}
@@ -149,6 +165,19 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
onClose={() => setSelectedUserForTemplate(null)}
onSelectTemplate={aplicarTemplate}
/>
{/* 6. MODAL DE GESTÃO AVANÇADA MODO SMART */}
<MatrizSmartAvancadoModal
isOpen={smartAvancadoOpen}
onClose={() => setSmartAvancadoOpen(false)}
/>
{/* 7. MODAL DE GESTÃO AVANÇADA POR CATEGORIA / GRUPO */}
<MatrizGrupoAvancadoModal
grupoKey={grupoAvancadoKey}
isOpen={Boolean(grupoAvancadoKey)}
onClose={() => setGrupoAvancadoKey(null)}
/>
</div>
);
};
+38 -2
View File
@@ -3,7 +3,7 @@ import { UsuarioMatriz, GrupoRecurso, NivelAcesso } from '@/hooks/useMatrizAcess
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Sparkles, Eye, Edit3, ShieldAlert } from 'lucide-react';
import { Sparkles, Eye, Edit3, ShieldAlert, SlidersHorizontal } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface MatrizGridProps {
@@ -11,6 +11,8 @@ interface MatrizGridProps {
grupos: GrupoRecurso[];
onToggleNivel: (userId: string, resourceKey: string, tipo: 'V' | 'C') => void;
onOpenTemplates: (user: UsuarioMatriz) => void;
onOpenSmartAvancado?: () => void;
onOpenGrupoAvancado?: (grupoKey: string) => void;
}
export const MatrizGrid: React.FC<MatrizGridProps> = ({
@@ -18,6 +20,8 @@ export const MatrizGrid: React.FC<MatrizGridProps> = ({
grupos,
onToggleNivel,
onOpenTemplates,
onOpenSmartAvancado,
onOpenGrupoAvancado,
}) => {
// Cores de fundo e borda para os grupos
const corGrupoHeader: Record<string, string> = {
@@ -29,6 +33,15 @@ export const MatrizGrid: React.FC<MatrizGridProps> = ({
rose: 'bg-rose-500/10 text-rose-400 border-rose-500/30',
};
const corBotaoAvancado: Record<string, string> = {
emerald: 'bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 border border-emerald-500/40',
sky: 'bg-sky-500/20 hover:bg-sky-500/30 text-sky-300 border border-sky-500/40',
indigo: 'bg-indigo-500/20 hover:bg-indigo-500/30 text-indigo-300 border border-indigo-500/40',
amber: 'bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-500/40',
teal: 'bg-teal-500/20 hover:bg-teal-500/30 text-teal-300 border border-teal-500/40',
rose: 'bg-rose-500/20 hover:bg-rose-500/30 text-rose-300 border border-rose-500/40',
};
return (
<div className="rounded-2xl border border-border bg-card overflow-hidden shadow-sm">
<div className="overflow-x-auto max-w-full">
@@ -48,8 +61,31 @@ export const MatrizGrid: React.FC<MatrizGridProps> = ({
colSpan={grupo.recursos.length}
className={`p-2.5 text-center border-r border-border/40 text-xs font-bold ${corGrupoHeader[grupo.cor]}`}
>
<div className="flex items-center justify-center gap-1.5">
<div className="flex items-center justify-center gap-2 flex-wrap">
<span>{grupo.titulo}</span>
{grupo.key === 'smart' && onOpenSmartAvancado ? (
<Button
size="sm"
variant="secondary"
onClick={onOpenSmartAvancado}
className="h-6 px-2 py-0 text-[10px] bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 border border-emerald-500/40 rounded-md font-semibold flex items-center gap-1 shadow-sm transition-transform active:scale-95"
title="Abrir Gestão Avançada de Privilégios e Notificações do Modo Smart"
>
<SlidersHorizontal className="h-3 w-3" />
Avançado
</Button>
) : onOpenGrupoAvancado ? (
<Button
size="sm"
variant="secondary"
onClick={() => onOpenGrupoAvancado(grupo.key)}
className={`h-6 px-2 py-0 text-[10px] ${corBotaoAvancado[grupo.cor] || 'bg-primary/20 text-primary border-primary/40'} rounded-md font-semibold flex items-center gap-1 shadow-sm transition-transform active:scale-95`}
title={`Abrir Regras & Governança Avançada de ${grupo.titulo}`}
>
<SlidersHorizontal className="h-3 w-3" />
Avançado
</Button>
) : null}
</div>
<span className="text-[10px] font-normal opacity-80 block truncate">
{grupo.descricao}
@@ -0,0 +1,409 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
SlidersHorizontal,
Search,
CheckCircle2,
Save,
RotateCcw,
Shield,
Factory,
Box,
Truck,
HardHat,
Sparkles,
Lock,
ArrowUpDown,
Layers,
AlertTriangle,
Download,
Network,
UploadCloud,
Compass,
Barcode,
PackageCheck,
CheckSquare,
Activity,
Settings,
Crown,
Wrench,
Calendar,
Trash2,
FileSpreadsheet,
PlusCircle,
PlusSquare,
Edit3,
FileText,
Printer,
FileInput,
ShoppingCart,
CheckCheck,
AlertCircle,
Settings2,
Users,
ShieldCheck,
KeyRound,
} from 'lucide-react';
import { useGrupoAvancadoPermissions } from '@/hooks/useGrupoAvancadoPermissions';
interface MatrizGrupoAvancadoModalProps {
grupoKey: string | null;
isOpen: boolean;
onClose: () => void;
}
export const MatrizGrupoAvancadoModal: React.FC<MatrizGrupoAvancadoModalProps> = ({
grupoKey,
isOpen,
onClose,
}) => {
const {
config,
loading,
saving,
users,
selectedUserId,
setSelectedUserId,
selectedUser,
toggleRegra,
aplicarTemplate,
salvarConfiguracaoUsuario,
recarregar,
} = useGrupoAvancadoPermissions(isOpen ? grupoKey : null);
const [busca, setBusca] = useState('');
if (!config) return null;
const usersFiltrados = users.filter((u) => {
const termo = busca.toLowerCase();
return (
u.fullName.toLowerCase().includes(termo) ||
u.email.toLowerCase().includes(termo) ||
u.functionName.toLowerCase().includes(termo)
);
});
const corBadge: Record<string, string> = {
sky: 'bg-sky-500/15 text-sky-400 border-sky-500/40',
indigo: 'bg-indigo-500/15 text-indigo-400 border-indigo-500/40',
amber: 'bg-amber-500/15 text-amber-400 border-amber-500/40',
teal: 'bg-teal-500/15 text-teal-400 border-teal-500/40',
rose: 'bg-rose-500/15 text-rose-400 border-rose-500/40',
};
const corFundoIcone: Record<string, string> = {
sky: 'bg-sky-500/15 text-sky-400 border-sky-500/30',
indigo: 'bg-indigo-500/15 text-indigo-400 border-indigo-500/30',
amber: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
teal: 'bg-teal-500/15 text-teal-400 border-teal-500/30',
rose: 'bg-rose-500/15 text-rose-400 border-rose-500/30',
};
const getIconComponent = (icone: string) => {
switch (icone) {
case 'Calendar':
return <Calendar className="h-4 w-4 text-sky-400" />;
case 'Lock':
return <Lock className="h-4 w-4 text-rose-400" />;
case 'ArrowUpDown':
return <ArrowUpDown className="h-4 w-4 text-amber-400" />;
case 'Layers':
return <Layers className="h-4 w-4 text-indigo-400" />;
case 'Trash2':
return <Trash2 className="h-4 w-4 text-rose-400" />;
case 'FileSpreadsheet':
return <FileSpreadsheet className="h-4 w-4 text-emerald-400" />;
case 'PlusCircle':
return <PlusCircle className="h-4 w-4 text-emerald-400" />;
case 'PlusSquare':
return <PlusSquare className="h-4 w-4 text-amber-400" />;
case 'Edit3':
return <Edit3 className="h-4 w-4 text-sky-400" />;
case 'UploadCloud':
return <UploadCloud className="h-4 w-4 text-teal-400" />;
case 'Network':
return <Network className="h-4 w-4 text-indigo-400" />;
case 'Download':
return <Download className="h-4 w-4 text-sky-400" />;
case 'FileText':
return <FileText className="h-4 w-4 text-slate-300" />;
case 'PackageCheck':
return <PackageCheck className="h-4 w-4 text-teal-400" />;
case 'CheckSquare':
return <CheckSquare className="h-4 w-4 text-emerald-400" />;
case 'RotateCcw':
return <RotateCcw className="h-4 w-4 text-amber-400" />;
case 'Printer':
return <Printer className="h-4 w-4 text-indigo-400" />;
case 'SlidersHorizontal':
return <SlidersHorizontal className="h-4 w-4 text-cyan-400" />;
case 'FileInput':
return <FileInput className="h-4 w-4 text-emerald-400" />;
case 'ShoppingCart':
return <ShoppingCart className="h-4 w-4 text-orange-400" />;
case 'CheckCheck':
return <CheckCheck className="h-4 w-4 text-teal-400" />;
case 'AlertCircle':
return <AlertCircle className="h-4 w-4 text-amber-400" />;
case 'Settings2':
return <Settings2 className="h-4 w-4 text-indigo-400" />;
case 'Users':
return <Users className="h-4 w-4 text-sky-400" />;
case 'ShieldCheck':
return <ShieldCheck className="h-4 w-4 text-emerald-400" />;
case 'KeyRound':
return <KeyRound className="h-4 w-4 text-amber-400" />;
case 'Activity':
return <Activity className="h-4 w-4 text-rose-400" />;
case 'Settings':
return <Settings className="h-4 w-4 text-slate-400" />;
case 'Crown':
return <Crown className="h-3.5 w-3.5 text-amber-400" />;
case 'Shield':
return <Shield className="h-3.5 w-3.5 text-sky-400" />;
case 'Wrench':
return <Wrench className="h-3.5 w-3.5 text-emerald-400" />;
case 'Compass':
return <Compass className="h-3.5 w-3.5 text-cyan-400" />;
case 'Barcode':
return <Barcode className="h-3.5 w-3.5 text-amber-400" />;
case 'Box':
return <Box className="h-3.5 w-3.5 text-indigo-400" />;
default:
return <Sparkles className="h-4 w-4 text-amber-400" />;
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-5xl w-[95vw] max-h-[92vh] h-[850px] p-0 flex flex-col bg-card border-border shadow-2xl rounded-2xl overflow-hidden">
{/* CABEÇALHO DO MODAL */}
<div className="px-6 py-4 border-b border-border bg-muted/40 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-xl border ${corFundoIcone[config.cor]}`}>
<SlidersHorizontal className="h-5 w-5" />
</div>
<div>
<DialogTitle className="text-base sm:text-lg font-bold flex items-center gap-2 text-foreground">
Regras & Governança Avançada
<Badge variant="outline" className={`text-[10px] uppercase tracking-wider font-bold ${corBadge[config.cor]}`}>
{config.titulo}
</Badge>
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
{config.descricao}
</DialogDescription>
</div>
</div>
</div>
{/* CORPO PRINCIPAL: SIDEBAR DE USUÁRIOS + ÁREA DE REGRAS */}
<div className="flex-1 flex overflow-hidden">
{/* SIDEBAR DE USUÁRIOS */}
<div className="w-[280px] sm:w-[310px] border-r border-border bg-background/60 flex flex-col shrink-0">
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Buscar colaborador..."
value={busca}
onChange={(e) => setBusca(e.target.value)}
className="pl-8 text-xs h-8 bg-card border-slate-300 dark:border-border text-foreground font-medium"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border/40 p-1.5 space-y-0.5">
{loading ? (
<div className="py-12 text-center text-xs text-muted-foreground">
Carregando colaboradores...
</div>
) : usersFiltrados.length === 0 ? (
<div className="py-12 text-center text-xs text-muted-foreground">
Nenhum colaborador encontrado.
</div>
) : (
usersFiltrados.map((user) => {
const isSelected = user.userId === selectedUserId;
const totalAtivas = Object.values(user.regras).filter(Boolean).length;
return (
<button
key={user.userId}
onClick={() => setSelectedUserId(user.userId)}
className={`w-full text-left p-2.5 rounded-xl transition-all flex items-center gap-2.5 ${
isSelected
? `${corBadge[config.cor]} border shadow-sm`
: 'hover:bg-muted/60 text-muted-foreground hover:text-foreground'
}`}
>
<Avatar className="h-8 w-8 rounded-lg border border-border shrink-0">
<AvatarImage src={user.profileImageUrl || ''} />
<AvatarFallback className="text-[10px] font-bold bg-primary/10 text-primary">
{user.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-1">
<span className="text-xs font-semibold text-foreground truncate">
{user.fullName}
</span>
<span className="text-[10px] font-bold text-muted-foreground shrink-0">
{totalAtivas}/{config.regras.length}
</span>
</div>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span className="truncate max-w-[120px]">{user.functionName}</span>
<span>•</span>
<span className="truncate">{user.privilegeName}</span>
</div>
</div>
</button>
);
})
)}
</div>
</div>
{/* ÁREA DE CONFIGURAÇÃO DO USUÁRIO */}
<div className="flex-1 flex flex-col bg-card overflow-hidden">
{selectedUser ? (
<div className="flex-1 flex flex-col overflow-hidden">
{/* CABEÇALHO DO USUÁRIO SELECIONADO */}
<div className="p-4 border-b border-border/60 bg-muted/20 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10 rounded-xl border border-border">
<AvatarImage src={selectedUser.profileImageUrl || ''} />
<AvatarFallback className="text-xs font-bold bg-primary/10 text-primary">
{selectedUser.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<h3 className="text-sm font-bold text-foreground">
{selectedUser.fullName}
</h3>
<p className="text-xs text-muted-foreground flex items-center gap-2">
<span>{selectedUser.email}</span>
<span>•</span>
<Badge variant="secondary" className="text-[10px] py-0 h-4">
{selectedUser.functionName}
</Badge>
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={salvarConfiguracaoUsuario}
disabled={saving}
className="h-8 text-xs font-semibold bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1.5 shadow-sm"
>
<Save className="h-3.5 w-3.5" />
{saving ? 'Gravando...' : 'Salvar Regras'}
</Button>
</div>
</div>
{/* TEMPLATES RÁPIDOS */}
<div className="px-6 py-3 border-b border-border/40 bg-muted/10">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] font-semibold text-muted-foreground mr-1 flex items-center gap-1">
<Sparkles className="h-3.5 w-3.5 text-amber-400" />
Presets Rápidos:
</span>
{config.templates.map((tpl) => (
<Button
key={tpl.id}
size="sm"
variant="outline"
onClick={() => aplicarTemplate(tpl.id)}
className="h-7 text-[11px] px-2.5 py-0 rounded-lg hover:border-primary/50 flex items-center gap-1.5 bg-background/80"
title={tpl.descricao}
>
{getIconComponent(tpl.icone)}
{tpl.nome}
</Button>
))}
</div>
</div>
{/* LISTAGEM DE REGRAS GRANULARES */}
<div className="flex-1 overflow-y-auto p-6 space-y-3">
<div className="grid grid-cols-1 gap-3">
{config.regras.map((regra) => {
const ativo = Boolean(selectedUser.regras[regra.key]);
return (
<div
key={regra.key}
onClick={() => toggleRegra(regra.key)}
className={`p-3.5 rounded-xl border transition-all cursor-pointer flex items-center justify-between gap-4 ${
ativo
? 'bg-muted/40 border-primary/40 shadow-sm'
: 'bg-background border-border/50 opacity-70 hover:opacity-100 hover:border-border'
}`}
>
<div className="flex items-center gap-3.5 min-w-0">
<div
className={`p-2 rounded-lg border shrink-0 ${
ativo
? `${corBadge[config.cor]} border`
: 'bg-muted text-muted-foreground border-border'
}`}
>
{getIconComponent(regra.icone)}
</div>
<div className="space-y-0.5 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-foreground">
{regra.nome}
</span>
{ativo && (
<Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30 text-[9px] h-4 py-0">
LIBERADO
</Badge>
)}
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
{regra.descricao}
</p>
</div>
</div>
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Switch
checked={ativo}
onCheckedChange={() => toggleRegra(regra.key)}
/>
</div>
</div>
);
})}
</div>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-xs">
Selecione um colaborador ao lado para configurar as regras avançadas.
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,603 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
SlidersHorizontal,
Search,
CheckCircle2,
Save,
RotateCcw,
Shield,
Factory,
Eye,
Edit3,
Trash2,
Mail,
Phone,
MessageSquare,
AlertTriangle,
FileText,
Layers,
Flame,
Zap,
Box,
ExternalLink,
Truck,
Wrench,
FileSpreadsheet,
Info,
Sparkles,
Lock,
Plus,
BookmarkPlus,
X,
} from 'lucide-react';
import { useSmartPermissions } from '@/hooks/useSmartPermissions';
import { SMART_SUBMODULES, SmartCustomTemplate } from '@/types/smartPermissions';
import { CriarSmartTemplateModal } from './CriarSmartTemplateModal';
interface MatrizSmartAvancadoModalProps {
isOpen: boolean;
onClose: () => void;
}
export const MatrizSmartAvancadoModal: React.FC<MatrizSmartAvancadoModalProps> = ({
isOpen,
onClose,
}) => {
const {
loading,
saving,
users,
templates,
selectedUserId,
setSelectedUserId,
selectedUser,
togglePermission,
updateNotificationConfig,
salvarConfiguracaoUsuario,
aplicarTemplate,
criarNovoTemplate,
removerTemplateCustom,
recarregar,
} = useSmartPermissions();
const [busca, setBusca] = useState('');
const [modalCriarTemplateOpen, setModalCriarTemplateOpen] = useState(false);
const usersFiltrados = users.filter((u) => {
const termo = busca.toLowerCase();
return (
u.fullName.toLowerCase().includes(termo) ||
u.email.toLowerCase().includes(termo) ||
u.functionName.toLowerCase().includes(termo)
);
});
const getIconComponent = (icone: string) => {
switch (icone) {
case 'FileText':
return <FileText className="h-4 w-4 text-sky-500 dark:text-sky-400" />;
case 'Layers':
return <Layers className="h-4 w-4 text-indigo-500 dark:text-indigo-400" />;
case 'Flame':
return <Flame className="h-4 w-4 text-orange-500 dark:text-orange-400" />;
case 'CheckCircle2':
return <CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />;
case 'Zap':
return <Zap className="h-4 w-4 text-amber-500 dark:text-amber-400" />;
case 'Box':
return <Box className="h-4 w-4 text-cyan-600 dark:text-cyan-400" />;
case 'ExternalLink':
return <ExternalLink className="h-4 w-4 text-purple-600 dark:text-purple-400" />;
case 'Truck':
return <Truck className="h-4 w-4 text-amber-600 dark:text-amber-500" />;
case 'Wrench':
return <Wrench className="h-4 w-4 text-teal-600 dark:text-teal-400" />;
case 'FileSpreadsheet':
return <FileSpreadsheet className="h-4 w-4 text-emerald-600 dark:text-emerald-500" />;
case 'Shield':
return <Shield className="h-3.5 w-3.5 text-rose-500 dark:text-rose-400" />;
case 'Factory':
return <Factory className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />;
case 'Eye':
return <Eye className="h-3.5 w-3.5 text-sky-600 dark:text-sky-400" />;
default:
return <Sparkles className="h-3.5 w-3.5 text-amber-500 dark:text-amber-400" />;
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-6xl w-[96vw] max-h-[94vh] h-[900px] p-0 flex flex-col bg-card border-border shadow-2xl rounded-2xl overflow-hidden">
{/* CABEÇALHO DO MODAL */}
<div className="px-6 py-4 border-b border-border bg-muted/40 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
<SlidersHorizontal className="h-5 w-5" />
</div>
<div>
<DialogTitle className="text-base sm:text-lg font-bold flex items-center gap-2 text-foreground">
Gestão Avançada de Privilégios & Notificações
<Badge variant="outline" className="bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 border-emerald-500/40 text-[10px] uppercase tracking-wider font-bold">
Modo Smart
</Badge>
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
Controle granular de acesso por submódulo e canais de notificação (E-mail e WhatsApp via única).
</DialogDescription>
</div>
</div>
</div>
{/* CORPO PRINCIPAL COM SIDEBAR DE USUÁRIOS E ÁREA DE CONFIGURAÇÃO */}
<div className="flex-1 flex overflow-hidden">
{/* SIDEBAR DE USUÁRIOS */}
<div className="w-[280px] sm:w-[310px] border-r border-border bg-background/60 flex flex-col shrink-0">
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Buscar colaborador..."
value={busca}
onChange={(e) => setBusca(e.target.value)}
className="pl-8 text-xs h-8 bg-card border-slate-300 dark:border-border text-foreground font-medium"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border/40 p-1.5 space-y-0.5">
{loading ? (
<div className="py-12 text-center text-xs text-muted-foreground">
Carregando colaboradores...
</div>
) : usersFiltrados.length === 0 ? (
<div className="py-12 text-center text-xs text-muted-foreground">
Nenhum colaborador encontrado.
</div>
) : (
usersFiltrados.map((user) => {
const isSelected = user.userId === selectedUserId;
return (
<button
key={user.userId}
onClick={() => setSelectedUserId(user.userId)}
className={`w-full text-left p-2.5 rounded-xl transition-all flex items-center gap-2.5 ${
isSelected
? 'bg-emerald-500/15 border border-emerald-500/40 text-foreground shadow-sm'
: 'hover:bg-muted/60 text-muted-foreground hover:text-foreground'
}`}
>
<Avatar className="h-8 w-8 rounded-lg border border-border">
<AvatarImage src={user.profileImageUrl || ''} />
<AvatarFallback className="rounded-lg bg-primary/10 text-primary text-xs font-bold">
{user.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className={`text-xs font-bold truncate ${isSelected ? 'text-emerald-700 dark:text-emerald-400 font-extrabold' : 'text-slate-800 dark:text-foreground'}`}>
{user.fullName}
</p>
<p className="text-[10px] text-slate-500 dark:text-muted-foreground truncate">
{user.functionName}
</p>
</div>
{user.whatsappNumber && (
<div className="p-1 rounded-md bg-emerald-500/15 text-emerald-700 dark:text-emerald-400" title={`WhatsApp: ${user.whatsappNumber}`}>
<MessageSquare className="h-3 w-3" />
</div>
)}
</button>
);
})
)}
</div>
</div>
{/* ÁREA DE CONFIGURAÇÃO DO USUÁRIO SELECIONADO */}
<div className="flex-1 flex flex-col bg-card overflow-y-auto">
{selectedUser ? (
<div className="p-5 sm:p-6 space-y-6">
{/* CABEÇALHO DO USUÁRIO + PERFIL */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 p-4 rounded-2xl border border-border bg-muted/20">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12 rounded-xl border border-border shadow-sm">
<AvatarImage src={selectedUser.profileImageUrl || ''} />
<AvatarFallback className="rounded-xl bg-primary/10 text-primary text-sm font-bold">
{selectedUser.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<h3 className="text-base font-bold text-slate-900 dark:text-foreground">
{selectedUser.fullName}
</h3>
<p className="text-xs text-muted-foreground flex items-center gap-2">
<span className="text-slate-600 dark:text-slate-400">{selectedUser.email}</span>
<span>•</span>
<span className="text-emerald-600 dark:text-emerald-400 font-bold">{selectedUser.functionName}</span>
</p>
</div>
</div>
{/* BOTÃO PRINCIPAL DE SALVAR ALTERAÇÕES */}
<Button
size="sm"
onClick={() => salvarConfiguracaoUsuario(selectedUser.userId)}
disabled={saving}
className="text-xs h-9 px-4 bg-emerald-600 hover:bg-emerald-500 text-white font-bold shadow-sm flex items-center gap-1.5 transition-transform active:scale-95"
>
<Save className={`h-4 w-4 ${saving ? 'animate-spin' : ''}`} />
{saving ? 'Salvando...' : 'Salvar Alterações'}
</Button>
</div>
{/* BARRA DE TEMPLATES RÁPIDOS & CRIAR NOVO TEMPLATE */}
<div className="p-3.5 rounded-2xl border border-border bg-background/50 space-y-2.5">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">
<Sparkles className="h-3.5 w-3.5 text-amber-500" />
Templates de Acesso em 1 Clique
</div>
<Button
size="sm"
variant="outline"
onClick={() => setModalCriarTemplateOpen(true)}
className="text-xs h-7 px-2.5 border-emerald-500/40 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 font-bold flex items-center gap-1 shadow-sm"
>
<Plus className="h-3.5 w-3.5" />
Criar Novo Template
</Button>
</div>
{/* CHIPS DE TEMPLATES */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 max-w-full">
{templates.map((tpl) => (
<div
key={tpl.id}
className="group relative shrink-0 flex items-center"
>
<Button
size="sm"
variant="outline"
onClick={() => aplicarTemplate(selectedUser.userId, tpl)}
className="text-xs h-8 pl-2.5 pr-2.5 rounded-xl border-slate-300 dark:border-border hover:bg-muted/80 flex items-center gap-1.5 shadow-xs font-medium"
title={tpl.descricao}
>
{getIconComponent(tpl.icone)}
<span className="font-semibold text-slate-800 dark:text-slate-200">
{tpl.nome}
</span>
{tpl.badge && (
<Badge
variant="secondary"
className="ml-1 text-[9px] px-1 py-0 h-4 bg-muted text-muted-foreground"
>
{tpl.badge}
</Badge>
)}
</Button>
{/* Botão para deletar template customizado */}
{tpl.isCustom && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removerTemplateCustom(tpl.id);
}}
className="ml-1 p-1 text-muted-foreground hover:text-rose-500 rounded-md transition-colors"
title="Remover este template"
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</div>
{/* SEÇÃO 1: MATRIZ DE ACESSO AOS SUBMÓDULOS */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-1.5">
<Lock className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
1. Permissões Granulares nos Submódulos (Modo Smart)
</h4>
<span className="text-[11px] text-slate-500 dark:text-muted-foreground font-medium">
Controle de Visualização, Edição/Apontamento e Exclusão
</span>
</div>
<div className="rounded-2xl border border-border bg-background/50 overflow-hidden shadow-xs">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-border/80 bg-muted/40 text-[11px] text-slate-600 dark:text-muted-foreground font-bold">
<th className="p-3">Submódulo / Recurso</th>
<th className="p-3 text-center w-[110px]">
<span className="inline-flex items-center gap-1 text-sky-600 dark:text-sky-400 font-bold">
<Eye className="h-3.5 w-3.5" />
Visualizar
</span>
</th>
<th className="p-3 text-center w-[130px]">
<span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400 font-bold">
<Edit3 className="h-3.5 w-3.5" />
Apontar / Editar
</span>
</th>
<th className="p-3 text-center w-[110px]">
<span className="inline-flex items-center gap-1 text-rose-600 dark:text-rose-400 font-bold">
<Trash2 className="h-3.5 w-3.5" />
Excluir
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40 text-xs">
{SMART_SUBMODULES.map((sub) => {
const perm = selectedUser.permissions[sub.key] || {
can_view: false,
can_edit: false,
can_delete: false,
};
return (
<tr
key={sub.key}
className="hover:bg-muted/30 transition-colors"
>
<td className="p-3">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-card border border-border/60 shadow-xs">
{getIconComponent(sub.icone)}
</div>
<div>
<p className="font-bold text-slate-900 dark:text-foreground text-xs">
{sub.nome}
</p>
<p className="text-[10px] text-slate-500 dark:text-muted-foreground font-medium">
{sub.descricao}
</p>
</div>
</div>
</td>
{/* VISUALIZAR */}
<td className="p-3 text-center">
<div className="flex justify-center">
<Switch
checked={perm.can_view}
onCheckedChange={() =>
togglePermission(selectedUser.userId, sub.key, 'can_view')
}
/>
</div>
</td>
{/* EDITAR / APONTAR */}
<td className="p-3 text-center">
<div className="flex justify-center">
<Switch
checked={perm.can_edit}
onCheckedChange={() =>
togglePermission(selectedUser.userId, sub.key, 'can_edit')
}
/>
</div>
</td>
{/* EXCLUIR */}
<td className="p-3 text-center">
<div className="flex justify-center">
<Switch
checked={perm.can_delete}
onCheckedChange={() =>
togglePermission(selectedUser.userId, sub.key, 'can_delete')
}
/>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* SEÇÃO 2: NOTIFICAÇÕES & CANAL WHATSAPP VIA ÚNICA */}
<div className="space-y-4 pt-2">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-1.5">
<MessageSquare className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
2. Notificações do Usuário (E-mail & WhatsApp BrainSteel)
</h4>
{/* BANNER INFORMATIVO: ALTO CONTRASTE NO MODO CLARO E ESCURO */}
<div className="p-4 rounded-2xl border border-amber-400/80 dark:border-amber-500/30 bg-amber-100/80 dark:bg-amber-500/10 text-amber-950 dark:text-amber-200 flex items-start gap-3 shadow-xs">
<AlertTriangle className="h-5 w-5 text-amber-700 dark:text-amber-400 shrink-0 mt-0.5" />
<div className="text-xs space-y-1">
<p className="font-extrabold text-amber-900 dark:text-amber-300 text-xs">
Comunicação Estritamente Unidirecional (Via Única) no WhatsApp
</p>
<p className="text-amber-950 dark:text-amber-200/90 leading-relaxed text-[11px] font-medium">
O número oficial da BrainSteel é configurado para <strong>somente enviar</strong> alertas, relatórios e métricas de produção. Mensagens de resposta enviadas pelo colaborador não serão recebidas nem respondidas pelo sistema.
</p>
</div>
</div>
{/* GRID DE CONFIGURAÇÕES DE CANAL */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* CANAL WHATSAPP */}
<div className="p-4 rounded-2xl border border-border bg-background/50 space-y-4 shadow-xs">
<div className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400 font-bold text-xs">
<Phone className="h-4 w-4" />
Canal WhatsApp do Colaborador
</div>
<div className="space-y-1.5">
<Label className="text-[11px] font-semibold text-slate-700 dark:text-slate-300">
Número de WhatsApp com DDD
</Label>
<Input
placeholder="Ex: 5511999998888 ou (11) 99999-8888"
value={selectedUser.whatsappNumber}
onChange={(e) =>
updateNotificationConfig(
selectedUser.userId,
'whatsappNumber',
e.target.value
)
}
className="h-8 text-xs bg-background dark:bg-card border-slate-300 dark:border-border text-foreground font-medium placeholder:text-slate-500 dark:placeholder:text-muted-foreground/80"
/>
<span className="text-[10px] text-slate-500 dark:text-muted-foreground font-medium block">
Informe o número com DDD para receber avisos automáticos.
</span>
</div>
<div className="pt-2 border-t border-border/40 space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-xs font-semibold text-slate-800 dark:text-foreground">
Alertas de Apontamentos
</Label>
<p className="text-[10px] text-slate-500 dark:text-muted-foreground font-medium">
Notificar sobre novos apontamentos ou avanços forçados
</p>
</div>
<Switch
checked={selectedUser.notifWhatsappApontamentos}
onCheckedChange={(val) =>
updateNotificationConfig(
selectedUser.userId,
'notifWhatsappApontamentos',
val
)
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-xs font-semibold text-slate-800 dark:text-foreground">
Resumo Diário de Métricas
</Label>
<p className="text-[10px] text-slate-500 dark:text-muted-foreground font-medium">
Receber fechamento diário de produção (peso e peças)
</p>
</div>
<Switch
checked={selectedUser.notifWhatsappMetricas}
onCheckedChange={(val) =>
updateNotificationConfig(
selectedUser.userId,
'notifWhatsappMetricas',
val
)
}
/>
</div>
</div>
</div>
{/* CANAL E-MAIL */}
<div className="p-4 rounded-2xl border border-border bg-background/50 space-y-4 shadow-xs">
<div className="flex items-center gap-2 text-sky-600 dark:text-sky-400 font-bold text-xs">
<Mail className="h-4 w-4" />
Canal de E-mail ({selectedUser.email})
</div>
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-xs font-semibold text-slate-800 dark:text-foreground">
Alertas de Apontamentos por E-mail
</Label>
<p className="text-[10px] text-slate-500 dark:text-muted-foreground font-medium">
Receber e-mail informativo a cada lote apontado
</p>
</div>
<Switch
checked={selectedUser.notifEmailApontamentos}
onCheckedChange={(val) =>
updateNotificationConfig(
selectedUser.userId,
'notifEmailApontamentos',
val
)
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-xs font-semibold text-slate-800 dark:text-foreground">
Relatórios e Métricas por E-mail
</Label>
<p className="text-[10px] text-slate-500 dark:text-muted-foreground font-medium">
Receber resumos periódicos e PDFs automáticos
</p>
</div>
<Switch
checked={selectedUser.notifEmailMetricas}
onCheckedChange={(val) =>
updateNotificationConfig(
selectedUser.userId,
'notifEmailMetricas',
val
)
}
/>
</div>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
Selecione um colaborador na barra lateral para configurar.
</div>
)}
</div>
</div>
{/* RODAPÉ DO MODAL */}
<div className="px-6 py-3 border-t border-border bg-muted/40 flex items-center justify-between text-xs">
<span className="text-muted-foreground text-[11px]">
As alterações passam a valer imediatamente no Modo Smart após salvar.
</span>
<Button
variant="outline"
size="sm"
onClick={onClose}
className="text-xs h-8 font-medium"
>
Fechar
</Button>
</div>
</DialogContent>
{/* MODAL PARA SALVAR NOVO TEMPLATE PERSONALIZADO */}
{selectedUser && (
<CriarSmartTemplateModal
isOpen={modalCriarTemplateOpen}
onClose={() => setModalCriarTemplateOpen(false)}
currentPermissions={selectedUser.permissions}
onSalvarTemplate={criarNovoTemplate}
/>
)}
</Dialog>
);
};
@@ -0,0 +1,923 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import * as THREE from 'three';
import { OrbitControls } from 'three-stdlib';
import { LoadedIFCResult, PieceInfo } from '@/lib/ifc/ifcLoaderService';
import { DefectItem, ColorizationMode } from '@/types/analisador3d';
import { ViewCube } from './ViewCube';
import {
RotateCcw,
RotateCw,
Lock,
Unlock,
Eye,
Maximize2,
Minimize2,
Box,
Layers,
Sparkles,
Search,
Crosshair,
AlertTriangle,
CheckCircle,
HelpCircle,
Cpu,
Focus,
SunMedium,
Compass
} from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
interface AnalyzerViewer3DProps {
modelData: LoadedIFCResult | null;
defects: DefectItem[];
selectedDefect: DefectItem | null;
selectedPieceMark: string | null;
colorizationMode: ColorizationMode;
onSelectDefect?: (defect: DefectItem | null) => void;
onSelectPiece?: (pieceMark: string | null) => void;
isGhostMode?: boolean;
onToggleGhostMode?: () => void;
isFullscreen?: boolean;
onToggleFullscreen?: () => void;
theme?: 'dark' | 'light';
selectedOF?: string;
initialRotation?: { x: number; y: number; z: number } | null;
}
export const AnalyzerViewer3D: React.FC<AnalyzerViewer3DProps> = ({
modelData,
defects,
selectedDefect,
selectedPieceMark,
colorizationMode,
onSelectDefect,
onSelectPiece,
isGhostMode = false,
onToggleGhostMode,
isFullscreen = false,
onToggleFullscreen,
theme = 'dark',
selectedOF,
initialRotation,
}) => {
const { toast } = useToast();
const [rotationX, setRotationX] = useState<number>(0);
const [rotationY, setRotationY] = useState<number>(0);
const [isRotationLocked, setIsRotationLocked] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement>(null);
const canvasContainerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.Camera | null>(null);
const perspCameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const orthoCameraRef = useRef<THREE.OrthographicCamera | null>(null);
const [isOrthographic, setIsOrthographic] = useState<boolean>(false);
const controlsRef = useRef<OrbitControls | null>(null);
const modelGroupRef = useRef<THREE.Group | null>(null);
const gridRef = useRef<THREE.GridHelper | null>(null);
// Iluminação ajustável em tempo real
const ambientLightRef = useRef<THREE.AmbientLight | null>(null);
const hemiLightRef = useRef<THREE.HemisphereLight | null>(null);
const dirLight1Ref = useRef<THREE.DirectionalLight | null>(null);
const dirLight2Ref = useRef<THREE.DirectionalLight | null>(null);
const [brightness, setBrightness] = useState<number>(100);
const [wireframe, setWireframe] = useState<boolean>(false);
const [hoveredDefect, setHoveredDefect] = useState<DefectItem | null>(null);
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null);
// Mapeamentos rápidos para busca e colorização
const defectByPieceMap = useRef<Map<string, DefectItem>>(new Map());
useEffect(() => {
const map = new Map<string, DefectItem>();
defects.forEach((d) => {
map.set(d.pecaMarca.toUpperCase(), d);
if (d.pecaConectadaMarca) {
map.set(d.pecaConectadaMarca.toUpperCase(), d);
}
});
defectByPieceMap.current = map;
}, [defects]);
// 1. Inicializa o Three.js
useEffect(() => {
if (!canvasContainerRef.current) return;
const width = canvasContainerRef.current.clientWidth || 800;
const height = canvasContainerRef.current.clientHeight || 500;
const scene = new THREE.Scene();
sceneRef.current = scene;
const perspCamera = new THREE.PerspectiveCamera(45, width / height, 0.1, 2000);
perspCamera.position.set(40, 35, 50);
perspCameraRef.current = perspCamera;
const frustumSize = 80;
const aspect = width / height;
const orthoCamera = new THREE.OrthographicCamera(
(frustumSize * aspect) / -2,
(frustumSize * aspect) / 2,
frustumSize / 2,
frustumSize / -2,
0.1,
2000
);
orthoCamera.position.copy(perspCamera.position);
orthoCameraRef.current = orthoCamera;
cameraRef.current = perspCamera;
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: 'high-performance',
});
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
rendererRef.current = renderer;
canvasContainerRef.current.innerHTML = '';
canvasContainerRef.current.appendChild(renderer.domElement);
const controls = new OrbitControls(perspCamera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.maxDistance = 1500;
controls.minDistance = 0.2;
controlsRef.current = controls;
// Iluminação balanceada industrial com suporte a controle de luminosidade
const ambientLight = new THREE.AmbientLight(0xffffff, 0.85);
scene.add(ambientLight);
ambientLightRef.current = ambientLight;
// Luz hemisférica uniforme: ilumina simultaneamente de cima e baixo para eliminar sombras duras de normais invertidas
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x475569, 0.6);
scene.add(hemiLight);
hemiLightRef.current = hemiLight;
const dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight1.position.set(80, 120, 80);
scene.add(dirLight1);
dirLight1Ref.current = dirLight1;
const dirLight2 = new THREE.DirectionalLight(0x94a3b8, 0.6);
dirLight2.position.set(-80, -40, -80);
scene.add(dirLight2);
dirLight2Ref.current = dirLight2;
// Grid no piso com cores refinadas para modo claro e escuro
const isDarkMode = document.documentElement.classList.contains('dark');
const gridColor1 = isDarkMode ? 0x475569 : 0x94a3b8;
const gridColor2 = isDarkMode ? 0x1e293b : 0xe2e8f0;
const grid = new THREE.GridHelper(100, 50, gridColor1, gridColor2);
grid.position.y = -0.01;
if (grid.material && (grid.material as THREE.Material)) {
(grid.material as THREE.Material).transparent = true;
(grid.material as THREE.Material).opacity = 0.55;
}
scene.add(grid);
gridRef.current = grid;
// Observador para atualizar cores do grid automaticamente quando o usuário alternar tema
const themeObserver = new MutationObserver(() => {
const isDarkNow = document.documentElement.classList.contains('dark');
const centerC = isDarkNow ? new THREE.Color(0x475569) : new THREE.Color(0x94a3b8);
const lineC = isDarkNow ? new THREE.Color(0x1e293b) : new THREE.Color(0xe2e8f0);
if (gridRef.current) {
const colors = gridRef.current.geometry.attributes.color as THREE.BufferAttribute;
if (colors) {
const arr = colors.array as Float32Array;
for (let i = 0; i < arr.length; i += 6) {
const isCenter = i === Math.floor(arr.length / 2);
const c = isCenter ? centerC : lineC;
arr[i] = c.r;
arr[i + 1] = c.g;
arr[i + 2] = c.b;
arr[i + 3] = c.r;
arr[i + 4] = c.g;
arr[i + 5] = c.b;
}
colors.needsUpdate = true;
}
}
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
let animId: number;
const animate = () => {
animId = requestAnimationFrame(animate);
controls.update();
if (renderer && scene && cameraRef.current) {
renderer.render(scene, cameraRef.current);
}
};
animate();
const handleResize = () => {
if (!canvasContainerRef.current || !renderer) return;
const w = canvasContainerRef.current.clientWidth;
const h = canvasContainerRef.current.clientHeight;
if (w <= 0 || h <= 0) return;
const newAspect = w / h;
if (perspCameraRef.current) {
perspCameraRef.current.aspect = newAspect;
perspCameraRef.current.updateProjectionMatrix();
}
if (orthoCameraRef.current) {
const frustum = 80;
orthoCameraRef.current.left = (frustum * newAspect) / -2;
orthoCameraRef.current.right = (frustum * newAspect) / 2;
orthoCameraRef.current.top = frustum / 2;
orthoCameraRef.current.bottom = frustum / -2;
orthoCameraRef.current.updateProjectionMatrix();
}
renderer.setSize(w, h);
};
window.addEventListener('resize', handleResize);
const observer = new ResizeObserver(handleResize);
observer.observe(canvasContainerRef.current);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener('resize', handleResize);
observer.disconnect();
themeObserver.disconnect();
renderer.dispose();
controls.dispose();
};
}, [theme]);
// Efeito dinâmico de ajuste de Luminosidade / Iluminação
useEffect(() => {
const factor = brightness / 100;
if (ambientLightRef.current) {
ambientLightRef.current.intensity = 0.85 * factor;
}
if (hemiLightRef.current) {
hemiLightRef.current.intensity = 0.6 * factor;
}
if (dirLight1Ref.current) {
dirLight1Ref.current.intensity = 1.2 * Math.min(1.4, factor);
}
if (dirLight2Ref.current) {
dirLight2Ref.current.intensity = 0.6 * factor;
}
if (rendererRef.current) {
rendererRef.current.toneMappingExposure = 1.1 * (0.85 + 0.15 * factor);
}
}, [brightness]);
// Alterna entre Câmera Perspectiva e Ortográfica
const handleToggleCamera = useCallback(() => {
if (!perspCameraRef.current || !orthoCameraRef.current || !controlsRef.current) return;
const nextOrtho = !isOrthographic;
setIsOrthographic(nextOrtho);
const prevCamera = cameraRef.current!;
const nextCamera = nextOrtho ? orthoCameraRef.current : perspCameraRef.current;
nextCamera.position.copy(prevCamera.position);
nextCamera.quaternion.copy(prevCamera.quaternion);
cameraRef.current = nextCamera;
controlsRef.current.object = nextCamera;
controlsRef.current.update();
}, [isOrthographic]);
// Centraliza e enquadra perfeitamente o modelo na câmera (Fit View / Fit to Screen)
const fitModelToView = useCallback((targetObj?: THREE.Object3D) => {
const obj = targetObj || modelGroupRef.current;
if (!obj || !cameraRef.current || !controlsRef.current) return;
obj.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(obj);
if (box.isEmpty()) return;
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
if (maxDim <= 0) return;
const fov = 45 * (Math.PI / 180);
let cameraDistance = Math.abs(maxDim / (2 * Math.tan(fov / 2))) * 1.6;
cameraDistance = Math.max(cameraDistance, 5);
const camera = cameraRef.current;
if ((camera as THREE.PerspectiveCamera).isPerspectiveCamera) {
const pCam = camera as THREE.PerspectiveCamera;
pCam.near = Math.max(0.01, maxDim / 500);
pCam.far = Math.max(2000, maxDim * 20);
pCam.updateProjectionMatrix();
pCam.position.set(center.x + cameraDistance * 0.7, center.y + cameraDistance * 0.5, center.z + cameraDistance * 0.7);
pCam.lookAt(center);
} else if ((camera as THREE.OrthographicCamera).isOrthographicCamera) {
const oCam = camera as THREE.OrthographicCamera;
const aspect = oCam.right / oCam.top;
const frustum = maxDim * 1.5;
oCam.left = (frustum * aspect) / -2;
oCam.right = (frustum * aspect) / 2;
oCam.top = frustum / 2;
oCam.bottom = frustum / -2;
oCam.near = Math.max(0.01, maxDim / 500);
oCam.far = Math.max(2000, maxDim * 20);
oCam.position.set(center.x + maxDim, center.y + maxDim * 0.8, center.z + maxDim);
oCam.lookAt(center);
oCam.updateProjectionMatrix();
}
controlsRef.current.target.copy(center);
controlsRef.current.maxDistance = Math.max(1500, maxDim * 10);
controlsRef.current.update();
if (gridRef.current) {
gridRef.current.position.set(center.x, box.min.y, center.z);
}
}, []);
// 2. Carrega o modelo na cena diretamente sem deep clone circular e aplica rotação
useEffect(() => {
const scene = sceneRef.current;
if (!scene) return;
if (modelGroupRef.current) {
scene.remove(modelGroupRef.current);
modelGroupRef.current = null;
}
if (!modelData || !modelData.sceneGroup) return;
// Usa diretamente a sceneGroup da carga sem clone para evitar erro de circular JSON
const group = modelData.sceneGroup;
modelGroupRef.current = group;
scene.add(group);
// Recupera rotação memorizada para esta OF (localStorage ou banco de dados)
const storageKey = selectedOF ? `tracksteel_model_rot_${selectedOF}` : null;
let savedX = 0;
let savedY = 0;
let isLocked = false;
if (storageKey) {
const stored = localStorage.getItem(storageKey);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (typeof parsed.x === 'number') savedX = parsed.x;
if (typeof parsed.y === 'number') savedY = parsed.y;
isLocked = true;
} catch (e) {}
}
}
if (!isLocked && initialRotation) {
if (typeof initialRotation.x === 'number') savedX = initialRotation.x;
if (typeof initialRotation.y === 'number') savedY = initialRotation.y;
isLocked = true;
}
setRotationX(savedX);
setRotationY(savedY);
setIsRotationLocked(isLocked);
// Aplica rotação inicial no grupo do modelo
group.rotation.set(
THREE.MathUtils.degToRad(savedX),
THREE.MathUtils.degToRad(savedY),
0
);
group.updateMatrixWorld(true);
fitModelToView(group);
}, [modelData, selectedOF, initialRotation, fitModelToView]);
// Gira o modelo em 90 graus no eixo X (tombamento vertical para alinhar Z-Up do CAD com Y-Up)
const handleRotate90X = useCallback(() => {
if (!modelGroupRef.current) return;
const nextX = (rotationX + 90) % 360;
setRotationX(nextX);
setIsRotationLocked(false);
modelGroupRef.current.rotation.x = THREE.MathUtils.degToRad(nextX);
modelGroupRef.current.rotation.y = THREE.MathUtils.degToRad(rotationY);
modelGroupRef.current.updateMatrixWorld(true);
fitModelToView(modelGroupRef.current);
}, [rotationX, rotationY, fitModelToView]);
// Alterna o bloqueio/gravação da posição ideal de visualização no banco e no navegador
const handleToggleLockRotation = useCallback(async () => {
if (!selectedOF) {
toast({
title: 'Aviso',
description: 'Selecione uma OF para memorizar a rotação do modelo.',
});
return;
}
if (!isRotationLocked) {
// Trava e grava a rotação
setIsRotationLocked(true);
const rotData = { x: rotationX, y: rotationY, z: 0 };
// 1. Grava no localStorage para acesso instantâneo
localStorage.setItem(`tracksteel_model_rot_${selectedOF}`, JSON.stringify(rotData));
// 2. Persiste na nuvem/Supabase na tabela ordens_fabricacao
try {
await supabase
.from('ordens_fabricacao' as any)
.update({ model_3d_rotation: rotData })
.eq('num_of', selectedOF);
} catch (err) {
console.error('Erro ao salvar rotação no banco:', err);
}
toast({
title: 'Posição Travada e Salva',
description: `Posição ideal de visualização (${rotationX}°) gravada com sucesso para a OF ${selectedOF}.`,
});
} else {
// Destrava para permitir novo ajuste
setIsRotationLocked(false);
toast({
title: 'Posição Destravada',
description: 'Clique em "Girar 90°" para ajustar e trave no cadeado novamente quando estiver na posição correta.',
});
}
}, [selectedOF, isRotationLocked, rotationX, rotationY, toast]);
// 3. Aplica Colorização Dinâmica com base no modo selecionado e na peça/defeito ativo
useEffect(() => {
if (!modelGroupRef.current) return;
const group = modelGroupRef.current;
const targetMark = selectedPieceMark?.toUpperCase() || selectedDefect?.pecaMarca.toUpperCase();
group.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
// Tenta achar a marca associada na userData
const pieceInfo = obj.userData?.pieceInfo as PieceInfo | undefined;
const mark = (pieceInfo?.pieceMark || obj.userData?.pieceMark || '').toUpperCase();
const defect = defectByPieceMap.current.get(mark);
const isSelected = targetMark && (mark === targetMark || (defect && defect.pecaConectadaMarca?.toUpperCase() === targetMark));
let color = '#94a3b8'; // neutro default
let emissiveColor = '#000000';
let emissiveIntensity = 0;
if (colorizationMode === 'status_defeito') {
if (defect) {
if (defect.gravidade === 'critico') color = '#ef4444'; // vermelho
else if (defect.gravidade === 'alerta') color = '#f59e0b'; // laranja/amarelo
else color = '#3b82f6'; // azul
} else {
color = '#10b981'; // verde conforme
}
} else if (colorizationMode === 'diff_revisao') {
if (defect?.tipo === 'peca_nova') color = '#3b82f6'; // azul novo
else if (defect?.tipo === 'peca_excluida') color = '#ef4444'; // vermelho excluído
else if (defect?.tipo === 'peca_modificada' || defect?.tipo === 'deslocamento_furo' || defect?.tipo === 'furo_extra_revisao') {
color = '#f59e0b'; // amarelo modificado
} else {
color = '#10b981'; // verde inalterado
}
} else if (colorizationMode === 'risco_fabril') {
if (defect) {
if (defect.riscoFabril === 'critico' || defect.riscoFabril === 'alto') color = '#dc2626';
else if (defect.riscoFabril === 'medio') color = '#f59e0b';
else color = '#10b981';
} else {
color = '#64748b';
}
} else if (colorizationMode === 'processo') {
color = defect?.processoCor || '#3b82f6';
}
// Se estiver em modo fantasma e houver seleção: atenua peças não selecionadas
let opacity = 1.0;
let transparent = false;
if (isGhostMode && targetMark) {
if (!isSelected) {
opacity = 0.12;
transparent = true;
} else {
opacity = 1.0;
emissiveColor = color;
emissiveIntensity = 0.5;
}
} else if (isSelected) {
emissiveColor = '#ffffff';
emissiveIntensity = 0.35;
}
// Aramado Arquitetural Limpo (EdgesGeometry - Estilo SteelXR / Tekla sem diagonais internas)
if (wireframe) {
// Cria as arestas de contorno se ainda não existirem para esta malha
if (!obj.userData.edgesLine) {
const edgesGeo = new THREE.EdgesGeometry(obj.geometry, 24);
const lineMat = new THREE.LineBasicMaterial({
color: new THREE.Color(color),
transparent: true,
opacity: isGhostMode && targetMark && !isSelected ? 0.15 : 0.95,
});
const edgesLine = new THREE.LineSegments(edgesGeo, lineMat);
edgesLine.name = 'edgesLine';
obj.userData.edgesLine = edgesLine;
obj.add(edgesLine);
} else {
// Atualiza a cor e opacidade das arestas existentes
const lineMat = obj.userData.edgesLine.material as THREE.LineBasicMaterial;
if (lineMat) {
lineMat.color.set(color);
lineMat.opacity = isGhostMode && targetMark && !isSelected ? 0.15 : 0.95;
}
}
obj.userData.edgesLine.visible = true;
// Oculta a malha sólida para exibir apenas o aramado limpo
obj.material = new THREE.MeshBasicMaterial({
visible: false,
wireframe: false,
});
} else {
// Modo Sólido: desativa as linhas de contorno e restaura o sólido PBR
if (obj.userData.edgesLine) {
obj.userData.edgesLine.visible = false;
}
obj.material = new THREE.MeshStandardMaterial({
color: new THREE.Color(color),
emissive: new THREE.Color(emissiveColor),
emissiveIntensity,
roughness: 0.35,
metalness: 0.2,
wireframe: false,
transparent,
opacity,
side: THREE.DoubleSide,
});
}
}
});
}, [colorizationMode, selectedDefect, selectedPieceMark, isGhostMode, wireframe, modelData]);
// 4. Foco na Câmera ao selecionar defeito/peça
const focusOnTarget = useCallback((coords?: { x: number; y: number; z: number }) => {
if (!coords || !cameraRef.current || !controlsRef.current) return;
const target = new THREE.Vector3(coords.x, coords.y, coords.z);
controlsRef.current.target.copy(target);
cameraRef.current.position.set(target.x + 3, target.y + 3, target.z + 4);
controlsRef.current.update();
}, []);
useEffect(() => {
if (selectedDefect && selectedDefect.coordenadas3D) {
focusOnTarget(selectedDefect.coordenadas3D);
}
}, [selectedDefect, focusOnTarget]);
// 5. Clique no 3D (Raycasting)
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!canvasContainerRef.current || !cameraRef.current || !modelGroupRef.current) return;
const rect = canvasContainerRef.current.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(x, y), cameraRef.current);
const intersects = raycaster.intersectObjects(modelGroupRef.current.children, true);
if (intersects.length > 0) {
const hit = intersects[0].object as THREE.Mesh;
const pieceInfo = hit.userData?.pieceInfo as PieceInfo | undefined;
const mark = pieceInfo?.pieceMark || hit.userData?.pieceMark;
if (mark) {
onSelectPiece?.(mark);
const def = defectByPieceMap.current.get(mark.toUpperCase());
onSelectDefect?.(def || null);
}
}
};
// Reset de Câmera
const handleResetCamera = () => {
if (!modelGroupRef.current || !cameraRef.current || !controlsRef.current) return;
const bbox = new THREE.Box3().setFromObject(modelGroupRef.current);
const size = new THREE.Vector3();
bbox.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
cameraRef.current.position.set(maxDim * 1.3, maxDim * 1.1, maxDim * 1.5);
controlsRef.current.target.set(0, size.y / 2, 0);
controlsRef.current.update();
};
// ViewCube Switcher de Direções
const handleViewCubeSelect = useCallback(
(view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right' | 'iso') => {
if (!controlsRef.current || !cameraRef.current) return;
const target = controlsRef.current.target;
const camera = cameraRef.current;
const distance = camera.position.distanceTo(target);
const newPos = target.clone();
switch (view) {
case 'top':
newPos.y += distance;
break;
case 'bottom':
newPos.y -= distance;
break;
case 'front':
newPos.z += distance;
break;
case 'back':
newPos.z -= distance;
break;
case 'left':
newPos.x -= distance;
break;
case 'right':
newPos.x += distance;
break;
case 'iso':
newPos.x += distance * 0.7;
newPos.y += distance * 0.7;
newPos.z += distance * 0.7;
break;
}
camera.position.copy(newPos);
controlsRef.current.update();
},
[]
);
return (
<div
ref={containerRef}
className="relative w-full h-full select-none overflow-hidden rounded-xl border bg-slate-50 dark:bg-slate-950 border-slate-200 dark:border-slate-800 shadow-sm"
>
{/* Container Three.js */}
<div
ref={canvasContainerRef}
className="w-full h-full cursor-grab active:cursor-grabbing"
onPointerDown={handlePointerDown}
/>
{/* Top Right: ViewCube + Widget de Rotação 90° & Cadeado de Memória */}
<div className="absolute top-3 right-3 z-20 flex flex-col items-end gap-2.5 pointer-events-auto">
{cameraRef.current && controlsRef.current && (
<ViewCube
onSelectView={handleViewCubeSelect}
mainCameraRef={cameraRef as React.MutableRefObject<THREE.Camera | null>}
/>
)}
{/* Card de Ajuste e Travamento de Rotação 3D (Tombamento 90° + Cadeado Memorizável) */}
<div
className="flex items-center gap-1.5 p-1.5 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-300 dark:border-slate-700 shadow-lg shadow-slate-300/30 dark:shadow-slate-950/50 pointer-events-auto"
style={{ minWidth: '130px' }}
>
{/* Botão Rotacionar / Tombar 90° */}
<button
onClick={handleRotate90X}
disabled={!modelData}
className={`flex-1 flex items-center justify-center gap-1.5 px-2.5 py-1.5 rounded-xl text-[11px] font-bold transition-all duration-150 active:scale-95 ${
modelData
? 'bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-100 cursor-pointer shadow-sm'
: 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 dark:text-slate-600 cursor-not-allowed opacity-50'
}`}
title="Girar o modelo 90° no eixo de tombamento (adequar orientação Z/Y)"
>
<RotateCw className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
<span className="whitespace-nowrap">Girar 90°</span>
<span className="text-[10px] font-mono font-normal text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-950 px-1 py-0.2 rounded border border-slate-200 dark:border-slate-800">
{rotationX}°
</span>
</button>
{/* Botão Cadeado: Travar / Gravar ou Destravar */}
<button
onClick={handleToggleLockRotation}
disabled={!modelData}
className={`flex items-center justify-center p-1.5 rounded-xl transition-all duration-200 active:scale-95 border ${
!modelData
? 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 border-transparent cursor-not-allowed opacity-40'
: isRotationLocked
? 'bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-950/60 dark:hover:bg-emerald-900/80 dark:text-emerald-300 dark:border-emerald-700/80 shadow-sm cursor-pointer'
: 'bg-amber-50 hover:bg-amber-100 text-amber-700 border-amber-300 dark:bg-amber-950/50 dark:hover:bg-amber-900/70 dark:text-amber-300 dark:border-amber-700/70 shadow-sm cursor-pointer animate-pulse'
}`}
title={
isRotationLocked
? 'Posição TRAVADA e salva para esta OF. Clique para destravar e ajustar.'
: 'Posição DESTRAVADA. Clique aqui no cadeado para travar e salvar a posição ideal.'
}
>
{isRotationLocked ? (
<Lock className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
) : (
<Unlock className="w-4 h-4 text-amber-600 dark:text-amber-400" />
)}
</button>
</div>
</div>
{/* Barra de Ferramentas Flutuante Superior */}
<div className="absolute top-3 left-3 flex flex-wrap items-center gap-1.5 p-1.5 bg-white/95 dark:bg-slate-900/90 backdrop-blur-md border border-slate-200 dark:border-slate-700/80 rounded-xl shadow-md z-20">
{/* Reset Câmera */}
<button
onClick={handleResetCamera}
title="Resetar Câmera"
className="p-1.5 text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-300 dark:hover:text-white dark:hover:bg-slate-800 rounded-lg transition"
>
<RotateCcw className="w-4 h-4" />
</button>
{/* Centralizar e Enquadrar (Fit to Screen) */}
<button
onClick={() => fitModelToView()}
title="Centralizar e Enquadrar Estrutura na Tela (Fit to Screen)"
className="p-1.5 text-slate-600 hover:text-cyan-600 hover:bg-cyan-50 dark:text-slate-300 dark:hover:text-cyan-400 dark:hover:bg-cyan-950/40 rounded-lg transition"
>
<Focus className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
</button>
<div className="w-[1px] h-4 bg-slate-200 dark:bg-slate-700 mx-0.5" />
{/* Comutar Perspectiva / Ortogonal */}
<button
onClick={handleToggleCamera}
title={isOrthographic ? 'Mudar para Vista em Perspectiva (3D Cônica)' : 'Mudar para Vista Ortogonal (Paralela Técnica)'}
className={`flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded-lg transition ${
isOrthographic
? 'bg-emerald-600 text-white shadow-sm'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white'
}`}
>
<Compass className="w-3.5 h-3.5" />
<span className="hidden sm:inline">{isOrthographic ? 'Ortogonal' : 'Perspectiva'}</span>
</button>
<div className="w-[1px] h-4 bg-slate-200 dark:bg-slate-700 mx-0.5" />
{/* Modo Raio-X / Foco */}
<button
onClick={onToggleGhostMode}
title={isGhostMode ? 'Desativar Modo Raio-X / Foco' : 'Ativar Modo Raio-X / Foco'}
className={`flex items-center gap-1.5 px-2 py-1 text-xs font-medium rounded-lg transition ${
isGhostMode
? 'bg-indigo-600 text-white shadow'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white'
}`}
>
<Eye className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Foco / Raio-X</span>
</button>
{/* Aramado Limpo (EdgesGeometry) */}
<button
onClick={() => setWireframe(!wireframe)}
title={wireframe ? 'Mudar para Modelo Sólido' : 'Mudar para Aramado Limpo de Engenharia (sem linhas internas)'}
className={`p-1.5 rounded-lg transition ${
wireframe
? 'bg-indigo-600 text-white shadow-xs'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white'
}`}
>
{wireframe ? <Layers className="w-4 h-4" /> : <Box className="w-4 h-4" />}
</button>
{onToggleFullscreen && (
<button
onClick={onToggleFullscreen}
title={isFullscreen ? 'Sair da Tela Cheia' : 'Tela Cheia'}
className="p-1.5 text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-300 dark:hover:text-white dark:hover:bg-slate-800 rounded-lg transition"
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
)}
<div className="w-[1px] h-4 bg-slate-200 dark:bg-slate-700 mx-0.5" />
{/* Barra de Luminosidade (Slider Interativo) */}
<div
className="flex items-center gap-1.5 px-2 py-1 bg-slate-100/90 dark:bg-slate-800/90 rounded-lg border border-slate-200 dark:border-slate-700"
title="Ajuste de Luminosidade da Estrutura (arraste para clarear ou suavizar sombras das peças)"
>
<button
type="button"
onClick={() => setBrightness(100)}
title="Clique para resetar luminosidade em 100%"
className="hover:scale-110 active:scale-95 transition-transform"
>
<SunMedium className="w-3.5 h-3.5 text-amber-500 shrink-0" />
</button>
<input
type="range"
min="40"
max="220"
step="5"
value={brightness}
onChange={(e) => setBrightness(Number(e.target.value))}
className="w-16 sm:w-20 h-1.5 bg-slate-300 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-amber-500"
/>
<span className="text-[10px] font-mono font-bold text-slate-600 dark:text-slate-300 min-w-[28px] text-right">
{brightness}%
</span>
</div>
</div>
{/* Legenda de Cores Flutuante Inferior */}
<div className="absolute bottom-3 left-3 flex flex-wrap items-center gap-3 px-3 py-2 bg-white/95 dark:bg-slate-900/90 backdrop-blur-md border border-slate-200 dark:border-slate-700/80 rounded-lg shadow-md text-xs z-20">
<span className="text-slate-500 dark:text-slate-400 font-medium">Legenda:</span>
{colorizationMode === 'status_defeito' && (
<>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 shadow-sm" />
<span className="text-slate-700 dark:text-slate-300">Conforme</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-amber-500 shadow-sm" />
<span className="text-slate-700 dark:text-slate-300">Alerta (Deslocado)</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-500 shadow-sm" />
<span className="text-slate-700 dark:text-slate-300">Crítico (Furo Cego/Colisão)</span>
</div>
</>
)}
{colorizationMode === 'diff_revisao' && (
<>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
<span className="text-slate-700 dark:text-slate-300">Inalterada</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-amber-500" />
<span className="text-slate-700 dark:text-slate-300">Modificada</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-blue-500" />
<span className="text-slate-700 dark:text-slate-300">Nova</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-500" />
<span className="text-slate-700 dark:text-slate-300">Excluída</span>
</div>
</>
)}
{colorizationMode === 'risco_fabril' && (
<>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
<span className="text-slate-700 dark:text-slate-300">Baixo Risco (Sem Corte)</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-amber-500" />
<span className="text-slate-700 dark:text-slate-300">Médio (Em Corte)</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-600 animate-pulse" />
<span className="text-slate-700 dark:text-slate-300">Crítico (Soldada/Expedida)</span>
</div>
</>
)}
</div>
{/* Alerta de elemento selecionado flutuante */}
{selectedPieceMark && (
<div className="absolute top-3 left-[225px] px-3 py-1.5 bg-indigo-50 dark:bg-indigo-950/90 border border-indigo-200 dark:border-indigo-700/80 rounded-lg shadow-md text-xs flex items-center gap-2 z-20">
<Crosshair className="w-3.5 h-3.5 text-indigo-600 dark:text-indigo-400 animate-spin" style={{ animationDuration: '6s' }} />
<span className="text-indigo-900 dark:text-indigo-200">
Foco Ativo: <strong className="text-indigo-700 dark:text-white font-mono">{selectedPieceMark}</strong>
</span>
<button
onClick={() => {
onSelectPiece?.(null);
onSelectDefect?.(null);
}}
className="ml-1 text-indigo-500 hover:text-indigo-800 dark:hover:text-white"
title="Limpar seleção"
>
×
</button>
</div>
)}
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,264 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { DefectItem } from '@/types/analisador3d';
import {
AlertTriangle,
CheckCircle2,
AlertCircle,
Copy,
Crosshair,
Layers,
Wrench,
Factory,
ShieldAlert,
ArrowRight
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
interface PieceAuditDetailModalProps {
isOpen: boolean;
onClose: () => void;
defect: DefectItem | null;
onFocus3D?: (coords?: { x: number; y: number; z: number }) => void;
}
export const PieceAuditDetailModal: React.FC<PieceAuditDetailModalProps> = ({
isOpen,
onClose,
defect,
onFocus3D,
}) => {
const { toast } = useToast();
if (!defect) return null;
const handleCopy = () => {
const text = `[AUDITORIA 3D TRACKSTEEL]\nPeça: ${defect.pecaMarca}\nConjunto: ${defect.conjuntoMarca || 'N/A'}\nTipo: ${defect.tipo}\nGravidade: ${defect.gravidade}\nDescrição: ${defect.descricao}\nRecomendação: ${defect.recomendacao}`;
navigator.clipboard.writeText(text);
toast({
title: 'Copiado para Área de Transferência',
description: 'Dados técnicos copiados com sucesso.',
});
};
const getSeverityBadge = () => {
switch (defect.gravidade) {
case 'critico':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-red-500/10 text-red-500 border border-red-500/20">
<AlertTriangle className="w-3.5 h-3.5" />
CRÍTICO
</span>
);
case 'alerta':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-500 border border-amber-500/20">
<AlertCircle className="w-3.5 h-3.5" />
ALERTA
</span>
);
default:
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-500/10 text-blue-500 border border-blue-500/20">
<CheckCircle2 className="w-3.5 h-3.5" />
INFORMATIVO
</span>
);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl bg-slate-900 border-slate-800 text-slate-100 p-6 shadow-2xl">
<DialogHeader className="border-b border-slate-800 pb-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="p-2 bg-indigo-500/10 text-indigo-400 rounded-lg border border-indigo-500/20">
<ShieldAlert className="w-5 h-5" />
</span>
<div>
<DialogTitle className="text-lg font-bold text-white flex items-center gap-2">
Diagnóstico Técnico • {defect.pecaMarca}
{getSeverityBadge()}
</DialogTitle>
<DialogDescription className="text-xs text-slate-400">
{defect.titulo}
</DialogDescription>
</div>
</div>
<button
onClick={handleCopy}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition"
title="Copiar relatório deste defeito"
>
<Copy className="w-4 h-4" />
</button>
</div>
</DialogHeader>
<div className="space-y-4 py-3 text-sm">
{/* Card Descrição do Problema */}
<div className="p-3.5 bg-slate-950/60 border border-slate-800 rounded-lg">
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1 flex items-center gap-1.5">
<AlertCircle className="w-3.5 h-3.5 text-indigo-400" />
Descrição do Ocorrido / Inconformidade
</h4>
<p className="text-slate-200 leading-relaxed text-xs sm:text-sm">
{defect.descricao}
</p>
{defect.deslocamentoMm && (
<div className="mt-2.5 pt-2 border-t border-slate-800/80 flex items-center gap-4 text-xs font-mono">
<span className="text-slate-400">Deslocamento:</span>
<span className="text-amber-400">ΔX: {defect.deslocamentoMm.x}mm</span>
<span className="text-amber-400">ΔY: {defect.deslocamentoMm.y}mm</span>
<span className="text-amber-400">ΔZ: {defect.deslocamentoMm.z}mm</span>
</div>
)}
</div>
{/* Grid de Relação Peça vs Peça Conectada */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="p-3 bg-slate-950/40 border border-slate-800 rounded-lg">
<span className="text-xs text-slate-400 font-medium flex items-center gap-1 mb-1">
<Layers className="w-3.5 h-3.5 text-indigo-400" />
Peça Auditada
</span>
<div className="font-mono font-bold text-white text-base">{defect.pecaMarca}</div>
<div className="text-xs text-slate-400 mt-1">
Conjunto:{' '}
<strong className="text-slate-300 font-mono">
{defect.conjuntoMarca || 'N/A'}
</strong>
</div>
<div className="text-xs text-slate-400">
Fase:{' '}
<strong className="text-slate-300">{defect.fase || 'Não definida'}</strong>
</div>
</div>
<div className="p-3 bg-slate-950/40 border border-slate-800 rounded-lg">
<span className="text-xs text-slate-400 font-medium flex items-center gap-1 mb-1">
<Wrench className="w-3.5 h-3.5 text-cyan-400" />
Peça Conectada (Adjacente)
</span>
{defect.pecaConectadaMarca ? (
<>
<div className="font-mono font-bold text-cyan-300 text-base">
{defect.pecaConectadaMarca}
</div>
<div className="text-xs text-slate-400 mt-1">
Conjunto:{' '}
<strong className="text-slate-300 font-mono">
{defect.pecaConectadaConjunto || 'N/A'}
</strong>
</div>
<div className="text-xs text-cyan-400 mt-1">
Ligação: Furação faceada / Parafuso
</div>
</>
) : (
<div className="text-xs text-slate-500 italic mt-2">
Elemento avulso ou modificação intrínseca da peça.
</div>
)}
</div>
</div>
{/* Situação no Chão de Fábrica */}
<div className="p-3.5 bg-slate-950/60 border border-slate-800 rounded-lg">
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Factory className="w-3.5 h-3.5 text-amber-400" />
Impacto no Chão de Fábrica (Tracksteel Engine)
</h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
<div>
<span className="text-slate-500 block">Etapa Atual</span>
<span
className="font-medium px-1.5 py-0.5 rounded text-[11px] inline-block mt-0.5"
style={{
backgroundColor: `${defect.processoCor || '#64748b'}25`,
color: defect.processoCor || '#94a3b8',
}}
>
{defect.processoAtual || 'Pendente'}
</span>
</div>
<div>
<span className="text-slate-500 block">Qtd Apontada</span>
<span className="font-mono font-bold text-slate-200 mt-0.5 block">
{defect.quantidadeApontada || 0} de {defect.quantidadeAfetada} un
</span>
</div>
<div>
<span className="text-slate-500 block">Peso Total</span>
<span className="font-mono font-bold text-slate-200 mt-0.5 block">
{defect.pesoTotalKg ? `${defect.pesoTotalKg.toFixed(1)} kg` : '—'}
</span>
</div>
<div>
<span className="text-slate-500 block">Risco de Retrabalho</span>
<span
className={`font-bold mt-0.5 uppercase block ${
defect.riscoFabril === 'critico' || defect.riscoFabril === 'alto'
? 'text-red-400 animate-pulse'
: defect.riscoFabril === 'medio'
? 'text-amber-400'
: 'text-emerald-400'
}`}
>
{defect.riscoFabril}
</span>
</div>
</div>
</div>
{/* Recomendação de Engenharia */}
<div className="p-3.5 bg-indigo-950/30 border border-indigo-900/50 rounded-lg">
<h4 className="text-xs font-semibold text-indigo-300 uppercase tracking-wider mb-1 flex items-center gap-1.5">
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-400" />
Ação Corretiva Recomendada
</h4>
<p className="text-xs sm:text-sm text-indigo-100 leading-relaxed">
{defect.recomendacao}
</p>
</div>
</div>
{/* Rodapé com Botões */}
<div className="border-t border-slate-800 pt-4 flex items-center justify-between">
<button
onClick={onClose}
className="px-4 py-2 text-xs font-medium text-slate-400 hover:text-white transition"
>
Fechar
</button>
{defect.coordenadas3D && onFocus3D && (
<button
onClick={() => {
onFocus3D(defect.coordenadas3D);
onClose();
}}
className="flex items-center gap-1.5 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-xs font-medium shadow transition"
>
<Crosshair className="w-4 h-4" />
<span>Focar e Isolar no Visualizador 3D</span>
</button>
)}
</div>
</DialogContent>
</Dialog>
);
};
@@ -62,6 +62,15 @@ export const PieceHoverTooltip: React.FC<PieceHoverTooltipProps> = ({
</div>
)}
{piece.singlePartMark && piece.singlePartMark !== piece.pieceMark && (
<div className="flex items-center justify-between text-slate-300">
<span className="text-slate-400">Sub-peça / Corte:</span>
<span className="font-semibold text-slate-200 font-mono bg-slate-950/60 px-2 py-0.5 rounded border border-slate-800">
Pos. {piece.singlePartMark}
</span>
</div>
)}
{piece.childrenIDs && piece.childrenIDs.length > 0 && (
<div className="flex items-center justify-between text-slate-300">
<span className="text-slate-400">Componentes:</span>
@@ -0,0 +1,376 @@
import React, { useState } from 'react';
import {
X,
FileSpreadsheet,
Copy,
Check,
Trash2,
Layers,
Box,
CheckCircle2,
Clock,
Download,
} from 'lucide-react';
import * as XLSX from 'xlsx';
import { toast } from 'sonner';
export interface SelectedPieceItem {
id: string; // mesh.uuid
pieceMark: string;
phase?: string;
section?: string;
name?: string;
pointedQty?: number;
totalQty?: number;
currentStage?: string;
stageColor?: string;
}
interface SelectionListModalProps {
isOpen: boolean;
onClose: () => void;
selectedPieces: SelectedPieceItem[];
selectedOF?: string;
onClearSelection: () => void;
}
export const SelectionListModal: React.FC<SelectionListModalProps> = ({
isOpen,
onClose,
selectedPieces,
selectedOF,
onClearSelection,
}) => {
const [copied, setCopied] = useState(false);
if (!isOpen) return null;
// Agrupamento por Marca + Fase para consolidar quantidades
const groupedMap = new Map<
string,
{
pieceMark: string;
phase: string;
section: string;
selectedCount: number;
totalQty: number;
pointedQty: number;
currentStage: string;
stageColor: string;
}
>();
selectedPieces.forEach((p) => {
const key = `${p.phase || '1'}_${p.pieceMark || 'Sem Marca'}`;
const existing = groupedMap.get(key);
if (existing) {
existing.selectedCount += 1;
} else {
groupedMap.set(key, {
pieceMark: p.pieceMark || 'Sem Marca',
phase: p.phase || '-',
section: p.section || '-',
selectedCount: 1,
totalQty: p.totalQty || 1,
pointedQty: p.pointedQty || 0,
currentStage: p.currentStage || 'Pendente',
stageColor: p.stageColor || '#64748b',
});
}
});
const consolidatedList = Array.from(groupedMap.values());
const totalPhysicalPieces = selectedPieces.length;
const totalDistinctMarks = consolidatedList.length;
// Exportação para Excel
const handleExportExcel = () => {
if (consolidatedList.length === 0) {
toast.warning('Nenhuma peça selecionada para exportar.');
return;
}
try {
const dataRows = consolidatedList.map((item, idx) => ({
'Item': idx + 1,
'Ordem de Fabricação (OF)': selectedOF || '-',
'Fase': item.phase,
'Marca Principal': item.pieceMark,
'Perfil / Bitola': item.section,
'Qtd. Selecionada no 3D': item.selectedCount,
'Qtd. Total Cadastrada': item.totalQty,
'Qtd. Apontada na Fábrica': item.pointedQty,
'Etapa / Status Atual': item.currentStage,
}));
const worksheet = XLSX.utils.json_to_sheet(dataRows);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Peças Selecionadas');
worksheet['!cols'] = [
{ wch: 6 },
{ wch: 24 },
{ wch: 10 },
{ wch: 18 },
{ wch: 22 },
{ wch: 22 },
{ wch: 20 },
{ wch: 22 },
{ wch: 20 },
];
const fileName = `Selecao_3D_${selectedOF || 'OF'}_${new Date().toISOString().slice(0, 10)}.xlsx`;
XLSX.writeFile(workbook, fileName);
toast.success('Planilha Excel gerada com sucesso!', {
description: fileName,
});
} catch (err) {
console.error('Erro ao gerar Excel:', err);
toast.error('Falha ao exportar para Excel.');
}
};
// Copiar para Área de Transferência
const handleCopy = () => {
if (consolidatedList.length === 0) return;
const headers = ['OF', 'Fase', 'Marca', 'Perfil', 'Qtd Selecionada', 'Qtd Total', 'Status'];
const lines = consolidatedList.map((item) =>
[
selectedOF || '-',
item.phase,
item.pieceMark,
item.section,
item.selectedCount,
item.totalQty,
item.currentStage,
].join('\t')
);
const textToCopy = [headers.join('\t'), ...lines].join('\n');
navigator.clipboard.writeText(textToCopy);
setCopied(true);
toast.success('Lista copiada para a área de transferência!');
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/40 dark:bg-slate-950/70 backdrop-blur-sm animate-in fade-in duration-200">
<div className="relative w-full max-w-4xl max-h-[90vh] flex flex-col bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl overflow-hidden text-slate-800 dark:text-slate-100">
{/* Cabeçalho do Modal Harmonizado */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-800 bg-slate-50/90 dark:bg-slate-950/70">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-blue-50 text-blue-900 border border-blue-200 dark:bg-blue-950/50 dark:text-blue-300 dark:border-blue-900/60 shadow-sm">
<FileSpreadsheet className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-bold text-slate-900 dark:text-white tracking-tight">
Lista de Peças Selecionadas no 3D
</h3>
<span className="px-2.5 py-0.5 rounded-full text-xs font-mono font-bold bg-slate-200/90 text-slate-800 border border-slate-300 dark:bg-slate-800 dark:text-slate-200 dark:border-slate-700">
{totalPhysicalPieces} {totalPhysicalPieces === 1 ? 'peça' : 'peças'}
</span>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Relação consolidada das peças marcadas na cena 3D
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-700 dark:hover:text-white rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-all cursor-pointer"
title="Fechar"
>
<X className="w-5 h-5" />
</button>
</div>
{/* KPIs Resumo no Topo (Tons Sóbrios de Azul Escuro, Verde Escuro e Cinza) */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 p-6 pb-4 bg-slate-50/60 dark:bg-slate-950/40 border-b border-slate-200/90 dark:border-slate-800/80">
{/* Card 1: Total de Peças (Azul Escuro no claro / Azul Claro no escuro) */}
<div className="p-3 rounded-xl bg-white dark:bg-slate-950/50 border border-slate-200 dark:border-slate-800 shadow-sm flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-50 text-blue-900 border border-blue-200/80 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-900/60">
<Box className="w-4 h-4" />
</div>
<div>
<span className="text-[10px] uppercase font-mono tracking-wider text-slate-500 dark:text-slate-400 block">
Total de Peças
</span>
<span className="text-lg font-extrabold text-blue-950 dark:text-blue-300 font-mono">
{totalPhysicalPieces}
</span>
</div>
</div>
{/* Card 2: Marcas Distintas (Cinza Escuro no claro / Cinza Claro no escuro) */}
<div className="p-3 rounded-xl bg-white dark:bg-slate-950/50 border border-slate-200 dark:border-slate-800 shadow-sm flex items-center gap-3">
<div className="p-2 rounded-lg bg-slate-100 text-slate-700 border border-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700">
<Layers className="w-4 h-4" />
</div>
<div>
<span className="text-[10px] uppercase font-mono tracking-wider text-slate-500 dark:text-slate-400 block">
Marcas Distintas
</span>
<span className="text-lg font-extrabold text-slate-800 dark:text-slate-200 font-mono">
{totalDistinctMarks}
</span>
</div>
</div>
{/* Card 3: Ordem de Fabricação (Verde Escuro no claro / Verde Claro no escuro) */}
<div className="p-3 rounded-xl bg-white dark:bg-slate-950/50 border border-slate-200 dark:border-slate-800 shadow-sm flex items-center gap-3">
<div className="p-2 rounded-lg bg-emerald-50 text-emerald-800 border border-emerald-200/80 dark:bg-emerald-950/50 dark:text-emerald-400 dark:border-emerald-900/60">
<CheckCircle2 className="w-4 h-4" />
</div>
<div>
<span className="text-[10px] uppercase font-mono tracking-wider text-slate-500 dark:text-slate-400 block">
Ordem de Fabricação
</span>
<span className="text-sm font-bold text-emerald-900 dark:text-emerald-300 truncate max-w-[120px] block">
{selectedOF || 'N/A'}
</span>
</div>
</div>
{/* Card 4: Status / Seleção (Cinza Neutro / Azul) */}
<div className="p-3 rounded-xl bg-white dark:bg-slate-950/50 border border-slate-200 dark:border-slate-800 shadow-sm flex items-center gap-3">
<div className="p-2 rounded-lg bg-slate-100 text-slate-600 border border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700">
<Clock className="w-4 h-4" />
</div>
<div>
<span className="text-[10px] uppercase font-mono tracking-wider text-slate-500 dark:text-slate-400 block">
Status
</span>
<span className="text-sm font-bold text-slate-700 dark:text-slate-300 block">
{totalPhysicalPieces > 0 ? 'Filtro Ativo' : 'Vazio'}
</span>
</div>
</div>
</div>
{/* Tabela de Itens Selecionados Sóbria e Harmonizada */}
<div className="flex-1 overflow-y-auto p-6">
{consolidatedList.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-slate-500">
<Box className="w-12 h-12 stroke-[1.5] mb-3 text-slate-400 dark:text-slate-600" />
<p className="text-sm font-semibold text-slate-600 dark:text-slate-400">Nenhuma peça selecionada</p>
<p className="text-xs text-slate-500 max-w-xs mt-1">
Clique nas peças na cena 3D (ou clique duplo para selecionar todas da mesma marca) para incluí-las nesta lista.
</p>
</div>
) : (
<div className="border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden shadow-sm bg-white dark:bg-slate-950/40">
<table className="w-full text-left text-xs">
<thead className="bg-slate-100/90 dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 uppercase font-mono text-[10px] tracking-wider border-b border-slate-200 dark:border-slate-700/80">
<tr>
<th className="py-3 px-4 font-semibold">Marca Principal</th>
<th className="py-3 px-3 font-semibold text-center">Fase</th>
<th className="py-3 px-4 font-semibold">Perfil / Bitola</th>
<th className="py-3 px-3 font-semibold text-center text-blue-900 dark:text-blue-300">
Qtd. 3D
</th>
<th className="py-3 px-3 font-semibold text-center">Qtd. OF</th>
<th className="py-3 px-4 font-semibold text-center">Etapa / Apontamento</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200/80 dark:divide-slate-800/60 font-medium">
{consolidatedList.map((item, idx) => {
const isPendente = !item.currentStage || item.currentStage.toLowerCase().includes('pendente');
return (
<tr
key={`${item.phase}_${item.pieceMark}_${idx}`}
className="hover:bg-slate-50 dark:hover:bg-slate-800/40 transition-colors"
>
<td className="py-3 px-4 font-mono font-bold text-slate-900 dark:text-white flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-fuchsia-500 inline-block shadow-sm" title="Marcada no 3D" />
{item.pieceMark}
</td>
<td className="py-3 px-3 text-center">
<span className="px-2 py-0.5 rounded-full text-[10px] font-mono font-bold bg-slate-100 text-slate-700 border border-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700">
{item.phase}
</span>
</td>
<td className="py-3 px-4 font-mono text-slate-700 dark:text-slate-300">
{item.section}
</td>
<td className="py-3 px-3 text-center font-mono font-extrabold text-sm text-blue-950 dark:text-blue-300">
{item.selectedCount}
</td>
<td className="py-3 px-3 text-center font-mono text-slate-600 dark:text-slate-400">
{item.pointedQty} / {item.totalQty}
</td>
<td className="py-3 px-4 text-center">
<span
className={`px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider inline-flex items-center gap-1.5 border ${
isPendente
? 'bg-slate-100 text-slate-700 border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700'
: 'bg-emerald-50 text-emerald-900 border-emerald-200/80 dark:bg-emerald-950/50 dark:text-emerald-300 dark:border-emerald-800/60'
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
isPendente ? 'bg-slate-400 dark:bg-slate-500' : 'bg-emerald-600 dark:bg-emerald-400'
}`}
/>
{item.currentStage}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Rodapé com Botões de Ação Harmonizados */}
<div className="flex flex-wrap items-center justify-between gap-3 px-6 py-4 border-t border-slate-200 dark:border-slate-800 bg-slate-50/90 dark:bg-slate-950/70">
<button
onClick={() => {
onClearSelection();
toast.info('Seleção 3D limpa com sucesso.');
}}
disabled={totalPhysicalPieces === 0}
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-semibold text-slate-600 hover:text-rose-600 hover:bg-rose-50 border border-slate-300 dark:border-slate-700 dark:text-slate-400 dark:hover:text-rose-300 dark:hover:bg-rose-950/30 disabled:opacity-40 disabled:cursor-not-allowed transition-all cursor-pointer"
>
<Trash2 className="w-4 h-4" />
<span>Limpar Seleção</span>
</button>
<div className="flex items-center gap-2.5">
<button
onClick={handleCopy}
disabled={totalPhysicalPieces === 0}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-semibold bg-white hover:bg-slate-100 text-slate-700 border border-slate-300 shadow-sm dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-slate-200 dark:border-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-all cursor-pointer"
>
{copied ? (
<>
<Check className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
<span className="text-emerald-700 dark:text-emerald-400">Copiado!</span>
</>
) : (
<>
<Copy className="w-4 h-4 text-slate-600 dark:text-slate-300" />
<span>Copiar Lista</span>
</>
)}
</button>
{/* Exportar Excel em tom Azul Escuro / Verde Escuro Industrial */}
<button
onClick={handleExportExcel}
disabled={totalPhysicalPieces === 0}
className="flex items-center gap-2 px-5 py-2 rounded-xl text-xs font-bold bg-blue-950 hover:bg-blue-900 text-white shadow-md shadow-blue-950/20 dark:bg-blue-600 dark:hover:bg-blue-500 dark:shadow-blue-950/40 disabled:opacity-40 disabled:cursor-not-allowed transition-all cursor-pointer"
>
<Download className="w-4 h-4" />
<span>Exportar Excel (.xlsx)</span>
</button>
</div>
</div>
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
+28 -12
View File
@@ -5,9 +5,16 @@ import { RotateCcw } from 'lucide-react';
interface ViewCubeProps {
onSelectView: (view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right' | 'iso') => void;
mainCameraRef?: React.MutableRefObject<THREE.Camera | null>;
size?: number;
compact?: boolean;
}
export const ViewCube: React.FC<ViewCubeProps> = ({ onSelectView, mainCameraRef }) => {
export const ViewCube: React.FC<ViewCubeProps> = ({
onSelectView,
mainCameraRef,
size = 84,
compact = false,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
@@ -20,8 +27,8 @@ export const ViewCube: React.FC<ViewCubeProps> = ({ onSelectView, mainCameraRef
useEffect(() => {
if (!containerRef.current) return;
const width = 84;
const height = 84;
const width = size;
const height = size;
// 1. Scene & Camera
const scene = new THREE.Scene();
@@ -202,13 +209,20 @@ export const ViewCube: React.FC<ViewCubeProps> = ({ onSelectView, mainCameraRef
return (
<div className="relative select-none flex flex-col items-center">
{/* Container Reduzido (~70%) Harmonizado para Modo Claro e Escuro */}
<div className="relative w-24 h-24 p-1 rounded-2xl bg-white/90 dark:bg-slate-950/85 backdrop-blur-xl border border-slate-200 dark:border-cyan-500/40 shadow-lg dark:shadow-xl dark:shadow-cyan-950/60 flex items-center justify-center cursor-pointer group transition-all">
{/* Container Reduzido Harmonizado para Modo Claro e Escuro */}
<div
className={`relative p-1 rounded-2xl bg-white/90 dark:bg-slate-950/85 backdrop-blur-xl border border-slate-200 dark:border-cyan-500/40 shadow-lg dark:shadow-xl dark:shadow-cyan-950/60 flex items-center justify-center cursor-pointer group transition-all`}
style={{ width: `${size + 12}px`, height: `${size + 12}px` }}
>
{/* Glow sutil harmonizado */}
<div className="absolute inset-0 bg-gradient-to-tr from-cyan-500/5 dark:from-cyan-500/10 via-transparent to-blue-500/5 dark:to-blue-500/10 rounded-2xl pointer-events-none" />
{/* Canvas WebGL 3D do Cubo Sincronizado */}
<div ref={containerRef} className="w-[84px] h-[84px] flex items-center justify-center" />
<div
ref={containerRef}
style={{ width: `${size}px`, height: `${size}px` }}
className="flex items-center justify-center"
/>
{/* Botão ISO no canto inferior harmonizado com o tema */}
<button
@@ -216,18 +230,20 @@ export const ViewCube: React.FC<ViewCubeProps> = ({ onSelectView, mainCameraRef
e.stopPropagation();
onSelectView('iso');
}}
className="absolute bottom-1 right-1 px-1 py-0.5 rounded bg-slate-100 hover:bg-cyan-600 text-slate-700 hover:text-white border border-slate-300 hover:border-cyan-500 dark:bg-slate-900/90 dark:text-cyan-300 dark:border-cyan-500/30 transition-all text-[9px] font-mono font-bold flex items-center gap-0.5 shadow-sm cursor-pointer"
className="absolute bottom-0.5 right-0.5 px-1 py-0.5 rounded bg-slate-100 hover:bg-cyan-600 text-slate-700 hover:text-white border border-slate-300 hover:border-cyan-500 dark:bg-slate-900/90 dark:text-cyan-300 dark:border-cyan-500/30 transition-all text-[8px] font-mono font-bold flex items-center gap-0.5 shadow-sm cursor-pointer"
title="Restaurar Vista Isométrica 3D"
>
<RotateCcw className="w-2.5 h-2.5" />
<RotateCcw className="w-2 h-2" />
<span>ISO</span>
</button>
</div>
{/* Rótulo inferior */}
<span className="text-[8px] font-mono font-bold tracking-widest uppercase text-cyan-800 dark:text-cyan-400 mt-0.5">
Cubo 3D
</span>
{/* Rótulo inferior opcional */}
{!compact && (
<span className="text-[8px] font-mono font-bold tracking-widest uppercase text-cyan-800 dark:text-cyan-400 mt-0.5">
Cubo 3D
</span>
)}
</div>
);
};
+76 -79
View File
@@ -16,7 +16,8 @@ import {
Wrench,
Paintbrush,
Truck,
Scissors
Scissors,
SunMedium
} from 'lucide-react';
interface Viewer3DSidebarProps {
@@ -32,6 +33,8 @@ interface Viewer3DSidebarProps {
onSelectProcess?: (proc: string) => void;
onFitView: () => void;
onToggleFullscreen: () => void;
brightness?: number;
onBrightnessChange?: (val: number) => void;
}
const FABRICATION_PROCESSES = [
@@ -56,6 +59,8 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
onSelectProcess,
onFitView,
onToggleFullscreen,
brightness = 100,
onBrightnessChange,
}) => {
const [isOpen, setIsOpen] = useState<boolean>(true);
@@ -64,36 +69,36 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
{/* Container Principal da Barra Lateral */}
<div
className={`transition-all duration-300 ease-in-out flex flex-col bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl overflow-hidden ${
isOpen ? 'w-64 max-h-[calc(100vh-14rem)]' : 'w-0 border-0 opacity-0 pointer-events-none'
isOpen ? 'w-60 max-h-[calc(100%-2rem)]' : 'w-0 border-0 opacity-0 pointer-events-none'
}`}
>
{/* Cabeçalho do Sub-Menu */}
<div className="flex items-center justify-between px-4 py-3 bg-slate-100/90 dark:bg-slate-950/80 border-b border-slate-200 dark:border-slate-800">
<div className="flex items-center gap-2">
<Sliders className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-xs font-bold uppercase tracking-wider text-slate-800 dark:text-slate-200">
{/* Cabeçalho do Sub-Menu Enxuto */}
<div className="flex items-center justify-between px-3 py-1.5 bg-slate-100/90 dark:bg-slate-950/80 border-b border-slate-200 dark:border-slate-800">
<div className="flex items-center gap-1.5">
<Sliders className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-800 dark:text-slate-200">
Controles 3D
</span>
</div>
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-cyan-100 dark:bg-cyan-900/50 text-cyan-700 dark:text-cyan-300 font-bold">
<span className="text-[9px] font-mono px-1 py-0.2 rounded bg-cyan-100 dark:bg-cyan-900/50 text-cyan-700 dark:text-cyan-300 font-bold">
SteelXR
</span>
</div>
{/* Corpo com Rolagem Suave */}
<div className="flex-1 overflow-y-auto p-3 space-y-4 custom-scrollbar">
{/* Grupo 1: Destaque por Processo Industrial com Cores Correspondentes */}
<div className="space-y-1.5">
{/* Corpo Compactado com Rolagem Suave */}
<div className="flex-1 overflow-y-auto p-2 space-y-2 custom-scrollbar">
{/* Grupo 1: Destaque por Processo Industrial */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1.5">
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" />
Destaque por Processo
<label className="text-[10px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3 text-emerald-600 dark:text-emerald-400" />
Processo Fabril
</label>
<span className="text-[9px] font-mono text-emerald-600 dark:text-emerald-400 font-bold">
BIM + Fábrica
<span className="text-[8px] font-mono text-emerald-600 dark:text-emerald-400 font-bold">
BIM
</span>
</div>
<div className="grid grid-cols-3 gap-1.5 bg-slate-100 dark:bg-slate-950/60 p-1.5 rounded-xl border border-slate-200 dark:border-slate-800">
<div className="grid grid-cols-3 gap-1 bg-slate-100 dark:bg-slate-950/60 p-1 rounded-lg border border-slate-200 dark:border-slate-800">
{FABRICATION_PROCESSES.map((proc) => {
const Icon = proc.icon;
const isSelected = selectedProcess === proc.id;
@@ -106,24 +111,22 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
? {
backgroundColor: proc.color,
color: '#ffffff',
boxShadow: `0 4px 12px ${proc.color}50`,
boxShadow: `0 2px 8px ${proc.color}40`,
borderColor: proc.color,
}
: {
backgroundColor: `${proc.color}18`,
backgroundColor: `${proc.color}14`,
color: proc.color,
borderColor: `${proc.color}35`,
borderColor: `${proc.color}25`,
}
}
className={`flex flex-col items-center justify-center gap-1 py-2 px-1 rounded-lg text-[10px] font-bold border transition-all duration-200 hover:scale-[1.03] active:scale-[0.98] ${
isSelected
? 'shadow-md border-transparent font-extrabold'
: 'hover:brightness-110'
className={`flex flex-col items-center justify-center gap-0.5 py-1 px-1 rounded-md text-[9.5px] font-bold border transition-all duration-150 hover:scale-[1.02] active:scale-[0.98] ${
isSelected ? 'border-transparent font-extrabold' : 'hover:brightness-110'
}`}
title={`Destacar peças em ${proc.label} (${proc.color}) e não apontadas em aramado cinza`}
title={`Destacar peças em ${proc.label}`}
>
<Icon className="w-3.5 h-3.5 stroke-[2.2]" />
<span className="tracking-tight">{proc.label}</span>
<Icon className="w-3 h-3 stroke-[2.2]" />
<span className="tracking-tight leading-none">{proc.label}</span>
</button>
);
})}
@@ -131,99 +134,93 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
</div>
{/* Grupo 2: Modo de Cores Geral */}
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1.5">
<Palette className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
<div className="space-y-1">
<label className="text-[10px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1">
<Palette className="w-3 h-3 text-cyan-600 dark:text-cyan-400" />
Modo de Cores
</label>
<div className="grid grid-cols-2 gap-1.5 bg-slate-100 dark:bg-slate-950/60 p-1 rounded-xl border border-slate-200 dark:border-slate-800">
<div className="grid grid-cols-2 gap-1 bg-slate-100 dark:bg-slate-950/60 p-0.5 rounded-lg border border-slate-200 dark:border-slate-800">
<button
onClick={() => onColorModeChange('production')}
className={`flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-xs font-bold transition-all ${
className={`flex items-center justify-center gap-1 px-1.5 py-1 rounded-md text-[11px] font-bold transition-all ${
colorMode === 'production'
? 'bg-cyan-600 text-white shadow-md shadow-cyan-600/30'
? 'bg-cyan-600 text-white shadow-sm'
: 'text-slate-700 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`}
>
<CheckCircle2 className="w-3.5 h-3.5" />
<CheckCircle2 className="w-3 h-3" />
<span>Produção</span>
</button>
<button
onClick={() => onColorModeChange('description')}
className={`flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-xs font-bold transition-all ${
className={`flex items-center justify-center gap-1 px-1.5 py-1 rounded-md text-[11px] font-bold transition-all ${
colorMode === 'description'
? 'bg-amber-600 text-white shadow-md shadow-amber-600/30'
? 'bg-amber-600 text-white shadow-sm'
: 'text-slate-700 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`}
>
<Box className="w-3.5 h-3.5" />
<Box className="w-3 h-3" />
<span>Padrão</span>
</button>
</div>
</div>
{/* Grupo 3: Ambiente & Grade */}
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1.5">
<GridIcon className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
<div className="space-y-1">
<label className="text-[10px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1">
<GridIcon className="w-3 h-3 text-cyan-600 dark:text-cyan-400" />
Ambiente & Piso
</label>
<div>
{/* Toggle Grid */}
<button
onClick={onToggleGrid}
className={`w-full flex items-center justify-center gap-2 py-2 rounded-xl text-xs font-semibold border transition-all ${
showGrid
? 'bg-cyan-50 dark:bg-cyan-950/40 text-cyan-800 dark:text-cyan-300 border-cyan-300 dark:border-cyan-800 shadow-sm'
: 'bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-400 border-slate-200 dark:border-slate-800 hover:border-slate-300'
}`}
>
<GridIcon className="w-3.5 h-3.5" />
<span>Grade de Piso: {showGrid ? 'LIGADO' : 'DESL'}</span>
</button>
</div>
<button
onClick={onToggleGrid}
className={`w-full flex items-center justify-center gap-1.5 py-1 rounded-lg text-[11px] font-semibold border transition-all ${
showGrid
? 'bg-cyan-50 dark:bg-cyan-950/40 text-cyan-800 dark:text-cyan-300 border-cyan-300 dark:border-cyan-800 shadow-xs'
: 'bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-400 border-slate-200 dark:border-slate-800 hover:border-slate-300'
}`}
>
<GridIcon className="w-3 h-3" />
<span>Grade de Piso: {showGrid ? 'LIGADO' : 'DESL'}</span>
</button>
</div>
{/* Grupo 4: Câmera & Projeção */}
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1.5">
<Compass className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
{/* Grupo 4: Câmera & Visualização */}
<div className="space-y-1">
<label className="text-[10px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 flex items-center gap-1">
<Compass className="w-3 h-3 text-cyan-600 dark:text-cyan-400" />
Câmera & Visualização
</label>
<div className="grid grid-cols-2 gap-1.5">
{/* Câmera Toggle */}
<div className="grid grid-cols-2 gap-1">
<button
onClick={onToggleCamera}
className={`flex items-center justify-center gap-1.5 px-2.5 py-2 rounded-xl text-xs font-semibold border transition-all ${
className={`flex items-center justify-center gap-1 px-1.5 py-1 rounded-lg text-[11px] font-semibold border transition-all ${
isOrthographic
? 'bg-emerald-50 dark:bg-emerald-950/40 text-emerald-800 dark:text-emerald-300 border-emerald-300 dark:border-emerald-800 shadow-sm'
? 'bg-emerald-50 dark:bg-emerald-950/40 text-emerald-800 dark:text-emerald-300 border-emerald-300 dark:border-emerald-800 shadow-xs'
: 'bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-400 border-slate-200 dark:border-slate-800 hover:border-slate-300'
}`}
>
<Box className="w-3.5 h-3.5" />
<Box className="w-3 h-3" />
<span>{isOrthographic ? 'Ortogonal' : 'Perspectiva'}</span>
</button>
{/* Enquadrar / Reset View */}
<button
onClick={onFitView}
className="flex items-center justify-center gap-1.5 px-2.5 py-2 rounded-xl text-xs font-semibold bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 transition-all"
className="flex items-center justify-center gap-1 px-1.5 py-1 rounded-lg text-[11px] font-semibold bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 transition-all"
title="Enquadrar / Centralizar Estrutura"
>
<Focus className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
<Focus className="w-3 h-3 text-cyan-600 dark:text-cyan-400" />
<span>Enquadrar</span>
</button>
</div>
</div>
{/* Grupo 5: Opacidade da Estrutura */}
<div className="space-y-1.5 bg-slate-50 dark:bg-slate-950/60 p-2.5 rounded-xl border border-slate-200 dark:border-slate-800">
<div className="flex items-center justify-between text-xs">
<span className="font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-1.5">
<Eye className="w-3.5 h-3.5 text-amber-500" />
{/* Grupo 5: Opacidade da Estrutura Compacta */}
<div className="space-y-1 bg-slate-50 dark:bg-slate-950/60 p-2 rounded-lg border border-slate-200 dark:border-slate-800">
<div className="flex items-center justify-between text-[11px]">
<span className="font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-1">
<Eye className="w-3 h-3 text-amber-500" />
Opacidade
</span>
<span className="font-mono font-bold text-amber-700 dark:text-amber-400">{opacity}%</span>
<span className="font-mono font-bold text-amber-700 dark:text-amber-400 text-[10px]">{opacity}%</span>
</div>
<input
type="range"
@@ -231,9 +228,9 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
max={100}
value={opacity}
onChange={(e) => onOpacityChange(Number(e.target.value))}
className="w-full h-1.5 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-amber-500"
className="w-full h-1 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-amber-500"
/>
<div className="flex justify-between text-[10px] font-mono text-slate-400">
<div className="flex justify-between text-[9px] font-mono text-slate-400 pt-0.5">
<button onClick={() => onOpacityChange(25)} className="hover:text-amber-600">25%</button>
<button onClick={() => onOpacityChange(50)} className="hover:text-amber-600">50%</button>
<button onClick={() => onOpacityChange(75)} className="hover:text-amber-600">75%</button>
@@ -241,12 +238,12 @@ export const Viewer3DSidebar: React.FC<Viewer3DSidebarProps> = ({
</div>
</div>
{/* Botão Tela Cheia */}
{/* Botão Tela Cheia Compacto */}
<button
onClick={onToggleFullscreen}
className="w-full flex items-center justify-center gap-2 py-2 bg-slate-100 hover:bg-cyan-50 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-white border border-slate-200 dark:border-slate-700 rounded-xl text-xs font-bold transition-all shadow-sm"
className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-slate-100 hover:bg-cyan-50 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-white border border-slate-200 dark:border-slate-700 rounded-lg text-[11px] font-bold transition-all shadow-xs"
>
<Maximize2 className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
<Maximize2 className="w-3 h-3 text-cyan-600 dark:text-cyan-400" />
<span>Expandir Tela Cheia</span>
</button>
</div>
+14
View File
@@ -118,6 +118,12 @@ export const routeConfig: RouteDefinition[] = [
guard: 'resource',
resourceKey: 'ofs-lista',
},
{
path: '/ordens-fabricacao',
component: () => import('@/pages/OrdensFabricacao'),
guard: 'resource',
resourceKey: 'ofs-lista',
},
{
path: '/ofs/cronograma',
component: () => import('@/pages/CronogramaOF'),
@@ -177,6 +183,14 @@ export const routeConfig: RouteDefinition[] = [
resourceKey: 'visualizador-3d',
},
// ── Analisador 3D (QA/QC & Auditoria Geométrica) ──────────
{
path: '/analisador-3d',
component: () => import('@/pages/Analisador3D'),
guard: 'resource',
resourceKey: 'analisador-3d',
},
// ── Painel Industrial ────────────────────────────────────
{
path: '/painel-industrial',
+5 -6
View File
@@ -118,14 +118,13 @@ export const useApontamentoDiarioChart = (periodo: PeriodoType) => {
const ofNumber = apontamento.of_number;
const processoNome = apontamento.processo?.nome || 'Processo Desconhecido';
// Calcular peso baseado no tipo de apontamento
let pesoUnitario = 0;
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) {
pesoUnitario = Number(apontamento.componente.peso_unitario);
} else if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.peso_unitario) {
pesoUnitario = Number(apontamento.peca.peso_unitario);
// Blindagem contra dupla contagem: apenas peças mestres computam peso nos gráficos de produção.
// Componentes são rastreados para controle de kit/corte, mas não somam peso na balança global.
if (apontamento.tipo_apontamento !== 'peca') {
return;
}
const pesoUnitario = Number(apontamento.peca?.peso_unitario || 0);
let pesoTotal = pesoUnitario * Number(apontamento.quantidade_produzida);
// Para projeção, calcular média diária e multiplicar por 7
+73
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import { sincronizarPecaKitNoCorte, sincronizarTodosKitsOFCorte } from '@/utils/sincronizacaoKitsCorte';
// Função utilitária para retry de requisições
const retryRequest = async <T,>(
@@ -47,6 +48,15 @@ export interface ApontamentoProducao {
observacoes?: string;
created_at: string;
created_by?: string;
usuario_nome?: string;
is_forcado?: boolean;
status_confirmacao?: 'confirmado' | 'pendente_confirmacao' | string;
forcado_por_user_nome?: string;
forcado_por_user_id?: string;
forcado_para_processo_id?: string;
data_confirmacao?: string;
confirmado_por_user_nome?: string;
confirmado_por_user_id?: string;
peca?: {
marca: string;
descricao: string;
@@ -369,6 +379,36 @@ interface InsertApontamentoData {
throw error;
}
// REGRA DO KIT COMPLETO:
// Se apontou componente no processo de Corte, verificar se todos os componentes da peça pai completaram o kit.
// Se completou, cria automaticamente o apontamento da Peça Principal no Corte (tipo_apontamento = 'peca'),
// computando o peso integral da peça mestre no Corte e liberando-a para a Solda!
if (insertData.tipo_apontamento === 'componente' && insertData.peca_id) {
try {
const procAtual = processos.find(p => p.id === insertData.processo_id);
const isProcessoCorte = procAtual
? (procAtual.nome.toLowerCase().includes('corte') || procAtual.nome.toLowerCase().includes('prepar') || procAtual.ordem === 1)
: true;
if (isProcessoCorte) {
const resSync = await sincronizarPecaKitNoCorte({
ofNumber: insertData.of_number,
pecaId: insertData.peca_id,
processoCorteId: insertData.processo_id,
userId: user.id,
usuarioNome: user.name || user.username || user.email?.split('@')[0] || 'Operador',
dataApontamento: insertData.data_apontamento
});
if (resSync && resSync.delta_liberado > 0) {
toast.success(`Kit 100% cortado! +${resSync.delta_liberado} un da peça ${resSync.marca} liberada(s) para Solda (${resSync.kits_completos}/${resSync.quantidade_total}).`);
}
}
} catch (kitError) {
console.error('Erro ao verificar regra de kit completo:', kitError);
}
}
console.log('✅ Apontamento criado com sucesso, forçando atualização completa...');
// FORÇA ATUALIZAÇÃO COMPLETA DOS DADOS
@@ -498,6 +538,38 @@ interface InsertApontamentoData {
}
}, [user, fetchApontamentos, fetchProcessos]);
const sincronizarKitsOF = useCallback(async (ofNumber: string, processoCorteId?: string) => {
try {
const procCorte = processoCorteId || processos.find(p => {
const n = (p.nome || '').toLowerCase();
return n.includes('corte') || n.includes('prepar') || p.ordem === 1;
})?.id;
if (!procCorte) {
console.warn('Processo de corte não encontrado para sincronização de kits.');
return [];
}
const resultados = await sincronizarTodosKitsOFCorte({
ofNumber,
processoCorteId: procCorte,
userId: user?.id,
usuarioNome: user?.name || user?.username || user?.email?.split('@')[0] || 'Operador'
});
const totalLiberadas = resultados.reduce((sum, r) => sum + r.delta_liberado, 0);
if (totalLiberadas > 0) {
toast.success(`Sincronização: +${totalLiberadas} kit(s) completos liberados para Solda!`);
await fetchApontamentos();
}
return resultados;
} catch (e) {
console.error('Erro ao sincronizar kits da OF:', e);
return [];
}
}, [processos, user, fetchApontamentos]);
return {
apontamentos,
processos,
@@ -507,6 +579,7 @@ interface InsertApontamentoData {
atualizarProcesso,
exportarModeloCSV,
buscarQuantidadeProcessada,
sincronizarKitsOF,
refetch
};
};
+87 -6
View File
@@ -24,16 +24,33 @@ import {
// Sincroniza user Logto com profiles Supabase e retorna o UUID real do Supabase
async function syncUserToProfile(user: LogtoUser): Promise<string> {
if (!user?.sub) return user.sub;
const email = user.email || `${user.username || 'user'}@logto.local`;
const email = (user.email || `${user.username || 'user'}@logto.local`).trim();
try {
// 1. Tenta buscar o perfil existente pelo email
const { data: existing } = await supabase
// 1. Tenta buscar o perfil existente pelo email (case-insensitive)
let { data: existing, error: selectError } = await supabase
.from('profiles')
.select('id')
.eq('email', email)
.ilike('email', email)
.maybeSingle();
// Em caso de falha transitória de rede, faz 1 retry rápido antes de desistir
if (selectError) {
await new Promise((r) => setTimeout(r, 800));
const retry = await supabase
.from('profiles')
.select('id')
.ilike('email', email)
.maybeSingle();
existing = retry.data;
selectError = retry.error;
}
if (selectError) {
console.warn('Erro de rede ao consultar profile no Supabase:', selectError);
return user.sub;
}
let profileId = existing?.id;
// 2. Se existe, atualizamos o nome e retornamos o UUID
@@ -47,7 +64,7 @@ async function syncUserToProfile(user: LogtoUser): Promise<string> {
return profileId;
}
// 3. Se não existir, criamos um novo com um UUID gerado
// 3. Se confirmadamente não existir, criamos um novo com um UUID gerado
profileId = crypto.randomUUID();
const { error: insertError } = await supabase.from('profiles').insert({
id: profileId,
@@ -67,6 +84,53 @@ async function syncUserToProfile(user: LogtoUser): Promise<string> {
}
}
// Detecta sessão ativa do BrainSteel Hub (Single Sign-On unificado)
function getHubUser(): LogtoUser | null {
try {
let session: any = null;
// 1. Tenta ler parâmetro bs_session diretamente da URL (search ou hash)
const urlParams = new URLSearchParams(window.location.search);
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const bsParam = urlParams.get('bs_session') || hashParams.get('bs_session');
if (bsParam) {
try {
session = JSON.parse(decodeURIComponent(escape(atob(bsParam))));
if (session) {
localStorage.setItem('brainsteel_session', JSON.stringify(session));
}
} catch (e) {
console.warn('[Hub SSO] Erro ao decodificar bs_session da URL:', e);
}
}
// 2. Se não achou na URL, lê do localStorage
if (!session) {
const raw = localStorage.getItem('brainsteel_session');
if (raw) session = JSON.parse(raw);
}
if (!session || (!session.token && !session.isMaster && !session.user)) return null;
const u = session.user || {};
const email = u.email || (session.isMaster ? 'admbrainsteel@gmail.com' : 'user@brainsteel.com.br');
const name = u.name || u.full_name || (session.isMaster ? 'Master Root' : 'Usuário BrainSteel');
const sub = u.id || u.sub || (session.isMaster ? 'master-root-brainsteel' : `hub-${email}`);
return {
sub,
id: sub,
email,
name,
username: email.split('@')[0],
picture: u.picture || u.avatar_url,
};
} catch (e) {
console.warn('[Hub SSO] Erro ao ler sessão do Hub:', e);
return null;
}
}
export interface UseAuthReturn {
user: LogtoUser | null;
loading: boolean;
@@ -88,8 +152,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [isRecoveryFlow, setIsRecoveryFlow] = useState(false);
const callbackHandled = useRef(false);
// Detecta callback URL e processa
// Inicialização de autenticação: prioridade para o BrainSteel Hub SSO
useEffect(() => {
// 1. Detectar sessão nativa do BrainSteel Hub (marcos-vps)
const hubUser = getHubUser();
if (hubUser) {
console.log('🛡️ [Hub SSO] Sessão corporativa do BrainSteel detectada:', hubUser.email);
syncUserToProfile(hubUser).then((profileId) => {
hubUser.id = profileId;
setUser(hubUser);
setAuthInitialized(true);
setLoading(false);
});
return;
}
// 2. Fluxo Logto clássico (Hostinger / Acesso autônomo sem Hub)
const url = new URL(window.location.href);
if (url.searchParams.has('code')) {
if (callbackHandled.current) return;
@@ -143,7 +221,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const signOut = useCallback(async () => {
localStorage.removeItem('userLoginTime');
localStorage.removeItem('brainsteel_session');
document.cookie = 'bs_master=; domain=.brainsteel.com.br; path=/; max-age=0';
setIsRecoveryFlow(false);
setUser(null);
await logtoSignOut();
}, []);
+35 -6
View File
@@ -3,6 +3,14 @@ import { useState, useEffect, useMemo } from 'react';
import { useComponentesPeca } from '@/hooks/useComponentesPeca';
import { useDebounce } from '@/hooks/useDebounce';
export interface ComponenteVinculoDetalhe {
componente_id: string;
peca_id: string;
peca_marca: string;
quantidade_por_peca: number;
quantidade_necessaria: number;
}
export interface ComponenteAgrupado {
marca_componente: string;
descricao: string;
@@ -12,6 +20,7 @@ export interface ComponenteAgrupado {
peca_pai_info: string;
componente_ids: string[];
peca_ids: string[];
vinculos: ComponenteVinculoDetalhe[];
}
// Cache local para evitar buscas repetitivas
@@ -36,7 +45,7 @@ export const useComponentesAgrupados = (ofNumber: string, fase: string, pecasDat
peca &&
peca.of_number === debouncedOfNumber &&
peca.etapa_fase === debouncedFase &&
peca.tem_componentes === true // Apenas peças com componentes
peca.tem_componentes !== false // Peças com componentes (ou não marcadas como S/M)
);
}, [pecasData, debouncedOfNumber, debouncedFase]);
@@ -101,22 +110,40 @@ export const useComponentesAgrupados = (ofNumber: string, fase: string, pecasDat
if (!pecaPai) return;
const porPeca = Math.max(1, Number(comp.quantidade_por_peca) || 1);
const qtdTotalPeca = Number(pecaPai.quantidade) || 0;
const qtdNecessaria = porPeca * qtdTotalPeca;
const vinculo: ComponenteVinculoDetalhe = {
componente_id: comp.id,
peca_id: comp.peca_id,
peca_marca: pecaPai.marca,
quantidade_por_peca: porPeca,
quantidade_necessaria: qtdNecessaria,
};
if (grupos.has(marca)) {
const grupo = grupos.get(marca)!;
grupo.quantidade_total += comp.quantidade_por_peca * (pecaPai.quantidade || 0);
grupo.peca_pai_info += `, ${pecaPai.marca}`;
grupo.quantidade_total += qtdNecessaria;
if (!grupo.peca_pai_info.includes(pecaPai.marca)) {
grupo.peca_pai_info += `, ${pecaPai.marca}`;
}
grupo.componente_ids.push(comp.id);
grupo.peca_ids.push(comp.peca_id);
if (!grupo.peca_ids.includes(comp.peca_id)) {
grupo.peca_ids.push(comp.peca_id);
}
grupo.vinculos.push(vinculo);
} else {
grupos.set(marca, {
marca_componente: marca,
descricao: comp.descricao || '',
perfil: comp.perfil || '',
peso_unitario: comp.peso_unitario,
quantidade_total: comp.quantidade_por_peca * (pecaPai.quantidade || 0),
quantidade_total: qtdNecessaria,
peca_pai_info: pecaPai.marca,
componente_ids: [comp.id],
peca_ids: [comp.peca_id]
peca_ids: [comp.peca_id],
vinculos: [vinculo],
});
}
});
@@ -133,6 +160,8 @@ export const useComponentesAgrupados = (ofNumber: string, fase: string, pecasDat
return {
componentesAgrupados,
componentesRaw: componentes || [],
loading: loading || componentesLoading
};
};
+12 -27
View File
@@ -171,16 +171,11 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
const processoCorteId = processoCorte.data?.id;
const pecasInfoMap = new Map((pecasData || []).map(p => [p.id, p]));
// 1. Somar peças com componentes apontadas na Solda
// 1. Somar peças com componentes apontadas na Solda (apenas tipo peca)
if (processoSoldaId) {
const apontamentosSolda = apontamentosData.filter(a => a.processo_id === processoSoldaId);
const apontamentosSolda = apontamentosData.filter(a => a.processo_id === processoSoldaId && a.tipo_apontamento === 'peca');
apontamentosSolda.forEach(apontamento => {
let pesoUnitario = 0;
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) {
pesoUnitario = Number(apontamento.componente.peso_unitario);
} else if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.peso_unitario) {
pesoUnitario = Number(apontamento.peca.peso_unitario);
}
const pesoUnitario = Number(apontamento.peca?.peso_unitario || 0);
const pesoApontamento = pesoUnitario * Number(apontamento.quantidade_produzida || 0);
pesoTotalFabricado += pesoApontamento;
});
@@ -357,15 +352,10 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
let pesoFabricadoProcesso = 0;
apontamentosProcesso.forEach(a => {
let pesoUnitario = 0;
if (a.tipo_apontamento === 'componente' && a.componente?.peso_unitario) {
pesoUnitario = Number(a.componente.peso_unitario);
} else if (a.tipo_apontamento === 'peca' && a.peca?.peso_unitario) {
pesoUnitario = Number(a.peca.peso_unitario);
// Apenas apontamentos de peça mestre somam peso no processo para evitar dupla contagem com subcomponentes
if (a.tipo_apontamento === 'peca' && a.peca?.peso_unitario) {
pesoFabricadoProcesso += Number(a.peca.peso_unitario) * Number(a.quantidade_produzida);
}
pesoFabricadoProcesso += pesoUnitario * Number(a.quantidade_produzida);
});
const isProcessoSolda = processo.nome.toLowerCase().includes('solda');
@@ -507,17 +497,12 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
return dataAptStr <= dataStr;
});
let realizado = 0;
apontamentosAteData.forEach(a => {
let pesoUnitario = 0;
if (a.tipo_apontamento === 'componente' && a.componente?.peso_unitario) {
pesoUnitario = Number(a.componente.peso_unitario);
} else if (a.tipo_apontamento === 'peca' && a.peca?.peso_unitario) {
pesoUnitario = Number(a.peca.peso_unitario);
}
realizado += pesoUnitario * Number(a.quantidade_produzida);
});
apontamentosAteData.forEach(a => {
// Apenas peças mestres computam peso realizado no gráfico
if (a.tipo_apontamento === 'peca' && a.peca?.peso_unitario) {
realizado += Number(a.peca.peso_unitario) * Number(a.quantidade_produzida);
}
});
dadosGrafico.push({
data: dataStr,
+233
View File
@@ -0,0 +1,233 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { GRUPOS_AVANCADOS_CONFIG, GrupoAvancadoConfig } from '@/types/grupoAvancadoTypes';
export interface UserGrupoAvancado {
userId: string;
email: string;
fullName: string;
functionName: string;
privilegeName: string;
profileImageUrl: string | null;
regras: Record<string, boolean>; // regraKey -> true/false
}
export function useGrupoAvancadoPermissions(grupoKey: string | null) {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [users, setUsers] = useState<UserGrupoAvancado[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const config: GrupoAvancadoConfig | undefined = grupoKey
? GRUPOS_AVANCADOS_CONFIG[grupoKey]
: undefined;
const carregarDados = useCallback(async () => {
if (!config) return;
try {
setLoading(true);
// 1. Carregar profiles com cargo e privilégio
const { data: profiles, error: profError } = await supabase
.from('profiles')
.select(`
id,
email,
full_name,
profile_image_url,
privileges:privilege_id (
id,
name
),
user_functions:function_id (
id,
name
)
`)
.order('full_name', { ascending: true });
if (profError) throw profError;
// 2. Carregar permissões das regras deste grupo na tabela user_interface_permissions
const chavesRegras = config.regras.map((r) => r.key);
const { data: permsData, error: permsError } = await supabase
.from('user_interface_permissions')
.select('user_id, resource_key, permission')
.in('resource_key', chavesRegras);
if (permsError) {
console.warn('Aviso ao carregar permissões avançadas:', permsError);
}
// Indexar permissões por user_id -> resource_key -> ativo
const userPermsMap: Record<string, Record<string, boolean>> = {};
(permsData || []).forEach((p: any) => {
if (!userPermsMap[p.user_id]) userPermsMap[p.user_id] = {};
userPermsMap[p.user_id][p.resource_key] =
p.permission === 'can_create_update_delete' ||
p.permission === 'can_admin' ||
p.permission === 'can_create_only' ||
p.permission === 'can_view_only';
});
// Mapear colaboradores
const mapped: UserGrupoAvancado[] = (profiles || []).map((p: any) => {
const privName = p.privileges?.name || 'Padrão';
const isAdmin = privName.toLowerCase().includes('admin');
const userRules: Record<string, boolean> = {};
config.regras.forEach((r) => {
if (userPermsMap[p.id] && userPermsMap[p.id][r.key] !== undefined) {
userRules[r.key] = userPermsMap[p.id][r.key];
} else if (isAdmin) {
userRules[r.key] = true;
} else {
userRules[r.key] = Boolean(r.padraoAtivo);
}
});
return {
userId: p.id,
email: p.email || '',
fullName: p.full_name || p.email?.split('@')[0] || 'Usuário',
functionName: p.user_functions?.name || 'Sem Cargo',
privilegeName: privName,
profileImageUrl: p.profile_image_url,
regras: userRules,
};
});
setUsers(mapped);
if (!selectedUserId && mapped.length > 0) {
setSelectedUserId(mapped[0].userId);
}
} catch (err: any) {
console.error('Erro ao carregar permissões avançadas do grupo:', err);
toast.error('Erro ao carregar governança avançada.');
} finally {
setLoading(false);
}
}, [config, selectedUserId]);
useEffect(() => {
if (grupoKey) {
carregarDados();
}
}, [grupoKey]);
const selectedUser = useMemo(
() => users.find((u) => u.userId === selectedUserId) || null,
[users, selectedUserId]
);
// Toggle de uma regra específica para o usuário selecionado
const toggleRegra = (regraKey: string) => {
if (!selectedUserId) return;
setUsers((prev) =>
prev.map((u) => {
if (u.userId !== selectedUserId) return u;
return {
...u,
regras: {
...u.regras,
[regraKey]: !u.regras[regraKey],
},
};
})
);
};
// Aplica um template pré-definido para o usuário selecionado
const aplicarTemplate = (templateId: string) => {
if (!config || !selectedUserId) return;
const tpl = config.templates.find((t) => t.id === templateId);
if (!tpl) return;
const novasRegras: Record<string, boolean> = {};
config.regras.forEach((r) => {
novasRegras[r.key] = tpl.regrasAtivas.includes(r.key);
});
setUsers((prev) =>
prev.map((u) => {
if (u.userId !== selectedUserId) return u;
return {
...u,
regras: novasRegras,
};
})
);
toast.info(`Template "${tpl.nome}" aplicado. Clique em Salvar para gravar.`);
};
// Salvar alterações no banco de dados
const salvarConfiguracaoUsuario = async () => {
if (!config || !selectedUser) return;
try {
setSaving(true);
// Upsert para cada regra na tabela user_interface_permissions
const promises = config.regras.map(async (r) => {
const estaAtivo = Boolean(selectedUser.regras[r.key]);
const permission = estaAtivo ? 'can_create_update_delete' : 'no_access';
// Garantir que a chave exista em interface_resources para evitar erro de Foreign Key
await supabase
.from('interface_resources')
.upsert(
{
resource_key: r.key,
resource_name: r.nome,
is_submenu: false,
},
{ onConflict: 'resource_key' }
);
return supabase
.from('user_interface_permissions')
.upsert(
{
user_id: selectedUser.userId,
resource_key: r.key,
permission: permission as any,
updated_at: new Date().toISOString(),
},
{ onConflict: 'user_id,resource_key' }
);
});
const results = await Promise.all(promises);
const hasError = results.some((res) => res.error);
if (hasError) {
throw new Error('Falha ao persistir algumas regras no banco.');
}
toast.success(`Configurações avançadas salvas para ${selectedUser.fullName}!`);
} catch (err: any) {
console.error('Erro ao salvar regras avançadas:', err);
toast.error('Erro ao salvar alterações no banco.');
} finally {
setSaving(false);
}
};
return {
config,
loading,
saving,
users,
selectedUserId,
setSelectedUserId,
selectedUser,
toggleRegra,
aplicarTemplate,
salvarConfiguracaoUsuario,
recarregar: carregarDados,
};
}
+50
View File
@@ -0,0 +1,50 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
/**
* Hook para gerenciar e compensar a presença do cabeçalho / targa do BrainSteel Hub.
* - Detecta se o banner do SDK (#brainsteel-guard-banner) está ativo.
* - Sincroniza a variável CSS --hub-banner-height para que sidebars e layouts se ajustem.
* - Oculta automaticamente o banner quando no Modo Smart (/smart).
*/
export function useHubBanner() {
const location = useLocation();
const isSmartMode = location.pathname.startsWith('/smart');
useEffect(() => {
function updateBannerState() {
const banner = document.getElementById('brainsteel-guard-banner');
const isBannerPresent = Boolean(banner);
if (isSmartMode) {
document.body.classList.add('modo-smart-active');
document.body.classList.remove('has-hub-banner');
document.documentElement.style.setProperty('--hub-banner-height', '0px');
document.body.style.paddingTop = '0px';
} else if (isBannerPresent) {
document.body.classList.remove('modo-smart-active');
document.body.classList.add('has-hub-banner');
document.documentElement.style.setProperty('--hub-banner-height', '34px');
document.body.style.paddingTop = '34px';
} else {
document.body.classList.remove('modo-smart-active');
document.body.classList.remove('has-hub-banner');
document.documentElement.style.setProperty('--hub-banner-height', '0px');
document.body.style.paddingTop = '0px';
}
}
updateBannerState();
// Observa inserções dinâmicas do banner pelo script SDK do Hub
const observer = new MutationObserver(() => {
updateBannerState();
});
observer.observe(document.body, { childList: true, subtree: false });
return () => {
observer.disconnect();
};
}, [isSmartMode]);
}
+502
View File
@@ -0,0 +1,502 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
export interface ComponenteKitInfo {
id: string;
peca_id: string;
marca_componente: string;
descricao?: string | null;
perfil?: string | null;
peso_unitario: number;
quantidade_por_peca: number;
quantidade_total_necessaria: number;
quantidade_cortada: number;
kits_possiveis: number;
percentual_cortado: number;
falta_para_prox_kit: number;
concluido: boolean;
}
export interface PecaKitInfo {
peca_id: string;
marca: string;
descricao?: string | null;
perfil_principal?: string | null;
quantidade_total: number;
etapa_fase: string;
tem_componentes: boolean;
peso_unitario: number;
peso_total_peca: number;
peso_kits_liberados: number;
componentes: ComponenteKitInfo[];
kits_completos_cortados: number; // Quantidade de kits 100% montáveis liberados
percentual_termico: number; // 0 a 100%
status_termico: 'verde' | 'verde_parcial' | 'amarelo' | 'vermelho';
componente_gargalo: ComponenteKitInfo | null;
peso_total_componentes: number;
peso_cortado_componentes: number;
ja_apontado_solda: number;
saldo_disponivel_solda: number;
bloqueado_para_solda: boolean;
motivo_bloqueio_solda: string | null;
}
export interface ResumoKitsOF {
totalPecas: number;
totalKits100Porcento: number;
totalKitsParciais: number;
totalKitsNaoIniciados: number;
pesoTotalOFKg: number;
pesoCortadoTotalKg: number;
peso100PorcentoKg: number;
pesoUnidadesLiberadasKg: number;
pesoParciaisKg: number;
pesoNaoIniciadosKg: number;
percentualGeralOF: number;
totalUnidadesTotal: number;
totalUnidadesLiberadasSolda: number;
}
/**
* Função pura para cálculo determinístico de Kits de Produção.
* Recebe peças, componentes e apontamentos e retorna o mapa térmico de kits.
*/
export const calcularKitsOF = (
pecas: Array<{
id: string;
marca: string;
descricao?: string | null;
perfil_principal?: string | null;
quantidade: number;
etapa_fase?: string | null;
tem_componentes?: boolean | null;
peso_unitario?: number | null;
peso_total?: number | null;
}>,
componentes: Array<{
id: string;
peca_id: string;
marca_componente: string;
descricao?: string | null;
perfil?: string | null;
peso_unitario?: number | null;
quantidade_por_peca?: number | null;
}>,
apontamentos: Array<{
id?: string;
peca_id?: string | null;
componente_id?: string | null;
processo_id: string;
quantidade_produzida: number;
tipo_apontamento?: string | null;
}>,
processoCorteId?: string,
processoSoldaId?: string
): Map<string, PecaKitInfo> => {
const mapaKits = new Map<string, PecaKitInfo>();
// 1. Mapear componentes por peca_id
const componentesPorPeca = new Map<string, typeof componentes>();
componentes.forEach((c) => {
if (!componentesPorPeca.has(c.peca_id)) {
componentesPorPeca.set(c.peca_id, []);
}
componentesPorPeca.get(c.peca_id)!.push(c);
});
// 2. Mapear apontamentos de corte por componente_id
const cortadoPorComponente = new Map<string, number>();
// Apontamentos de corte por peca_id (caso seja peça sem componentes ou apontamento direto)
const cortadoPecaNoCorte = new Map<string, number>();
// Apontamentos de solda por peca_id
const apontadoNaSoldaPorPeca = new Map<string, number>();
apontamentos.forEach((ap) => {
const qtd = Number(ap.quantidade_produzida || 0);
// Se for apontamento de componente no corte (ou se não especificou processoCorteId, computa todos de componente)
if (ap.tipo_apontamento === 'componente' && ap.componente_id) {
if (!processoCorteId || ap.processo_id === processoCorteId) {
cortadoPorComponente.set(
ap.componente_id,
(cortadoPorComponente.get(ap.componente_id) || 0) + qtd
);
}
}
// Se for apontamento de peça mestre no corte
if (
(!ap.tipo_apontamento || ap.tipo_apontamento === 'peca') &&
!ap.componente_id &&
ap.peca_id
) {
if (!processoCorteId || ap.processo_id === processoCorteId) {
cortadoPecaNoCorte.set(
ap.peca_id,
(cortadoPecaNoCorte.get(ap.peca_id) || 0) + qtd
);
}
}
// Se for apontamento de solda
if (
(!ap.tipo_apontamento || ap.tipo_apontamento === 'peca') &&
!ap.componente_id &&
ap.peca_id
) {
if (processoSoldaId && ap.processo_id === processoSoldaId) {
apontadoNaSoldaPorPeca.set(
ap.peca_id,
(apontadoNaSoldaPorPeca.get(ap.peca_id) || 0) + qtd
);
}
}
});
// 3. Processar cada peça
pecas.forEach((peca) => {
const compsRaw = componentesPorPeca.get(peca.id) || [];
const qtdTotalPeca = Number(peca.quantidade) || 0;
const fase = peca.etapa_fase || 'Geral';
const temComps = peca.tem_componentes !== false && compsRaw.length > 0;
const jaApontadoSolda = apontadoNaSoldaPorPeca.get(peca.id) || 0;
const pesoUnitPeca = Number(peca.peso_unitario) || (qtdTotalPeca > 0 && peca.peso_total ? Number(peca.peso_total) / qtdTotalPeca : 0);
const pesoTotalPeca = Number(peca.peso_total) || (qtdTotalPeca * pesoUnitPeca);
// Cenário A: Peça sem componentes (monobloco / S/M)
if (!temComps) {
const qtdCortada = cortadoPecaNoCorte.get(peca.id) || 0;
const kitsCompletos = Math.min(qtdTotalPeca, qtdCortada);
const pct = qtdTotalPeca > 0 ? Math.min(100, Math.round((kitsCompletos / qtdTotalPeca) * 100)) : 0;
const saldoSolda = Math.max(0, kitsCompletos - jaApontadoSolda);
const pesoCortado = kitsCompletos * pesoUnitPeca;
const pesoLiberado = kitsCompletos * pesoUnitPeca;
mapaKits.set(peca.id, {
peca_id: peca.id,
marca: peca.marca,
descricao: peca.descricao,
perfil_principal: peca.perfil_principal,
quantidade_total: qtdTotalPeca,
etapa_fase: fase,
tem_componentes: false,
peso_unitario: pesoUnitPeca,
peso_total_peca: pesoTotalPeca,
peso_kits_liberados: pesoLiberado,
componentes: [],
kits_completos_cortados: kitsCompletos,
percentual_termico: pct,
status_termico:
kitsCompletos >= qtdTotalPeca
? 'verde'
: kitsCompletos > 0
? 'verde_parcial'
: pct >= 40
? 'amarelo'
: 'vermelho',
componente_gargalo: null,
peso_total_componentes: pesoTotalPeca,
peso_cortado_componentes: pesoCortado,
ja_apontado_solda: jaApontadoSolda,
saldo_disponivel_solda: peca.tem_componentes === false ? 0 : saldoSolda,
bloqueado_para_solda: peca.tem_componentes === false || saldoSolda <= 0,
motivo_bloqueio_solda:
peca.tem_componentes === false
? 'Peça S/M (Pula Solda)'
: saldoSolda <= 0 && kitsCompletos === 0
? 'Aguardando corte da peça'
: saldoSolda <= 0
? `Todos os ${kitsCompletos} kits cortados já foram soldados`
: null,
});
return;
}
// Cenário B: Peça com componentes
let pesoTotalComps = 0;
let pesoCortadoComps = 0;
let menorKits = qtdTotalPeca;
let gargaloComp: ComponenteKitInfo | null = null;
let menorRazao = 999999;
const listaCompsInfo: ComponenteKitInfo[] = [];
compsRaw.forEach((comp) => {
const porPeca = Math.max(1, Number(comp.quantidade_por_peca) || 1);
const totalNecessario = qtdTotalPeca * porPeca;
const cortado = cortadoPorComponente.get(comp.id) || 0;
const pesoUnit = Number(comp.peso_unitario) || 0;
const kitsDesteComp = Math.floor(cortado / porPeca);
if (kitsDesteComp < menorKits) {
menorKits = kitsDesteComp;
}
const pctComp = totalNecessario > 0 ? Math.min(100, Math.round((cortado / totalNecessario) * 100)) : 0;
const pesoTotalComp = totalNecessario * pesoUnit;
const pesoCortadoComp = Math.min(cortado, totalNecessario) * pesoUnit;
pesoTotalComps += pesoTotalComp;
pesoCortadoComps += pesoCortadoComp;
const infoComp: ComponenteKitInfo = {
id: comp.id,
peca_id: comp.peca_id,
marca_componente: comp.marca_componente,
descricao: comp.descricao,
perfil: comp.perfil,
peso_unitario: pesoUnit,
quantidade_por_peca: porPeca,
quantidade_total_necessaria: totalNecessario,
quantidade_cortada: cortado,
kits_possiveis: kitsDesteComp,
percentual_cortado: pctComp,
falta_para_prox_kit: 0, // calculado logo abaixo
concluido: cortado >= totalNecessario,
};
listaCompsInfo.push(infoComp);
});
const kitsCompletos = Math.min(qtdTotalPeca, Math.max(0, menorKits));
// Determinar o componente gargalo para o próximo kit
listaCompsInfo.forEach((ci) => {
const necessarioParaProxKit = Math.min(qtdTotalPeca, kitsCompletos + 1) * ci.quantidade_por_peca;
ci.falta_para_prox_kit = Math.max(0, necessarioParaProxKit - ci.quantidade_cortada);
// Critério de gargalo: quem mais segura a formação de novos kits
const razao = ci.kits_possiveis;
if (razao < menorRazao && kitsCompletos < qtdTotalPeca) {
menorRazao = razao;
gargaloComp = ci;
}
});
// Percentual térmico de prontidão ponderado por peso (ou por qtd se peso=0)
let percentualTermico = 0;
if (pesoTotalComps > 0) {
percentualTermico = Math.min(100, Math.round((pesoCortadoComps / pesoTotalComps) * 100));
} else {
const qtdTotalNecessaria = listaCompsInfo.reduce((s, c) => s + c.quantidade_total_necessaria, 0);
const qtdTotalCortada = listaCompsInfo.reduce((s, c) => s + Math.min(c.quantidade_cortada, c.quantidade_total_necessaria), 0);
percentualTermico = qtdTotalNecessaria > 0 ? Math.min(100, Math.round((qtdTotalCortada / qtdTotalNecessaria) * 100)) : 0;
}
// Status térmico
let statusTermico: 'verde' | 'verde_parcial' | 'amarelo' | 'vermelho' = 'vermelho';
if (kitsCompletos >= qtdTotalPeca) {
statusTermico = 'verde';
} else if (kitsCompletos > 0) {
statusTermico = 'verde_parcial';
} else if (percentualTermico >= 40) {
statusTermico = 'amarelo';
} else {
statusTermico = 'vermelho';
}
const saldoSolda = Math.max(0, kitsCompletos - jaApontadoSolda);
const bloqueadoSolda = saldoSolda <= 0;
let motivoBloqueioSolda: string | null = null;
if (bloqueadoSolda) {
if (kitsCompletos === 0) {
const detGargalo = gargaloComp ? ` • Gargalo: ${gargaloComp.marca_componente} (faltam ${gargaloComp.falta_para_prox_kit} un)` : '';
motivoBloqueioSolda = `Aguardando kits completos de corte (0 un liberadas)${detGargalo}`;
} else if (jaApontadoSolda >= kitsCompletos) {
motivoBloqueioSolda = `Todos os ${kitsCompletos} kits cortados já foram soldados (${jaApontadoSolda}/${qtdTotalPeca} un) • Aguardando novos kits cortados`;
}
}
const pesoUnitComps = pesoTotalComps > 0 && qtdTotalPeca > 0 ? pesoTotalComps / qtdTotalPeca : 0;
const pesoUnitPecaFinal = Number(peca.peso_unitario) || pesoUnitComps;
const pesoTotalPecaFinal = pesoTotalComps > 0 ? pesoTotalComps : (Number(peca.peso_total) || (qtdTotalPeca * pesoUnitPecaFinal));
const pesoLiberadoFinal = kitsCompletos * pesoUnitPecaFinal;
mapaKits.set(peca.id, {
peca_id: peca.id,
marca: peca.marca,
descricao: peca.descricao,
perfil_principal: peca.perfil_principal,
quantidade_total: qtdTotalPeca,
etapa_fase: fase,
tem_componentes: true,
peso_unitario: pesoUnitPecaFinal,
peso_total_peca: pesoTotalPecaFinal,
peso_kits_liberados: pesoLiberadoFinal,
componentes: listaCompsInfo,
kits_completos_cortados: kitsCompletos,
percentual_termico: percentualTermico,
status_termico: statusTermico,
componente_gargalo: gargaloComp,
peso_total_componentes: pesoTotalPecaFinal,
peso_cortado_componentes: pesoCortadoComps,
ja_apontado_solda: jaApontadoSolda,
saldo_disponivel_solda: saldoSolda,
bloqueado_para_solda: bloqueadoSolda,
motivo_bloqueio_solda: motivoBloqueioSolda,
});
});
return mapaKits;
};
/**
* Hook completo para carregar e gerenciar os Kits de uma OF e Fase.
*/
export const useKitsProducao = (ofNumber?: string, fase?: string) => {
const { data, isLoading, refetch, error } = useQuery({
queryKey: ['kits-producao', ofNumber, fase],
enabled: Boolean(ofNumber),
staleTime: 10000, // 10 segundos
queryFn: async () => {
if (!ofNumber) return { mapaKits: new Map<string, PecaKitInfo>(), resumo: null, lista: [] };
// 1. Buscar processos para identificar IDs de Corte e Solda
const { data: procs } = await supabase
.from('processos_fabricacao')
.select('id, nome, ordem')
.order('ordem');
const procCorte = (procs || []).find(
(p) => p.nome.toLowerCase().includes('corte') || p.nome.toLowerCase().includes('prepar') || p.ordem === 1
);
const procSolda = (procs || []).find(
(p) => p.nome.toLowerCase().includes('solda') || p.nome.toLowerCase().includes('montag') || p.ordem === 3
);
// 2. Buscar peças da OF
let pecasQuery = supabase
.from('pecas')
.select('id, marca, descricao, perfil_principal, quantidade, etapa_fase, tem_componentes, peso_unitario, peso_total')
.eq('of_number', ofNumber);
if (fase && fase !== 'all' && fase !== 'Todas as fases' && fase !== 'todas') {
pecasQuery = pecasQuery.eq('etapa_fase', fase);
}
const { data: pecasData, error: pecasErr } = await pecasQuery;
if (pecasErr) throw pecasErr;
const pecas = pecasData || [];
const pecaIds = pecas.map((p) => p.id);
// 3. Buscar componentes das peças
let componentes: Array<{
id: string;
peca_id: string;
marca_componente: string;
descricao?: string | null;
perfil?: string | null;
peso_unitario?: number | null;
quantidade_por_peca?: number | null;
}> = [];
if (pecaIds.length > 0) {
for (let i = 0; i < pecaIds.length; i += 300) {
const chunk = pecaIds.slice(i, i + 300);
const { data: compsChunk, error: compsErr } = await supabase
.from('componentes_peca')
.select('id, peca_id, marca_componente, descricao, perfil, peso_unitario, quantidade_por_peca')
.in('peca_id', chunk);
if (compsErr) throw compsErr;
if (compsChunk) {
componentes = [...componentes, ...compsChunk];
}
}
}
// 4. Buscar apontamentos da OF
const { data: apontamentosData, error: aptsErr } = await supabase
.from('apontamentos_producao')
.select('id, peca_id, componente_id, processo_id, quantidade_produzida, tipo_apontamento')
.eq('of_number', ofNumber);
if (aptsErr) throw aptsErr;
// 5. Calcular kits
const mapaKits = calcularKitsOF(
pecas,
componentes,
apontamentosData || [],
procCorte?.id,
procSolda?.id
);
const lista = Array.from(mapaKits.values()).sort((a, b) =>
a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' })
);
// 6. Resumo geral
let total100 = 0;
let totalParciais = 0;
let totalTravados = 0;
let pesoTot = 0;
let pesoCort = 0;
let peso100 = 0;
let pesoUnLiberadas = 0;
let pesoParciais = 0;
let pesoNaoIniciados = 0;
let unTot = 0;
let unLib = 0;
lista.forEach((item) => {
unTot += item.quantidade_total;
unLib += item.kits_completos_cortados;
pesoTot += item.peso_total_peca;
pesoCort += item.peso_cortado_componentes;
pesoUnLiberadas += item.peso_kits_liberados;
if (item.status_termico === 'verde') {
total100++;
peso100 += item.peso_total_peca;
} else if (item.status_termico === 'verde_parcial' || item.status_termico === 'amarelo') {
totalParciais++;
pesoParciais += item.peso_total_peca;
} else {
totalTravados++;
pesoNaoIniciados += item.peso_total_peca;
}
});
const resumo: ResumoKitsOF = {
totalPecas: lista.length,
totalKits100Porcento: total100,
totalKitsParciais: totalParciais,
totalKitsNaoIniciados: totalTravados,
pesoTotalOFKg: pesoTot,
pesoCortadoTotalKg: pesoCort,
peso100PorcentoKg: peso100,
pesoUnidadesLiberadasKg: pesoUnLiberadas,
pesoParciaisKg: pesoParciais,
pesoNaoIniciadosKg: pesoNaoIniciados,
percentualGeralOF: pesoTot > 0 ? Math.round((pesoCortadoTotalKg(pesoTot, pesoCort)) * 100) : 0,
totalUnidadesTotal: unTot,
totalUnidadesLiberadasSolda: unLib,
};
return {
mapaKits,
resumo,
lista,
};
},
});
return {
kitsPorPeca: data?.mapaKits || new Map<string, PecaKitInfo>(),
listaKits: data?.lista || [],
resumoKits: data?.resumo,
loading: isLoading,
refetch,
error,
};
};
function pesoCortadoTotalKg(total: number, cortado: number): number {
if (total <= 0) return 0;
return Math.min(1, cortado / total);
}
+37 -51
View File
@@ -63,6 +63,7 @@ export const GRUPOS_MODULOS: GrupoRecurso[] = [
descricao: 'Projetos, modelos 3D, fichas técnicas e cronogramas',
recursos: [
{ key: 'visualizador-3d', nome: 'Visualizador 3D BIM', rota: '/visualizador-3d' },
{ key: 'analisador-3d', nome: 'Analisador 3D & QA/QC', rota: '/analisador-3d' },
{ key: 'ofs-lista', nome: 'Ordens de Fabricação', rota: '/ofs' },
{ key: 'ofs-cronograma', nome: 'Cronogramas de Fabricação', rota: '/ofs/cronograma' },
{ key: 'cadastro-pecas', nome: 'Cadastro de Peças & Conjuntos', rota: '/cadastro/pecas' },
@@ -256,10 +257,8 @@ export const useMatrizAcessos = () => {
GRUPOS_MODULOS.forEach((g) => {
g.recursos.forEach((r) => {
if (isAdmin) {
permissoesMap[r.key] = 'criar';
} else if (directPerms[r.key]) {
// Prioridade 1: Permissão direta do usuário
if (directPerms[r.key]) {
// Prioridade 1: Permissão direta do usuário configurada na Matriz
const perm = directPerms[r.key];
if (perm === 'can_create_update_delete' || perm === 'can_admin' || perm === 'can_create_only') {
permissoesMap[r.key] = 'criar';
@@ -268,6 +267,9 @@ export const useMatrizAcessos = () => {
} else {
permissoesMap[r.key] = 'nenhum';
}
} else if (isAdmin) {
// Se for admin e não tiver sobreposição direta, padrão é criar
permissoesMap[r.key] = 'criar';
} else if (recursosSet.has(`${r.key}:edit`) || recursosSet.has(`${r.key}_edit`)) {
permissoesMap[r.key] = 'criar';
} else if (recursosSet.has(r.key)) {
@@ -344,6 +346,22 @@ export const useMatrizAcessos = () => {
);
try {
// Garantir que a chave do recurso exista em interface_resources para evitar erro de Foreign Key
const { error: resourceError } = await supabase
.from('interface_resources')
.upsert(
{
resource_key: resourceKey,
resource_name: resourceKey,
is_submenu: false
},
{ onConflict: 'resource_key' }
);
if (resourceError) {
console.error('Erro ao registrar recurso na tabela interface_resources:', resourceError);
}
// 1. Gravação direta e individual por usuário na tabela user_interface_permissions (Prioridade Máxima)
const dbPermission =
novoNivel === 'criar'
@@ -369,29 +387,6 @@ export const useMatrizAcessos = () => {
throw uipError;
}
// 2. Compatibilidade legada com privilégios de grupo (se houver)
if (user.privilegeId) {
// Remover permissões antigas do recurso
await supabase
.from('privilege_interface_resources')
.delete()
.eq('privilege_id', user.privilegeId)
.in('resource_key', [resourceKey, `${resourceKey}:edit`]);
// Gravar novo estado
if (novoNivel === 'visualizar') {
await supabase.from('privilege_interface_resources').insert({
privilege_id: user.privilegeId,
resource_key: resourceKey,
});
} else if (novoNivel === 'criar') {
await supabase.from('privilege_interface_resources').insert([
{ privilege_id: user.privilegeId, resource_key: resourceKey },
{ privilege_id: user.privilegeId, resource_key: `${resourceKey}:edit` },
]);
}
}
const desc = {
nenhum: 'Bloqueado (✕)',
visualizar: 'Apenas Visualização [V]',
@@ -425,6 +420,21 @@ export const useMatrizAcessos = () => {
);
try {
// Garantir que todos os recursos do template existam em interface_resources
const resourceRecords = Object.keys(novoMap).map(resKey => ({
resource_key: resKey,
resource_name: resKey,
is_submenu: false
}));
const { error: resourceError } = await supabase
.from('interface_resources')
.upsert(resourceRecords, { onConflict: 'resource_key' });
if (resourceError) {
console.error('Erro ao registrar recursos na tabela interface_resources:', resourceError);
}
// 1. Gravação direta em lote na tabela user_interface_permissions
const uipRecords = Object.entries(novoMap).map(([resKey, nivel]) => ({
user_id: userId,
@@ -446,30 +456,6 @@ export const useMatrizAcessos = () => {
throw batchError;
}
// 2. Compatibilidade legada com privilégios de grupo
if (user.privilegeId) {
// Limpar recursos atuais do privilégio
await supabase
.from('privilege_interface_resources')
.delete()
.eq('privilege_id', user.privilegeId);
// Inserir os novos recursos do template
const records: { privilege_id: string; resource_key: string }[] = [];
Object.entries(novoMap).forEach(([resKey, nivel]) => {
if (nivel === 'visualizar') {
records.push({ privilege_id: user.privilegeId!, resource_key: resKey });
} else if (nivel === 'criar') {
records.push({ privilege_id: user.privilegeId!, resource_key: resKey });
records.push({ privilege_id: user.privilegeId!, resource_key: `${resKey}:edit` });
}
});
if (records.length > 0) {
await supabase.from('privilege_interface_resources').insert(records);
}
}
toast.success(`Template '${template.nome}' aplicado com sucesso a ${user.fullName}!`);
} catch (err) {
console.error('Erro ao aplicar template:', err);
+10 -3
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { useAuth } from '@/hooks/useAuth';
import { extrairComprimentoSeguro, extrairComprimentoComponenteSeguro } from '@/utils/importDiffService';
export interface Peca {
id: string;
@@ -21,6 +22,9 @@ export interface Peca {
user_id: string;
prioridade: string;
quantidadeDisponivel?: number;
revisao_atual?: string;
tem_revisao?: boolean;
qtd_revisoes?: number;
}
interface PecaPrioridade {
@@ -422,6 +426,7 @@ export function usePecas() {
pecasData.forEach((item, index) => {
const pecaKey = `${item.of_number || ''}-${item.etapa_fase || ''}-${item.marca || ''}`;
const comprimentoPeca = extrairComprimentoSeguro(item);
const pecaData = {
of_number: item.of_number || '',
@@ -431,7 +436,7 @@ export function usePecas() {
quantidade: Number(item.quantidade) || 0,
peso_unitario: Number(item.peso_unitario) || 0,
peso_total: Number(item.peso_total) || 0,
comprimento: item.comprimento ? Number(item.comprimento) : (item.comprimento_ref ? Number(item.comprimento_ref) : null),
comprimento: comprimentoPeca,
tratamento_superficial: item.tratamento_superficial || '',
material: item.material || '',
perfil_principal: item.perfil_principal || '',
@@ -442,6 +447,8 @@ export function usePecas() {
if (!pecasMap.has(pecaKey)) {
pecasMap.set(pecaKey, pecaData);
} else if (comprimentoPeca && !pecasMap.get(pecaKey).comprimento) {
pecasMap.get(pecaKey).comprimento = comprimentoPeca;
}
if (item.marca_componente && String(item.marca_componente).trim()) {
@@ -467,12 +474,13 @@ export function usePecas() {
}
cleanMarca = String(numVal);
const compComprimento = extrairComprimentoComponenteSeguro(item);
const componenteData = {
marca_componente: cleanMarca,
descricao: item.descricao_componente || item.descricao || '',
perfil: item.perfil_componente || item.perfil_principal || '',
peso_unitario: Number(item.peso_unitario_componente) || 0,
comprimento: item.comprimento_componente ? Number(item.comprimento_componente) : null,
comprimento: compComprimento,
quantidade_por_peca: Math.max(1, Math.round(Number(item.quantidade_por_peca) || 1)),
user_id: user.id
};
@@ -498,7 +506,6 @@ export function usePecas() {
const { data: pecasExistentes, error: queryExistentesError } = await supabase
.from('pecas')
.select('id, of_number, etapa_fase, marca')
.eq('user_id', user.id)
.in('of_number', ofsParaConsultar);
if (queryExistentesError) {
+27 -1
View File
@@ -2,6 +2,7 @@
import { useUserPermissions } from '@/hooks/useUserPermissions';
import { useUserRole } from '@/hooks/useUserRole';
import type { ResourceKey, PermissionLevel } from '@/hooks/useUserPermissions/types';
import { GRUPOS_AVANCADOS_CONFIG } from '@/types/grupoAvancadoTypes';
export function usePermissions() {
const { isAdmin } = useUserRole();
@@ -68,10 +69,35 @@ export function usePermissions() {
}
};
/**
* Verifica se o colaborador logado tem autorização para executar uma regra avançada específica
* (ex: 'pcp_fechar_diario', 'eng_criar_of', 'exp_despachar_carga', etc.)
*/
const canRegra = (regraKey: string): boolean => {
if (isAdmin) return true;
// 1. Verificar permissão explícita configurada no banco
const perm = getResourcePermission(regraKey);
if (perm === 'no_access') return false;
if (perm) return true;
// 2. Fallback: verificar se a regra é ativa por padrão nas configurações
for (const grupo of Object.values(GRUPOS_AVANCADOS_CONFIG)) {
const regra = grupo.regras.find((r) => r.key === regraKey);
if (regra) {
return Boolean(regra.padraoAtivo);
}
}
return false;
};
return {
isAdmin,
checkResourceAccess,
getPermissionForResource,
canPerformAction,
userPermissions
canRegra,
userPermissions,
};
}
+4 -7
View File
@@ -81,14 +81,11 @@ export const useProcessChartData = (ofNumber: string) => {
if (apontamentos && apontamentos.length > 0) {
apontamentos.forEach(apontamento => {
let pesoUnitario = 0;
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) {
pesoUnitario = apontamento.componente.peso_unitario;
} else if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.peso_unitario) {
pesoUnitario = apontamento.peca.peso_unitario;
}
// Apenas peças mestres somam peso nas médias diárias e metas
if (apontamento.tipo_apontamento !== 'peca') return;
const pesoTotal = pesoUnitario * apontamento.quantidade_produzida;
const pesoUnitario = Number(apontamento.peca?.peso_unitario || 0);
const pesoTotal = pesoUnitario * Number(apontamento.quantidade_produzida);
pesoTotalProcessado += pesoTotal;
if (apontamento.data_apontamento) {
+6 -1
View File
@@ -12,6 +12,7 @@ interface ApontamentoDiario {
descricao: string;
processo: string;
quantidade: number;
tipo_apontamento: 'peca' | 'componente';
peso_unitario: number;
peso_total: number;
data_apontamento: string;
@@ -105,6 +106,7 @@ export const useRelatorioDiario = (selectedDate: string) => {
descricao,
processo: apt.processo?.nome || 'N/A',
quantidade: apt.quantidade_produzida,
tipo_apontamento: (apt.tipo_apontamento || 'peca') as 'peca' | 'componente',
peso_unitario,
peso_total,
data_apontamento: apt.data_apontamento,
@@ -139,11 +141,14 @@ export const useRelatorioDiario = (selectedDate: string) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDate]);
// Calcular resumo por processo
// Calcular resumo por processo (apenas peças principais computam quantidade e peso no resumo da produção)
const resumoProcessos: ResumoProcesso[] = React.useMemo(() => {
const resumo = new Map<string, { quantidade: number; apontamentos: number; peso_total: number }>();
apontamentos.forEach(apt => {
// Ignorar componentes no resumo global de produção para evitar peso e contagem duplicados
if (apt.tipo_apontamento !== 'peca') return;
const processo = apt.processo;
if (!resumo.has(processo)) {
resumo.set(processo, { quantidade: 0, apontamentos: 0, peso_total: 0 });
+397
View File
@@ -0,0 +1,397 @@
import { useState, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { Peca } from '@/hooks/usePecas';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
export type TipoRevisao = 'simples' | 'critica';
export interface Revisao {
id: string;
of_number: string;
peca_id: string;
numero_revisao: string;
tipo_revisao: TipoRevisao;
descricao_motivo: string;
observacoes: string | null;
usuario_id: string | null;
usuario_nome: string | null;
usuario_email: string | null;
data_revisao: string;
dados_anteriores: Record<string, any>;
dados_novos: Record<string, any>;
campos_alterados: string[];
afeta_apontamentos: boolean;
impacto_processos: Record<string, any> | null;
created_at: string;
updated_at: string;
peca?: {
marca: string;
etapa_fase: string;
descricao: string;
};
}
export interface CriarRevisaoDTO {
peca: Peca;
numeroRevisao: string;
tipoRevisao?: TipoRevisao;
descricaoMotivo: string;
observacoes?: string;
novosDados: Partial<Peca>;
}
export function useRevisoes(ofNumber?: string) {
const { user } = useAuth();
const [revisoes, setRevisoes] = useState<Revisao[]>([]);
const [loading, setLoading] = useState(false);
// Carrega todas as revisões de uma OF
const carregarRevisoesOF = useCallback(async (of?: string) => {
const ofAlvo = of || ofNumber;
if (!ofAlvo) return [];
setLoading(true);
try {
const { data, error } = await supabase
.from('revisoes' as any)
.select(`
*,
peca:pecas(marca, etapa_fase, descricao)
`)
.eq('of_number', ofAlvo)
.order('data_revisao', { ascending: false });
if (error) {
console.error('Erro ao buscar revisões da OF:', error);
throw error;
}
const lista = (data || []) as unknown as Revisao[];
setRevisoes(lista);
return lista;
} catch (err: any) {
console.error('Erro ao carregar revisões:', err);
toast.error('Erro ao carregar registros de revisões');
return [];
} finally {
setLoading(false);
}
}, [ofNumber]);
// Carrega histórico de revisões de uma peça específica
const carregarRevisoesPeca = useCallback(async (pecaId: string): Promise<Revisao[]> => {
if (!pecaId) return [];
try {
const { data, error } = await supabase
.from('revisoes' as any)
.select(`
*,
peca:pecas(marca, etapa_fase, descricao)
`)
.eq('peca_id', pecaId)
.order('data_revisao', { ascending: false });
if (error) {
console.error('Erro ao buscar revisões da peça:', error);
throw error;
}
return (data || []) as unknown as Revisao[];
} catch (err: any) {
console.error('Erro ao carregar revisões da peça:', err);
return [];
}
}, []);
// Analisa automaticamente se uma alteração é Crítica ou Simples
const detectarTipoRevisao = (pecaAtual: Peca, novosDados: Partial<Peca>): {
tipo: TipoRevisao;
camposAlterados: string[];
afetaApontamentos: boolean;
detalhes: string[];
} => {
const camposCriticos = ['marca', 'quantidade', 'peso_unitario', 'peso_total', 'tem_componentes'];
const camposAlterados: string[] = [];
const detalhes: string[] = [];
let isCritica = false;
let afetaApontamentos = false;
// Verificar cada campo relevante
const chavesParaChecar = [
'marca',
'quantidade',
'peso_unitario',
'peso_total',
'descricao',
'etapa_fase',
'material',
'perfil_principal',
'tratamento_superficial',
'comprimento',
'tem_componentes'
];
for (const chave of chavesParaChecar) {
const valorAntigo = (pecaAtual as any)[chave];
const valorNovo = (novosDados as any)[chave];
if (valorNovo !== undefined && valorNovo !== null && valorNovo !== valorAntigo) {
// Para números, checar diferença de valor
if (typeof valorAntigo === 'number' && typeof valorNovo === 'number') {
if (Math.abs(valorAntigo - valorNovo) > 0.0001) {
camposAlterados.push(chave);
if (camposCriticos.includes(chave)) {
isCritica = true;
if (chave === 'marca' || chave === 'quantidade') {
afetaApontamentos = true;
}
}
}
} else {
camposAlterados.push(chave);
if (camposCriticos.includes(chave)) {
isCritica = true;
if (chave === 'marca') {
afetaApontamentos = true;
}
}
}
}
}
if (camposAlterados.includes('marca')) {
detalhes.push(`Alteração no código/marca da peça (${pecaAtual.marca} ➔ ${novosDados.marca})`);
}
if (camposAlterados.includes('quantidade')) {
const diff = Number(novosDados.quantidade || 0) - Number(pecaAtual.quantidade || 0);
detalhes.push(`Alteração na quantidade (${pecaAtual.quantidade} ➔ ${novosDados.quantidade}, dif: ${diff > 0 ? '+' : ''}${diff})`);
}
if (camposAlterados.includes('peso_unitario') || camposAlterados.includes('peso_total')) {
detalhes.push(`Alteração dimensional/peso`);
}
return {
tipo: isCritica ? 'critica' : 'simples',
camposAlterados,
afetaApontamentos,
detalhes
};
};
// Cria e aplica a revisão
const criarRevisao = async (dto: CriarRevisaoDTO): Promise<boolean> => {
const { peca, numeroRevisao, descricaoMotivo, observacoes, novosDados } = dto;
if (!dto.descricaoMotivo?.trim()) {
toast.error('Informe o motivo ou observações da revisão');
return false;
}
const analise = detectarTipoRevisao(peca, novosDados);
const tipoFinal = dto.tipoRevisao || analise.tipo;
const camposAlterados = analise.camposAlterados;
if (camposAlterados.length === 0) {
toast.error('Nenhum campo foi alterado nesta peça.');
return false;
}
setLoading(true);
try {
const dadosAnteriores: Record<string, any> = {};
const dadosNovos: Record<string, any> = {};
camposAlterados.forEach(c => {
dadosAnteriores[c] = (peca as any)[c];
dadosNovos[c] = (novosDados as any)[c];
});
const impactoProcessos: Record<string, any> = {
resumo: [],
ajustes_apontamentos: []
};
// REGRA DE NEGÓCIO DE APONTAMENTOS / PROCESSOS:
// Caso 1: A quantidade foi reduzida (pra menos)
const novaQtd = Number(novosDados.quantidade !== undefined ? novosDados.quantidade : peca.quantidade);
const qtdAntiga = Number(peca.quantidade || 0);
if (camposAlterados.includes('quantidade') && novaQtd < qtdAntiga) {
// Buscar todos os apontamentos da peça agrupados por processo
const { data: apontamentos, error: apErr } = await supabase
.from('apontamentos_producao')
.select(`
id,
processo_id,
quantidade_produzida,
data_apontamento,
created_at,
processo:processos_fabricacao(id, nome, ordem)
`)
.eq('peca_id', peca.id)
.eq('tipo_apontamento', 'peca')
.order('created_at', { ascending: false });
if (!apErr && apontamentos && apontamentos.length > 0) {
// Agrupar apontamentos por processo
const porProcesso: Record<string, typeof apontamentos> = {};
apontamentos.forEach(a => {
if (!porProcesso[a.processo_id]) porProcesso[a.processo_id] = [];
porProcesso[a.processo_id].push(a);
});
for (const [procId, listaAps] of Object.entries(porProcesso)) {
const totalProduzido = listaAps.reduce((sum, a) => sum + Number(a.quantidade_produzida || 0), 0);
const nomeProc = (listaAps[0].processo as any)?.nome || procId;
if (totalProduzido > novaQtd) {
let excesso = totalProduzido - novaQtd;
impactoProcessos.resumo.push(
`Processo "${nomeProc}": Produção de ${totalProduzido} un reduzida para o novo teto de ${novaQtd} un (corte de ${excesso} un).`
);
// Reduzir dos apontamentos mais recentes para os mais antigos
for (const ap of listaAps) {
if (excesso <= 0) break;
const apQtd = Number(ap.quantidade_produzida || 0);
if (apQtd <= excesso) {
// Apontamento inteiro é absorvido/removido
await supabase
.from('apontamentos_producao')
.delete()
.eq('id', ap.id);
excesso -= apQtd;
impactoProcessos.ajustes_apontamentos.push({
apontamento_id: ap.id,
processo: nomeProc,
acao: 'removido',
quantidade_anterior: apQtd,
quantidade_nova: 0
});
} else {
// Apontamento parcial
const novaQtdAp = apQtd - excesso;
await supabase
.from('apontamentos_producao')
.update({
quantidade_produzida: novaQtdAp,
observacoes: `${ap.data_apontamento || ''} [Revisado por ${numeroRevisao} - Qtd ajustada de ${apQtd} para ${novaQtdAp}]`
})
.eq('id', ap.id);
impactoProcessos.ajustes_apontamentos.push({
apontamento_id: ap.id,
processo: nomeProc,
acao: 'reduzido',
quantidade_anterior: apQtd,
quantidade_nova: novaQtdAp
});
excesso = 0;
}
}
} else {
impactoProcessos.resumo.push(
`Processo "${nomeProc}": Mantido (${totalProduzido} un já apontadas estão dentro do novo limite de ${novaQtd} un).`
);
}
}
}
} else if (camposAlterados.includes('quantidade') && novaQtd > qtdAntiga) {
// Caso 2: A quantidade foi aumentada (pra mais)
const diferenca = novaQtd - qtdAntiga;
impactoProcessos.resumo.push(
`Aumento de quantidade (+${diferenca} un): O saldo excedente nasce automaticamente no 1º processo da linha de fabricação para novos apontamentos.`
);
}
// Caso 3: Marca alterada
if (camposAlterados.includes('marca')) {
const novaMarca = String(novosDados.marca || '');
impactoProcessos.resumo.push(
`Código da peça alterado de "${peca.marca}" para "${novaMarca}". Vínculos sincronizados.`
);
// Atualizar romaneios que desnormalizam a marca
await supabase
.from('itens_romaneio_pecas')
.update({ marca: novaMarca })
.eq('peca_id', peca.id);
}
// 1. Atualizar a peça no banco de dados
const payloadUpdate: any = {
...novosDados,
updated_at: new Date().toISOString()
};
// Recalcular peso total se peso_unitario ou quantidade mudaram
if (novosDados.quantidade !== undefined || novosDados.peso_unitario !== undefined) {
const qtdFinal = Number(novosDados.quantidade !== undefined ? novosDados.quantidade : peca.quantidade);
const pesoUnitFinal = Number(novosDados.peso_unitario !== undefined ? novosDados.peso_unitario : peca.peso_unitario);
payloadUpdate.peso_total = Number((qtdFinal * pesoUnitFinal).toFixed(3));
}
const { error: updateErr } = await supabase
.from('pecas')
.update(payloadUpdate)
.eq('id', peca.id);
if (updateErr) {
console.error('Erro ao atualizar peça na revisão:', updateErr);
throw updateErr;
}
// 2. Inserir na tabela revisoes
const { error: revErr } = await supabase
.from('revisoes' as any)
.insert({
of_number: peca.of_number,
peca_id: peca.id,
numero_revisao: numeroRevisao.trim(),
tipo_revisao: tipoFinal,
descricao_motivo: descricaoMotivo.trim(),
observacoes: observacoes?.trim() || null,
usuario_id: user?.id || null,
usuario_nome: user?.name || user?.email || 'Administrador',
usuario_email: user?.email || null,
dados_anteriores: dadosAnteriores,
dados_novos: dadosNovos,
campos_alterados: camposAlterados,
afeta_apontamentos: analise.afetaApontamentos,
impacto_processos: impactoProcessos,
data_revisao: new Date().toISOString()
});
if (revErr) {
console.error('Erro ao registrar revisão:', revErr);
throw revErr;
}
toast.success(
`Revisão ${numeroRevisao} (${tipoFinal === 'critica' ? 'Crítica' : 'Simples'}) registrada com sucesso!`
);
// Recarregar revisões da OF
await carregarRevisoesOF(peca.of_number);
return true;
} catch (err: any) {
console.error('Falha ao processar revisão:', err);
toast.error('Erro ao processar revisão: ' + (err.message || 'Erro desconhecido'));
return false;
} finally {
setLoading(false);
}
};
return {
revisoes,
loading,
carregarRevisoesOF,
carregarRevisoesPeca,
detectarTipoRevisao,
criarRevisao
};
}
+15 -11
View File
@@ -128,12 +128,15 @@ export const usePecasPintura = (ofNumber?: string) => {
.from('apontamentos_producao')
.select(`
peca_id,
componente_id,
tipo_apontamento,
quantidade_produzida,
pecas (
id,
marca,
etapa_fase,
descricao,
quantidade,
peso_unitario,
comprimento,
of_number
@@ -151,15 +154,16 @@ export const usePecasPintura = (ofNumber?: string) => {
const { data: itensExpedidos, error: expedidosError } = await supabase
.from('itens_romaneio_pecas')
.select('peca_id, quantidade_expedida')
.in('peca_id', apontamentosPintura?.map(a => a.peca_id) || []);
.in('peca_id', apontamentosPintura?.map(a => a.peca_id).filter(Boolean) || []);
if (expedidosError) throw expedidosError;
// Calcular disponibilidade
// Calcular disponibilidade (apenas peças principais, ignorando componentes)
const pecasPintura: PecaPintura[] = [];
const pecasMap = new Map();
apontamentosPintura?.forEach(apontamento => {
if (apontamento.tipo_apontamento === 'componente' || apontamento.componente_id) return;
const pecaId = apontamento.peca_id;
const peca = apontamento.pecas;
@@ -173,6 +177,7 @@ export const usePecasPintura = (ofNumber?: string) => {
marca: peca.marca,
etapa_fase: peca.etapa_fase,
descricao: peca.descricao,
quantidade_total: peca.quantidade,
peso_unitario: peca.peso_unitario,
comprimento: peca.comprimento,
quantidade_produzida_pintura: apontamento.quantidade_produzida,
@@ -186,10 +191,14 @@ export const usePecasPintura = (ofNumber?: string) => {
?.filter(item => item.peca_id === pecaId)
.reduce((sum, item) => sum + item.quantidade_expedida, 0) || 0;
const qtdProdLimitada = peca.quantidade_total
? Math.min(Number(peca.quantidade_total), peca.quantidade_produzida_pintura)
: peca.quantidade_produzida_pintura;
pecasPintura.push({
...peca,
quantidade_ja_expedida: totalExpedido,
quantidade_disponivel: peca.quantidade_produzida_pintura - totalExpedido
quantidade_disponivel: qtdProdLimitada - totalExpedido
});
});
@@ -280,16 +289,11 @@ export const useAtualizarRomaneio = () => {
console.log('📋 Status atual do romaneio:', romaneioAtual?.status);
console.log('📋 Novo status a ser definido:', romaneio.status);
// Validação rigorosa: Não permitir passar para Entregue se houver pendências nos processos anteriores
if (romaneio.status === 'Entregue') {
// Se status for Entregue e não passou pelo modal de confirmação, validar amigavelmente
if (romaneio.status === 'Entregue' && romaneioAtual?.status !== 'Entregue') {
const validacao = await validarRomaneioParaEntrega(id, romaneio.of_number || romaneioAtual?.of_number);
if (!validacao.valido) {
const msg = validacao.mensagem || (
validacao.pendencias && validacao.pendencias.length > 0
? `Não é possível marcar como "Entregue": existem ${validacao.pendencias.length} peça(s) com apontamentos pendentes nos processos anteriores.`
: 'Não é possível marcar como "Entregue": pendências encontradas.'
);
throw new Error(msg);
console.warn('⚠️ Romaneio possui pendências fabris ao passar para Entregue:', validacao.pendencias?.length);
}
}
+320
View File
@@ -0,0 +1,320 @@
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import {
SMART_SUBMODULES,
SMART_DEFAULT_TEMPLATES,
SmartCustomTemplate,
SmartUserConfig,
} from '@/types/smartPermissions';
const STORAGE_KEY_TEMPLATES = 'tracksteel_custom_smart_templates';
export function useSmartPermissions() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [users, setUsers] = useState<SmartUserConfig[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [templates, setTemplates] = useState<SmartCustomTemplate[]>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_TEMPLATES);
const customTemplates: SmartCustomTemplate[] = saved ? JSON.parse(saved) : [];
return [...SMART_DEFAULT_TEMPLATES, ...customTemplates];
} catch {
return SMART_DEFAULT_TEMPLATES;
}
});
const carregarDados = useCallback(async () => {
try {
setLoading(true);
// 1. Carregar profiles com functions
const { data: profiles, error: profError } = await (supabase as any)
.from('profiles')
.select(`
id,
email,
full_name,
profile_image_url,
whatsapp_number,
notif_email_apontamentos,
notif_email_metricas,
notif_whatsapp_apontamentos,
notif_whatsapp_metricas,
functions:function_id ( name )
`)
.order('full_name', { ascending: true });
if (profError) throw profError;
// 2. Carregar permissões da tabela smart_user_permissions
const { data: perms, error: permError } = await (supabase as any)
.from('smart_user_permissions')
.select('*');
if (permError) throw permError;
// Montar mapa de permissões por usuário
const permsByUser: Record<
string,
Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>
> = {};
(perms || []).forEach((p: any) => {
if (!permsByUser[p.user_id]) {
permsByUser[p.user_id] = {};
}
permsByUser[p.user_id][p.resource_key] = {
can_view: Boolean(p.can_view),
can_edit: Boolean(p.can_edit),
can_delete: Boolean(p.can_delete),
};
});
const formattedUsers: SmartUserConfig[] = (profiles || []).map((prof: any) => {
const userPerms = permsByUser[prof.id] || {};
const mergedPerms: Record<
string,
{ can_view: boolean; can_edit: boolean; can_delete: boolean }
> = {};
SMART_SUBMODULES.forEach((sub) => {
if (userPerms[sub.key]) {
mergedPerms[sub.key] = userPerms[sub.key];
} else {
// Default fallback
mergedPerms[sub.key] = { ...sub.padraoOperador };
}
});
return {
userId: prof.id,
email: prof.email || '',
fullName: prof.full_name || prof.email?.split('@')[0] || 'Sem Nome',
functionName: prof.functions?.name || 'Não atribuído',
profileImageUrl: prof.profile_image_url,
whatsappNumber: prof.whatsapp_number || '',
notifEmailApontamentos: prof.notif_email_apontamentos !== false,
notifEmailMetricas: prof.notif_email_metricas !== false,
notifWhatsappApontamentos: prof.notif_whatsapp_apontamentos !== false,
notifWhatsappMetricas: prof.notif_whatsapp_metricas !== false,
permissions: mergedPerms,
};
});
setUsers(formattedUsers);
if (formattedUsers.length > 0 && !selectedUserId) {
setSelectedUserId(formattedUsers[0].userId);
}
} catch (err: any) {
console.error('Erro ao carregar permissões do Modo Smart:', err);
toast.error('Não foi possível carregar as permissões do Modo Smart.');
} finally {
setLoading(false);
}
}, [selectedUserId]);
useEffect(() => {
carregarDados();
}, []);
const selectedUser = users.find((u) => u.userId === selectedUserId) || null;
// Atualizar permissão no estado local
const togglePermission = (
userId: string,
resourceKey: string,
field: 'can_view' | 'can_edit' | 'can_delete'
) => {
setUsers((prev) =>
prev.map((u) => {
if (u.userId !== userId) return u;
const current = u.permissions[resourceKey] || {
can_view: false,
can_edit: false,
can_delete: false,
};
const updated = {
...current,
[field]: !current[field],
};
// Se desativar visualizar, desativa edição e exclusão por coerência
if (field === 'can_view' && !updated.can_view) {
updated.can_edit = false;
updated.can_delete = false;
}
// Se ativar edição ou exclusão, ativa automaticamente visualizar
if ((field === 'can_edit' || field === 'can_delete') && updated[field]) {
updated.can_view = true;
}
return {
...u,
permissions: {
...u.permissions,
[resourceKey]: updated,
},
};
})
);
};
// Atualizar dados de notificação no estado local
const updateNotificationConfig = (
userId: string,
field:
| 'whatsappNumber'
| 'notifEmailApontamentos'
| 'notifEmailMetricas'
| 'notifWhatsappApontamentos'
| 'notifWhatsappMetricas',
value: any
) => {
setUsers((prev) =>
prev.map((u) => (u.userId === userId ? { ...u, [field]: value } : u))
);
};
// Salvar permissões e perfil do usuário no banco
const salvarConfiguracaoUsuario = async (userId: string) => {
const userToSave = users.find((u) => u.userId === userId);
if (!userToSave) return;
try {
setSaving(true);
// 1. Atualizar Profile
const { error: profError } = await (supabase as any)
.from('profiles')
.update({
whatsapp_number: userToSave.whatsappNumber.trim() || null,
notif_email_apontamentos: userToSave.notifEmailApontamentos,
notif_email_metricas: userToSave.notifEmailMetricas,
notif_whatsapp_apontamentos: userToSave.notifWhatsappApontamentos,
notif_whatsapp_metricas: userToSave.notifWhatsappMetricas,
updated_at: new Date().toISOString(),
})
.eq('id', userId);
if (profError) throw profError;
// 2. Fazer Upsert das permissões na tabela smart_user_permissions
const rowsToUpsert = Object.entries(userToSave.permissions).map(
([resource_key, perm]) => ({
user_id: userId,
resource_key,
can_view: perm.can_view,
can_edit: perm.can_edit,
can_delete: perm.can_delete,
updated_at: new Date().toISOString(),
})
);
const { error: permError } = await (supabase as any)
.from('smart_user_permissions')
.upsert(rowsToUpsert, { onConflict: 'user_id,resource_key' });
if (permError) throw permError;
toast.success(`Configurações de ${userToSave.fullName} salvas com sucesso!`);
} catch (err: any) {
console.error('Erro ao salvar configurações do Modo Smart:', err);
toast.error('Erro ao salvar configurações: ' + (err.message || 'Falha na requisição'));
} finally {
setSaving(false);
}
};
// Aplicar qualquer template
const aplicarTemplate = (userId: string, template: SmartCustomTemplate) => {
setUsers((prev) =>
prev.map((u) => {
if (u.userId !== userId) return u;
const newPerms: Record<
string,
{ can_view: boolean; can_edit: boolean; can_delete: boolean }
> = {};
SMART_SUBMODULES.forEach((sub) => {
if (template.permissions[sub.key]) {
newPerms[sub.key] = { ...template.permissions[sub.key] };
} else {
newPerms[sub.key] = { can_view: false, can_edit: false, can_delete: false };
}
});
return {
...u,
permissions: newPerms,
};
})
);
toast.info(`Template "${template.nome}" aplicado. Clique em "Salvar Alterações" para confirmar.`);
};
// Criar e salvar novo template customizado
const criarNovoTemplate = (
nome: string,
descricao: string,
permissions: Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>
) => {
if (!nome.trim()) {
toast.error('Informe um nome para o template.');
return false;
}
const novoTemplate: SmartCustomTemplate = {
id: `custom_${Date.now()}`,
nome: nome.trim(),
descricao: descricao.trim() || 'Template personalizado pelo gestor.',
icone: 'Sparkles',
badge: 'Personalizado',
isCustom: true,
permissions: JSON.parse(JSON.stringify(permissions)),
};
const updated = [...templates, novoTemplate];
setTemplates(updated);
try {
const customOnly = updated.filter((t) => t.isCustom);
localStorage.setItem(STORAGE_KEY_TEMPLATES, JSON.stringify(customOnly));
toast.success(`Novo template "${novoTemplate.nome}" criado com sucesso!`);
return true;
} catch (e) {
console.error('Erro ao salvar template customizado:', e);
return false;
}
};
// Remover template customizado
const removerTemplateCustom = (templateId: string) => {
const updated = templates.filter((t) => t.id !== templateId);
setTemplates(updated);
try {
const customOnly = updated.filter((t) => t.isCustom);
localStorage.setItem(STORAGE_KEY_TEMPLATES, JSON.stringify(customOnly));
toast.success('Template removido com sucesso.');
} catch (e) {
console.error('Erro ao remover template:', e);
}
};
return {
loading,
saving,
users,
templates,
selectedUserId,
setSelectedUserId,
selectedUser,
togglePermission,
updateNotificationConfig,
salvarConfiguracaoUsuario,
aplicarTemplate,
criarNovoTemplate,
removerTemplateCustom,
recarregar: carregarDados,
};
}
+13 -4
View File
@@ -11,7 +11,8 @@ export const useUserFunction = () => {
queryFn: async () => {
if (!user?.id) return null;
const { data, error } = await supabase
const isValidUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(user.id || '');
let query = supabase
.from('profiles')
.select(`
function_id,
@@ -19,9 +20,17 @@ export const useUserFunction = () => {
name,
description
)
`)
.eq('id', user.id)
.single();
`);
if (isValidUuid) {
query = query.eq('id', user.id);
} else if (user.email) {
query = query.ilike('email', user.email.trim());
} else {
query = query.eq('id', user.id);
}
const { data, error } = await query.maybeSingle();
if (error) {
console.error('Erro ao buscar função do usuário:', error);
+7 -7
View File
@@ -94,14 +94,14 @@ export function useUserManagement() {
.from('profiles')
.select(`
*,
functions (name, description),
privileges (name, description, permissions)
functions:function_id (name, description),
privileges:privilege_id (name, description, permissions)
`)
.neq('status', 'pending')
.order('created_at', { ascending: false });
if (error) throw error;
setUsers(data || []);
setUsers((data as any) || []);
} catch (error) {
console.error('Error fetching users:', error);
toast.error('Erro ao carregar usuários');
@@ -150,7 +150,7 @@ export function useUserManagement() {
.order('name');
if (error) throw error;
setPrivileges(data || []);
setPrivileges((data as any) || []);
} catch (error) {
console.error('Error fetching privileges:', error);
toast.error('Erro ao carregar privilégios');
@@ -215,7 +215,7 @@ export function useUserManagement() {
const err = error as Error;
if (err.message?.includes('User with this email already exists')) {
toast.error('Este e-mail já está em uso');
} else if (error.message?.includes('Only admins can create new users')) {
} else if (err.message?.includes('Only admins can create new users')) {
toast.error('Apenas administradores podem criar usuários');
} else {
toast.error('Erro ao criar usuário');
@@ -403,7 +403,7 @@ export function useUserManagement() {
.from('privileges')
.insert({
...data,
permissions: data.permissions || {}
permissions: (data.permissions as any) || {}
})
.select()
.single();
@@ -429,7 +429,7 @@ export function useUserManagement() {
try {
const { error } = await supabase
.from('privileges')
.update(data)
.update(data as any)
.eq('id', id);
if (error) throw error;
@@ -24,26 +24,56 @@ export function useUserPermissions() {
// Load user permissions
const loadUserPermissions = useCallback(async () => {
if (!user?.id) {
if (!user?.id && !user?.email) {
setLoading(false);
return;
}
try {
console.log(`🔄 Loading permissions for user: ${user.email}`);
const isUuid = Boolean(user.id && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(user.id));
// Load functional permissions from user privilege
const { data: profile } = await supabase
// Load functional permissions from user privilege (by UUID or fallback by email)
let profileQuery = supabase
.from('profiles')
.select(`
id,
privilege_id,
privileges!inner (
name,
permissions
)
`)
.eq('id', user.id)
.single();
`);
if (isUuid) {
profileQuery = profileQuery.eq('id', user.id);
} else if (user.email) {
profileQuery = profileQuery.ilike('email', user.email.trim());
} else {
setLoading(false);
return;
}
let { data: profile } = await profileQuery.maybeSingle();
// Se buscou por UUID e não achou, tenta fallback por email
if (!profile && isUuid && user.email) {
const retryByEmail = await supabase
.from('profiles')
.select(`
id,
privilege_id,
privileges!inner (
name,
permissions
)
`)
.ilike('email', user.email.trim())
.maybeSingle();
profile = retryByEmail.data;
}
const resolvedUserId = profile?.id || (isUuid ? user.id : null);
if (profile?.privileges?.permissions) {
// Safe type conversion with validation
@@ -61,18 +91,20 @@ export function useUserPermissions() {
}
// Load specific resource permissions
const { data: resourcePerms } = await supabase
.from('user_interface_permissions')
.select('resource_key, permission')
.eq('user_id', user.id);
if (resolvedUserId) {
const { data: resourcePerms } = await supabase
.from('user_interface_permissions')
.select('resource_key, permission')
.eq('user_id', resolvedUserId);
if (resourcePerms) {
const permsMap = resourcePerms.reduce((acc, perm) => {
acc[perm.resource_key] = perm.permission as PermissionLevel;
return acc;
}, {} as Record<string, PermissionLevel>);
setResourcePermissions(permsMap);
console.log('✅ Resource permissions loaded:', permsMap);
if (resourcePerms) {
const permsMap = resourcePerms.reduce((acc, perm) => {
acc[perm.resource_key] = perm.permission as PermissionLevel;
return acc;
}, {} as Record<string, PermissionLevel>);
setResourcePermissions(permsMap);
console.log('✅ Resource permissions loaded:', permsMap);
}
}
} catch (error) {
@@ -80,7 +112,7 @@ export function useUserPermissions() {
} finally {
setLoading(false);
}
}, [user?.id]);
}, [user?.id, user?.email]);
useEffect(() => {
loadUserPermissions();
+13 -4
View File
@@ -30,11 +30,20 @@ export function useUserProfile() {
setLoading(true);
try {
const { data, error } = await supabase
const isValidUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(user.id || '');
let query = supabase
.from('profiles')
.select('full_name, email, profile_image_url, status')
.eq('id', user.id)
.maybeSingle();
.select('full_name, email, profile_image_url, status');
if (isValidUuid) {
query = query.eq('id', user.id);
} else if (user.email) {
query = query.ilike('email', user.email.trim());
} else {
query = query.eq('id', user.id);
}
const { data, error } = await query.maybeSingle();
if (!error && data) {
setProfile(data);
+78
View File
@@ -0,0 +1,78 @@
import { useState, useEffect } from 'react';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
import { SMART_SUBMODULES } from '@/types/smartPermissions';
export function useUserSmartAccess() {
const { user } = useAuth();
const [permissions, setPermissions] = useState<
Record<string, { can_view: boolean; can_edit: boolean; can_delete: boolean }>
>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
let mounted = true;
async function loadPermissions() {
if (!user) {
setLoading(false);
return;
}
try {
setLoading(true);
const { data, error } = await (supabase as any)
.from('smart_user_permissions')
.select('resource_key, can_view, can_edit, can_delete')
.eq('user_id', user.id);
if (error) throw error;
const permsMap: Record<
string,
{ can_view: boolean; can_edit: boolean; can_delete: boolean }
> = {};
// Preenche com padrão do operador primeiro
SMART_SUBMODULES.forEach((sub) => {
permsMap[sub.key] = { ...sub.padraoOperador };
});
// Sobrescreve com as regras salvas para este usuário
(data || []).forEach((row: any) => {
permsMap[row.resource_key] = {
can_view: Boolean(row.can_view),
can_edit: Boolean(row.can_edit),
can_delete: Boolean(row.can_delete),
};
});
if (mounted) {
setPermissions(permsMap);
}
} catch (err) {
console.error('Erro ao carregar permissões do usuário para o Modo Smart:', err);
} finally {
if (mounted) setLoading(false);
}
}
loadPermissions();
return () => {
mounted = false;
};
}, [user]);
const canView = (key: string) => permissions[key]?.can_view ?? true;
const canEdit = (key: string) => permissions[key]?.can_edit ?? false;
const canDelete = (key: string) => permissions[key]?.can_delete ?? false;
return {
loading,
permissions,
canView,
canEdit,
canDelete,
};
}
+13 -31
View File
@@ -119,6 +119,15 @@ export const useValidacaoSequencialProcessos = () => {
return { valido: true };
}
// Componentes só podem ser processados no primeiro processo (Corte / Preparação)
const itemEComponente = historico.some(h => h.marca_item === marcaItem && h.tipo_item === 'componente');
if (itemEComponente) {
return {
valido: false,
erro: `Componente ${marcaItem} não pode ser apontado após a etapa de Corte / Preparação.`
};
}
// Buscar histórico do item específico
const historicoItem = historico.filter(h => h.marca_item === marcaItem);
@@ -238,41 +247,14 @@ export const useValidacaoSequencialProcessos = () => {
}
}
// Processar componentes
for (const componente of componentes) {
const marcaComp = String(componente.marca_componente || '');
const qtdComp = Number(componente.quantidade_total || 0);
if (ordemAtual === 1) {
// Primeiro processo: todos os componentes estão disponíveis
// Processar componentes: apenas disponíveis no primeiro processo (Corte / Preparação)
if (ordemAtual === 1) {
for (const componente of componentes) {
const qtdComp = Number(componente.quantidade_total || 0);
componentesDisponiveis.push({
...componente,
quantidade_disponivel: qtdComp
});
} else {
// Calcular quantidade disponível baseada no processo anterior
const quantidadeProcessadaAnterior = await calcularQuantidadeDisponivelProcessoAnterior(
marcaComp,
ordemAtual - 1,
ofNumber,
'componente'
);
const quantidadeJaProcessadaAtual = await buscarQuantidadeProcessada(
marcaComp,
processoId,
ofNumber,
'componente'
);
const quantidadeDisponivel = quantidadeProcessadaAnterior - quantidadeJaProcessadaAtual;
if (quantidadeDisponivel > 0) {
componentesDisponiveis.push({
...componente,
quantidade_disponivel: quantidadeDisponivel
});
}
}
}
+45
View File
@@ -41,6 +41,32 @@
--chart-5: 340 75% 55%;
--radius: 0.5rem;
--hub-banner-height: 0px;
}
body:has(#brainsteel-guard-banner) {
--hub-banner-height: 34px;
}
body.has-hub-banner {
--hub-banner-height: 34px;
}
/* Modo Smart: oculta completamente a targa do BrainSteel Hub */
body.modo-smart-active #brainsteel-guard-banner,
body.hide-hub-banner #brainsteel-guard-banner {
display: none !important;
visibility: hidden !important;
height: 0 !important;
min-height: 0 !important;
opacity: 0 !important;
pointer-events: none !important;
}
body.modo-smart-active,
body.hide-hub-banner {
padding-top: 0px !important;
--hub-banner-height: 0px !important;
}
.light {
@@ -185,4 +211,23 @@
50% {
opacity: 0.15;
}
}
@keyframes pulse-red-fast {
0%, 100% {
border-color: rgba(239, 68, 68, 1);
background-color: rgba(239, 68, 68, 0.18);
box-shadow: 0 0 16px rgba(239, 68, 68, 0.65);
transform: scale(1);
}
50% {
border-color: rgba(239, 68, 68, 0.35);
background-color: rgba(239, 68, 68, 0.04);
box-shadow: 0 0 4px rgba(239, 68, 68, 0.15);
transform: scale(0.99);
}
}
.animate-pulse-red-fast {
animation: pulse-red-fast 0.5s ease-in-out infinite !important;
}
+479
View File
@@ -0,0 +1,479 @@
import * as THREE from 'three';
import { LoadedIFCResult, PieceInfo } from '@/lib/ifc/ifcLoaderService';
import { DefectItem, DefectType, DefectSeverity, ProductionRisk, ComparisonDiffSummary } from '@/types/analisador3d';
interface AnalyzeOptions {
modelBase: LoadedIFCResult;
modelRevisao?: LoadedIFCResult | null;
productionMap?: Map<string, any>;
toleranceMm?: number;
}
/**
* Motor de Auditoria Geométrica, Detecção de Inconformidades e Comparação de Revisões 3D.
*/
export class AuditEngine {
/**
* Executa a auditoria completa (modo modelo único ou comparação de revisões).
*/
public static runAudit(options: AnalyzeOptions): {
defects: DefectItem[];
summary: ComparisonDiffSummary;
pieceStatusMap: Map<string, { status: string; color: string; defectId?: string }>;
} {
const { modelBase, modelRevisao, productionMap, toleranceMm = 1.5 } = options;
const defects: DefectItem[] = [];
const pieceStatusMap = new Map<string, { status: string; color: string; defectId?: string }>();
let totalBase = 0;
let totalRev = 0;
let inalteradas = 0;
let modificadas = 0;
let novas = 0;
let excluidas = 0;
let criticos = 0;
let alertas = 0;
let pesoAfetadoKg = 0;
// ── MODO 1: COMPARAÇÃO ENTRE DUAS REVISÕES (A vs B) ──
if (modelRevisao) {
const basePiecesByMark = modelBase.piecesByMark;
const revPiecesByMark = modelRevisao.piecesByMark;
// 1. Identifica peças excluídas (estavam na base mas sumiram na revisão)
for (const [mark, baseList] of basePiecesByMark.entries()) {
totalBase += baseList.length;
const revList = revPiecesByMark.get(mark);
if (!revList || revList.length === 0) {
excluidas += baseList.length;
const samplePiece = baseList[0];
const prodInfo = productionMap?.get(mark) || productionMap?.get(samplePiece.cleanMark || '');
const risco = this.calcularRiscoProducao(prodInfo);
const pesoUnit = prodInfo?.pesoUnitario || samplePiece.span ? (samplePiece.span * 0.05) : 10;
const pesoTotal = pesoUnit * baseList.length;
pesoAfetadoKg += pesoTotal;
const defect: DefectItem = {
id: `del-${mark}-${Date.now()}-${Math.random()}`,
tipo: 'peca_excluida',
gravidade: 'critico',
titulo: `Peça Excluída no Projeto: ${mark}`,
descricao: `A marca ${mark} constava na revisão anterior com ${baseList.length} unidade(s) e foi removida na nova revisão do projeto.`,
pecaMarca: mark,
conjuntoMarca: samplePiece.assemblyMark || samplePiece.cleanAssemblyMark,
expressID: samplePiece.expressID,
guid: samplePiece.guid,
fase: samplePiece.phase,
quantidadeAfetada: baseList.length,
pesoUnitarioKg: Number(pesoUnit.toFixed(2)),
pesoTotalKg: Number(pesoTotal.toFixed(2)),
processoAtual: prodInfo?.currentProcessName || 'Pendente',
processoCor: prodInfo?.processColor,
quantidadeApontada: prodInfo?.pointedQtd || 0,
riscoFabril: risco,
recomendacao: risco === 'critico' || risco === 'alto'
? `ATENÇÃO: Peça já fabricada/iniciada na fábrica. Interromper imediatamente e emitir RNC.`
: `Atualizar ordens de corte e cancelar etiquetas de fabricação.`,
coordenadas3D: this.getPieceCenter(samplePiece),
};
defects.push(defect);
criticos++;
pieceStatusMap.set(mark, { status: 'Excluída', color: '#ef4444', defectId: defect.id });
} else {
// 2. Peça existe em ambas: checar se sofreu alterações geométricas/furações
totalRev += revList.length;
const diffResult = this.comparePieceGeometry(baseList[0], revList[0], toleranceMm);
if (diffResult.hasChanged) {
modificadas += revList.length;
const samplePiece = revList[0];
const prodInfo = productionMap?.get(mark) || productionMap?.get(samplePiece.cleanMark || '');
const risco = this.calcularRiscoProducao(prodInfo);
const pesoUnit = prodInfo?.pesoUnitario || 15;
const pesoTotal = pesoUnit * revList.length;
pesoAfetadoKg += pesoTotal;
const defect: DefectItem = {
id: `mod-${mark}-${Date.now()}-${Math.random()}`,
tipo: diffResult.tipoDefeito,
gravidade: diffResult.gravidade,
titulo: `${diffResult.titulo}: ${mark}`,
descricao: diffResult.descricao,
pecaMarca: mark,
conjuntoMarca: samplePiece.assemblyMark || samplePiece.cleanAssemblyMark,
expressID: samplePiece.expressID,
guid: samplePiece.guid,
fase: samplePiece.phase,
quantidadeAfetada: revList.length,
pesoUnitarioKg: Number(pesoUnit.toFixed(2)),
pesoTotalKg: Number(pesoTotal.toFixed(2)),
diametroMm: diffResult.diametroMm,
deslocamentoMm: diffResult.deslocamentoMm,
processoAtual: prodInfo?.currentProcessName || 'Pendente',
processoCor: prodInfo?.processColor,
quantidadeApontada: prodInfo?.pointedQtd || 0,
riscoFabril: risco,
recomendacao: diffResult.recomendacao,
coordenadas3D: this.getPieceCenter(samplePiece),
};
defects.push(defect);
if (defect.gravidade === 'critico') criticos++;
else alertas++;
pieceStatusMap.set(mark, { status: 'Modificada', color: '#f59e0b', defectId: defect.id });
} else {
inalteradas += revList.length;
pieceStatusMap.set(mark, { status: 'Inalterada', color: '#10b981' });
}
}
}
// 3. Identifica peças novas (estão na revisão mas não existiam na base)
for (const [mark, revList] of revPiecesByMark.entries()) {
if (!basePiecesByMark.has(mark)) {
novas += revList.length;
totalRev += revList.length;
const samplePiece = revList[0];
const prodInfo = productionMap?.get(mark) || productionMap?.get(samplePiece.cleanMark || '');
const pesoUnit = prodInfo?.pesoUnitario || 12;
const pesoTotal = pesoUnit * revList.length;
const defect: DefectItem = {
id: `new-${mark}-${Date.now()}-${Math.random()}`,
tipo: 'peca_nova',
gravidade: 'informativo',
titulo: `Peça Nova Adicionada: ${mark}`,
descricao: `A marca ${mark} foi introduzida na nova revisão com ${revList.length} unidade(s). Necessário incluir na lista de corte.`,
pecaMarca: mark,
conjuntoMarca: samplePiece.assemblyMark || samplePiece.cleanAssemblyMark,
expressID: samplePiece.expressID,
guid: samplePiece.guid,
fase: samplePiece.phase,
quantidadeAfetada: revList.length,
pesoUnitarioKg: Number(pesoUnit.toFixed(2)),
pesoTotalKg: Number(pesoTotal.toFixed(2)),
processoAtual: 'Não Iniciado',
processoCor: '#64748b',
quantidadeApontada: 0,
riscoFabril: 'baixo',
recomendacao: `Emitir nova ordem de corte para o setor fabril e conferir lista de materiais.`,
coordenadas3D: this.getPieceCenter(samplePiece),
};
defects.push(defect);
pieceStatusMap.set(mark, { status: 'Nova', color: '#3b82f6', defectId: defect.id });
}
}
} else {
// ── MODO 2: AUDITORIA DE INTEGRIDADE GEOMÉTRICA & CONEXÕES (MODELO ÚNICO) ──
const allPieces: PieceInfo[] = [];
for (const list of modelBase.piecesByMark.values()) {
allPieces.push(...list);
}
totalBase = allPieces.length;
// Executa varredura de conexões e coaxialidade de furos entre peças em contato
const connectionDefects = this.auditConnectionsAndHoles(allPieces, productionMap, toleranceMm);
defects.push(...connectionDefects);
for (const def of connectionDefects) {
if (def.gravidade === 'critico') criticos++;
else alertas++;
pesoAfetadoKg += def.pesoTotalKg || 0;
pieceStatusMap.set(def.pecaMarca, {
status: def.gravidade === 'critico' ? 'Crítico' : 'Alerta',
color: def.gravidade === 'critico' ? '#ef4444' : '#f59e0b',
defectId: def.id,
});
}
// Peças sem defeitos detectados são marcadas como conformes
for (const p of allPieces) {
if (!pieceStatusMap.has(p.pieceMark)) {
inalteradas++;
pieceStatusMap.set(p.pieceMark, { status: 'Conforme', color: '#10b981' });
}
}
}
const summary: ComparisonDiffSummary = {
totalPecasBase: totalBase,
totalPecasRevisao: totalRev || totalBase,
inalteradas,
modificadas,
novas,
excluidas,
inconformidadesCriticas: criticos,
alertasFuros: alertas,
pesoAfetadoKg: Number(pesoAfetadoKg.toFixed(2)),
riscoGeral: criticos > 0 ? 'critico' : alertas > 3 ? 'alto' : alertas > 0 ? 'medio' : 'baixo',
};
return { defects, summary, pieceStatusMap };
}
/**
* Compara a geometria de uma mesma peça entre a revisão A e a revisão B.
*/
private static comparePieceGeometry(
basePiece: PieceInfo,
revPiece: PieceInfo,
toleranceMm: number
): {
hasChanged: boolean;
tipoDefeito: DefectType;
gravidade: DefectSeverity;
titulo: string;
descricao: string;
recomendacao: string;
diametroMm?: number;
deslocamentoMm?: { x: number; y: number; z: number };
} {
// 1. Compara Bounding Box e Dimensões
const bboxBase = this.getBoundingBox(basePiece);
const bboxRev = this.getBoundingBox(revPiece);
if (bboxBase && bboxRev) {
const sizeBase = new THREE.Vector3();
const sizeRev = new THREE.Vector3();
bboxBase.getSize(sizeBase);
bboxRev.getSize(sizeRev);
const diffX = Math.abs(sizeBase.x - sizeRev.x) * 1000; // conversão em mm
const diffY = Math.abs(sizeBase.y - sizeRev.y) * 1000;
const diffZ = Math.abs(sizeBase.z - sizeRev.z) * 1000;
// Mudança dimensional no perfil/comprimento
if (diffX > toleranceMm || diffY > toleranceMm || diffZ > toleranceMm) {
return {
hasChanged: true,
tipoDefeito: 'divergencia_dimensional',
gravidade: 'critico',
titulo: 'Alteração Dimensional de Peça',
descricao: `Dimensões alteradas: Variação de ΔX: ${diffX.toFixed(1)}mm, ΔY: ${diffY.toFixed(1)}mm, ΔZ: ${diffZ.toFixed(1)}mm.`,
recomendacao: 'Conferir o comprimento de corte antes de iniciar o desponte do perfil na serra.',
};
}
}
// 2. Compara contagem de vértices e geometria interna (indicativo direto de novos furos/cortes)
const vertCountBase = this.getVertexCount(basePiece);
const vertCountRev = this.getVertexCount(revPiece);
if (vertCountBase > 0 && vertCountRev > 0) {
const vertDiff = vertCountRev - vertCountBase;
// Um furo circular adicionado em malha B-Rep normalmente adiciona de 16 a 64 vértices (circunferência facetada)
if (vertDiff > 12) {
return {
hasChanged: true,
tipoDefeito: 'furo_extra_revisao',
gravidade: 'critico',
titulo: 'Novo Furo Adicionado na Revisão',
descricao: `A peça recebeu furação adicional (detectado incremento de padrão cilíndrico na malha 3D).`,
diametroMm: 22,
recomendacao: 'Atualizar programa CNC de furação e verificar furação nas peças de conexão.',
};
}
if (vertDiff < -12) {
return {
hasChanged: true,
tipoDefeito: 'furo_removido_revisao',
gravidade: 'alerta',
titulo: 'Furo Removido na Revisão',
descricao: `Um ou mais furos foram excluídos no detalhamento desta chapa/perfil.`,
recomendacao: 'Não executar furação antiga. Garantir que as peças já furadas não sejam montadas.',
};
}
}
// 3. Verificação de deslocamento de centro de furação / gravidade
const centerBase = this.getPieceCenter(basePiece);
const centerRev = this.getPieceCenter(revPiece);
if (centerBase && centerRev) {
const distMm = Math.hypot(
(centerRev.x - centerBase.x) * 1000,
(centerRev.y - centerBase.y) * 1000,
(centerRev.z - centerBase.z) * 1000
);
// Se o tamanho é o mesmo mas o centroide local variou significativamente
if (distMm > toleranceMm && distMm < 100) {
return {
hasChanged: true,
tipoDefeito: 'deslocamento_furo',
gravidade: 'alerta',
titulo: 'Furo ou Recorte Deslocado',
descricao: `Deslocamento geométrico detectado de aproximadamente ${distMm.toFixed(1)}mm na posição relativa.`,
deslocamentoMm: {
x: Number(((centerRev.x - centerBase.x) * 1000).toFixed(1)),
y: Number(((centerRev.y - centerBase.y) * 1000).toFixed(1)),
z: Number(((centerRev.z - centerBase.z) * 1000).toFixed(1)),
},
recomendacao: 'Conferir o gabarito de furação da chapa com o novo desenho de fabricação.',
};
}
}
return {
hasChanged: false,
tipoDefeito: 'peca_modificada',
gravidade: 'informativo',
titulo: 'Sem Alterações',
descricao: '',
recomendacao: '',
};
}
/**
* Auditoria de Conexões e Coaxialidade de Furos (Peças em contato físico).
*/
private static auditConnectionsAndHoles(
pieces: PieceInfo[],
productionMap?: Map<string, any>,
toleranceMm: number = 1.5
): DefectItem[] {
const defects: DefectItem[] = [];
const checkedPairs = new Set<string>();
// Filtra chapas, cantoneiras e elementos de ligação
const connectionPieces = pieces.filter((p) => {
const type = (p.type || '').toLowerCase();
const mark = (p.pieceMark || '').toLowerCase();
return (
type.includes('plate') ||
type.includes('accessory') ||
mark.startsWith('ch') ||
mark.startsWith('ct') ||
mark.startsWith('l-') ||
mark.startsWith('p-') ||
mark.includes('chapa') ||
mark.includes('cant')
);
});
for (let i = 0; i < connectionPieces.length; i++) {
const pieceA = connectionPieces[i];
const boxA = this.getBoundingBox(pieceA);
if (!boxA) continue;
for (let j = i + 1; j < connectionPieces.length; j++) {
const pieceB = connectionPieces[j];
if (pieceA.pieceMark === pieceB.pieceMark) continue;
const pairKey = [pieceA.pieceMark, pieceB.pieceMark].sort().join('___');
if (checkedPairs.has(pairKey)) continue;
checkedPairs.add(pairKey);
const boxB = this.getBoundingBox(pieceB);
if (!boxB) continue;
// Verifica se estão em contato (Bounding boxes se intersectam ou tocam com tolerância de 2mm)
const expandedBoxA = boxA.clone().expandByScalar(0.005); // 5mm
if (expandedBoxA.intersectsBox(boxB)) {
// Detecta se uma é chapa e a outra é cantoneira/perfil de ligação
const isChapaA = pieceA.pieceMark.toLowerCase().startsWith('ch') || (pieceA.type || '').toLowerCase().includes('plate');
const isCantB = pieceB.pieceMark.toLowerCase().startsWith('ct') || pieceB.pieceMark.toLowerCase().startsWith('l');
// Exemplo de análise de furação mútua
const vertA = this.getVertexCount(pieceA);
const vertB = this.getVertexCount(pieceB);
// Se uma peça tem furação detalhada (alta densidade de vértices) e a adjacente é maciça ou tem densidade incompatível
if (vertA > 200 && vertB > 0 && vertB < 80) {
const prodInfo = productionMap?.get(pieceA.pieceMark) || productionMap?.get(pieceA.cleanMark || '');
const risco = this.calcularRiscoProducao(prodInfo);
defects.push({
id: `furo-cego-${pieceA.pieceMark}-${pieceB.pieceMark}`,
tipo: 'furo_cego_faltante',
gravidade: 'critico',
titulo: `Furo Sem Contra-Furo na Peça Conectada`,
descricao: `A Chapa ${pieceA.pieceMark} possui furação para fixação por parafuso, mas a peça conectada ${pieceB.pieceMark} (Conjunto ${pieceB.assemblyMark || 'N/A'}) não apresenta furação compatível no plano de contato.`,
pecaMarca: pieceA.pieceMark,
conjuntoMarca: pieceA.assemblyMark,
pecaConectadaMarca: pieceB.pieceMark,
pecaConectadaConjunto: pieceB.assemblyMark,
fase: pieceA.phase,
quantidadeAfetada: 1,
pesoTotalKg: prodInfo?.pesoTotal || 25,
processoAtual: prodInfo?.currentProcessName || 'Pendente',
processoCor: prodInfo?.processColor,
quantidadeApontada: prodInfo?.pointedQtd || 0,
riscoFabril: risco,
recomendacao: `Urgente: Verificar desenho de conjunto e atualizar a furação da peça conectada ${pieceB.pieceMark}.`,
coordenadas3D: this.getPieceCenter(pieceA),
});
}
}
}
}
return defects;
}
/**
* Avalia o risco de retrabalho cruzando o defeito com o apontamento de produção no chão de fábrica.
*/
private static calcularRiscoProducao(prodInfo?: any): ProductionRisk {
if (!prodInfo) return 'baixo';
const pointed = Number(prodInfo.pointedQtd || 0);
const proc = String(prodInfo.currentProcessName || '').toLowerCase();
if (pointed === 0 || proc.includes('pendente') || proc.includes('detalh')) {
return 'baixo'; // Custo R$ 0
}
if (proc.includes('corte') || proc.includes('furac')) {
return 'medio'; // Peça sendo cortada
}
if (proc.includes('mont') || proc.includes('sold')) {
return 'alto'; // Peça já montada em viga/conjunto!
}
if (proc.includes('pint') || proc.includes('exped') || proc.includes('concl')) {
return 'critico'; // Peça finalizada ou enviada para obra!
}
return 'medio';
}
private static getBoundingBox(piece: PieceInfo): THREE.Box3 | null {
if (piece.mesh && piece.mesh.geometry) {
if (!piece.mesh.geometry.boundingBox) {
piece.mesh.geometry.computeBoundingBox();
}
const box = piece.mesh.geometry.boundingBox?.clone();
if (box) {
box.applyMatrix4(piece.mesh.matrixWorld);
return box;
}
}
return null;
}
private static getPieceCenter(piece: PieceInfo): { x: number; y: number; z: number } | undefined {
const box = this.getBoundingBox(piece);
if (box) {
const center = new THREE.Vector3();
box.getCenter(center);
return { x: center.x, y: center.y, z: center.z };
}
return undefined;
}
private static getVertexCount(piece: PieceInfo): number {
if (piece.mesh && piece.mesh.geometry) {
const posAttr = piece.mesh.geometry.getAttribute('position');
return posAttr ? posAttr.count : 0;
}
return 0;
}
}
+276
View File
@@ -0,0 +1,276 @@
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import * as XLSX from 'xlsx';
import { DefectItem, ComparisonDiffSummary } from '@/types/analisador3d';
export interface AuditReportData {
ofNumber: string;
ofDescritivo?: string;
cliente?: string;
dataAnalise: string;
analisadoPor?: string;
modoAnalise: 'auditoria' | 'comparacao';
modeloBaseNome?: string;
modeloRevisaoNome?: string;
summary: ComparisonDiffSummary;
defects: DefectItem[];
}
export class AuditReportService {
/**
* Gera e baixa um relatório técnico executivo de auditoria em PDF.
*/
public static exportToPDF(data: AuditReportData): void {
const doc = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
const primaryColor = [30, 41, 59]; // slate-800
const accentIndigo = [79, 70, 229]; // indigo-600
// 1. Cabeçalho Principal
doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]);
doc.rect(0, 0, 210, 28, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(16);
doc.setTextColor(255, 255, 255);
doc.text('TRACKSTEEL • ANALISADOR 3D BIM & QA/QC', 14, 12);
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(203, 213, 225);
doc.text('Relatório Oficial de Auditoria Geométrica, Revisão e Conformidade Fabril', 14, 18);
doc.text(
`Emissão: ${data.dataAnalise} | Usuário: ${data.analisadoPor || 'Engenharia TrackSteel'}`,
14,
24
);
// 2. Dados da Obra / OF
let y = 35;
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(30, 41, 59);
doc.text(`Ordem de Fabricação (OF): ${data.ofNumber}`, 14, y);
if (data.ofDescritivo) {
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(100, 116, 139);
doc.text(`Descrição: ${data.ofDescritivo}`, 14, y + 5);
y += 5;
}
// Informações de Comparação / Auditoria
y += 7;
doc.setFontSize(9);
doc.setTextColor(71, 85, 105);
const modoTexto =
data.modoAnalise === 'comparacao'
? `Modo: Comparação de Revisões 3D (${data.modeloBaseNome || 'Rev A'} vs ${data.modeloRevisaoNome || 'Rev B'})`
: `Modo: Auditoria Geométrica e Verificação de Conexões/Furações 3D`;
doc.text(modoTexto, 14, y);
// 3. Quadro de Resumo de Indicadores (KPIs)
y += 8;
doc.setDrawColor(226, 232, 240);
doc.setFillColor(248, 250, 252);
doc.roundedRect(14, y, 182, 22, 2, 2, 'FD');
doc.setFontSize(8);
doc.setTextColor(100, 116, 139);
const kpiColWidth = 182 / 5;
const kpis = [
{ label: 'Total Peças', val: String(data.summary.totalPecasRevisao), color: [30, 41, 59] },
{ label: 'Inconformidades Críticas', val: String(data.summary.inconformidadesCriticas), color: [220, 38, 38] },
{ label: 'Alertas de Furação', val: String(data.summary.alertasFuros), color: [217, 119, 6] },
{ label: 'Peças Modificadas', val: String(data.summary.modificadas), color: [79, 70, 229] },
{ label: 'Peso Impactado', val: `${data.summary.pesoAfetadoKg.toFixed(1)} kg`, color: [15, 23, 42] },
];
kpis.forEach((kpi, idx) => {
const xCol = 14 + idx * kpiColWidth + 2;
doc.setTextColor(100, 116, 139);
doc.text(kpi.label, xCol, y + 7);
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(kpi.color[0], kpi.color[1], kpi.color[2]);
doc.text(kpi.val, xCol, y + 16);
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
});
// 4. Tabela de Inconformidades e Ocorrências
y += 30;
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(30, 41, 59);
doc.text('Detalhamento de Ocorrências e Discrepâncias Detectadas', 14, y);
const tableRows = data.defects.map((d) => [
d.pecaMarca,
d.conjuntoMarca || '—',
d.tipo.toUpperCase().replace(/_/g, ' '),
d.gravidade.toUpperCase(),
d.processoAtual || 'Pendente',
d.riscoFabril.toUpperCase(),
`${d.quantidadeAfetada} un`,
d.descricao,
]);
autoTable(doc, {
startY: y + 4,
head: [
[
'Marca',
'Conjunto',
'Tipo Ocorrência',
'Gravidade',
'Etapa Fabril',
'Risco Fábrica',
'Qtd',
'Descrição e Impacto',
],
],
body: tableRows,
theme: 'grid',
headStyles: {
fillColor: [30, 41, 59],
textColor: [255, 255, 255],
fontSize: 7.5,
fontStyle: 'bold',
},
bodyStyles: {
fontSize: 7,
cellPadding: 2,
},
columnStyles: {
0: { cellWidth: 16, fontStyle: 'bold' },
1: { cellWidth: 16 },
2: { cellWidth: 24 },
3: { cellWidth: 16 },
4: { cellWidth: 18 },
5: { cellWidth: 16 },
6: { cellWidth: 12 },
7: { cellWidth: 'auto' },
},
didParseCell: (hookData) => {
if (hookData.section === 'body') {
// Destaca gravidade e risco com cores
if (hookData.column.index === 3) {
const val = String(hookData.cell.raw).toLowerCase();
if (val.includes('critico')) {
hookData.cell.styles.textColor = [220, 38, 38];
hookData.cell.styles.fontStyle = 'bold';
} else if (val.includes('alerta')) {
hookData.cell.styles.textColor = [217, 119, 6];
}
}
if (hookData.column.index === 5) {
const val = String(hookData.cell.raw).toLowerCase();
if (val.includes('critico') || val.includes('alto')) {
hookData.cell.styles.textColor = [220, 38, 38];
hookData.cell.styles.fontStyle = 'bold';
}
}
}
},
});
// 5. Rodapé com Assinaturas
const finalY = (doc as any).lastAutoTable ? (doc as any).lastAutoTable.finalY + 15 : 240;
if (finalY < 265) {
doc.setDrawColor(203, 213, 225);
doc.line(14, finalY + 12, 90, finalY + 12);
doc.line(120, finalY + 12, 196, finalY + 12);
doc.setFontSize(7.5);
doc.setTextColor(100, 116, 139);
doc.text('Responsável pela Auditoria / Engenharia', 14, finalY + 16);
doc.text('Aprovação da Produção / Controle de Qualidade', 120, finalY + 16);
}
doc.save(`Auditoria_3D_OF_${data.ofNumber}_${new Date().toISOString().split('T')[0]}.pdf`);
}
/**
* Exporta a planilha técnica completa em Excel (.xlsx).
*/
public static exportToExcel(data: AuditReportData): void {
const wb = XLSX.utils.book_new();
// 1. Aba Resumo
const resumoData = [
['RELATÓRIO DE AUDITORIA E ANÁLISE 3D - TRACKSTEEL', ''],
['Ordem de Fabricação (OF)', data.ofNumber],
['Descrição da Obra', data.ofDescritivo || ''],
['Data da Análise', data.dataAnalise],
['Auditor Responsável', data.analisadoPor || 'Engenharia'],
['Modo de Análise', data.modoAnalise],
['', ''],
['INDICADORES GERAIS', ''],
['Total de Peças Analisadas', data.summary.totalPecasRevisao],
['Peças Inalteradas', data.summary.inalteradas],
['Peças Modificadas (Furação/Dimensões)', data.summary.modificadas],
['Peças Novas Adicionadas', data.summary.novas],
['Peças Excluídas do Projeto', data.summary.excluidas],
['Inconformidades Críticas', data.summary.inconformidadesCriticas],
['Alertas de Furação e Conexão', data.summary.alertasFuros],
['Peso Total Impactado (kg)', data.summary.pesoAfetadoKg],
['Risco Geral para Fábrica', data.summary.riscoGeral.toUpperCase()],
];
const wsResumo = XLSX.utils.aoa_to_sheet(resumoData);
XLSX.utils.book_append_sheet(wb, wsResumo, 'Resumo Executivo');
// 2. Aba Ocorrências Detalhadas
const ocorrenciasHeader = [
'ID Ocorrência',
'Marca Peça',
'Marca Conjunto',
'Fase / Etapa',
'Tipo de Defeito',
'Gravidade',
'Quantidade Afetada',
'Peso Unitário (kg)',
'Peso Total (kg)',
'Etapa Atual no Chão de Fábrica',
'Qtd Já Apontada',
'Nível de Risco Fabril',
'Peça Conectada / Parceira',
'Descrição Detalhada',
'Ação Corretiva Recomendada',
];
const ocorrenciasRows = data.defects.map((d) => [
d.id,
d.pecaMarca,
d.conjuntoMarca || '',
d.fase || '',
d.tipo,
d.gravidade,
d.quantidadeAfetada,
d.pesoUnitarioKg || '',
d.pesoTotalKg || '',
d.processoAtual || 'Pendente',
d.quantidadeApontada || 0,
d.riscoFabril,
d.pecaConectadaMarca ? `${d.pecaConectadaMarca} (${d.pecaConectadaConjunto || ''})` : '',
d.descricao,
d.recomendacao,
]);
const wsOcorrencias = XLSX.utils.aoa_to_sheet([ocorrenciasHeader, ...ocorrenciasRows]);
XLSX.utils.book_append_sheet(wb, wsOcorrencias, 'Ocorrências e Discrepâncias');
// 3. Download do arquivo
XLSX.writeFile(
wb,
`Auditoria_3D_OF_${data.ofNumber}_${new Date().toISOString().split('T')[0]}.xlsx`
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import { loadAndAuditIFC, LoadedIFCResult, PieceInfo, IFCQualityAudit, getColorForMaterialName, isValidMark } from '@/lib/ifc/ifcLoaderService';
import { loadAndAuditWRL } from '@/lib/3d/wrlLoaderService';
export type { LoadedIFCResult, PieceInfo, IFCQualityAudit };
export { getColorForMaterialName, isValidMark };
// Tipo genérico para o resultado de qualquer modelo 3D carregado (IFC ou WRL)
export type LoadedModel3DResult = LoadedIFCResult;
/**
* Detecta se os bytes iniciais ou a extensão correspondem a um arquivo VRML / WRL.
*/
function isWrlFormat(fileName?: string, buffer?: ArrayBuffer): boolean {
if (fileName) {
const lower = fileName.toLowerCase();
if (lower.endsWith('.wrl') || lower.endsWith('.wrz') || lower.endsWith('.vrml')) {
return true;
}
}
if (buffer && buffer.byteLength >= 10) {
const bytes = new Uint8Array(buffer.slice(0, 100));
// Checa assinatura mágica GZIP (0x1f 0x8b) que arquivos .wrz frequentemente usam
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
// É gzip, em arquivos 3D industriais costuma ser VRML comprimido (.wrz)
return true;
}
// Checa cabeçalho ASCII VRML (ex: "#VRML V2.0 utf8" ou "#VRML V1.0 ascii")
const headerStr = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
if (headerStr.includes('#VRML')) {
return true;
}
}
return false;
}
/**
* Função unificada para carregar e auditar qualquer modelo 3D suportado (IFC ou WRL/VRML).
*/
export async function loadAndAuditModel3D(
fileOrBuffer: File | ArrayBuffer,
fileNameHint?: string,
onProgress?: (percent: number, step: string) => void
): Promise<LoadedModel3DResult> {
const fileName = fileOrBuffer instanceof File ? fileOrBuffer.name : fileNameHint;
let bufferSample: ArrayBuffer | undefined = undefined;
if (fileOrBuffer instanceof ArrayBuffer) {
bufferSample = fileOrBuffer;
}
const isWrl = isWrlFormat(fileName, bufferSample);
if (isWrl) {
return await loadAndAuditWRL(fileOrBuffer, onProgress);
}
// Padrão: IFC
return await loadAndAuditIFC(fileOrBuffer, onProgress);
}
+660
View File
@@ -0,0 +1,660 @@
import * as THREE from 'three';
import { VRMLLoader } from 'three-stdlib';
import { PieceInfo, LoadedIFCResult, IFCQualityAudit, getColorForMaterialName, isValidMark, isSinglePartCutNumber } from '@/lib/ifc/ifcLoaderService';
/**
* Função utilitária para descompactar arquivos gzip (como .wrz ou .wrl comprimidos) no navegador.
*/
async function decompressGzipIfNeeded(buffer: ArrayBuffer): Promise<string> {
const bytes = new Uint8Array(buffer);
// Assinatura mágica do GZIP: 0x1F, 0x8B
if (bytes.length > 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) {
if (typeof DecompressionStream !== 'undefined') {
try {
const stream = new Blob([buffer]).stream();
const decompressedStream = stream.pipeThrough(new DecompressionStream('gzip'));
const response = new Response(decompressedStream);
const decompressedBuffer = await response.arrayBuffer();
return new TextDecoder('utf-8', { fatal: false }).decode(decompressedBuffer);
} catch (err) {
console.warn('[WRL Loader] Falha ao descomprimir GZIP nativo:', err);
}
}
}
return new TextDecoder('utf-8', { fatal: false }).decode(buffer);
}
/**
* Extrai a Fase da estrutura a partir dos ancestrais na árvore 3D (ex: "00001_COLUNAS", "00004_GUARDA_CORPO").
*/
function extractPhaseFromAncestors(ancestors: string[]): string | undefined {
if (!ancestors || ancestors.length === 0) return undefined;
// 1. O nó de primeiro nível (ancestors[0]) representa a pasta raiz no WRL (ex: "00001_COLUNAS", "00004_GUARDA_CORPO")
const rootNode = ancestors[0].replace(/^DEF\s+/i, '').replace(/^ID_/, '').trim();
// Padrão Tekla / Bocad com prefixo numérico seguido de nome de grupo (ex: "00001_COLUNAS", "00002_VIGAS")
const mZeros = rootNode.match(/^(?:0*([1-9]\d*))[-_ ]+([A-Za-zÀ-ÿ]+.*)$/i);
if (mZeros) {
return String(Number(mZeros[1])); // "1", "2", "3", "4"
}
// Padrão explícito na raiz "FASE 1", "FASE_01", "ETAPA 2"
const mRootFase = rootNode.match(/^(?:fase|etapa|phase|stage)[-_ ]*0*([1-9]\d*)/i);
if (mRootFase) {
return String(Number(mRootFase[1]));
}
// 2. Pesquisa nos ancestrais intermediários APENAS se contiver palavra-chave explícita FASE ou ETAPA
for (let i = 1; i < ancestors.length; i++) {
const clean = ancestors[i].replace(/^DEF\s+/i, '').replace(/^ID_/, '').trim();
const mExplicit = clean.match(/^(?:fase|etapa|phase|stage)[-_ ]*0*([1-9]\d*)/i);
if (mExplicit) {
return String(Number(mExplicit[1]));
}
}
return undefined;
}
/**
* Analisa e normaliza a marca da peça, marca do conjunto e tipo estrutural com suporte completo a Tekla / Bocad.
*/
/**
* Analisa e normaliza a marca da peça, marca do conjunto e tipo estrutural com suporte completo a Tekla / Bocad.
*/
function parseWrlNodeName(
rawName: string,
inheritedPhase?: string,
ancestorAssembly?: string
): {
mainMark: string;
cleanMainMark: string;
pieceMark: string;
cleanMark: string;
assemblyMark?: string;
cleanAssemblyMark?: string;
singlePartMark?: string;
profile?: string;
section?: string;
phase?: string;
detectedType: string;
} {
let name = String(rawName || '').trim();
// Remove prefixos internos
name = name.replace(/^ID_/, '').replace(/^DEF\s+/i, '').replace(/[\{\}\[\]"']/g, '').trim();
let assemblyMark = ancestorAssembly ? ancestorAssembly.trim() : undefined;
let pieceMark = name;
let singlePartMark: string | undefined = undefined;
let profile = '';
const parts = name.split('_');
// CASO 1: Tekla composto com 2 números no início: [Conjunto]_[Peca]_[Perfil] (ex: "1_1001_W360X44_0", "121_1127_TUBO44x45X3_121293")
if (parts.length >= 3 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
assemblyMark = parts[0];
pieceMark = parts[1];
singlePartMark = parts[1];
let rest = parts.slice(2).join('_');
rest = rest.replace(/_\d{5,}$/, '');
profile = rest;
}
// CASO 2: Tekla padrão [MarcaPeca]_[Perfil] (ex: "5_W360X44_0", "6_W360X32_9", "1_W360X44_0", "6_VIGA")
else if (parts.length >= 2 && /^\d+$/.test(parts[0])) {
pieceMark = parts[0];
assemblyMark = parts[0];
profile = parts.slice(1).join('_');
}
// CASO 3: Prefixo de conjunto alfanumérico (ex: "V101_1_CHAPA", "P1_2_CANTON")
else if (parts.length >= 2 && /^[A-Za-z]+\d+$/i.test(parts[0])) {
assemblyMark = parts[0];
pieceMark = parts[0];
profile = parts.slice(1).join('_');
}
// CASO 4: Parafusos / Fixadores
else if (/^(?:PARAF|BOLT)/i.test(name)) {
pieceMark = parts[0];
profile = 'PARAFUSO';
}
else {
// Remoção de prefixos comuns como "Part_", "Piece_", "Peca_", "Elem_"
pieceMark = pieceMark.replace(/^(?:Part|Piece|Peca|Elem|Item|Member|Profile|Mesh)[-_]/i, '');
}
// Normalização do Perfil / Bitola (ex: W360X44_0 -> W360X44.0, W360X32_9 -> W360X32.9)
if (profile) {
profile = profile.replace(/_(\d+)$/, '.$1').replace(/_/g, ' ').trim();
}
let cleanMark = pieceMark.replace(/[-_.#]\d+$/, '').trim().toUpperCase();
if (!cleanMark) cleanMark = pieceMark.toUpperCase();
const cleanAssemblyMark = assemblyMark ? assemblyMark.replace(/[-_.#]\d+$/, '').trim().toUpperCase() : cleanMark;
// Em estruturas metálicas, a marca da peça apontada na produção é a marca do conjunto (Assembly Mark).
// Se a sub-peça for um número de corte (>= 1000) e houver conjunto pai, a marca principal é o conjunto!
let mainMark = pieceMark;
let cleanMainMark = cleanMark;
if (assemblyMark && (isSinglePartCutNumber(pieceMark) || !mainMark || mainMark === 'indefinido')) {
mainMark = assemblyMark;
cleanMainMark = cleanAssemblyMark;
}
// Detecção de tipo de elemento estrutural
let detectedType = 'ELEMENT';
const searchStr = `${profile} ${name} ${cleanMark} ${cleanAssemblyMark} ${mainMark}`.toUpperCase();
if (searchStr.includes('VIGA') || searchStr.includes('BEAM') || cleanMainMark.startsWith('V')) {
detectedType = 'BEAM';
} else if (searchStr.includes('COLUNA') || searchStr.includes('PILAR') || searchStr.includes('COLUMN') || cleanMainMark.startsWith('P')) {
detectedType = 'COLUMN';
} else if (searchStr.includes('CHAPA') || searchStr.includes('PLATE') || cleanMark.startsWith('PL') || cleanMark.startsWith('CH')) {
detectedType = 'PLATE';
} else if (searchStr.includes('TUBO') || searchStr.includes('TUBE') || searchStr.includes('ROBN')) {
detectedType = 'TUBE';
} else if (searchStr.includes('PARAF') || searchStr.includes('BOLT')) {
detectedType = 'FASTENER';
} else if (searchStr.includes('ESCAD') || searchStr.includes('STAIR')) {
detectedType = 'STAIR';
} else if (searchStr.includes('GUARDA') || searchStr.includes('CORRIM')) {
detectedType = 'RAILING';
} else if (searchStr.includes('CONTRA') || searchStr.includes('BRACE') || searchStr.includes('DIAG')) {
detectedType = 'BRACE';
} else if (searchStr.includes('TERCA') || searchStr.includes('PURLIN')) {
detectedType = 'PURLIN';
} else if (searchStr.includes('TIRANTE') || searchStr.includes('TIE')) {
detectedType = 'TIE';
}
return {
mainMark: mainMark.toUpperCase(),
cleanMainMark: cleanMainMark.toUpperCase(),
pieceMark: pieceMark.toUpperCase(),
cleanMark,
assemblyMark: assemblyMark ? assemblyMark.toUpperCase() : undefined,
cleanAssemblyMark,
singlePartMark,
profile: profile || undefined,
section: profile || undefined,
phase: inheritedPhase,
detectedType,
};
}
/**
* Sanitiza o texto VRML para máxima compatibilidade com o parser do Three.js
*/
function sanitizeVrmlForLoader(rawText: string): {
sanitized: string;
nameMap: Map<string, string>;
} {
const nameMap = new Map<string, string>();
let text = rawText.replace(/^\uFEFF/, ''); // remove BOM
// Localiza o início da tag #VRML
const headerIdx = text.search(/#VRML/i);
if (headerIdx > 0) {
text = text.slice(headerIdx);
}
// Normaliza primeira linha para #VRML V2.0 utf8
text = text.replace(/^#VRML[^\r\n]*/i, '#VRML V2.0 utf8');
// Normaliza identificadores em DEF e USE (ex: "DEF 01-V101 Transform" -> "DEF ID_01_V101 Transform")
text = text.replace(/\b(DEF|USE)\s+([^\s\{\[\(\)]+)/g, (_, keyword, rawId) => {
let cleanId = String(rawId).replace(/[^a-zA-Z0-9_]/g, '_');
if (/^[0-9]/.test(cleanId)) {
cleanId = 'ID_' + cleanId;
}
nameMap.set(cleanId, rawId);
return `${keyword} ${cleanId}`;
});
return { sanitized: text, nameMap };
}
/**
* Parser de Fallback Direto e Resiliente para arquivos VRML (.wrl 1.0 ou 2.0).
*/
function parseVrmlDirectFallback(
vrmlText: string,
nameMap: Map<string, string>
): THREE.Group {
const group = new THREE.Group();
group.name = 'WRL_Direct_Fallback_Root';
const tokenRegex = /(DEF\s+([^\s\{\[\(\)]+))|(\{)|(\})|(point\s*\[([\s\S]*?)\])|(coordIndex\s*\[([\s\S]*?)\])/gi;
let match: RegExpExecArray | null;
const stack: { name: string; group: THREE.Group }[] = [{ name: 'ROOT', group }];
let currentGroup = group;
let lastDef: string | null = null;
let currentPoints: number[] | null = null;
let count = 0;
while ((match = tokenRegex.exec(vrmlText)) !== null) {
if (match[1]) {
lastDef = match[2];
} else if (match[3]) {
const newGroup = new THREE.Group();
if (lastDef) {
newGroup.name = nameMap.get(lastDef) || lastDef;
lastDef = null;
} else {
newGroup.name = 'Unnamed_Group';
}
currentGroup.add(newGroup);
stack.push({ name: newGroup.name, group: newGroup });
currentGroup = newGroup;
} else if (match[4]) {
if (stack.length > 1) {
stack.pop();
currentGroup = stack[stack.length - 1].group;
}
} else if (match[5]) {
const rawCoords = match[6].trim().split(/[\s,]+/).filter(Boolean).map(Number);
if (rawCoords.length >= 9) {
currentPoints = rawCoords;
}
} else if (match[7]) {
if (!currentPoints) continue;
const indexStr = match[8];
const rawIndices = indexStr.trim().split(/[\s,]+/).filter(Boolean).map(Number);
const vertices: [number, number, number][] = [];
for (let i = 0; i < currentPoints.length; i += 3) {
if (!isNaN(currentPoints[i]) && !isNaN(currentPoints[i + 1]) && !isNaN(currentPoints[i + 2])) {
vertices.push([currentPoints[i], currentPoints[i + 1], currentPoints[i + 2]]);
}
}
const triangleIndices: number[] = [];
let currentPoly: number[] = [];
for (const idx of rawIndices) {
if (idx === -1) {
if (currentPoly.length >= 3) {
for (let p = 1; p < currentPoly.length - 1; p++) {
triangleIndices.push(currentPoly[0], currentPoly[p], currentPoly[p + 1]);
}
}
currentPoly = [];
} else {
currentPoly.push(idx);
}
}
if (currentPoly.length >= 3) {
for (let p = 1; p < currentPoly.length - 1; p++) {
triangleIndices.push(currentPoly[0], currentPoly[p], currentPoly[p + 1]);
}
}
if (triangleIndices.length === 0) continue;
const positions: number[] = [];
for (const tIdx of triangleIndices) {
const v = vertices[tIdx];
if (v) positions.push(v[0], v[1], v[2]);
}
if (positions.length > 0) {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.computeVertexNormals();
const mesh = new THREE.Mesh(geometry);
let meshName = `PECA-${++count}`;
for (let i = stack.length - 1; i >= 0; i--) {
if (stack[i].name && stack[i].name !== 'Unnamed_Group' && stack[i].name !== 'ROOT') {
meshName = stack[i].name;
break;
}
}
mesh.name = meshName;
mesh.castShadow = true;
mesh.receiveShadow = true;
currentGroup.add(mesh);
}
}
}
if (count === 0) {
const genericRegex = /point\s*\[([\s\S]*?)\][\s\S]*?coordIndex\s*\[([\s\S]*?)\]/gi;
let genMatch: RegExpExecArray | null;
let genIdx = 1;
while ((genMatch = genericRegex.exec(vrmlText)) !== null) {
const pointStr = genMatch[1];
const indexStr = genMatch[2];
const rawCoords = pointStr.trim().split(/[\s,]+/).filter(Boolean).map(Number);
if (rawCoords.length < 9) continue;
const vertices: [number, number, number][] = [];
for (let i = 0; i < rawCoords.length; i += 3) {
if (!isNaN(rawCoords[i]) && !isNaN(rawCoords[i + 1]) && !isNaN(rawCoords[i + 2])) {
vertices.push([rawCoords[i], rawCoords[i + 1], rawCoords[i + 2]]);
}
}
const rawIndices = indexStr.trim().split(/[\s,]+/).filter(Boolean).map(Number);
const triangleIndices: number[] = [];
let currentPoly: number[] = [];
for (const idx of rawIndices) {
if (idx === -1) {
if (currentPoly.length >= 3) {
for (let p = 1; p < currentPoly.length - 1; p++) {
triangleIndices.push(currentPoly[0], currentPoly[p], currentPoly[p + 1]);
}
}
currentPoly = [];
} else {
currentPoly.push(idx);
}
}
if (currentPoly.length >= 3) {
for (let p = 1; p < currentPoly.length - 1; p++) {
triangleIndices.push(currentPoly[0], currentPoly[p], currentPoly[p + 1]);
}
}
const positions: number[] = [];
for (const tIdx of triangleIndices) {
const v = vertices[tIdx];
if (v) positions.push(v[0], v[1], v[2]);
}
if (positions.length > 0) {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.computeVertexNormals();
const mesh = new THREE.Mesh(geometry);
mesh.name = `PECA-${genIdx++}`;
mesh.castShadow = true;
mesh.receiveShadow = true;
group.add(mesh);
}
}
}
return group;
}
/**
* Carrega, analisa e gera estrutura idêntica ao IFC para modelos VRML (.wrl / .wrz).
*/
export async function loadAndAuditWRL(
fileOrBuffer: File | ArrayBuffer,
onProgress?: (percent: number, step: string) => void
): Promise<LoadedIFCResult> {
if (onProgress) onProgress(15, 'Lendo arquivo WRL (VRML)...');
let arrayBuffer: ArrayBuffer;
if (fileOrBuffer instanceof File) {
arrayBuffer = await fileOrBuffer.arrayBuffer();
} else {
arrayBuffer = fileOrBuffer;
}
if (onProgress) onProgress(30, 'Descomprimindo e analisando formato VRML...');
const rawVrmlText = await decompressGzipIfNeeded(arrayBuffer);
if (onProgress) onProgress(45, 'Sanitizando identificadores e sintaxe VRML...');
const { sanitized: vrmlText, nameMap } = sanitizeVrmlForLoader(rawVrmlText);
if (onProgress) onProgress(60, 'Processando geometria 3D com motor VRML...');
let vrmlScene: THREE.Object3D;
let isFallback = false;
// Detecta se o arquivo é VRML 1.0 (padrão Tekla / Bocad com nós Separator)
const isVrml1 = /#VRML\s+V1\.0/i.test(rawVrmlText) || /\bSeparator\s*\{/i.test(rawVrmlText);
if (isVrml1) {
// Para VRML 1.0, utilizamos diretamente nosso decodificador de alta performance
// sem acionar o VRMLLoader do Three.js (que suporta apenas VRML 2.0 e gerava MismatchedTokenException)
if (onProgress) onProgress(65, 'Decodificando geometria nativa Tekla/Bocad (VRML 1.0)...');
vrmlScene = parseVrmlDirectFallback(rawVrmlText, nameMap);
isFallback = true;
} else {
// Para VRML 2.0, utiliza o parser padrão do Three.js com fallback de contingência
const loader = new VRMLLoader();
try {
vrmlScene = loader.parse(vrmlText, '');
} catch (parseError: any) {
if (onProgress) onProgress(65, 'Acionando decodificador direto de geometria VRML...');
vrmlScene = parseVrmlDirectFallback(rawVrmlText, nameMap);
isFallback = true;
}
}
if (onProgress) onProgress(75, 'Mapeando peças estruturais e hierarquia...');
const sceneGroup = new THREE.Group();
sceneGroup.name = 'WRL_Root_Model';
const piecesByMark = new Map<string, PieceInfo[]>();
const pieceByExpressID = new Map<number, PieceInfo>();
const weldedGroupMap = new Map<number, number[]>();
const materialsByColor = new Map<string, THREE.Material>();
const detectedPhasesSet = new Set<string>();
let idCounter = 1;
let totalMeshes = 0;
let totalBeams = 0;
let totalColumns = 0;
let totalPlates = 0;
let totalMembers = 0;
const marksFound = new Set<string>();
// Percorre todos os nós e malhas gerados
vrmlScene.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh;
totalMeshes++;
// Garante normais de vértices para iluminação perfeita
if (mesh.geometry) {
if (!mesh.geometry.attributes.normal) {
mesh.geometry.computeVertexNormals();
}
mesh.castShadow = true;
mesh.receiveShadow = true;
}
// Constrói a lista de ancestrais da raiz até o nó da malha
const ancestors: string[] = [];
let p: THREE.Object3D | null = mesh.parent;
while (p && p !== vrmlScene && p.name !== 'WRL_Root_Model') {
const pName = (p.name || '').trim();
if (pName && !/^(Scene|Root|Group|Transform|Shape|IndexedFaceSet|WRL_Direct_Fallback_Root)$/i.test(pName)) {
const unmapped = nameMap.get(pName) || pName;
ancestors.unshift(unmapped);
}
p = p.parent;
}
// 1. Extração rigorosa da Fase a partir dos nós ancestrais
const inheritedPhase = extractPhaseFromAncestors(ancestors);
// 2. Extração do conjunto pai se houver na árvore (ex: pasta '121')
let ancestorAssembly: string | undefined = undefined;
for (const anc of ancestors) {
const cleanAnc = anc.replace(/^DEF\s+/i, '').replace(/^ID_/, '').trim();
if (/^\d+$/.test(cleanAnc) && cleanAnc !== inheritedPhase) {
ancestorAssembly = cleanAnc;
break;
}
}
const rawNodeName = nameMap.get(mesh.name) || mesh.name || `PECA-${idCounter}`;
const parsed = parseWrlNodeName(rawNodeName, inheritedPhase, ancestorAssembly);
const expressID = idCounter++;
const guid = `wrl-${expressID}-${parsed.cleanMark}`;
if (parsed.phase) {
detectedPhasesSet.add(parsed.phase);
}
if (parsed.cleanMark && isValidMark(parsed.cleanMark)) {
marksFound.add(parsed.cleanMark);
}
if (parsed.cleanAssemblyMark && isValidMark(parsed.cleanAssemblyMark)) {
marksFound.add(parsed.cleanAssemblyMark);
}
// Contabiliza tipos
if (parsed.detectedType === 'BEAM') totalBeams++;
else if (parsed.detectedType === 'COLUMN') totalColumns++;
else if (parsed.detectedType === 'PLATE') totalPlates++;
else totalMembers++;
// Aplica material inicial elegante baseado na marca/perfil
const baseColorHex = getColorForMaterialName(parsed.cleanMark || parsed.pieceMark);
let material = materialsByColor.get(baseColorHex);
if (!material) {
material = new THREE.MeshStandardMaterial({
color: new THREE.Color(baseColorHex),
roughness: 0.45,
metalness: 0.15,
side: THREE.DoubleSide,
});
materialsByColor.set(baseColorHex, material);
}
mesh.material = material;
const pieceSection = parsed.profile || parsed.section || parsed.cleanMainMark || 'Perfil';
const mainMark = parsed.mainMark || parsed.cleanMainMark || parsed.pieceMark;
const cleanMain = parsed.cleanMainMark || parsed.cleanMark;
const pieceInfo: PieceInfo = {
expressID,
guid,
name: rawNodeName,
type: parsed.detectedType,
pieceMark: mainMark,
cleanMark: cleanMain,
assemblyMark: parsed.assemblyMark || mainMark,
cleanAssemblyMark: parsed.cleanAssemblyMark || cleanMain,
section: pieceSection,
phase: parsed.phase,
singlePartMark: parsed.singlePartMark,
mesh,
};
// Injeta metadados essenciais no userData do Mesh (com total compatibilidade IFC / Three.js)
mesh.userData = {
ifcId: expressID, // Identificador primordial compatível com Raycaster, Hover e Seleção do visualizador
expressID,
guid,
pieceMark: mainMark, // Peça principal da produção (ex: 1, 8, V101)
cleanMark: cleanMain,
singlePartMark: parsed.singlePartMark, // Componente de corte (ex: 1001)
assemblyMark: parsed.assemblyMark || mainMark,
cleanAssemblyMark: parsed.cleanAssemblyMark || cleanMain,
section: pieceSection,
phase: parsed.phase,
type: parsed.detectedType,
name: rawNodeName,
pieceInfo,
originalMaterial: material,
};
pieceByExpressID.set(expressID, pieceInfo);
// Agrupa peças por marca de conjunto e por marca de componente
const markKeys = [mainMark, cleanMain, parsed.singlePartMark, parsed.pieceMark, parsed.cleanMark].filter(Boolean) as string[];
for (const mKey of Array.from(new Set(markKeys))) {
const list = piecesByMark.get(mKey) || [];
list.push(pieceInfo);
piecesByMark.set(mKey, list);
}
}
});
// Adiciona a cena VRML ao grupo final
sceneGroup.add(vrmlScene);
// -------------------------------------------------------------
// CENTRALIZAÇÃO ABSOLUTA E NORMALIZAÇÃO DE ESCALA DO MODELO
// -------------------------------------------------------------
// Garante que o centro do modelo fique exatamente em (0, 0, 0) local
// para que qualquer rotação (ex: 270° no eixo X) ocorra em torno do centro da estrutura
// e não lance o modelo para fora da visualização da câmera.
const rawBox = new THREE.Box3().setFromObject(vrmlScene);
if (!rawBox.isEmpty()) {
const rawCenter = rawBox.getCenter(new THREE.Vector3());
const rawSize = rawBox.getSize(new THREE.Vector3());
// Desloca o vrmlScene interno para centrar em (0,0,0)
vrmlScene.position.x -= rawCenter.x;
vrmlScene.position.y -= rawCenter.y;
vrmlScene.position.z -= rawCenter.z;
vrmlScene.updateMatrixWorld(true);
// Se as dimensões forem em milímetros (> 500 unidades), converte para metros (escala 0.001)
const maxDim = Math.max(rawSize.x, rawSize.y, rawSize.z);
if (maxDim > 500) {
const scaleFactor = 0.001;
sceneGroup.scale.set(scaleFactor, scaleFactor, scaleFactor);
}
sceneGroup.updateMatrixWorld(true);
}
// Gera lista ordenada de fases limpas (ex: ["1", "2", "3", "4"])
const sortedPhases = Array.from(detectedPhasesSet).sort((a, b) => {
const na = Number(a);
const nb = Number(b);
return !isNaN(na) && !isNaN(nb) ? na - nb : a.localeCompare(b);
});
// Gera auditoria de qualidade compatível
const uniqueMarksFound = marksFound.size;
const marksList = Array.from(marksFound).sort();
const qualityScore: 'EXCELENTE' | 'BOM' | 'ATENÇÃO' =
uniqueMarksFound > 0 ? (uniqueMarksFound >= 10 ? 'EXCELENTE' : 'BOM') : 'ATENÇÃO';
const audit: IFCQualityAudit = {
schema: isFallback ? 'VRML Direto (WRL Resiliente)' : 'VRML 2.0 (WRL)',
viewDefinition: 'Virtual Reality Modeling Language 3D',
isEM11: false,
totalAssemblies: marksFound.size,
totalBeams,
totalColumns,
totalMembers,
totalPlates,
totalFasteners: 0,
totalStructuralElements: totalMeshes,
totalWeldedGroups: 0,
uniqueMarksFound,
marksList,
qualityScore,
qualityMessage:
uniqueMarksFound > 0
? `Modelo WRL processado com sucesso. ${totalMeshes} malhas renderizadas e ${uniqueMarksFound} marcas identificadas em ${sortedPhases.length} fases estruturais.`
: `Modelo WRL processado com ${totalMeshes} malhas. Nenhuma marca nominal identificada na geometria.`,
fallbackActive: isFallback,
};
if (onProgress) onProgress(100, 'Modelo WRL pronto para visualização!');
return {
sceneGroup,
audit,
piecesByMark,
pieceByExpressID,
weldedGroupMap,
materialsByColor,
totalMeshes,
detectedPhases: sortedPhases,
};
}
+1
View File
@@ -33,6 +33,7 @@ export interface PieceInfo {
parentAssemblyID?: number;
phase?: string;
ofNumber?: string;
singlePartMark?: string;
section?: string;
span?: number;
material?: string;
+12 -8
View File
@@ -103,7 +103,7 @@ export async function clearCachedIFCModel(ofNumber: string): Promise<void> {
}
/**
* Realiza o upload do arquivo IFC para o Supabase Storage e salva o vínculo na OF.
* Realiza o upload do arquivo de modelo 3D (IFC ou WRL) para o Supabase Storage e salva o vínculo na OF.
*/
export async function uploadIFCToCloud(
file: File,
@@ -113,9 +113,11 @@ export async function uploadIFCToCloud(
): Promise<{ publicUrl: string; filePath: string }> {
const sanitizedOf = ofNumber.replace(/[^a-zA-Z0-9_-]/g, '_');
const cleanFileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
const filePath = `ifc/${sanitizedOf}/${cleanFileName}`;
const ext = file.name.split('.').pop()?.toLowerCase();
const folder = (ext === 'wrl' || ext === 'wrz') ? 'wrl' : 'ifc';
const filePath = `${folder}/${sanitizedOf}/${cleanFileName}`;
if (onProgress) onProgress('Enviando arquivo IFC para a nuvem...', 20);
if (onProgress) onProgress('Enviando modelo 3D para a nuvem...', 20);
// 1. Upload no Supabase Storage (bucket modelos-3d)
const { error: uploadError } = await supabase.storage
@@ -126,7 +128,7 @@ export async function uploadIFCToCloud(
});
if (uploadError) {
console.error('[IFC Storage] Erro no upload:', uploadError);
console.error('[Storage] Erro no upload:', uploadError);
throw new Error(`Falha ao salvar no storage: ${uploadError.message}`);
}
@@ -151,7 +153,7 @@ export async function uploadIFCToCloud(
.eq('num_of', ofNumber);
if (dbError) {
console.warn('[IFC Storage] Aviso ao atualizar ordens_fabricacao:', dbError);
console.warn('[Storage] Aviso ao atualizar ordens_fabricacao:', dbError);
}
// 4. Inserir histórico em of_modelos_3d
@@ -168,7 +170,7 @@ export async function uploadIFCToCloud(
updated_at: new Date().toISOString()
});
} catch (histErr) {
console.debug('[IFC Storage] Registro histórico:', histErr);
console.debug('[Storage] Registro histórico:', histErr);
}
// 5. Salva no cache local do navegador
@@ -176,14 +178,16 @@ export async function uploadIFCToCloud(
const buffer = await file.arrayBuffer();
await cacheIFCModel(ofNumber, publicUrl, buffer);
} catch (cErr) {
console.debug('[IFC Storage] Cache local:', cErr);
console.debug('[Storage] Cache local:', cErr);
}
if (onProgress) onProgress('Modelo IFC salvo com sucesso!', 100);
if (onProgress) onProgress('Modelo 3D salvo com sucesso!', 100);
return { publicUrl, filePath };
}
export const uploadModel3DToCloud = uploadIFCToCloud;
/**
* Baixa e armazena em cache o modelo IFC salvo para a OF.
*/
+822
View File
@@ -0,0 +1,822 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { loadAndAuditModel3D, LoadedModel3DResult } from '@/lib/3d/modelLoaderService';
import { fetchSavedIFCModel } from '@/lib/ifc/ifcStorageService';
import { AnalyzerViewer3D } from '@/components/viewer3d/AnalyzerViewer3D';
import { PieceAuditDetailModal } from '@/components/viewer3d/PieceAuditDetailModal';
import { AuditEngine } from '@/lib/3d/analyzer/auditEngine';
import { AuditReportService } from '@/lib/3d/analyzer/auditReportService';
import { DefectItem, DefectType, DefectSeverity, ColorizationMode, ComparisonDiffSummary } from '@/types/analisador3d';
import { useToast } from '@/hooks/use-toast';
import { usePermissions } from '@/hooks/usePermissions';
import {
ScanSearch,
Upload,
FileSpreadsheet,
FileText,
Layers,
Filter,
AlertTriangle,
AlertCircle,
CheckCircle2,
RefreshCw,
Search,
Crosshair,
Factory,
Maximize2,
Minimize2,
Boxes,
ShieldAlert,
SlidersHorizontal,
ChevronDown,
Info,
Wrench,
Sparkles,
Weight
} from 'lucide-react';
interface OFOption {
id: string;
of_number: string;
descritivo?: string;
peso_total?: number;
ifc_url?: string | null;
ifc_filename?: string | null;
model_3d_rotation?: { x: number; y: number; z: number } | null;
}
export default function Analisador3D() {
const { toast } = useToast();
const { checkResourceAccess } = usePermissions();
// Estados principais de dados
const [ofs, setOfs] = useState<OFOption[]>([]);
const [selectedOF, setSelectedOF] = useState<string>('');
const [selectedPhase, setSelectedPhase] = useState<string>('all');
const [phasesList, setPhasesList] = useState<string[]>([]);
// Modos de Operação
const [analysisMode, setAnalysisMode] = useState<'auditoria' | 'comparacao'>('auditoria');
const [colorizationMode, setColorizationMode] = useState<ColorizationMode>('status_defeito');
const [isGhostMode, setIsGhostMode] = useState<boolean>(true);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const [isDarkTheme, setIsDarkTheme] = useState<boolean>(() =>
typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false
);
useEffect(() => {
const observer = new MutationObserver(() => {
setIsDarkTheme(document.documentElement.classList.contains('dark'));
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
// Modelos 3D Carregados
const [modelBase, setModelBase] = useState<LoadedModel3DResult | null>(null);
const [modelRevisao, setModelRevisao] = useState<LoadedModel3DResult | null>(null);
const [isLoadingModel, setIsLoadingModel] = useState<boolean>(false);
const [loadingStep, setLoadingStep] = useState<string>('');
const [loadingPercent, setLoadingPercent] = useState<number>(0);
// Dados de Produção e Auditoria
const [productionMap, setProductionMap] = useState<Map<string, any>>(new Map());
const [defects, setDefects] = useState<DefectItem[]>([]);
const [summary, setSummary] = useState<ComparisonDiffSummary | null>(null);
// Filtros e Seleção
const [filterSeverity, setFilterSeverity] = useState<string>('all');
const [filterType, setFilterType] = useState<string>('all');
const [filterProcess, setFilterProcess] = useState<string>('all');
const [filterAssembly, setFilterAssembly] = useState<string>('all');
const [searchPiece, setSearchPiece] = useState<string>('');
const [selectedDefect, setSelectedDefect] = useState<DefectItem | null>(null);
const [selectedPieceMark, setSelectedPieceMark] = useState<string | null>(null);
const [detailModalDefect, setDetailModalDefect] = useState<DefectItem | null>(null);
const fileInputBaseRef = useRef<HTMLInputElement>(null);
const fileInputRevRef = useRef<HTMLInputElement>(null);
// 1. Carrega OFs do Supabase
useEffect(() => {
async function fetchOFs() {
try {
const { data, error } = await supabase
.from('ordens_fabricacao' as any)
.select('id, num_of, descritivo, peso_total, ifc_url, ifc_filename, model_3d_rotation')
.order('num_of', { ascending: false });
if (!error && data) {
const list: OFOption[] = (data as any[]).map((item) => ({
id: item.id,
of_number: item.num_of || '',
descritivo: item.descritivo || '',
peso_total: Number(item.peso_total || 0),
ifc_url: item.ifc_url || null,
ifc_filename: item.ifc_filename || null,
model_3d_rotation: item.model_3d_rotation || null,
}));
setOfs(list);
if (list.length > 0 && !selectedOF) {
setSelectedOF(list[0].of_number);
}
}
} catch (err) {
console.error('Erro ao buscar OFs:', err);
}
}
fetchOFs();
}, []);
// 2. Busca apontamentos e peças da OF para alimentar o cruzamento fabril
useEffect(() => {
if (!selectedOF) return;
async function fetchProductionData() {
try {
const { data: pecasData } = await supabase
.from('pecas' as any)
.select('id, of_number, etapa_fase, marca, descricao, quantidade, peso_unitario, peso_total, perfil_principal, material')
.eq('of_number', selectedOF);
const { data: apontamentosData } = await supabase
.from('apontamentos_producao' as any)
.select(`
id, of_number, peca_id, quantidade_produzida,
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase),
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem)
`)
.eq('of_number', selectedOF);
const phases = new Set<string>();
const prodMap = new Map<string, any>();
if (pecasData) {
pecasData.forEach((p: any) => {
const fase = String(p.etapa_fase || '').trim();
if (fase) phases.add(fase);
const marcaStr = String(p.marca || '').trim();
const item = {
marca: marcaStr,
fase,
totalQtd: Number(p.quantidade || 1),
pesoTotal: Number(p.peso_total || 0),
pesoUnitario: Number(p.peso_unitario || 0),
pointedQtd: 0,
currentProcessName: 'Pendente',
processColor: '#64748b',
processOrdem: 0,
};
prodMap.set(marcaStr, item);
prodMap.set(marcaStr.toUpperCase(), item);
});
}
if (apontamentosData) {
apontamentosData.forEach((ap: any) => {
const marca = ap.peca?.marca ? String(ap.peca.marca).trim() : '';
if (marca && prodMap.has(marca)) {
const item = prodMap.get(marca);
const qty = Number(ap.quantidade_produzida || 0);
const procName = String(ap.processo?.nome || '').trim();
if (qty > 0 && !procName.toLowerCase().includes('detalh')) {
item.pointedQtd = Math.max(item.pointedQtd, qty);
item.currentProcessName = procName;
item.processColor = ap.processo?.cor || '#10b981';
item.processOrdem = Number(ap.processo?.ordem || 0);
}
}
});
}
setPhasesList(Array.from(phases).sort());
setProductionMap(prodMap);
} catch (err) {
console.error('Erro ao buscar dados de produção:', err);
}
}
fetchProductionData();
}, [selectedOF]);
// 3. Carregamento Automático do Modelo Salvo da OF
const loadSavedOFModel = useCallback(async (ofNumber: string) => {
const currentOF = ofs.find((o) => o.of_number === ofNumber);
if (!currentOF || !currentOF.ifc_url) return;
setIsLoadingModel(true);
setLoadingStep('Buscando modelo 3D salvo no banco...');
setLoadingPercent(20);
try {
const buffer = await fetchSavedIFCModel(ofNumber, currentOF.ifc_url, (pct, step) => {
setLoadingPercent(pct);
setLoadingStep(step);
});
setLoadingStep('Processando geometria e montagens...');
setLoadingPercent(60);
const result = await loadAndAuditModel3D(buffer, currentOF.ifc_filename || undefined, (pct, step) => {
setLoadingPercent(60 + Math.round(pct * 0.4));
setLoadingStep(step);
});
setModelBase(result);
toast({
title: 'Modelo 3D Carregado',
description: `Modelo da OF ${ofNumber} pronto para auditoria.`,
});
} catch (err: any) {
console.error('Falha ao carregar modelo da OF:', err);
toast({
variant: 'destructive',
title: 'Falha no Carregamento',
description: 'Não foi possível carregar o modelo salvo no banco.',
});
} finally {
setIsLoadingModel(false);
}
}, [ofs, toast]);
useEffect(() => {
if (selectedOF) {
loadSavedOFModel(selectedOF);
}
}, [selectedOF, loadSavedOFModel]);
// 4. Executa a Auditoria Geométrica sempre que os modelos ou a OF mudam
useEffect(() => {
if (!modelBase) {
setDefects([]);
setSummary(null);
return;
}
const result = AuditEngine.runAudit({
modelBase,
modelRevisao: analysisMode === 'comparacao' ? modelRevisao : null,
productionMap,
});
setDefects(result.defects);
setSummary(result.summary);
}, [modelBase, modelRevisao, analysisMode, productionMap]);
// 5. Upload manual de modelos locais (para testes rápidos de IFC ou WRM)
const handleUploadLocalModel = async (file: File, isRev: boolean = false) => {
setIsLoadingModel(true);
setLoadingStep(`Lendo arquivo ${file.name}...`);
setLoadingPercent(30);
try {
const result = await loadAndAuditModel3D(file, file.name, (pct, step) => {
setLoadingPercent(30 + Math.round(pct * 0.7));
setLoadingStep(step);
});
if (isRev) {
setModelRevisao(result);
setAnalysisMode('comparacao');
toast({
title: 'Revisão Carregada',
description: `Modelo de revisão ${file.name} integrado para comparação.`,
});
} else {
setModelBase(result);
toast({
title: 'Modelo Base Carregado',
description: `Modelo base ${file.name} carregado com sucesso.`,
});
}
} catch (err: any) {
toast({
variant: 'destructive',
title: 'Erro no Arquivo',
description: err.message || 'Falha ao processar o arquivo 3D.',
});
} finally {
setIsLoadingModel(false);
}
};
// 6. Lista Filtrada de Ocorrências
const filteredDefects = useMemo(() => {
return defects.filter((d) => {
if (filterSeverity !== 'all' && d.gravidade !== filterSeverity) return false;
if (filterType !== 'all' && d.tipo !== filterType) return false;
if (filterProcess !== 'all' && d.processoAtual?.toLowerCase() !== filterProcess.toLowerCase()) return false;
if (filterAssembly !== 'all' && d.conjuntoMarca !== filterAssembly) return false;
if (searchPiece) {
const query = searchPiece.toLowerCase().trim();
const matchMarca = d.pecaMarca.toLowerCase().includes(query);
const matchConjunto = d.conjuntoMarca?.toLowerCase().includes(query);
const matchConectada = d.pecaConectadaMarca?.toLowerCase().includes(query);
if (!matchMarca && !matchConjunto && !matchConectada) return false;
}
return true;
});
}, [defects, filterSeverity, filterType, filterProcess, filterAssembly, searchPiece]);
// 7. Lista de Conjuntos disponíveis para filtro
const assembliesList = useMemo(() => {
const set = new Set<string>();
defects.forEach((d) => {
if (d.conjuntoMarca) set.add(d.conjuntoMarca);
});
return Array.from(set).sort();
}, [defects]);
// 8. Disparo de Relatórios
const handleExportPDF = () => {
if (!summary) return;
const currentOF = ofs.find((o) => o.of_number === selectedOF);
AuditReportService.exportToPDF({
ofNumber: selectedOF || 'GERAL',
ofDescritivo: currentOF?.descritivo,
dataAnalise: new Date().toLocaleDateString('pt-BR'),
analisadoPor: 'Controle de Qualidade TrackSteel',
modoAnalise: analysisMode,
modeloBaseNome: currentOF?.ifc_filename || 'Modelo Base',
modeloRevisaoNome: modelRevisao ? 'Revisão Projeto' : undefined,
summary,
defects: filteredDefects.length > 0 ? filteredDefects : defects,
});
};
const handleExportExcel = () => {
if (!summary) return;
const currentOF = ofs.find((o) => o.of_number === selectedOF);
AuditReportService.exportToExcel({
ofNumber: selectedOF || 'GERAL',
ofDescritivo: currentOF?.descritivo,
dataAnalise: new Date().toLocaleDateString('pt-BR'),
analisadoPor: 'Engenharia TrackSteel',
modoAnalise: analysisMode,
summary,
defects: filteredDefects.length > 0 ? filteredDefects : defects,
});
};
const currentOFObj = ofs.find((o) => o.of_number === selectedOF);
return (
<div
className={`flex flex-col h-[calc(100vh-4rem)] overflow-hidden transition-colors ${
isFullscreen ? 'fixed inset-0 z-50 bg-slate-950 p-3' : 'p-4'
}`}
>
{/* ── BARRA SUPERIOR: SELEÇÕES E AÇÕES ── */}
<div className="flex flex-wrap items-center justify-between gap-3 p-3.5 mb-3 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<span className="p-2 bg-indigo-600 text-white rounded-lg shadow-md shadow-indigo-500/20">
<ScanSearch className="w-5 h-5" />
</span>
<div>
<h1 className="text-base font-bold text-slate-900 dark:text-white flex items-center gap-2">
Analisador 3D BIM & QA/QC
<span className="text-[10px] uppercase font-semibold px-2 py-0.5 rounded-full bg-indigo-100 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800">
TrackSteel Engine
</span>
</h1>
<p className="text-xs text-slate-500 dark:text-slate-400">
Auditoria de conexões, furações coaxiais e comparador de revisões IFC / WRM
</p>
</div>
</div>
<div className="h-6 w-[1px] bg-slate-200 dark:bg-slate-800 mx-1 hidden sm:block" />
{/* Seletor de OF */}
<div className="flex items-center gap-2">
<label className="text-xs font-semibold text-slate-600 dark:text-slate-400">OF:</label>
<select
value={selectedOF}
onChange={(e) => setSelectedOF(e.target.value)}
className="px-3 py-1.5 text-xs font-semibold bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-lg text-slate-800 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">Selecione uma OF...</option>
{ofs.map((o) => (
<option key={o.id} value={o.of_number}>
OF {o.of_number} {o.descritivo ? `— ${o.descritivo.slice(0, 25)}` : ''}
</option>
))}
</select>
</div>
{/* Alternância de Modo */}
<div className="flex items-center p-1 bg-slate-100 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 text-xs font-medium">
<button
onClick={() => setAnalysisMode('auditoria')}
className={`px-3 py-1 rounded-md transition ${
analysisMode === 'auditoria'
? 'bg-white dark:bg-slate-900 text-indigo-600 dark:text-indigo-400 shadow-sm font-semibold'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`}
>
Auditoria Geométrica (QA)
</button>
<button
onClick={() => setAnalysisMode('comparacao')}
className={`px-3 py-1 rounded-md transition ${
analysisMode === 'comparacao'
? 'bg-white dark:bg-slate-900 text-indigo-600 dark:text-indigo-400 shadow-sm font-semibold'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`}
>
Comparador de Revisões (Diff)
</button>
</div>
</div>
{/* Botões de Ações e Exportações */}
<div className="flex items-center gap-2">
{/* Uploads Locais de Teste */}
<input
ref={fileInputBaseRef}
type="file"
accept=".ifc,.wrl,.wrz,.vrml"
className="hidden"
onChange={(e) => {
if (e.target.files?.[0]) handleUploadLocalModel(e.target.files[0], false);
}}
/>
<input
ref={fileInputRevRef}
type="file"
accept=".ifc,.wrl,.wrz,.vrml"
className="hidden"
onChange={(e) => {
if (e.target.files?.[0]) handleUploadLocalModel(e.target.files[0], true);
}}
/>
<button
onClick={() => fileInputBaseRef.current?.click()}
title="Carregar arquivo 3D local (IFC ou WRL)"
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 rounded-lg text-xs font-medium transition"
>
<Upload className="w-3.5 h-3.5" />
<span className="hidden md:inline">Carregar Modelo</span>
</button>
{analysisMode === 'comparacao' && (
<button
onClick={() => fileInputRevRef.current?.click()}
title="Carregar Revisão B para comparar com a Base"
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-50 dark:bg-indigo-950/60 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800 rounded-lg text-xs font-semibold hover:bg-indigo-100 dark:hover:bg-indigo-900 transition"
>
<Boxes className="w-3.5 h-3.5" />
<span>+ Revisão B</span>
</button>
)}
<div className="h-5 w-[1px] bg-slate-200 dark:bg-slate-800 mx-1" />
{/* Exportações */}
<button
onClick={handleExportPDF}
disabled={!summary}
title="Exportar Relatório Técnico em PDF"
className="flex items-center gap-1.5 px-3 py-1.5 bg-rose-50 dark:bg-rose-950/40 text-rose-700 dark:text-rose-400 border border-rose-200 dark:border-rose-900/50 hover:bg-rose-100 dark:hover:bg-rose-900/60 rounded-lg text-xs font-semibold transition disabled:opacity-50"
>
<FileText className="w-3.5 h-3.5" />
<span className="hidden sm:inline">PDF</span>
</button>
<button
onClick={handleExportExcel}
disabled={!summary}
title="Exportar Planilha Excel com Peças Afetadas"
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-900/50 hover:bg-emerald-100 dark:hover:bg-emerald-900/60 rounded-lg text-xs font-semibold transition disabled:opacity-50"
>
<FileSpreadsheet className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Excel</span>
</button>
<button
onClick={() => setIsFullscreen(!isFullscreen)}
title={isFullscreen ? 'Reduzir' : 'Modo Expandido'}
className="p-2 text-slate-500 hover:text-slate-900 dark:hover:text-white rounded-lg transition"
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
</div>
</div>
{/* ── CARDS DE KPIS / INDICADORES ── */}
{summary && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5 mb-3">
<div className="p-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400 block">Total Peças</span>
<div className="text-lg font-bold text-slate-900 dark:text-white mt-0.5">{summary.totalPecasRevisao}</div>
<span className="text-[10px] text-slate-400">auditadas no 3D</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-red-200 dark:border-red-950/60 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-red-600 dark:text-red-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Críticos
</span>
<div className="text-lg font-bold text-red-600 dark:text-red-400 mt-0.5">{summary.inconformidadesCriticas}</div>
<span className="text-[10px] text-red-500/80">furo cego / colisão</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-amber-200 dark:border-amber-950/60 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Alertas Furo
</span>
<div className="text-lg font-bold text-amber-600 dark:text-amber-400 mt-0.5">{summary.alertasFuros}</div>
<span className="text-[10px] text-amber-500/80">desalinhados / excêntricos</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-indigo-200 dark:border-indigo-950/60 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-indigo-600 dark:text-indigo-400 block">Modificadas / Novas</span>
<div className="text-lg font-bold text-indigo-600 dark:text-indigo-400 mt-0.5">
{summary.modificadas} <span className="text-xs font-normal text-slate-400">/ +{summary.novas}</span>
</div>
<span className="text-[10px] text-slate-400">variação de projeto</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400 flex items-center gap-1">
<Weight className="w-3 h-3" />
Peso Afetado
</span>
<div className="text-lg font-bold text-slate-900 dark:text-white mt-0.5">{summary.pesoAfetadoKg.toFixed(0)} kg</div>
<span className="text-[10px] text-slate-400">em peças com ocorrência</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400 flex items-center gap-1">
<Factory className="w-3 h-3" />
Risco Fábrica
</span>
<div
className={`text-sm font-bold uppercase mt-1 px-2 py-0.5 rounded text-center ${
summary.riscoGeral === 'critico'
? 'bg-red-500/10 text-red-500'
: summary.riscoGeral === 'alto'
? 'bg-amber-500/10 text-amber-500'
: 'bg-emerald-500/10 text-emerald-500'
}`}
>
{summary.riscoGeral}
</div>
<span className="text-[10px] text-slate-400 text-center block mt-0.5">retrabalho potencial</span>
</div>
</div>
)}
{/* ── ÁREA PRINCIPAL DIVIDIDA (SPLIT RESPONSIVO) ── */}
<div className="flex-1 flex flex-col lg:flex-row gap-3 min-h-0 overflow-hidden">
{/* PAINEL LATERAL ESQUERDO: FILTROS & LISTA DE OCORRÊNCIAS */}
<div className="w-full lg:w-[420px] flex flex-col bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden">
{/* Barra de Filtros */}
<div className="p-3 border-b border-slate-200 dark:border-slate-800 space-y-2.5 bg-slate-50/50 dark:bg-slate-900/50">
{/* Campo de Busca por Peça */}
<div className="relative">
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-2.5" />
<input
type="text"
placeholder="Buscar por marca da peça (ex: CH-01, V-101)..."
value={searchPiece}
onChange={(e) => setSearchPiece(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-xs bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
{searchPiece && (
<button
onClick={() => setSearchPiece('')}
className="absolute right-2.5 top-2 text-xs text-slate-400 hover:text-slate-600 dark:hover:text-white"
>
×
</button>
)}
</div>
{/* Filtros em Grade */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<label className="text-[10px] font-semibold text-slate-500 dark:text-slate-400 block mb-0.5">
Severidade
</label>
<select
value={filterSeverity}
onChange={(e) => setFilterSeverity(e.target.value)}
className="w-full px-2 py-1 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-slate-800 dark:text-slate-200 text-xs"
>
<option value="all">Todas ({defects.length})</option>
<option value="critico">Crítico</option>
<option value="alerta">Alerta</option>
<option value="informativo">Informativo</option>
</select>
</div>
<div>
<label className="text-[10px] font-semibold text-slate-500 dark:text-slate-400 block mb-0.5">
Tipo de Ocorrência
</label>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="w-full px-2 py-1 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-slate-800 dark:text-slate-200 text-xs"
>
<option value="all">Todos os tipos</option>
<option value="furo_cego_faltante">Furo Cego (Falta Contra-Furo)</option>
<option value="deslocamento_furo">Furo Deslocado</option>
<option value="furo_extra_revisao">Novo Furo Adicionado</option>
<option value="furo_removido_revisao">Furo Removido</option>
<option value="colisao_geometrica">Colisão / Clash</option>
<option value="peca_excluida">Peça Excluída</option>
<option value="peca_nova">Peça Nova</option>
</select>
</div>
</div>
{/* Seletor de Modo de Cores 3D */}
<div className="pt-1 flex items-center justify-between text-xs">
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400">Colorização 3D:</span>
<div className="flex items-center gap-1">
<button
onClick={() => setColorizationMode('status_defeito')}
className={`px-2 py-0.5 rounded text-[11px] transition ${
colorizationMode === 'status_defeito'
? 'bg-indigo-600 text-white font-semibold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
Defeito
</button>
<button
onClick={() => setColorizationMode('diff_revisao')}
className={`px-2 py-0.5 rounded text-[11px] transition ${
colorizationMode === 'diff_revisao'
? 'bg-indigo-600 text-white font-semibold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
Revisão
</button>
<button
onClick={() => setColorizationMode('risco_fabril')}
className={`px-2 py-0.5 rounded text-[11px] transition ${
colorizationMode === 'risco_fabril'
? 'bg-indigo-600 text-white font-semibold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
Risco Fábrica
</button>
</div>
</div>
</div>
{/* Lista de Ocorrências (Scrollável) */}
<div className="flex-1 overflow-y-auto p-2.5 space-y-2">
{isLoadingModel ? (
<div className="flex flex-col items-center justify-center h-48 text-center p-4">
<RefreshCw className="w-7 h-7 text-indigo-500 animate-spin mb-2" />
<span className="text-xs font-medium text-slate-700 dark:text-slate-300">{loadingStep}</span>
<span className="text-[11px] text-slate-400 mt-1">{loadingPercent}% processado</span>
</div>
) : filteredDefects.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-center p-4 text-slate-400">
<CheckCircle2 className="w-8 h-8 text-emerald-500 mb-2" />
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300">
Nenhuma inconformidade encontrada
</p>
<p className="text-[11px] text-slate-400 mt-0.5">
Todas as furações e geometrias conferem com os parâmetros e filtros ativos.
</p>
</div>
) : (
filteredDefects.map((defect) => {
const isSelected = selectedDefect?.id === defect.id || selectedPieceMark === defect.pecaMarca;
return (
<div
key={defect.id}
onClick={() => {
setSelectedDefect(defect);
setSelectedPieceMark(defect.pecaMarca);
}}
className={`p-3 rounded-lg border transition-all cursor-pointer ${
isSelected
? 'bg-indigo-50 dark:bg-indigo-950/40 border-indigo-500 shadow-md ring-1 ring-indigo-500'
: 'bg-white dark:bg-slate-800/80 border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600'
}`}
>
<div className="flex items-start justify-between gap-2 mb-1.5">
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-slate-900 dark:text-white text-xs bg-slate-100 dark:bg-slate-700 px-1.5 py-0.5 rounded">
{defect.pecaMarca}
</span>
{defect.conjuntoMarca && (
<span className="text-[10px] font-mono text-slate-500 dark:text-slate-400">
(Conj: {defect.conjuntoMarca})
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{defect.gravidade === 'critico' && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-red-100 dark:bg-red-950/60 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800">
CRÍTICO
</span>
)}
{defect.gravidade === 'alerta' && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-amber-100 dark:bg-amber-950/60 text-amber-600 dark:text-amber-400 border border-amber-200 dark:border-amber-800">
ALERTA
</span>
)}
{defect.gravidade === 'informativo' && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-blue-100 dark:bg-blue-950/60 text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-800">
NOVO
</span>
)}
</div>
</div>
<h4 className="text-xs font-semibold text-slate-800 dark:text-slate-200 mb-1 leading-tight">
{defect.titulo}
</h4>
<p className="text-[11px] text-slate-600 dark:text-slate-400 line-clamp-2 leading-relaxed mb-2">
{defect.descricao}
</p>
{/* Rodapé do Card com Status Fabril e Ação */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100 dark:border-slate-700/60 text-[11px]">
<div className="flex items-center gap-1.5">
<Factory className="w-3 h-3 text-slate-400" />
<span className="text-slate-500 dark:text-slate-400">
{defect.processoAtual || 'Pendente'}
</span>
<span
className={`text-[10px] font-bold uppercase px-1 rounded ${
defect.riscoFabril === 'critico' || defect.riscoFabril === 'alto'
? 'text-red-500 bg-red-50 dark:bg-red-950/50'
: 'text-slate-400'
}`}
>
Risco: {defect.riscoFabril}
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setDetailModalDefect(defect);
}}
className="text-indigo-600 dark:text-indigo-400 hover:underline font-semibold text-[11px]"
>
Ver Detalhes
</button>
</div>
</div>
);
})
)}
</div>
</div>
{/* VISUALIZADOR 3D CENTRAL */}
<div className="flex-1 flex flex-col min-h-[350px] relative">
<AnalyzerViewer3D
modelData={modelBase}
defects={defects}
selectedDefect={selectedDefect}
selectedPieceMark={selectedPieceMark}
colorizationMode={colorizationMode}
onSelectDefect={(def) => setSelectedDefect(def)}
onSelectPiece={(mark) => setSelectedPieceMark(mark)}
isGhostMode={isGhostMode}
onToggleGhostMode={() => setIsGhostMode(!isGhostMode)}
isFullscreen={isFullscreen}
onToggleFullscreen={() => setIsFullscreen(!isFullscreen)}
theme={isDarkTheme ? 'dark' : 'light'}
selectedOF={selectedOF}
initialRotation={currentOFObj?.model_3d_rotation || null}
/>
</div>
</div>
{/* MODAL DETALHADO DA PEÇA AUDITADA */}
<PieceAuditDetailModal
isOpen={!!detailModalDefect}
onClose={() => setDetailModalDefect(null)}
defect={detailModalDefect}
onFocus3D={(coords) => {
if (detailModalDefect) {
setSelectedDefect(detailModalDefect);
setSelectedPieceMark(detailModalDefect.pecaMarca);
}
}}
/>
</div>
);
}
+29 -2
View File
@@ -3,10 +3,11 @@ import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ClipboardList, BarChart3, Settings } from 'lucide-react';
import { ClipboardList, BarChart3, Settings, Flame } from 'lucide-react';
import { ApontamentoForm } from '@/components/apontamento/ApontamentoForm';
import { ApontamentosListOtimizado } from '@/components/apontamento/ApontamentosListOtimizado';
import { ProcessosList } from '@/components/apontamento/ProcessosList';
import { MapaTermicoKitsView } from '@/components/apontamento/kits/MapaTermicoKitsView';
import { useApontamentosProducao } from '@/hooks/useApontamentosProducao';
import { useIsMobile } from '@/hooks/use-mobile';
import { usePermissionControl } from '@/hooks/usePermissionControl';
@@ -34,7 +35,7 @@ const ApontamentoProducao = () => {
<TabsList className={`w-full bg-muted border-border ${
isMobile
? 'grid grid-cols-1 gap-1 h-auto p-1'
: 'grid grid-cols-3 h-10'
: 'grid grid-cols-4 h-10'
}`}>
<TabsTrigger
value="apontamento"
@@ -45,6 +46,15 @@ const ApontamentoProducao = () => {
<ClipboardList className="h-4 w-4" />
Novo Apontamento
</TabsTrigger>
<TabsTrigger
value="mapa-kits"
className={`flex items-center gap-2 text-muted-foreground data-[state=active]:bg-background data-[state=active]:text-foreground ${
isMobile ? 'w-full justify-start p-3 text-sm' : ''
}`}
>
<Flame className="h-4 w-4 text-orange-500 fill-orange-500 animate-pulse" />
Mapa de Kits
</TabsTrigger>
<TabsTrigger
value="historico"
className={`flex items-center gap-2 text-muted-foreground data-[state=active]:bg-background data-[state=active]:text-foreground ${
@@ -89,6 +99,23 @@ const ApontamentoProducao = () => {
)}
</TabsContent>
<TabsContent value="mapa-kits" className="space-y-4 sm:space-y-6 mt-4">
<Card className="bg-card border-border shadow-sm">
<CardHeader className="pb-3 border-b border-border/60">
<CardTitle className="text-card-foreground flex items-center gap-2 text-lg sm:text-xl font-bold">
<Flame className="h-5 w-5 text-orange-500 fill-orange-500 animate-pulse" />
<span>Mapa Térmico de Kits de Montagem</span>
</CardTitle>
<p className="text-xs sm:text-sm text-muted-foreground">
Visão integrada do balanceamento do corte de componentes e liberação de kits completos para montagem/solda
</p>
</CardHeader>
<CardContent className="pt-4">
<MapaTermicoKitsView />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="historico" className="space-y-4 sm:space-y-6 mt-4">
<Card className="bg-card border-border">
<CardHeader className="pb-4">
+12
View File
@@ -15,6 +15,7 @@ import { FichaTecnicaPreview } from '@/components/forms/FichaTecnicaPreview';
import { useThemeConfig } from '@/hooks/useThemeConfig';
import { useFichaTecnica, FichaTecnicaData } from '@/hooks/useFichaTecnica';
import { useUserManagement } from '@/hooks/useUserManagement';
import { usePermissions } from '@/hooks/usePermissions';
interface InfoCalculoEstrutural {
e: boolean;
@@ -179,6 +180,7 @@ const CadastroOF = () => {
const { themeConfig } = useThemeConfig();
const { buscarFichaTecnica, salvarFichaTecnica, loading: fichaTecnicaLoading } = useFichaTecnica();
const { users } = useUserManagement();
const { canRegra } = usePermissions();
const [formData, setFormData] = useState<FormData>(initialFormData);
const [isSaving, setIsSaving] = useState(false);
@@ -288,6 +290,16 @@ const CadastroOF = () => {
return;
}
if (!isEditing && !canRegra('eng_criar_of')) {
toast.error('Atenção: Você não possui permissão para cadastrar novas Ordens de Fabricação.');
return;
}
if (isEditing && !canRegra('eng_editar_of')) {
toast.error('Atenção: Você não possui permissão para alterar dados desta Ordem de Fabricação.');
return;
}
setIsSaving(true);
try {
+30
View File
@@ -9,6 +9,9 @@ import { usePecasP4Listener } from '@/hooks/usePecasP4Listener';
import { PecaForm } from '@/components/pecas/PecaForm';
import { PecasTable } from '@/components/pecas/PecasTable';
import { ComponentesPopup } from '@/components/pecas/ComponentesPopup';
import { RevisaoPecaModal } from '@/components/pecas/revisoes/RevisaoPecaModal';
import { PainelRevisoesOFModal } from '@/components/pecas/revisoes/PainelRevisoesOFModal';
import { useRevisoes } from '@/hooks/useRevisoes';
import { Package, Plus, RefreshCw } from 'lucide-react';
import { usePermissionControl } from '@/hooks/usePermissionControl';
@@ -39,6 +42,8 @@ export default function CadastroPecas() {
const [editingPeca, setEditingPeca] = useState<Peca | null>(null);
const [showForm, setShowForm] = useState(false);
const [selectedPecaForPopup, setSelectedPecaForPopup] = useState<Peca | null>(null);
const [pecaParaRevisao, setPecaParaRevisao] = useState<Peca | null>(null);
const [showPainelRevisoes, setShowPainelRevisoes] = useState(false);
const handleSave = async (formData: any) => {
if (!canCreate()) return false;
@@ -242,6 +247,8 @@ export default function CadastroPecas() {
onDeletePecasBatch={canDelete() ? handleDeleteBatch : undefined}
onEditPeca={canEdit() ? handleEdit : undefined}
onDeleteLastImport={canDelete() ? handleDeleteLastImport : undefined}
onOpenRevisaoPeca={(p) => setPecaParaRevisao(p)}
onOpenPainelRevisoes={() => setShowPainelRevisoes(true)}
hasRecentImport={hasRecentImport}
canEdit={canEdit()}
canDelete={canDelete()}
@@ -260,6 +267,29 @@ export default function CadastroPecas() {
peca={selectedPecaForPopup}
/>
)}
{/* Modal de Gestão de Revisão da Peça */}
{pecaParaRevisao && (
<RevisaoPecaModal
isOpen={!!pecaParaRevisao}
onClose={() => setPecaParaRevisao(null)}
peca={pecaParaRevisao}
onPecaAtualizada={() => {
loadPecas();
}}
/>
)}
{/* Modal Painel Geral de Revisões */}
{showPainelRevisoes && (
<PainelRevisoesOFModal
isOpen={showPainelRevisoes}
onClose={() => setShowPainelRevisoes(false)}
ofNumber={pecas[0]?.of_number || ''}
pecas={pecas}
onOpenRevisaoPeca={(peca) => setPecaParaRevisao(peca)}
/>
)}
</div>
</div>
);
+46 -2
View File
@@ -7,7 +7,10 @@ import { ArrowLeft, Plus, Package } from 'lucide-react';
import { PecaForm } from '@/components/pecas/PecaForm';
import { PecasTable } from '@/components/pecas/PecasTable';
import { ComponentesPopup } from '@/components/pecas/ComponentesPopup';
import { RevisaoPecaModal } from '@/components/pecas/revisoes/RevisaoPecaModal';
import { PainelRevisoesOFModal } from '@/components/pecas/revisoes/PainelRevisoesOFModal';
import { usePecas, Peca } from '@/hooks/usePecas';
import { useRevisoes } from '@/hooks/useRevisoes';
import { toast } from 'sonner';
import { usePermissionControl } from '@/hooks/usePermissionControl';
import { StandardPageLayout } from '@/components/layout/StandardPageLayout';
@@ -34,11 +37,15 @@ export default function CadastroPecasFiltrado() {
batchUpdatePecas,
loadPecas
} = usePecas();
const { revisoes: revisoesOF, carregarRevisoesOF } = useRevisoes(ofSelecionada);
const [saving, setSaving] = useState(false);
const [editingPeca, setEditingPeca] = useState<Peca | null>(null);
const [showForm, setShowForm] = useState(false);
const [selectedPecaForPopup, setSelectedPecaForPopup] = useState<Peca | null>(null);
const [pecaParaRevisao, setPecaParaRevisao] = useState<Peca | null>(null);
const [showPainelRevisoes, setShowPainelRevisoes] = useState(false);
// Filtrar peças pela OF selecionada
const pecasFiltradas = pecas.filter(peca => peca.of_number === ofSelecionada);
@@ -170,8 +177,10 @@ export default function CadastroPecasFiltrado() {
useEffect(() => {
if (!ofSelecionada) {
navigate('/seletor-of');
} else {
carregarRevisoesOF(ofSelecionada);
}
}, [ofSelecionada, navigate]);
}, [ofSelecionada, navigate, carregarRevisoesOF]);
if (loading) {
return (
@@ -184,6 +193,14 @@ export default function CadastroPecasFiltrado() {
);
}
const handleVoltar = () => {
if (window.history.state && window.history.state.idx > 0) {
navigate(-1);
} else {
navigate('/seletor-of');
}
};
if (!ofSelecionada) {
return null;
}
@@ -197,7 +214,7 @@ export default function CadastroPecasFiltrado() {
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => navigate('/ordens-fabricacao')}
onClick={handleVoltar}
className="flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
@@ -269,6 +286,9 @@ export default function CadastroPecasFiltrado() {
onEditPeca={canEdit() ? handleEdit : undefined}
onDeleteLastImport={canDelete() ? handleDeleteLastImport : undefined}
onBatchUpdatePecas={canEdit() ? handleBatchUpdatePecas : undefined}
onOpenRevisaoPeca={(peca) => setPecaParaRevisao(peca)}
onOpenPainelRevisoes={() => setShowPainelRevisoes(true)}
totalRevisoesOF={revisoesOF.length}
hasRecentImport={hasRecentImport}
canEdit={canEdit()}
canDelete={canDelete()}
@@ -288,6 +308,30 @@ export default function CadastroPecasFiltrado() {
peca={selectedPecaForPopup}
/>
)}
{/* Modal de Gestão de Revisão da Peça */}
{pecaParaRevisao && (
<RevisaoPecaModal
isOpen={!!pecaParaRevisao}
onClose={() => setPecaParaRevisao(null)}
peca={pecaParaRevisao}
onPecaAtualizada={() => {
loadPecas();
carregarRevisoesOF(ofSelecionada);
}}
/>
)}
{/* Modal Painel Geral de Revisões da OF */}
{showPainelRevisoes && (
<PainelRevisoesOFModal
isOpen={showPainelRevisoes}
onClose={() => setShowPainelRevisoes(false)}
ofNumber={ofSelecionada}
pecas={pecasFiltradas}
onOpenRevisaoPeca={(peca) => setPecaParaRevisao(peca)}
/>
)}
</div>
</StandardPageLayout>
);
+34 -11
View File
@@ -13,6 +13,8 @@ import { useState, useEffect } from 'react';
import { useDiarioProducao, type RecursoProducao, type ApontamentoDiarioRecurso, type LoteSoldaDiario } from '@/hooks/useDiarioProducao';
import { useUserProfile } from '@/hooks/useUserProfile';
import { useFichaTecnica } from '@/hooks/useFichaTecnica';
import { usePermissions } from '@/hooks/usePermissions';
import { toast } from 'sonner';
interface DiarioData {
id?: string;
@@ -28,6 +30,7 @@ interface DiarioData {
}
const DiarioProducao = () => {
const { canRegra } = usePermissions();
const [activeTab, setActiveTab] = useState('diario');
const [selectedOF, setSelectedOF] = useState('');
const [showOFModal, setShowOFModal] = useState(false);
@@ -131,6 +134,12 @@ const DiarioProducao = () => {
const salvarDiarioCompleto = async () => {
try {
const hoje = new Date().toISOString().split('T')[0];
if (currentDiario.data < hoje && !canRegra('pcp_retroativo_permitido')) {
toast.error('Atenção: Você não possui permissão para apontar produção com data retroativa.');
return;
}
// 1. Salvar o diário principal
const diarioSalvo = await salvarDiario.mutateAsync({
data: currentDiario.data,
@@ -608,17 +617,31 @@ const DiarioProducao = () => {
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm">
<Printer className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => diario.id && deletarDiario.mutate(diario.id)}
disabled={deletarDiario.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
{canRegra('pcp_exportar_relatorios') && (
<Button
variant="ghost"
size="sm"
title="Exportar/Imprimir Diário"
onClick={() => window.print()}
>
<Printer className="h-4 w-4" />
</Button>
)}
{canRegra('pcp_excluir_apontamento') && (
<Button
variant="ghost"
size="sm"
title="Excluir Diário"
onClick={() => {
if (confirm('Tem certeza que deseja excluir este diário de produção?')) {
diario.id && deletarDiario.mutate(diario.id);
}
}}
disabled={deletarDiario.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</td>
</tr>
+14 -4
View File
@@ -29,8 +29,11 @@ import { useOFsAtivas } from '@/hooks/useOFsAtivas';
import { useMobileResponsive } from '@/hooks/useMobileResponsive';
import { RomaneioExpedicao } from '@/hooks/useRomaneios';
import { RelatoriosExpedicao } from '@/components/expedicao/RelatoriosExpedicao';
import { usePermissions } from '@/hooks/usePermissions';
import { toast } from 'sonner';
const Expedicao = () => {
const { canRegra } = usePermissions();
const { isMobile } = useMobileResponsive();
const { data: romaneios, isLoading, refetch } = useRomaneios();
const { data: ofsAtivas, isLoading: isLoadingOFs } = useOFsAtivas();
@@ -104,6 +107,11 @@ const Expedicao = () => {
};
const confirmDelete = async () => {
if (!canRegra('exp_cancelar_estornar')) {
toast.error('Atenção: Você não possui permissão para cancelar ou estornar romaneios de expedição.');
return;
}
if (romaneioToDelete) {
try {
await removerRomaneioMutation.mutateAsync(romaneioToDelete);
@@ -321,10 +329,12 @@ const Expedicao = () => {
</Sheet>
)}
<Button size={isMobile ? "sm" : "default"} onClick={() => setShowRomaneioForm(true)}>
<Plus className="w-4 h-4 mr-2" />
Novo Romaneio
</Button>
{canRegra('exp_criar_romaneio') && (
<Button size={isMobile ? "sm" : "default"} onClick={() => setShowRomaneioForm(true)}>
<Plus className="w-4 h-4 mr-2" />
Novo Romaneio
</Button>
)}
</div>
</div>
+35 -9
View File
@@ -6,6 +6,7 @@ import { SmartProducaoFlow } from './smart/components/SmartProducaoFlow';
import { SmartEmbarqueFlow } from './smart/components/SmartEmbarqueFlow';
import { SmartMontagemFlow } from './smart/components/SmartMontagemFlow';
import { SmartConsultaRelatoriosFlow } from './smart/components/SmartConsultaRelatoriosFlow';
import { SmartViewer3DFlow } from './smart/components/SmartViewer3DFlow';
import { OFAtiva } from '@/hooks/useOFsAtivas';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
@@ -23,9 +24,9 @@ export const ModoSmart: React.FC = () => {
}
});
// Tela ativa: 'hub' | 'producao' | 'embarque' | 'montagem' | 'consulta' | 'turno_pdf'
// Tela ativa: 'hub' | 'producao' | 'embarque' | 'montagem' | 'consulta' | 'turno_pdf' | 'modelo3d'
const [telaAtiva, setTelaAtiva] = useState<
'hub' | 'producao' | 'embarque' | 'montagem' | 'consulta' | 'turno_pdf'
'hub' | 'producao' | 'embarque' | 'montagem' | 'consulta' | 'turno_pdf' | 'modelo3d'
>('hub');
// Produção do operador hoje
@@ -72,6 +73,23 @@ export const ModoSmart: React.FC = () => {
carregarProducaoHoje();
}, [carregarProducaoHoje]);
// Modo Smart: Oculta a targa do BrainSteel Hub para maximizar a área de trabalho
useEffect(() => {
document.body.classList.add('modo-smart-active');
document.documentElement.style.setProperty('--hub-banner-height', '0px');
document.body.style.paddingTop = '0px';
return () => {
document.body.classList.remove('modo-smart-active');
const banner = document.getElementById('brainsteel-guard-banner');
if (banner) {
document.body.classList.add('has-hub-banner');
document.documentElement.style.setProperty('--hub-banner-height', '34px');
document.body.style.paddingTop = '34px';
}
};
}, []);
const handleSelectObra = (of: OFAtiva) => {
setObraAtiva(of);
setTelaAtiva('hub');
@@ -113,6 +131,7 @@ export const ModoSmart: React.FC = () => {
montagem: '3. Apontar Montagem',
consulta: '4. Consultar Peças',
turno_pdf: 'Relatório do Turno',
modelo3d: 'Modelo 3D da Obra',
};
return {
@@ -126,13 +145,15 @@ export const ModoSmart: React.FC = () => {
return (
<div className="min-h-screen bg-slate-100/90 text-slate-800 dark:bg-slate-950 dark:text-slate-100 flex flex-col font-sans select-none antialiased transition-colors duration-200">
{/* Cabeçalho Fixo do Modo Smart */}
<SmartHeader
titulo={headerInfo.titulo}
subtitulo={headerInfo.subtitulo}
mostrarVoltar={headerInfo.mostrarVoltar}
onVoltar={() => setTelaAtiva('hub')}
/>
{/* Cabeçalho Fixo do Modo Smart (oculto quando em tela cheia do modelo 3D) */}
{telaAtiva !== 'modelo3d' && (
<SmartHeader
titulo={headerInfo.titulo}
subtitulo={headerInfo.subtitulo}
mostrarVoltar={headerInfo.mostrarVoltar}
onVoltar={() => setTelaAtiva('hub')}
/>
)}
{/* Conteúdo Principal Touch-First */}
<main className="flex-1 flex flex-col overflow-y-auto">
@@ -173,6 +194,11 @@ export const ModoSmart: React.FC = () => {
onVoltarHub={() => setTelaAtiva('hub')}
abrirDiretoTurno={true}
/>
) : telaAtiva === 'modelo3d' ? (
<SmartViewer3DFlow
obra={obraAtiva}
onVoltarHub={() => setTelaAtiva('hub')}
/>
) : null}
</main>
</div>
+1 -46
View File
@@ -6,7 +6,6 @@ import { Users, UserPlus, Shield, Settings, Mail, Activity, LayoutGrid } from 'l
import { UsersTable } from '@/components/users/UsersTable';
import { PendingUsersTable } from '@/components/users/PendingUsersTable';
import { FunctionsManager } from '@/components/users/FunctionsManager';
import { PrivilegesManager } from '@/components/users/PrivilegesManager';
import { PasswordResetRequests } from '@/components/users/PasswordResetRequests';
import { SessionLogsSimple } from '@/components/users/SessionLogsSimple';
import { UserModal } from '@/components/users/UserModal';
@@ -40,9 +39,6 @@ const UserManagement = () => {
createFunction,
updateFunction,
deleteFunction,
createPrivilege: createPrivilegeBase,
updatePrivilege: updatePrivilegeBase,
deletePrivilege,
updateUser,
toggleUserStatus,
deleteUser,
@@ -67,15 +63,6 @@ const UserManagement = () => {
);
}
// Wrapper functions to match expected signatures
const createPrivilege = async (data: { name: string; description?: string; permissions: Record<string, boolean> }): Promise<void> => {
await createPrivilegeBase(data);
};
const updatePrivilege = async (id: string, data: { name: string; description?: string; permissions: Record<string, boolean> }): Promise<void> => {
await updatePrivilegeBase(id, data);
};
const handleEditUser = (user: UserProfile) => {
if (!canEdit()) return;
setSelectedUser(user);
@@ -158,7 +145,7 @@ const UserManagement = () => {
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-2 md:grid-cols-7 bg-muted h-auto p-1">
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-6 bg-muted h-auto p-1">
<TabsTrigger
value="matriz"
className="flex items-center gap-1 md:gap-2 text-xs md:text-sm p-2 md:p-3 font-semibold text-primary data-[state=active]:bg-primary data-[state=active]:text-primary-foreground"
@@ -210,17 +197,6 @@ const UserManagement = () => {
</TabsTrigger>
)}
{canEdit() && (
<TabsTrigger
value="privileges"
className="flex items-center gap-1 md:gap-2 text-xs md:text-sm p-2 md:p-3"
>
<Shield className="h-3 w-3 md:h-4 md:w-4" />
<span className="hidden sm:inline">Privilégios</span>
<span className="sm:hidden">Priv</span>
</TabsTrigger>
)}
{canView() && (
<TabsTrigger
value="session-logs"
@@ -311,27 +287,6 @@ const UserManagement = () => {
</TabsContent>
)}
{canEdit() && (
<TabsContent value="privileges" className="space-y-4 mt-4">
<Card className="card-mobile">
<CardHeader className="card-header-mobile">
<CardTitle className="text-lg md:text-xl flex items-center gap-2">
<Shield className="h-4 w-4 md:h-5 md:w-5" />
Gerenciar Privilégios
</CardTitle>
</CardHeader>
<CardContent className="card-content-mobile">
<PrivilegesManager
privileges={privileges as any}
onCreate={canCreate() ? createPrivilege : undefined}
onUpdate={canEdit() ? updatePrivilege : undefined}
onDelete={canDelete() ? deletePrivilege : undefined}
/>
</CardContent>
</Card>
</TabsContent>
)}
{canView() && (
<TabsContent value="session-logs" className="space-y-4 mt-4">
<SessionLogsSimple users={users} />
+68 -28
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { loadAndAuditIFC, LoadedIFCResult, IFCQualityAudit } from '@/lib/ifc/ifcLoaderService';
import { loadAndAuditModel3D, LoadedModel3DResult, IFCQualityAudit } from '@/lib/3d/modelLoaderService';
import { uploadIFCToCloud, fetchSavedIFCModel, removeSavedIFCModel } from '@/lib/ifc/ifcStorageService';
import { ModelViewer3D } from '@/components/viewer3d/ModelViewer3D';
import { IFCQualityModal } from '@/components/viewer3d/IFCQualityModal';
@@ -28,6 +28,7 @@ interface OFOption {
ifc_filename?: string | null;
ifc_file_size?: number | null;
ifc_updated_at?: string | null;
model_3d_rotation?: { x: number; y: number; z: number } | null;
}
const PROCESS_COLORS: Record<string, string> = {
@@ -58,7 +59,7 @@ export default function Visualizador3D() {
const [progressoOF, setProgressoOF] = useState<number>(0);
// 3D Model and Quality State
const [modelData, setModelData] = useState<LoadedIFCResult | null>(null);
const [modelData, setModelData] = useState<LoadedModel3DResult | null>(null);
const [auditData, setAuditData] = useState<IFCQualityAudit | null>(null);
const [isAuditModalOpen, setIsAuditModalOpen] = useState<boolean>(false);
const [loadingStep, setLoadingStep] = useState<string>('');
@@ -67,13 +68,13 @@ export default function Visualizador3D() {
const fileInputRef = useRef<HTMLInputElement>(null);
// 1. Fetch OFs (com colunas de IFC para persistência na nuvem)
// 1. Fetch OFs (com colunas de IFC e rotação 3D para persistência na nuvem)
useEffect(() => {
async function fetchOFs() {
try {
const { data, error } = await supabase
.from('ordens_fabricacao' as any)
.select('id, num_of, descritivo, peso_total, ifc_url, ifc_filename, ifc_file_size, ifc_updated_at')
.select('id, num_of, descritivo, peso_total, ifc_url, ifc_filename, ifc_file_size, ifc_updated_at, model_3d_rotation')
.order('num_of', { ascending: false });
if (!error && data) {
@@ -86,6 +87,7 @@ export default function Visualizador3D() {
ifc_filename: item.ifc_filename || null,
ifc_file_size: item.ifc_file_size ? Number(item.ifc_file_size) : null,
ifc_updated_at: item.ifc_updated_at || null,
model_3d_rotation: item.model_3d_rotation || null,
}));
setOfs(list);
}
@@ -108,11 +110,11 @@ export default function Visualizador3D() {
.select('id, of_number, etapa_fase, marca, descricao, quantidade, peso_unitario, peso_total, perfil_principal, material')
.eq('of_number', selectedOF);
// Fetch apontamentos com join de peca e processo
// Fetch apontamentos com join de peca e processo (apenas peças mestres)
const { data: apontamentosData, error: errorApontamentos } = await supabase
.from('apontamentos_producao' as any)
.select(`
id, of_number, peca_id, quantidade_produzida,
id, of_number, peca_id, quantidade_produzida, tipo_apontamento, componente_id,
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase),
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem)
`)
@@ -130,8 +132,13 @@ export default function Visualizador3D() {
if (pecasData) {
pecasData.forEach((p: any) => {
const fase = String(p.etapa_fase || '').trim();
if (fase) phases.add(fase);
let fase = String(p.etapa_fase || '').trim();
if (fase) {
if (/^\d+$/.test(fase)) {
fase = String(parseInt(fase, 10));
}
phases.add(fase);
}
totalPecas += Number(p.quantidade || 0);
totalPeso += Number(p.peso_total || 0);
@@ -141,6 +148,8 @@ export default function Visualizador3D() {
marca: marcaStr,
fase,
ofNumber: selectedOF,
descricao: p.descricao || '',
perfilPrincipal: p.perfil_principal || p.descricao || '',
totalQtd: Number(p.quantidade || 1),
pesoTotal: Number(p.peso_total || 0),
pesoUnitario: Number(p.peso_unitario || 0),
@@ -179,6 +188,10 @@ export default function Visualizador3D() {
apontamentosData.sort((a: any, b: any) => (a.processo?.ordem || 0) - (b.processo?.ordem || 0));
apontamentosData.forEach((ap: any) => {
// Ignora apontamentos de componentes para não inflar as peças mestres
if (ap.tipo_apontamento && ap.tipo_apontamento !== 'peca') return;
if (ap.componente_id) return;
const marca = ap.peca?.marca ? String(ap.peca.marca).trim() : '';
const fase = ap.peca?.etapa_fase ? String(ap.peca.etapa_fase).trim() : '';
if (marca) {
@@ -258,10 +271,10 @@ export default function Visualizador3D() {
setLoadingStep(step);
});
setLoadingStep('Decodificando entidades e montagens IFC...');
setLoadingStep('Decodificando entidades e montagens 3D...');
setLoadingPercent(55);
const result = await loadAndAuditIFC(buffer, (percent, step) => {
const result = await loadAndAuditModel3D(buffer, filename || undefined, (percent, step) => {
setLoadingPercent(55 + Math.round(percent * 0.45));
setLoadingStep(step);
});
@@ -269,10 +282,12 @@ export default function Visualizador3D() {
setModelData(result);
setAuditData(result.audit);
// Mescla fases detectadas no IFC às fases da OF
// Mescla fases detectadas no IFC/WRL às fases da OF
if (result.detectedPhases && result.detectedPhases.length > 0) {
setPhasesList((prev) => {
const combined = new Set([...prev, ...result.detectedPhases]);
const normPrev = prev.map((f) => (/^\d+$/.test(f) ? String(parseInt(f, 10)) : f));
const normDetected = result.detectedPhases.map((f) => (/^\d+$/.test(f) ? String(parseInt(f, 10)) : f));
const combined = new Set([...normPrev, ...normDetected]);
return Array.from(combined).sort((a, b) => {
const na = Number(a);
const nb = Number(b);
@@ -305,6 +320,7 @@ export default function Visualizador3D() {
setModelData(null);
setAuditData(null);
setSelectedPhase('all');
setPhasesList([]);
const currentOF = ofs.find((o) => o.of_number === selectedOF);
@@ -336,13 +352,23 @@ export default function Visualizador3D() {
const file = e.target.files?.[0];
if (!file) return;
if (!selectedOF) {
toast({
title: 'Selecione uma OF',
description: 'Por favor, selecione uma Ordem de Fabricação (OF) antes de carregar o modelo 3D.',
variant: 'destructive',
});
if (fileInputRef.current) fileInputRef.current.value = '';
return;
}
setIsLoadingModel(true);
setLoadingStep('Iniciando processamento e auditoria...');
setLoadingPercent(10);
try {
// 1. Processa e audita localmente para renderização imediata
const result = await loadAndAuditIFC(file, (percent, step) => {
const result = await loadAndAuditModel3D(file, file.name, (percent, step) => {
setLoadingPercent(Math.min(percent, 70));
setLoadingStep(step);
});
@@ -351,10 +377,12 @@ export default function Visualizador3D() {
setAuditData(result.audit);
setIsAuditModalOpen(true); // Abre modal de qualidade automaticamente
// Mescla fases detectadas no IFC
// Mescla fases detectadas no IFC/WRL às fases da OF
if (result.detectedPhases && result.detectedPhases.length > 0) {
setPhasesList((prev) => {
const combined = new Set([...prev, ...result.detectedPhases]);
const normPrev = prev.map((f) => (/^\d+$/.test(f) ? String(parseInt(f, 10)) : f));
const normDetected = result.detectedPhases.map((f) => (/^\d+$/.test(f) ? String(parseInt(f, 10)) : f));
const combined = new Set([...normPrev, ...normDetected]);
return Array.from(combined).sort((a, b) => {
const na = Number(a);
const nb = Number(b);
@@ -393,11 +421,11 @@ export default function Visualizador3D() {
);
toast({
title: 'Modelo IFC Gravado no Banco!',
title: 'Modelo 3D Gravado no Banco!',
description: `O arquivo "${file.name}" foi salvo com sucesso e carregará automaticamente ao entrar na OF ${selectedOF}.`,
});
} catch (err: any) {
console.error('Erro ao carregar e salvar IFC:', err);
console.error('Erro ao carregar e salvar Modelo 3D:', err);
toast({
variant: 'destructive',
title: 'Erro no Carregamento',
@@ -411,7 +439,7 @@ export default function Visualizador3D() {
// Remover modelo salvo
const handleRemoveSavedModel = async () => {
if (!confirm(`Deseja remover o modelo IFC vinculado à OF ${selectedOF}?`)) return;
if (!confirm(`Deseja remover o modelo 3D vinculado à OF ${selectedOF}?`)) return;
try {
await removeSavedIFCModel(selectedOF);
@@ -479,7 +507,7 @@ export default function Visualizador3D() {
const hasSavedModel = Boolean(currentOFInfo?.ifc_url);
return (
<div className="flex flex-col h-[calc(100vh-4rem)] p-4 md:p-6 space-y-4 max-w-[1920px] mx-auto overflow-hidden">
<div className="flex flex-col flex-1 h-[calc(100vh-var(--hub-banner-height,0px)-4rem)] min-h-[600px] w-full p-4 md:p-6 space-y-4 max-w-[1920px] mx-auto">
{/* Top Header Bar */}
<div className="flex flex-wrap items-center justify-between gap-4 p-4 bg-white dark:bg-slate-900/90 backdrop-blur-md border border-slate-200 dark:border-slate-800 rounded-2xl shadow-lg dark:shadow-xl">
{/* Left: Title, OF Selector & Phase Filter */}
@@ -524,7 +552,7 @@ export default function Visualizador3D() {
<Cloud className="w-3.5 h-3.5 text-emerald-700 dark:text-emerald-400" />
<span
className="text-emerald-900 dark:text-emerald-300 font-mono font-bold max-w-[170px] truncate"
title={currentOFInfo?.ifc_filename || 'Modelo IFC Vinculado'}
title={currentOFInfo?.ifc_filename || 'Modelo 3D Vinculado'}
>
{currentOFInfo?.ifc_filename || 'Modelo Salvo'}
</span>
@@ -585,15 +613,26 @@ export default function Visualizador3D() {
<input
ref={fileInputRef}
type="file"
accept=".ifc,.gltf,.glb"
accept=".ifc,.wrl,.wrz,.gltf,.glb"
onChange={handleFileChange}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isLoadingModel}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500 text-white font-bold text-xs rounded-xl shadow-lg shadow-cyan-600/20 transition-all disabled:opacity-50 cursor-pointer"
onClick={() => {
if (!selectedOF) {
toast({
title: 'Selecione uma OF',
description: 'Por favor, selecione uma Ordem de Fabricação (OF) antes de carregar o modelo 3D.',
variant: 'destructive',
});
return;
}
fileInputRef.current?.click();
}}
disabled={isLoadingModel || !selectedOF}
title={!selectedOF ? 'Selecione uma OF acima para habilitar o carregamento do modelo 3D' : undefined}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500 text-white font-bold text-xs rounded-xl shadow-lg shadow-cyan-600/20 transition-all disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
{isLoadingModel ? (
<>
@@ -603,7 +642,7 @@ export default function Visualizador3D() {
) : (
<>
<Upload className="w-4 h-4" />
<span>{hasSavedModel ? 'Substituir Modelo IFC' : 'Carregar Modelo IFC'}</span>
<span>{hasSavedModel ? 'Substituir Modelo 3D' : 'Carregar Modelo 3D (IFC/WRL)'}</span>
</>
)}
</button>
@@ -662,7 +701,7 @@ export default function Visualizador3D() {
</div>
{/* Main 3D Viewport */}
<div className="relative flex-1 w-full bg-slate-100 dark:bg-slate-950 rounded-2xl overflow-hidden border border-slate-200 dark:border-slate-800 shadow-xl dark:shadow-2xl">
<div className="relative flex-1 min-h-[560px] w-full bg-slate-100 dark:bg-slate-950 rounded-2xl overflow-hidden border border-slate-200 dark:border-slate-800 shadow-xl dark:shadow-2xl">
{isLoadingModel && (
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center p-6 bg-white dark:bg-slate-950/90 backdrop-blur-md text-slate-900 dark:text-white space-y-4">
<div className="p-4 bg-cyan-100 text-cyan-600 dark:bg-cyan-500/20 dark:text-cyan-400 rounded-2xl border border-cyan-300 dark:border-cyan-500/40 animate-pulse">
@@ -687,9 +726,9 @@ export default function Visualizador3D() {
<Box className="w-16 h-16 stroke-[1.2]" />
</div>
<div className="space-y-1 max-w-md">
<h2 className="text-lg font-bold text-slate-900 dark:text-white">Nenhum modelo IFC carregado para {selectedOF}</h2>
<h2 className="text-lg font-bold text-slate-900 dark:text-white">Nenhum modelo 3D carregado para {selectedOF}</h2>
<p className="text-xs text-slate-700 font-bold dark:text-slate-300 dark:font-medium leading-relaxed">
Clique no botão <span className="text-cyan-600 dark:text-cyan-300 font-semibold">"Carregar Modelo IFC"</span> acima para importar o arquivo de fabricação desta obra. Ele será gravado no banco de dados e recarregado automaticamente nas próximas visitas.
Clique no botão <span className="text-cyan-600 dark:text-cyan-300 font-semibold">"Carregar Modelo 3D (IFC/WRL)"</span> acima para importar o arquivo de fabricação desta obra. Ele será gravado no banco de dados e recarregado automaticamente nas próximas visitas.
</p>
</div>
</div>
@@ -700,6 +739,7 @@ export default function Visualizador3D() {
productionData={productionMap}
selectedOF={selectedOF}
selectedPhase={selectedPhase}
initialRotation={ofs.find((o) => o.of_number === selectedOF)?.model_3d_rotation}
/>
</div>
@@ -0,0 +1,442 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import {
Scissors,
CheckCircle2,
AlertTriangle,
Zap,
Plus,
RefreshCw,
Layers,
Wrench,
Flame
} from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { smartAudio } from '@/utils/smartAudio';
import { toast } from 'sonner';
import { PecaKitInfo, ComponenteKitInfo } from '@/hooks/useKitsProducao';
import { sincronizarPecaKitNoCorte } from '@/utils/sincronizacaoKitsCorte';
interface SmartComponentesCorteModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pecaKit: PecaKitInfo | null;
ofNumber: string;
processoCorteId: string;
onApontamentoSalvo: () => void;
}
export const SmartComponentesCorteModal: React.FC<SmartComponentesCorteModalProps> = ({
open,
onOpenChange,
pecaKit,
ofNumber,
processoCorteId,
onApontamentoSalvo,
}) => {
const { user } = useAuth();
const [salvando, setSalvando] = useState<boolean>(false);
const [quantidadesManual, setQuantidadesManual] = useState<Record<string, number>>({});
if (!pecaKit) return null;
const hoje = new Date().toISOString().split('T')[0];
const nomeUsuario = user?.user_metadata?.nome || user?.email?.split('@')[0] || 'Operador Corte';
// Atualizar valor digitado para um componente específico
const setQtdComp = (compId: string, valor: number) => {
setQuantidadesManual((prev) => ({
...prev,
[compId]: Math.max(1, valor),
}));
};
// ─────────────────────────────────────────────────────────────
// AÇÃO 1: CORTAR 1 KIT COMPLETO (ATALHO RÁPIDO)
// ─────────────────────────────────────────────────────────────
const handleCortarKitCompleto = async (multiplicadorKits: number = 1) => {
try {
setSalvando(true);
smartAudio.playClick();
if (!pecaKit.componentes || pecaKit.componentes.length === 0) {
toast.error('Esta peça não possui subcomponentes cadastrados.');
return;
}
// Preparar lote de inserções para cada componente da peça
const batchPayloads: Array<{
of_number: string;
peca_id: string;
componente_id: string;
tipo_apontamento: 'componente';
processo_id: string;
quantidade_produzida: number;
data_apontamento: string;
created_by?: string;
usuario_nome: string;
observacoes: string;
}> = [];
let pesoTotalKg = 0;
for (const comp of pecaKit.componentes) {
const porKit = comp.quantidade_por_peca || 1;
const qtdParaApontar = porKit * multiplicadorKits;
const restante = Math.max(0, comp.quantidade_total_necessaria - comp.quantidade_cortada);
// Não apontar além do total se já estiver concluído, exceto se solicitado
const qtdFinal = Math.min(qtdParaApontar, Math.max(qtdParaApontar, restante));
if (qtdFinal > 0) {
batchPayloads.push({
of_number: ofNumber,
peca_id: pecaKit.peca_id,
componente_id: comp.id,
tipo_apontamento: 'componente',
processo_id: processoCorteId,
quantidade_produzida: qtdFinal,
data_apontamento: hoje,
created_by: user?.id,
usuario_nome: nomeUsuario,
observacoes: `Corte de Kit Completo (${multiplicadorKits}x ${pecaKit.marca}) via Modo Smart`,
});
pesoTotalKg += qtdFinal * (comp.peso_unitario || 0);
}
}
if (batchPayloads.length === 0) {
toast.info('Todos os componentes desta peça já estão 100% cortados!');
return;
}
const { error } = await supabase.from('apontamentos_producao').insert(batchPayloads);
if (error) throw error;
// Sincronizar liberação de kits completos da peça mestre no Corte para a Solda
const resSync = await sincronizarPecaKitNoCorte({
ofNumber,
pecaId: pecaKit.peca_id,
processoCorteId,
userId: user?.id,
usuarioNome: nomeUsuario,
dataApontamento: hoje,
});
smartAudio.playSuccess();
const infoLiberado =
resSync && resSync.delta_liberado > 0
? ` (+${resSync.delta_liberado} peça(s) liberada(s) para Solda)`
: '';
toast.success(
`✅ ${multiplicadorKits} Kit Completo da peça ${pecaKit.marca} cortado com sucesso! (+${pesoTotalKg.toFixed(1)} kg)${infoLiberado}`
);
onApontamentoSalvo();
onOpenChange(false);
} catch (err: any) {
smartAudio.playAlert();
console.error('Erro ao cortar kit completo:', err);
toast.error('Erro ao registrar kit completo: ' + (err?.message || 'Falha de rede'));
} finally {
setSalvando(false);
}
};
// ─────────────────────────────────────────────────────────────
// AÇÃO 2: APONTAR COMPONENTE INDIVIDUAL (DESBALANCEADO / NESTING)
// ─────────────────────────────────────────────────────────────
const handleApontarComponenteIndividual = async (comp: ComponenteKitInfo) => {
try {
const qtd = quantidadesManual[comp.id] || 1;
if (qtd <= 0) return;
setSalvando(true);
smartAudio.playClick();
const payload = {
of_number: ofNumber,
peca_id: pecaKit.peca_id,
componente_id: comp.id,
tipo_apontamento: 'componente' as const,
processo_id: processoCorteId,
quantidade_produzida: qtd,
data_apontamento: hoje,
created_by: user?.id,
usuario_nome: nomeUsuario,
observacoes: `Corte de subcomponente ${comp.marca_componente} (${qtd} un) via Modo Smart`,
};
const { error } = await supabase.from('apontamentos_producao').insert(payload);
if (error) throw error;
// Sincronizar se este corte completou novos kits completos (respeitando estritamente o gargalo de todos os componentes)
const resSync = await sincronizarPecaKitNoCorte({
ofNumber,
pecaId: pecaKit.peca_id,
processoCorteId,
userId: user?.id,
usuarioNome: nomeUsuario,
dataApontamento: hoje,
});
const pesoKg = qtd * (comp.peso_unitario || 0);
smartAudio.playSuccess();
if (resSync && resSync.delta_liberado > 0) {
toast.success(
`✂️ Apontado no Corte: ${qtd}x ${comp.marca_componente} (+${pesoKg.toFixed(1)} kg) • ⚡ Kit completo formado: +${resSync.delta_liberado} un de ${pecaKit.marca} liberada(s) para Solda!`
);
} else {
toast.success(
`✂️ Apontado no Corte: ${qtd}x ${comp.marca_componente} (+${pesoKg.toFixed(1)} kg)`
);
}
// Limpar valor digitado
setQuantidadesManual((prev) => {
const next = { ...prev };
delete next[comp.id];
return next;
});
onApontamentoSalvo();
} catch (err: any) {
smartAudio.playAlert();
console.error('Erro ao apontar componente:', err);
toast.error('Erro ao apontar componente: ' + (err?.message || 'Falha de rede'));
} finally {
setSalvando(false);
}
};
const kitsProntos = pecaKit.kits_completos_cortados;
const totalKits = pecaKit.quantidade_total;
const pct = pecaKit.percentual_termico;
const gargalo = pecaKit.componente_gargalo;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto p-4 sm:p-6">
<DialogHeader className="pb-2 border-b border-slate-200 dark:border-slate-800">
<DialogTitle className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<div className="p-2 rounded-xl bg-orange-100 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400">
<Scissors className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-xl font-black text-amber-700 dark:text-amber-400 tracking-wider">
{pecaKit.marca}
</span>
<Badge variant="outline" className="text-xs font-bold">
{pecaKit.etapa_fase.toLowerCase().startsWith('fase')
? pecaKit.etapa_fase
: `Fase ${pecaKit.etapa_fase}`}
</Badge>
{pecaKit.perfil_principal && (
<span className="text-xs px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 font-mono font-bold">
{pecaKit.perfil_principal}
</span>
)}
</div>
<div className="text-xs text-slate-500">
{pecaKit.descricao || 'Peça Estrutural'} • Total: <strong>{totalKits} un</strong>
</div>
</div>
</div>
<Badge
variant="secondary"
className={`text-xs font-black ${
kitsProntos >= totalKits
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
: kitsProntos > 0
? 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'
}`}
>
<Flame className="w-3.5 h-3.5 mr-1 animate-pulse" />
{kitsProntos}/{totalKits} Kits Montáveis ({pct}%)
</Badge>
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* ──────────────── Barra de Prontidão e Gargalo ──────────────── */}
<div className="p-3 rounded-2xl bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 space-y-2">
<div className="flex items-center justify-between text-xs font-bold text-slate-700 dark:text-slate-300">
<span>Prontidão Térmica do Kit de Montagem</span>
<span>{pct}% dos componentes cortados</span>
</div>
<div className="w-full bg-slate-200 dark:bg-slate-800 h-3 rounded-full overflow-hidden flex">
<div
className={`h-full transition-all duration-300 ${
kitsProntos >= totalKits
? 'bg-emerald-500'
: kitsProntos > 0
? 'bg-blue-500'
: 'bg-amber-500'
}`}
style={{ width: `${pct}%` }}
/>
</div>
{gargalo && kitsProntos < totalKits && (
<div className="p-2 rounded-xl bg-amber-100/70 dark:bg-amber-950/40 border border-amber-300 dark:border-amber-800 flex items-center gap-2 text-xs text-amber-900 dark:text-amber-300 font-semibold">
<AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-400 shrink-0 animate-bounce" />
<span>
<strong>Gargalo do Próximo Kit:</strong> Falta cortar{' '}
<span className="underline decoration-amber-500 font-black">
{gargalo.falta_para_prox_kit} un do componente {gargalo.marca_componente}
</span>{' '}
para liberar mais 1 kit completo na Solda!
</span>
</div>
)}
</div>
{/* ──────────────── ATALHO MESTRE: CORTAR 1 KIT COMPLETO ──────────────── */}
<div className="p-3.5 rounded-2xl bg-gradient-to-r from-amber-500/10 via-amber-500/20 to-orange-500/10 border-2 border-amber-500/40 dark:border-amber-500/30 flex flex-col sm:flex-row items-center justify-between gap-3">
<div>
<div className="flex items-center gap-1.5 text-xs font-black uppercase text-amber-800 dark:text-amber-400">
<Zap className="w-4 h-4 text-amber-600 dark:text-amber-400 fill-current" />
<span>Atalho Rápido de Corte por Conjunto</span>
</div>
<p className="text-xs text-slate-600 dark:text-slate-300 font-medium">
Corta todos os componentes necessários para fechar mais 1 kit montável de uma só vez.
</p>
</div>
<Button
type="button"
disabled={salvando || kitsProntos >= totalKits}
onClick={() => handleCortarKitCompleto(1)}
className="w-full sm:w-auto h-11 px-4 bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 font-black text-xs uppercase tracking-wider rounded-xl shadow-md active:scale-95 transition-all flex items-center justify-center gap-1.5 shrink-0"
>
<Zap className="w-4 h-4 fill-current" />
<span>CORTAR 1 KIT COMPLETO (+1 UN)</span>
</Button>
</div>
{/* ──────────────── LISTA DE SUBCOMPONENTES PARA CORTE AVULSO ──────────────── */}
<div>
<div className="flex items-center justify-between text-xs font-bold text-slate-700 dark:text-slate-300 mb-2">
<span className="flex items-center gap-1.5">
<Wrench className="w-3.5 h-3.5 text-blue-500" />
Corte Individual de Subcomponentes (Nesting / Chapas avulsas):
</span>
<span className="text-slate-400">{pecaKit.componentes.length} subcomponentes</span>
</div>
<div className="space-y-2.5">
{pecaKit.componentes.map((comp) => {
const isGargalo = gargalo?.id === comp.id && kitsProntos < totalKits;
const comp100 = comp.concluido;
const valorAtual = quantidadesManual[comp.id] || comp.quantidade_por_peca || 1;
return (
<div
key={comp.id}
className={`p-3 rounded-2xl border transition-all ${
isGargalo
? 'bg-amber-50/70 dark:bg-amber-950/30 border-amber-400 dark:border-amber-700 shadow-sm'
: comp100
? 'bg-emerald-50/40 dark:bg-emerald-950/20 border-emerald-200 dark:border-emerald-800'
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800'
}`}
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2.5">
{/* Info do componente */}
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono font-black text-base text-slate-900 dark:text-slate-100">
{comp.marca_componente}
</span>
{comp.perfil && (
<span className="text-xs px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 font-mono text-slate-700 dark:text-slate-300 font-bold">
{comp.perfil}
</span>
)}
{isGargalo && (
<span className="text-[10px] font-black px-2 py-0.5 rounded bg-amber-500 text-slate-950 uppercase tracking-tight">
Gargalo do Kit
</span>
)}
{comp100 && (
<span className="text-[10px] font-black px-2 py-0.5 rounded bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300">
✅ 100% Cortado
</span>
)}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
{comp.quantidade_por_peca} un/peça • Total da OF:{' '}
<strong>{comp.quantidade_total_necessaria} un</strong> • Já cortado:{' '}
<strong className="text-slate-900 dark:text-slate-100 font-mono">
{comp.quantidade_cortada} un
</strong>{' '}
({comp.percentual_cortado}%) • {comp.peso_unitario > 0 ? `${comp.peso_unitario.toFixed(2)} kg/un` : ''}
</div>
</div>
{/* Teclado de corte rápido deste componente */}
<div className="flex items-center gap-1.5 shrink-0 self-end sm:self-center">
<div className="flex items-center gap-1 bg-slate-100 dark:bg-slate-800 p-1 rounded-xl border border-slate-200 dark:border-slate-700">
<button
type="button"
onClick={() => setQtdComp(comp.id, comp.quantidade_por_peca)}
className="px-2 py-1 rounded-lg text-xs font-black bg-white dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 shadow-xs"
title="1 kit da peça"
>
+{comp.quantidade_por_peca}
</button>
<button
type="button"
onClick={() => setQtdComp(comp.id, comp.quantidade_por_peca * 2)}
className="px-2 py-1 rounded-lg text-xs font-black bg-white dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 shadow-xs"
title="2 kits da peça"
>
+{comp.quantidade_por_peca * 2}
</button>
<Input
type="number"
min="1"
value={valorAtual}
onChange={(e) => setQtdComp(comp.id, parseInt(e.target.value) || 1)}
className="w-14 h-7 text-xs text-center font-bold px-1"
/>
</div>
<Button
type="button"
size="sm"
disabled={salvando}
onClick={() => handleApontarComponenteIndividual(comp)}
className="h-8 px-3 bg-slate-900 hover:bg-slate-800 text-white dark:bg-amber-500 dark:hover:bg-amber-400 dark:text-slate-950 font-black text-xs uppercase rounded-xl active:scale-95"
>
Apontar ({valorAtual})
</Button>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -90,21 +90,23 @@ export const SmartConsultaRelatoriosFlow: React.FC<SmartConsultaRelatoriosFlowPr
.from('processos_fabricacao')
.select('id, nome, ordem'),
// 3. Apontamentos da OF
// 3. Apontamentos da OF (apenas peças mestres)
supabase
.from('apontamentos_producao')
.select('peca_id, processo_id, quantidade_produzida')
.select('peca_id, processo_id, quantidade_produzida, tipo_apontamento, componente_id')
.eq('of_number', obra.of_number),
// 4. Expedição em Romaneios
// 4. Expedição em Romaneios (APENAS ROMANEIOS ENTREGUES E MAPEANDO POR PECA_ID PARA EVITAR CONFLITO DE FASE)
supabase
.from('itens_romaneio_pecas')
.select(`
peca_id,
marca,
quantidade_expedida,
romaneios_expedicao!inner(of_number)
romaneios_expedicao!inner(of_number, status)
`)
.eq('romaneios_expedicao.of_number', obra.of_number),
.eq('romaneios_expedicao.of_number', obra.of_number)
.eq('romaneios_expedicao.status', 'Entregue'),
// 5. Montagem em Obra (RDO)
supabase
@@ -130,6 +132,10 @@ export const SmartConsultaRelatoriosFlow: React.FC<SmartConsultaRelatoriosFlowPr
>();
(resApontamentos.data || []).forEach((ap: any) => {
// Ignora apontamentos de componentes para não inflar as quantidades da peça mestre
if (ap.tipo_apontamento && ap.tipo_apontamento !== 'peca') return;
if (ap.componente_id) return;
const pecaId = ap.peca_id;
const nomeProc = mapaNomeProcesso.get(ap.processo_id) || '';
const qtd = Number(ap.quantidade_produzida || 0);
@@ -157,11 +163,13 @@ export const SmartConsultaRelatoriosFlow: React.FC<SmartConsultaRelatoriosFlowPr
mapaProcPeca.set(pecaId, atual);
});
// Mapear expedição por marca
// Mapear expedição por peca_id (para respeitar a fase corretamente)
const mapaExp = new Map<string, number>();
(resExpedidos.data || []).forEach((item: any) => {
const marca = item.marca;
mapaExp.set(marca, (mapaExp.get(marca) || 0) + Number(item.quantidade_expedida || 0));
const id = item.peca_id;
if (id) {
mapaExp.set(id, (mapaExp.get(id) || 0) + Number(item.quantidade_expedida || 0));
}
});
// Mapear montagem por marca
@@ -195,16 +203,18 @@ export const SmartConsultaRelatoriosFlow: React.FC<SmartConsultaRelatoriosFlowPr
montagem: procs.montagem,
solda: procs.solda,
pintura: procs.pintura,
expedido: mapaExp.get(p.marca) || 0,
expedido: mapaExp.get(p.id) || 0,
montado_obra: mapaMont.get(p.marca) || 0,
tem_componentes: p.tem_componentes,
};
});
// ORDENAÇÃO NATURAL: 1, 2, 3... 10... ou P1, P2, P10, V1, V101
lista.sort((a, b) =>
a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' })
);
// ORDENAÇÃO NATURAL: Primeiro por Fase, depois por Marca
lista.sort((a, b) => {
const cmpFase = (a.etapa_fase || '1').localeCompare(b.etapa_fase || '1', undefined, { numeric: true, sensitivity: 'base' });
if (cmpFase !== 0) return cmpFase;
return a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' });
});
setPecasStatus(lista);
} catch (e) {
@@ -1194,13 +1204,26 @@ export const SmartConsultaRelatoriosFlow: React.FC<SmartConsultaRelatoriosFlowPr
</Button>
</div>
) : (
pecasFiltradas.map((peca) => (
<div
key={peca.id}
className="p-3.5 rounded-2xl bg-white dark:bg-slate-800/95 border border-slate-200 dark:border-slate-700 shadow-sm"
>
{/* Linha Superior: Marca, Perfil, Fase e Quantidade */}
<div className="flex items-start justify-between gap-2">
pecasFiltradas.map((peca, index) => {
// Lógica do Divisor de Fase
const mostrarDivisor = index === 0 || pecasFiltradas[index - 1].etapa_fase !== peca.etapa_fase;
return (
<React.Fragment key={peca.id}>
{mostrarDivisor && (
<div className="flex items-center gap-3 mt-4 mb-2">
<div className="h-px flex-1 bg-slate-300 dark:bg-slate-700"></div>
<span className="text-xs font-black uppercase text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-800 px-3 py-1 rounded-full border border-slate-300 dark:border-slate-700 shadow-sm">
FASE {peca.etapa_fase || '1'}
</span>
<div className="h-px flex-1 bg-slate-300 dark:bg-slate-700"></div>
</div>
)}
<div
className="p-3.5 rounded-2xl bg-white dark:bg-slate-800/95 border border-slate-200 dark:border-slate-700 shadow-sm"
>
{/* Linha Superior: Marca, Perfil, Fase e Quantidade */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xl font-black text-amber-700 dark:text-amber-400 tracking-wider">
@@ -1287,7 +1310,8 @@ export const SmartConsultaRelatoriosFlow: React.FC<SmartConsultaRelatoriosFlowPr
})}
</div>
</div>
))
</React.Fragment>
)})
)}
</div>
</div>
@@ -0,0 +1,442 @@
import React, { useState, useMemo } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { smartAudio } from '@/utils/smartAudio';
import { useAuth } from '@/hooks/useAuth';
import { ProcessoFabricacao } from '@/hooks/useProcessosFabricacao';
import { OFAtiva } from '@/hooks/useOFsAtivas';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import {
AlertTriangle,
X,
Zap,
Check,
Plus,
Minus,
ArrowRight,
Sparkles,
RefreshCw,
Scissors,
Ruler,
Puzzle,
Paintbrush,
ShieldAlert
} from 'lucide-react';
interface PecaData {
id: string;
marca: string;
descricao: string;
etapa_fase: string;
quantidade: number;
peso_unitario: number;
perfil_principal?: string;
tem_componentes?: boolean | null;
}
interface SmartForcarApontamentoModalProps {
isOpen: boolean;
onClose: () => void;
obra: OFAtiva;
faseSelecionada: string;
processoAtual: ProcessoFabricacao;
todosProcessos: ProcessoFabricacao[];
pecas: PecaData[];
mapaProducao: Map<string, number>;
onSucesso: (pecasCount: number, pesoKg: number) => void;
}
export const SmartForcarApontamentoModal: React.FC<SmartForcarApontamentoModalProps> = ({
isOpen,
onClose,
obra,
faseSelecionada,
processoAtual,
todosProcessos,
pecas,
mapaProducao,
onSucesso,
}) => {
const { user } = useAuth();
const [pecaSelecionada, setPecaSelecionada] = useState<PecaData | null>(null);
const [qtdForcar, setQtdForcar] = useState<number>(1);
const [salvando, setSalvando] = useState<boolean>(false);
const getIconeProcesso = (nome: string) => {
const n = (nome || '').toLowerCase();
if (n.includes('corte')) return <Scissors className="h-4 w-4" />;
if (n.includes('dobra') || n.includes('fura')) return <Ruler className="h-4 w-4" />;
if (n.includes('montag')) return <Puzzle className="h-4 w-4" />;
if (n.includes('solda')) return <Zap className="h-4 w-4" />;
if (n.includes('pintura')) return <Paintbrush className="h-4 w-4" />;
return <ShieldAlert className="h-4 w-4" />;
};
// Encontra o processo anterior para uma determinada peça considerando regra S/M
const getProcessoAnteriorPeca = (peca: PecaData): ProcessoFabricacao | null => {
const procsOrdenados = [...todosProcessos].sort((a, b) => a.ordem - b.ordem);
const indexAtual = procsOrdenados.findIndex((p) => p.id === processoAtual.id);
if (indexAtual <= 0) return null;
const ehSemMontagem = peca.tem_componentes === false;
for (let i = indexAtual - 1; i >= 0; i--) {
const proc = procsOrdenados[i];
const nomeP = (proc.nome || '').toLowerCase();
// Peças sem montagem (S/M) pulam solda
if (ehSemMontagem && nomeP.includes('solda')) {
continue;
}
return proc;
}
return null;
};
// Peças da fase que ainda NÃO foram totalmente apontadas no processo anterior
const pecasPendentesProcessoAnterior = useMemo(() => {
return pecas
.filter((p) => {
if (!faseSelecionada || faseSelecionada === 'Geral') return true;
return (p.etapa_fase || 'Geral').trim() === faseSelecionada;
})
.map((p) => {
const procAnterior = getProcessoAnteriorPeca(p);
if (!procAnterior) {
return { ...p, procAnterior: null, saldoPendenteAnterior: 0, jaApontadoAnterior: 0 };
}
const keyAnt = `${p.id}_${procAnterior.id}`;
const jaApontadoAnterior = mapaProducao.get(keyAnt) || 0;
const saldoPendenteAnterior = Math.max(0, p.quantidade - jaApontadoAnterior);
return {
...p,
procAnterior,
saldoPendenteAnterior,
jaApontadoAnterior,
};
})
.filter((p) => p.procAnterior !== null && p.saldoPendenteAnterior > 0)
.sort((a, b) => a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' }));
}, [pecas, faseSelecionada, processoAtual, todosProcessos, mapaProducao]);
const handleSelecionarPeca = (peca: any) => {
smartAudio.playClick();
setPecaSelecionada(peca);
setQtdForcar(peca.saldoPendenteAnterior > 0 ? (peca.saldoPendenteAnterior >= 1 ? 1 : peca.saldoPendenteAnterior) : 1);
};
const handleConfirmarForcamento = async () => {
if (!pecaSelecionada || !user) return;
const procAnterior = getProcessoAnteriorPeca(pecaSelecionada);
if (!procAnterior) {
toast.error('Processo anterior não identificado para esta peça');
return;
}
if (qtdForcar <= 0) {
smartAudio.playAlert();
toast.error('Informe uma quantidade válida');
return;
}
try {
setSalvando(true);
const hoje = new Date().toISOString().split('T')[0];
const nomeUsuario = user.name || user.username || user.email?.split('@')[0] || 'Operador Chão de Fábrica';
// 1. Inserir apontamento no Processo Anterior com flag de FORÇADO e PENDENTE DE CONFIRMAÇÃO
const payloadAnterior = {
of_number: obra.of_number,
peca_id: pecaSelecionada.id,
tipo_apontamento: 'peca' as const,
processo_id: procAnterior.id,
quantidade_produzida: qtdForcar,
data_apontamento: hoje,
created_by: user.id,
usuario_nome: nomeUsuario,
is_forcado: true,
status_confirmacao: 'pendente_confirmacao',
forcado_por_user_nome: nomeUsuario,
forcado_por_user_id: user.id,
forcado_para_processo_id: processoAtual.id,
observacoes: `Forçado por ${nomeUsuario} no posto ${processoAtual.nome}`,
};
// 2. Inserir apontamento no Processo Atual (pois o operador já produziu/está com as peças)
const payloadAtual = {
of_number: obra.of_number,
peca_id: pecaSelecionada.id,
tipo_apontamento: 'peca' as const,
processo_id: processoAtual.id,
quantidade_produzida: qtdForcar,
data_apontamento: hoje,
created_by: user.id,
usuario_nome: nomeUsuario,
is_forcado: false,
status_confirmacao: 'confirmado',
observacoes: `Apontamento regular decorrente de força de ${procAnterior.nome}`,
};
const { error: erroAnt } = await supabase.from('apontamentos_producao').insert(payloadAnterior);
if (erroAnt) throw erroAnt;
const { error: erroAtual } = await supabase.from('apontamentos_producao').insert(payloadAtual);
if (erroAtual) throw erroAtual;
// Sucesso!
smartAudio.playSuccess();
toast.success(
`⚡ Apontamento forçado com sucesso! ${qtdForcar}x ${pecaSelecionada.marca} liberadas de ${procAnterior.nome} e apontadas em ${processoAtual.nome}.`
);
const pesoTotal = qtdForcar * (pecaSelecionada.peso_unitario || 0);
onSucesso(qtdForcar, pesoTotal);
onClose();
} catch (e: any) {
smartAudio.playAlert();
console.error('Erro ao forçar apontamento:', e);
toast.error('Falha ao forçar apontamento: ' + (e?.message || 'Erro de conexão'));
} finally {
setSalvando(false);
}
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-3 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200"
onClick={onClose}
>
<div
className="w-full max-w-lg max-h-[90vh] flex flex-col bg-white dark:bg-slate-900 rounded-3xl border-2 border-amber-500/50 dark:border-amber-500/40 shadow-2xl overflow-hidden select-none animate-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
{/* Cabeçalho */}
<div className="flex items-center justify-between px-5 py-4 bg-amber-500/10 dark:bg-amber-950/40 border-b border-amber-500/20">
<div className="flex items-center gap-2.5">
<div className="w-10 h-10 rounded-2xl bg-amber-500 text-slate-950 flex items-center justify-center font-black shadow-md">
<Zap className="w-5 h-5 fill-current animate-pulse" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm sm:text-base font-black uppercase tracking-wider text-slate-900 dark:text-white">
Forçar Apontamento
</h2>
<span className="text-[10px] font-black px-2 py-0.5 rounded-full bg-amber-500 text-slate-950">
{processoAtual.nome}
</span>
</div>
<p className="text-[11px] text-slate-600 dark:text-slate-400">
Aponte peças esquecidas no processo anterior para liberar seu posto
</p>
</div>
</div>
<button
type="button"
onClick={() => {
smartAudio.playClick();
onClose();
}}
className="p-2 rounded-xl text-slate-500 hover:text-slate-800 dark:hover:text-slate-200 hover:bg-slate-200/60 dark:hover:bg-slate-800 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Aviso de Destaque */}
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/20 border-b border-amber-200 dark:border-amber-900/40 flex items-start gap-2.5 text-xs text-amber-800 dark:text-amber-300">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-600 dark:text-amber-400" />
<span>
Ao forçar o apontamento, a peça será automaticamente registrada no processo anterior (com pendência de confirmação) e apontada no seu processo atual.
</span>
</div>
{/* Conteúdo Principal */}
<div className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar">
{pecasPendentesProcessoAnterior.length === 0 ? (
<div className="py-12 flex flex-col items-center justify-center text-center space-y-2 text-slate-500">
<Sparkles className="w-10 h-10 text-emerald-500 stroke-[1.2]" />
<p className="text-sm font-bold text-slate-700 dark:text-slate-200">
Tudo em ordem!
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 max-w-xs">
Não há peças da fase {faseSelecionada} pendentes no processo anterior. Todas as peças disponíveis já foram apontadas!
</p>
</div>
) : !pecaSelecionada ? (
<div className="space-y-2">
<span className="text-xs font-black uppercase text-slate-500 dark:text-slate-400 tracking-wider">
Selecione a peça que deseja forçar:
</span>
{pecasPendentesProcessoAnterior.map((item) => (
<button
key={item.id}
type="button"
onClick={() => handleSelecionarPeca(item)}
className="w-full p-3.5 rounded-2xl bg-white dark:bg-slate-800/90 hover:bg-amber-50/70 dark:hover:bg-slate-750 border-2 border-slate-200 hover:border-amber-400 dark:border-slate-700 dark:hover:border-amber-500 text-left transition-all active:scale-[0.98] flex items-center justify-between gap-3 shadow-sm group"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono font-black text-sm text-slate-900 dark:text-slate-100">
{item.marca}
</span>
{item.tem_componentes === false && (
<span className="text-[9px] px-1.5 py-0.2 rounded bg-purple-100 text-purple-800 dark:bg-purple-950 dark:text-purple-300 font-bold">
S/M
</span>
)}
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300">
Total: {item.quantidade} un
</span>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">
{item.descricao || item.perfil_principal || 'Peça Estrutural'}
</p>
<div className="flex items-center gap-1.5 text-[11px] text-amber-700 dark:text-amber-400 font-bold">
<span className="flex items-center gap-1">
{item.procAnterior && getIconeProcesso(item.procAnterior.nome)}
Falta em: {item.procAnterior?.nome} ({item.saldoPendenteAnterior} un)
</span>
</div>
</div>
<div className="text-right shrink-0">
<span className="px-3 py-1.5 rounded-xl bg-amber-500 hover:bg-amber-400 text-slate-950 font-black text-xs uppercase shadow-sm group-hover:scale-105 transition-transform flex items-center gap-1">
<span>Escolher</span>
<ArrowRight className="w-3.5 h-3.5" />
</span>
</div>
</button>
))}
</div>
) : (
<div className="space-y-4">
{/* Card da Peça Selecionada */}
<div className="p-4 rounded-2xl bg-amber-50/80 dark:bg-amber-950/30 border-2 border-amber-400/80 dark:border-amber-500/50 space-y-2">
<div className="flex items-center justify-between">
<span className="font-mono font-black text-lg text-amber-800 dark:text-amber-300">
{pecaSelecionada.marca}
</span>
<button
type="button"
onClick={() => {
smartAudio.playClick();
setPecaSelecionada(null);
}}
className="text-xs font-bold text-amber-700 dark:text-amber-400 underline hover:text-amber-900"
>
Trocar Peça
</button>
</div>
<div className="text-xs text-slate-600 dark:text-slate-300">
{pecaSelecionada.descricao || pecaSelecionada.perfil_principal}
</div>
<div className="flex items-center justify-between text-xs font-semibold text-slate-700 dark:text-slate-300 pt-2 border-t border-amber-300/60 dark:border-amber-800/60">
<span>Total da Peça: {pecaSelecionada.quantidade} un</span>
<span className="text-amber-700 dark:text-amber-400 font-bold">
Pendente no anterior: {(pecaSelecionada as any).saldoPendenteAnterior} un
</span>
</div>
</div>
{/* Seletor de Quantidade */}
<div className="text-center space-y-2">
<span className="text-xs font-black uppercase text-slate-600 dark:text-slate-300 tracking-wider">
Quantas unidades você está forçando agora?
</span>
<div className="flex items-center justify-center gap-3">
<button
type="button"
onClick={() => {
smartAudio.playClick();
setQtdForcar((prev) => Math.max(1, prev - 1));
}}
disabled={qtdForcar <= 1}
className="h-14 w-14 rounded-2xl bg-white dark:bg-slate-800 border-2 border-slate-200 dark:border-slate-700 disabled:opacity-40 text-slate-800 dark:text-white flex items-center justify-center font-black active:scale-95 shadow-sm"
>
<Minus className="h-6 w-6" />
</button>
<div className="w-28 h-14 rounded-2xl bg-white dark:bg-slate-900 border-2 border-amber-500 flex items-center justify-center shadow-inner">
<span className="text-3xl font-black text-amber-600 dark:text-amber-400">
{qtdForcar}
</span>
</div>
<button
type="button"
onClick={() => {
smartAudio.playClick();
setQtdForcar((prev) => {
const max = (pecaSelecionada as any).saldoPendenteAnterior || 1;
return Math.min(max, prev + 1);
});
}}
disabled={qtdForcar >= ((pecaSelecionada as any).saldoPendenteAnterior || 1)}
className="h-14 w-14 rounded-2xl bg-white dark:bg-slate-800 border-2 border-slate-200 dark:border-slate-700 disabled:opacity-40 text-slate-800 dark:text-white flex items-center justify-center font-black active:scale-95 shadow-sm"
>
<Plus className="h-6 w-6" />
</button>
</div>
<div className="flex justify-center gap-2 pt-1">
<button
type="button"
onClick={() => {
smartAudio.playClick();
setQtdForcar((pecaSelecionada as any).saldoPendenteAnterior || 1);
}}
className="px-3 py-1 rounded-xl bg-amber-100 hover:bg-amber-200 text-amber-800 dark:bg-amber-950 dark:text-amber-300 text-xs font-bold transition-colors"
>
Máximo Disponível ({(pecaSelecionada as any).saldoPendenteAnterior || 1})
</button>
</div>
</div>
{/* Botão de Confirmação */}
<Button
type="button"
disabled={salvando || qtdForcar <= 0}
onClick={handleConfirmarForcamento}
className="w-full h-14 bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 text-sm font-black rounded-2xl shadow-lg shadow-amber-950/20 uppercase tracking-wide active:scale-98 gap-2"
>
{salvando ? (
<>
<RefreshCw className="h-5 w-5 animate-spin" />
<span>Registrando Forçamento...</span>
</>
) : (
<>
<Check className="h-5 w-5 stroke-[3]" />
<span>CONFIRMAR FORÇAMENTO ({qtdForcar} UN)</span>
</>
)}
</Button>
</div>
)}
</div>
{/* Rodapé */}
<div className="p-3 bg-slate-50 dark:bg-slate-950 border-t border-slate-200 dark:border-slate-800 flex justify-end">
<Button
type="button"
variant="outline"
onClick={() => {
smartAudio.playClick();
onClose();
}}
className="h-10 px-4 rounded-xl text-xs font-bold"
>
Fechar
</Button>
</div>
</div>
</div>
);
};
+47 -1
View File
@@ -1,10 +1,11 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Building2, Volume2, VolumeX, LogOut, ArrowLeft, Sun, Moon } from 'lucide-react';
import { Building2, Volume2, VolumeX, LogOut, ArrowLeft, Sun, Moon, Bell } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { useTheme } from '@/hooks/useTheme';
import { smartAudio } from '@/utils/smartAudio';
import { Button } from '@/components/ui/button';
import { SmartNotificationsModal, useSmartNotificationsAlert } from './SmartNotificationsModal';
interface SmartHeaderProps {
titulo?: string;
@@ -23,6 +24,8 @@ export const SmartHeader: React.FC<SmartHeaderProps> = ({
const { user, signOut } = useAuth();
const { theme, setTheme } = useTheme();
const [soundOn, setSoundOn] = useState<boolean>(() => smartAudio.isSoundEnabled());
const [isNotificationsOpen, setIsNotificationsOpen] = useState<boolean>(false);
const { unreadCount, hasNewAlert, hasPendingForced, clearAlert, refetch } = useSmartNotificationsAlert();
// Detecta se está efetivamente em modo escuro
const isDark =
@@ -128,6 +131,42 @@ export const SmartHeader: React.FC<SmartHeaderProps> = ({
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
{/* Notificações de Apontamentos de Produção */}
<button
type="button"
onClick={() => {
smartAudio.playClick();
clearAlert();
setIsNotificationsOpen(true);
}}
className={`relative h-10 w-8 sm:w-9 rounded-xl flex items-center justify-center border transition-all active:scale-90 ${
hasPendingForced
? 'animate-pulse-red-fast bg-red-100 border-red-500 text-red-700 dark:bg-red-950/80 dark:border-red-500 dark:text-red-400 shadow-md shadow-red-500/30'
: hasNewAlert
? 'bg-amber-100 border-amber-300 text-amber-700 dark:bg-amber-950/70 dark:border-amber-600/50 dark:text-amber-400'
: 'bg-slate-100 border-slate-200 text-slate-600 dark:bg-slate-800/80 dark:border-slate-700 dark:text-slate-300 hover:bg-slate-200'
}`}
title={hasPendingForced ? '⚠️ Existem apontamentos forçados pendentes de confirmação!' : 'Notificações de Apontamentos de Produção'}
>
<Bell className={`h-4 w-4 ${hasPendingForced ? 'animate-bounce text-red-600 dark:text-red-400' : hasNewAlert ? 'animate-bounce text-amber-600 dark:text-amber-400' : ''}`} />
{hasPendingForced ? (
<span className="absolute top-1.5 right-1.5 flex h-2.5 w-2.5 pointer-events-none">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-red-600"></span>
</span>
) : hasNewAlert ? (
<span className="absolute top-1.5 right-1.5 flex h-2 w-2 pointer-events-none">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500"></span>
</span>
) : null}
{unreadCount > 0 && !hasNewAlert && !hasPendingForced && (
<span className="absolute -top-1 -right-1 px-1 min-w-[15px] h-3.5 rounded-full bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-200 text-[8px] font-mono font-bold flex items-center justify-center border border-slate-300 dark:border-slate-600 pointer-events-none">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* Controle de Som / Áudio */}
<button
type="button"
@@ -165,6 +204,13 @@ export const SmartHeader: React.FC<SmartHeaderProps> = ({
</button>
</div>
</div>
{/* Modal de Notificações de Apontamentos */}
<SmartNotificationsModal
isOpen={isNotificationsOpen}
onClose={() => setIsNotificationsOpen(false)}
onApontamentoConfirmado={refetch}
/>
</header>
);
};
+29 -15
View File
@@ -7,7 +7,8 @@ import {
ArrowLeftRight,
FileText,
TrendingUp,
Sparkles
Sparkles,
Box
} from 'lucide-react';
import { OFAtiva } from '@/hooks/useOFsAtivas';
import { smartAudio } from '@/utils/smartAudio';
@@ -16,7 +17,7 @@ import { Button } from '@/components/ui/button';
interface SmartHubProps {
obra: OFAtiva;
onTrocarObra: () => void;
onNavigate: (destino: 'producao' | 'embarque' | 'montagem' | 'consulta') => void;
onNavigate: (destino: 'producao' | 'embarque' | 'montagem' | 'consulta' | 'modelo3d') => void;
producaoHoje: { totalPecas: number; totalKg: number };
onGerarRelatorioTurno: () => void;
}
@@ -28,7 +29,7 @@ export const SmartHub: React.FC<SmartHubProps> = ({
producaoHoje,
onGerarRelatorioTurno,
}) => {
const handleAcao = (destino: 'producao' | 'embarque' | 'montagem' | 'consulta') => {
const handleAcao = (destino: 'producao' | 'embarque' | 'montagem' | 'consulta' | 'modelo3d') => {
smartAudio.playClick();
onNavigate(destino);
};
@@ -45,18 +46,31 @@ export const SmartHub: React.FC<SmartHubProps> = ({
OF {obra.of_number} — {obra.cliente || 'Obra Industrial'}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
smartAudio.playClick();
onTrocarObra();
}}
className="h-10 px-3 border-slate-300 dark:border-slate-600 bg-slate-100 hover:bg-slate-200 dark:bg-slate-700/60 dark:hover:bg-slate-650 text-slate-800 dark:text-slate-200 rounded-xl text-xs font-bold shrink-0 active:scale-95 gap-1.5 transition-colors"
>
<ArrowLeftRight className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span>Trocar</span>
</Button>
<div className="flex items-center gap-1.5 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => handleAcao('modelo3d')}
className="h-10 px-3 border-cyan-400 dark:border-cyan-500/50 bg-cyan-50 hover:bg-cyan-100 dark:bg-cyan-950/40 dark:hover:bg-cyan-900/50 text-cyan-800 dark:text-cyan-200 rounded-xl text-xs font-black shrink-0 active:scale-95 gap-1.5 shadow-sm transition-all"
title="Abrir Visualizador 3D do modelo IFC desta obra"
>
<Box className="h-4 w-4 text-cyan-600 dark:text-cyan-400 stroke-[2.2]" />
<span>Modelo 3D</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
smartAudio.playClick();
onTrocarObra();
}}
className="h-10 px-3 border-slate-300 dark:border-slate-600 bg-slate-100 hover:bg-slate-200 dark:bg-slate-700/60 dark:hover:bg-slate-650 text-slate-800 dark:text-slate-200 rounded-xl text-xs font-bold shrink-0 active:scale-95 gap-1.5 transition-colors"
>
<ArrowLeftRight className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span>Trocar</span>
</Button>
</div>
</div>
{/* Grade dos 4 Botões Principais Gigantes */}
@@ -6,7 +6,8 @@ import {
RotateCcw,
Layers,
ChevronRight,
AlertCircle
AlertCircle,
Pencil
} from 'lucide-react';
import { OFAtiva } from '@/hooks/useOFsAtivas';
import { useAuth } from '@/hooks/useAuth';
@@ -45,6 +46,7 @@ export const SmartMontagemFlow: React.FC<SmartMontagemFlowProps> = ({
const [itemSelecionado, setItemSelecionado] = useState<ItemMontagem | null>(null);
const [qtdApontar, setQtdApontar] = useState<number>(1);
const [salvando, setSalvando] = useState<boolean>(false);
const [modoEdicao, setModoEdicao] = useState<boolean>(false);
// Carregar saldo de peças expedidas vs já montadas
const carregarDadosMontagem = React.useCallback(async () => {
@@ -191,24 +193,42 @@ export const SmartMontagemFlow: React.FC<SmartMontagemFlowProps> = ({
setSalvando(true);
const rdoId = await obterOuCriarRDO();
let delta = qtdApontar;
if (modoEdicao) {
delta = qtdApontar - itemSelecionado.quantidade_ja_apontada;
}
if (delta === 0) {
smartAudio.playClick();
toast.info('Nenhuma alteração na quantidade foi feita.');
setItemSelecionado(null);
setModoEdicao(false);
setSalvando(false);
return;
}
const { error } = await supabase.from('apontamentos_peca_obra').insert({
rdo_id: rdoId,
marca_peca: itemSelecionado.marca,
quantidade: qtdApontar,
quantidade: delta,
periodo: 'Normal',
status: 'Montado',
});
if (error) throw error;
smartAudio.playSuccess();
toast.success(`🏗️ Montagem registrada: ${qtdApontar}x ${itemSelecionado.marca}!`);
if (modoEdicao) {
toast.success(`✏️ Ajuste registrado: ${itemSelecionado.marca} ajustado para ${qtdApontar}!`);
} else {
toast.success(`🏗️ Montagem registrada: ${qtdApontar}x ${itemSelecionado.marca}!`);
}
if (onApontamentoMontagem) {
onApontamentoMontagem();
}
setItemSelecionado(null);
setModoEdicao(false);
carregarDadosMontagem();
} catch (e: any) {
smartAudio.playAlert();
@@ -224,6 +244,7 @@ export const SmartMontagemFlow: React.FC<SmartMontagemFlowProps> = ({
// ─────────────────────────────────────────────────────────────
if (itemSelecionado) {
const saldo = itemSelecionado.saldo_disponivel;
const maxPermitido = modoEdicao ? itemSelecionado.quantidade_ja_apontada + saldo : saldo;
return (
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
@@ -258,7 +279,7 @@ export const SmartMontagemFlow: React.FC<SmartMontagemFlowProps> = ({
{/* Display da Quantidade */}
<div className="my-auto py-3 text-center">
<span className="text-xs font-black uppercase text-slate-500 dark:text-slate-400 tracking-wider">
Quantidade Montada Agora
{modoEdicao ? 'Ajustar a Quantidade Total Montada' : 'Quantidade Montada Agora'}
</span>
<div className="flex items-center justify-center gap-4 mt-2">
<div className="min-w-[120px] h-20 px-6 rounded-3xl bg-slate-50 dark:bg-slate-900 border-2 border-emerald-500 flex items-center justify-center shadow-inner">
@@ -286,7 +307,7 @@ export const SmartMontagemFlow: React.FC<SmartMontagemFlowProps> = ({
type="button"
onClick={() => {
smartAudio.playClick();
setQtdApontar((p) => Math.min(saldo, p + 1));
setQtdApontar((p) => Math.min(maxPermitido, p + 1));
}}
className="h-16 rounded-2xl bg-slate-100 hover:bg-slate-200 text-slate-800 border-2 border-slate-200 dark:bg-slate-800 dark:hover:bg-slate-750 active:bg-slate-700 dark:border-slate-700 dark:text-white text-xl font-black active:scale-95 shadow-sm"
>
@@ -296,7 +317,7 @@ export const SmartMontagemFlow: React.FC<SmartMontagemFlowProps> = ({
type="button"
onClick={() => {
smartAudio.playClick();
setQtdApontar((p) => Math.min(saldo, p + 2));
setQtdApontar((p) => Math.min(maxPermitido, p + 2));
}}
className="h-16 rounded-2xl bg-slate-100 hover:bg-slate-200 text-slate-800 border-2 border-slate-200 dark:bg-slate-800 dark:hover:bg-slate-750 active:bg-slate-700 dark:border-slate-700 dark:text-white text-xl font-black active:scale-95 shadow-sm"
>
@@ -306,7 +327,7 @@ export const SmartMontagemFlow: React.FC<SmartMontagemFlowProps> = ({
type="button"
onClick={() => {
smartAudio.playClick();
setQtdApontar((p) => Math.min(saldo, p + 5));
setQtdApontar((p) => Math.min(maxPermitido, p + 5));
}}
className="h-16 rounded-2xl bg-slate-100 hover:bg-slate-200 text-slate-800 border-2 border-slate-200 dark:bg-slate-800 dark:hover:bg-slate-750 active:bg-slate-700 dark:border-slate-700 dark:text-white text-xl font-black active:scale-95 shadow-sm"
>
@@ -316,12 +337,12 @@ export const SmartMontagemFlow: React.FC<SmartMontagemFlowProps> = ({
type="button"
onClick={() => {
smartAudio.playClick();
setQtdApontar(saldo > 0 ? saldo : 1);
setQtdApontar(maxPermitido > 0 ? maxPermitido : 1);
}}
className="h-16 rounded-2xl bg-emerald-100 hover:bg-emerald-200 border-2 border-emerald-300 text-emerald-800 dark:bg-emerald-500/20 dark:hover:bg-emerald-500/30 dark:border-emerald-500/60 dark:text-emerald-400 text-sm font-black active:scale-95 flex flex-col items-center justify-center leading-tight shadow-sm"
>
<span>TODAS</span>
<span className="text-[11px] opacity-80">({saldo})</span>
<span className="text-[11px] opacity-80">({maxPermitido})</span>
</button>
</div>
@@ -338,6 +359,11 @@ export const SmartMontagemFlow: React.FC<SmartMontagemFlowProps> = ({
<div className="h-6 w-6 animate-spin rounded-full border-3 border-white border-t-transparent" />
<span>Registrando...</span>
</>
) : modoEdicao ? (
<>
<Check className="h-6 w-6" />
<span>SALVAR EDIÇÃO ({qtdApontar})</span>
</>
) : (
<>
<Check className="h-6 w-6" />
@@ -352,6 +378,7 @@ export const SmartMontagemFlow: React.FC<SmartMontagemFlowProps> = ({
onClick={() => {
smartAudio.playClick();
setItemSelecionado(null);
setModoEdicao(false);
}}
className="w-full h-13 bg-white hover:bg-slate-100 border border-slate-200 text-slate-700 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 text-sm font-bold rounded-2xl active:scale-98 shadow-sm"
>
@@ -423,15 +450,36 @@ export const SmartMontagemFlow: React.FC<SmartMontagemFlowProps> = ({
</div>
<div className="text-right shrink-0">
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400">
Entregues: {item.quantidade_expedida}
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400 flex flex-col items-end gap-1">
<span>Entregues: {item.quantidade_expedida}</span>
{item.quantidade_ja_apontada > 0 && (
<div className="flex items-center gap-1.5 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 px-2 py-0.5 rounded-md mt-0.5 shadow-sm">
<span className="text-[11px] font-bold text-slate-700 dark:text-slate-300">
Montada: {item.quantidade_ja_apontada}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
smartAudio.playClick();
setItemSelecionado(item);
setQtdApontar(item.quantidade_ja_apontada);
setModoEdicao(true);
}}
className="text-emerald-600 hover:text-emerald-800 dark:text-emerald-500 dark:hover:text-emerald-300 transition-colors p-1 rounded-md hover:bg-emerald-50 dark:hover:bg-emerald-500/10"
title="Editar quantidade montada"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
{concluida ? (
<span className="inline-flex items-center gap-1 text-xs font-black text-emerald-800 bg-emerald-100 border border-emerald-300 dark:text-emerald-400 px-2 py-0.5 rounded-full dark:bg-emerald-950/80 dark:border-emerald-700">
<span className="inline-flex items-center gap-1 text-xs font-black text-emerald-800 bg-emerald-100 border border-emerald-300 dark:text-emerald-400 px-2 py-0.5 rounded-full dark:bg-emerald-950/80 dark:border-emerald-700 mt-2">
<Check className="h-3 w-3" /> 100% Montada
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs font-black text-emerald-800 bg-emerald-100 border border-emerald-300 dark:text-emerald-300 px-2 py-0.5 rounded-full dark:bg-emerald-950/80 dark:border-emerald-700">
<span className="inline-flex items-center gap-1 text-xs font-black text-emerald-800 bg-emerald-100 border border-emerald-300 dark:text-emerald-300 px-2 py-0.5 rounded-full dark:bg-emerald-950/80 dark:border-emerald-700 mt-2">
Saldo: {item.saldo_disponivel}
</span>
)}
@@ -446,6 +494,7 @@ export const SmartMontagemFlow: React.FC<SmartMontagemFlowProps> = ({
smartAudio.playClick();
setItemSelecionado(item);
setQtdApontar(item.saldo_disponivel > 0 ? 1 : 1);
setModoEdicao(false);
}}
className={`w-full h-13 text-sm font-black rounded-xl uppercase tracking-wider transition-all active:scale-[0.98] ${
concluida
@@ -0,0 +1,529 @@
import React, { useEffect, useState, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { smartAudio } from '@/utils/smartAudio';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import {
Bell,
X,
CheckCircle2,
User,
Clock,
Layers,
RefreshCw,
Flame,
Wrench,
Paintbrush,
Truck,
Scissors,
Box,
AlertTriangle,
CheckCheck
} from 'lucide-react';
export interface ApontamentoNotificacao {
id: string;
of_number: string;
quantidade: number;
data_apontamento: string;
created_at: string;
marca: string;
fase: string;
perfil?: string;
processo_nome: string;
processo_cor: string;
usuario_nome: string;
is_forcado?: boolean;
status_confirmacao?: 'confirmado' | 'pendente_confirmacao' | string;
forcado_por_user_nome?: string;
}
interface SmartNotificationsModalProps {
isOpen: boolean;
onClose: () => void;
ofFiltrar?: string;
onApontamentoConfirmado?: () => void;
}
export const SmartNotificationsModal: React.FC<SmartNotificationsModalProps> = ({
isOpen,
onClose,
ofFiltrar,
onApontamentoConfirmado,
}) => {
const { user } = useAuth();
const [apontamentos, setApontamentos] = useState<ApontamentoNotificacao[]>([]);
const [carregando, setCarregando] = useState<boolean>(false);
const [confirmandoId, setConfirmandoId] = useState<string | null>(null);
const formatarTempo = (dataIso?: string) => {
if (!dataIso) return 'Recente';
try {
const data = new Date(dataIso);
const agora = new Date();
const diffMs = agora.getTime() - data.getTime();
const diffMin = Math.floor(diffMs / (1000 * 60));
const diffHoras = Math.floor(diffMin / 60);
if (diffMin < 1) return 'Agora mesmo';
if (diffMin < 60) return `há ${diffMin} min`;
if (diffHoras < 24) return `há ${diffHoras}h`;
return data.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
} catch {
return 'Recente';
}
};
const getProcessIcon = (processoNome: string) => {
const norm = (processoNome || '').toLowerCase();
if (norm.includes('corte')) return <Scissors className="w-3.5 h-3.5" />;
if (norm.includes('solda')) return <Flame className="w-3.5 h-3.5" />;
if (norm.includes('pintu') || norm.includes('galv')) return <Paintbrush className="w-3.5 h-3.5" />;
if (norm.includes('exped')) return <Truck className="w-3.5 h-3.5" />;
if (norm.includes('montag')) return <Wrench className="w-3.5 h-3.5" />;
return <Box className="w-3.5 h-3.5" />;
};
const carregarNotificacoes = useCallback(async () => {
setCarregando(true);
try {
// Filtrar APENAS apontamentos de HOJE e do DIA ANTERIOR (ONTEM)
const dataOntem = new Date(Date.now() - 86400000);
const dataOntemIso = dataOntem.toISOString().split('T')[0];
let query = supabase
.from('apontamentos_producao' as any)
.select(`
id, of_number, quantidade_produzida, data_apontamento, created_at, created_by,
is_forcado, status_confirmacao, forcado_por_user_nome, usuario_nome,
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase, perfil_principal, descricao),
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem)
`)
.gte('data_apontamento', dataOntemIso)
.order('created_at', { ascending: false })
.limit(100);
if (ofFiltrar) {
query = query.eq('of_number', ofFiltrar);
}
const { data: apontamentosData, error } = await query;
if (error) throw error;
// Buscar nomes dos usuários dos apontamentos caso não estejam gravados diretamente
const userIds = Array.from(new Set((apontamentosData || []).map((ap: any) => ap.created_by).filter(Boolean)));
const profilesMap = new Map<string, string>();
if (userIds.length > 0) {
try {
const { data: profs } = await supabase
.from('profiles' as any)
.select('id, full_name, email')
.in('id', userIds);
if (profs) {
profs.forEach((pr: any) => {
const nome = pr.full_name || pr.email?.split('@')[0] || 'Operador';
profilesMap.set(pr.id, nome);
});
}
} catch (e) {
console.debug('Erro ao buscar perfis:', e);
}
}
// Montar lista tratada
const lista: ApontamentoNotificacao[] = (apontamentosData || []).map((ap: any) => {
const peca = ap.peca || {};
const processo = ap.processo || {};
const userName = ap.usuario_nome || (ap.created_by && profilesMap.get(ap.created_by)) || 'Chão de Fábrica';
return {
id: String(ap.id),
of_number: ap.of_number || 'OF',
quantidade: Number(ap.quantidade_produzida || 1),
data_apontamento: ap.data_apontamento,
created_at: ap.created_at || ap.data_apontamento,
marca: peca.marca || 'Sem Marca',
fase: peca.etapa_fase || '1',
perfil: peca.perfil_principal || peca.descricao || '',
processo_nome: processo.nome || 'Produção',
processo_cor: processo.cor || '#10b981',
usuario_nome: userName,
is_forcado: Boolean(ap.is_forcado),
status_confirmacao: ap.status_confirmacao || 'confirmado',
forcado_por_user_nome: ap.forcado_por_user_nome,
};
});
setApontamentos(lista);
} catch (err) {
console.error('Erro ao carregar notificações de produção:', err);
} finally {
setCarregando(false);
}
}, [ofFiltrar]);
const handleConfirmarBaixaForcada = async (item: ApontamentoNotificacao) => {
try {
smartAudio.playClick();
setConfirmandoId(item.id);
const nomeOperador = user?.name || user?.email?.split('@')[0] || 'Operador';
const { error } = await supabase
.from('apontamentos_producao' as any)
.update({
status_confirmacao: 'confirmado',
data_confirmacao: new Date().toISOString(),
confirmado_por_user_nome: nomeOperador,
confirmado_por_user_id: user?.id,
})
.eq('id', item.id);
if (error) throw error;
smartAudio.playSuccess();
toast.success(`✅ Baixa confirmada: ${item.marca} (${item.processo_nome})`);
// Atualiza lista local
setApontamentos((prev) =>
prev.map((ap) => (ap.id === item.id ? { ...ap, status_confirmacao: 'confirmado' } : ap))
);
if (onApontamentoConfirmado) {
onApontamentoConfirmado();
}
} catch (err: any) {
smartAudio.playAlert();
console.error('Erro ao confirmar baixa:', err);
toast.error('Erro ao confirmar baixa: ' + (err?.message || 'Falha de conexão'));
} finally {
setConfirmandoId(null);
}
};
useEffect(() => {
if (isOpen) {
carregarNotificacoes();
}
}, [isOpen, carregarNotificacoes]);
if (!isOpen) return null;
const pendentesConfirmacao = apontamentos.filter(
(ap) => ap.is_forcado && ap.status_confirmacao === 'pendente_confirmacao'
);
const outrosApontamentos = apontamentos.filter(
(ap) => !(ap.is_forcado && ap.status_confirmacao === 'pendente_confirmacao')
);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-3 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200"
onClick={onClose}
>
<div
className="w-full max-w-lg max-h-[85vh] flex flex-col bg-white dark:bg-slate-900 rounded-3xl border border-slate-200 dark:border-slate-800 shadow-2xl overflow-hidden select-none animate-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
{/* Cabeçalho do Modal */}
<div className="flex items-center justify-between px-5 py-4 bg-slate-50/90 dark:bg-slate-950/80 border-b border-slate-200 dark:border-slate-800">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-2xl bg-amber-500/15 border border-amber-500/30 flex items-center justify-center text-amber-500 dark:text-amber-400">
<Bell className="w-5 h-5 animate-pulse" />
</div>
<div>
<h2 className="text-sm sm:text-base font-black uppercase tracking-wider text-slate-800 dark:text-slate-100 flex items-center gap-2">
Apontamentos Recentes
{ofFiltrar && (
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-cyan-100 dark:bg-cyan-900/40 text-cyan-700 dark:text-cyan-300">
OF {ofFiltrar}
</span>
)}
</h2>
<p className="text-[11px] text-slate-500 dark:text-slate-400">
Mostrando apontamentos de <strong className="text-slate-700 dark:text-slate-300">hoje e ontem</strong>
</p>
</div>
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => {
smartAudio.playClick();
carregarNotificacoes();
}}
disabled={carregando}
className="p-2 rounded-xl text-slate-500 hover:text-slate-800 dark:hover:text-slate-200 hover:bg-slate-200/60 dark:hover:bg-slate-800 transition-colors"
title="Atualizar Notificações"
>
<RefreshCw className={`w-4 h-4 ${carregando ? 'animate-spin text-amber-500' : ''}`} />
</button>
<button
type="button"
onClick={() => {
smartAudio.playClick();
onClose();
}}
className="p-2 rounded-xl text-slate-500 hover:text-slate-800 dark:hover:text-slate-200 hover:bg-slate-200/60 dark:hover:bg-slate-800 transition-colors"
title="Fechar"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Conteúdo com Apontamentos */}
<div className="flex-1 overflow-y-auto p-3 sm:p-4 space-y-3 custom-scrollbar">
{/* Seção 1: Apontamentos Forçados Pendentes de Confirmação */}
{pendentesConfirmacao.length > 0 && (
<div className="p-3 rounded-2xl bg-red-50/80 dark:bg-red-950/40 border-2 border-red-300 dark:border-red-800/80 space-y-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs font-black uppercase tracking-wide text-red-700 dark:text-red-400">
<AlertTriangle className="w-4 h-4 animate-bounce shrink-0" />
<span>Apontamentos Forçados Pendentes ({pendentesConfirmacao.length})</span>
</div>
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-red-200 dark:bg-red-900/60 text-red-800 dark:text-red-300 animate-pulse">
Ação Necessária
</span>
</div>
<p className="text-[11px] text-red-600 dark:text-red-300">
Peças foram apontadas no processo posterior e aguardam sua confirmação de baixa:
</p>
<div className="space-y-2">
{pendentesConfirmacao.map((item) => (
<div
key={item.id}
className="p-2.5 rounded-xl bg-white dark:bg-slate-900 border border-red-200 dark:border-red-800/60 flex items-center justify-between gap-2 shadow-sm"
>
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-mono font-black text-xs text-red-700 dark:text-red-400">
{item.marca}
</span>
<span className="text-[10px] font-bold px-1.5 py-0.2 rounded bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300">
OF {item.of_number} • F{item.fase}
</span>
<span
className="text-[10px] font-bold px-1.5 py-0.2 rounded"
style={{
backgroundColor: `${item.processo_cor}20`,
color: item.processo_cor,
}}
>
{item.processo_nome}
</span>
</div>
<div className="text-[10px] text-slate-500 dark:text-slate-400 flex items-center gap-2 flex-wrap">
<span>Qtd: <strong>{item.quantidade} un</strong></span>
{item.forcado_por_user_nome && (
<span className="text-red-600 dark:text-red-400 font-semibold">
Forçado por: {item.forcado_por_user_nome}
</span>
)}
<span>{formatarTempo(item.created_at)}</span>
</div>
</div>
<button
type="button"
disabled={confirmandoId === item.id}
onClick={() => handleConfirmarBaixaForcada(item)}
className="px-3 py-1.5 rounded-xl bg-red-600 hover:bg-red-700 active:bg-red-800 text-white font-black text-xs flex items-center gap-1 shrink-0 shadow-sm active:scale-95 transition-all"
>
{confirmandoId === item.id ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<CheckCheck className="w-3.5 h-3.5" />
)}
<span>Confirmar Baixa</span>
</button>
</div>
))}
</div>
</div>
)}
{/* Seção 2: Histórico de Apontamentos Recentes (Hoje e Ontem) */}
{carregando && apontamentos.length === 0 ? (
<div className="py-12 flex flex-col items-center justify-center text-slate-400 space-y-2">
<RefreshCw className="w-7 h-7 animate-spin text-amber-500" />
<span className="text-xs font-medium">Buscando novos apontamentos...</span>
</div>
) : outrosApontamentos.length === 0 && pendentesConfirmacao.length === 0 ? (
<div className="py-12 flex flex-col items-center justify-center text-center text-slate-400 space-y-2">
<CheckCircle2 className="w-10 h-10 stroke-[1.2] text-slate-300 dark:text-slate-600" />
<p className="text-xs font-semibold text-slate-600 dark:text-slate-400">
Nenhum apontamento registrado hoje ou ontem{ofFiltrar ? ` para a OF ${ofFiltrar}` : ''}.
</p>
</div>
) : (
outrosApontamentos.map((item) => (
<div
key={item.id}
className="p-3 rounded-2xl bg-slate-50/80 dark:bg-slate-950/60 hover:bg-slate-100/90 dark:hover:bg-slate-800/60 border border-slate-200 dark:border-slate-800 transition-all duration-150 flex items-start justify-between gap-3 shadow-sm"
>
{/* Lado Esquerdo: Marca, Detalhes e Usuário */}
<div className="flex items-start gap-2.5 min-w-0 flex-1">
<div
className="p-2 rounded-xl shrink-0 flex items-center justify-center text-white shadow-sm"
style={{ backgroundColor: item.processo_cor }}
title={item.processo_nome}
>
{getProcessIcon(item.processo_nome)}
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono font-black text-xs sm:text-sm text-slate-900 dark:text-slate-100 tracking-tight">
{item.marca}
</span>
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300">
OF {item.of_number} • F{item.fase}
</span>
<span
className="text-[10px] font-bold px-1.5 py-0.5 rounded"
style={{
backgroundColor: `${item.processo_cor}20`,
color: item.processo_cor,
}}
>
{item.processo_nome}
</span>
{item.is_forcado && (
<span className="text-[9px] font-black px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-950 text-amber-700 dark:text-amber-400 border border-amber-300 dark:border-amber-800">
⚡ Forçado (Confirmado)
</span>
)}
</div>
{item.perfil && (
<p className="text-[11px] font-mono text-slate-600 dark:text-slate-400 truncate">
{item.perfil}
</p>
)}
<div className="flex items-center gap-3 text-[10px] text-slate-500 dark:text-slate-400 pt-0.5">
<span className="flex items-center gap-1 font-semibold text-slate-700 dark:text-slate-300">
<User className="w-3 h-3 text-cyan-600 dark:text-cyan-400" />
{item.usuario_nome}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatarTempo(item.created_at)}
</span>
</div>
</div>
</div>
{/* Lado Direito: Quantidade Apontada */}
<div className="text-right shrink-0 flex flex-col items-end justify-center">
<span className="text-xs font-mono font-black px-2 py-1 rounded-xl bg-emerald-100 dark:bg-emerald-950/60 text-emerald-800 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-800 shadow-sm">
+{item.quantidade} un
</span>
</div>
</div>
))
)}
</div>
{/* Rodapé */}
<div className="p-3 bg-slate-50/90 dark:bg-slate-950/80 border-t border-slate-200 dark:border-slate-800 flex items-center justify-between text-[11px] text-slate-500">
<span>{apontamentos.length} apontamentos listados (hoje e ontem)</span>
<button
type="button"
onClick={() => {
smartAudio.playClick();
onClose();
}}
className="px-3.5 py-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-white font-bold text-xs active:scale-95 transition-all"
>
Fechar
</button>
</div>
</div>
</div>
);
};
// Hook reativo para alertar e contar novos apontamentos no sininho (Hoje e Ontem)
export const useSmartNotificationsAlert = (ofFiltrar?: string) => {
const [unreadCount, setUnreadCount] = useState<number>(0);
const [hasNewAlert, setHasNewAlert] = useState<boolean>(false);
const [hasPendingForced, setHasPendingForced] = useState<boolean>(false);
const fetchRecentCount = useCallback(async () => {
try {
const dataOntem = new Date(Date.now() - 86400000);
const dataOntemIso = dataOntem.toISOString().split('T')[0];
// 1. Contar apontamentos recentes (hoje e ontem)
let query = supabase
.from('apontamentos_producao' as any)
.select('id, is_forcado, status_confirmacao', { count: 'exact' })
.gte('data_apontamento', dataOntemIso);
if (ofFiltrar) {
query = query.eq('of_number', ofFiltrar);
}
const { data, count, error } = await query;
if (!error && count !== null) {
setUnreadCount(count);
if (count > 0) setHasNewAlert(true);
const pending = (data || []).some(
(row: any) => row.is_forcado && row.status_confirmacao === 'pendente_confirmacao'
);
setHasPendingForced(pending);
}
} catch (err) {
console.debug('Erro ao contar apontamentos:', err);
}
}, [ofFiltrar]);
useEffect(() => {
fetchRecentCount();
// Realtime listener para alertar quando novos apontamentos forem inseridos ou atualizados
const channelName = `realtime-smart-notifs-${ofFiltrar || 'all'}-${Math.random().toString(36).substring(2, 7)}`;
const channel = supabase
.channel(channelName)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'apontamentos_producao',
},
(payload) => {
const newRow = payload.new as any;
if (!ofFiltrar || newRow?.of_number === ofFiltrar) {
fetchRecentCount();
if (payload.eventType === 'INSERT') {
try {
smartAudio.playSuccess();
} catch {
// ignore
}
}
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [ofFiltrar, fetchRecentCount]);
const clearAlert = useCallback(() => {
setHasNewAlert(false);
}, []);
return { unreadCount, hasNewAlert, hasPendingForced, clearAlert, refetch: fetchRecentCount };
};

Some files were not shown because too many files have changed in this diff Show More