feat: implementar Modo Smart touch-first com comutador bidirecional sem logout
This commit is contained in:
@@ -0,0 +1,273 @@
|
|||||||
|
# Plano de Arquitetura & UX: "Modo Smart" (TrackSteel APP)
|
||||||
|
|
||||||
|
> **Documento de Planejamento de Interface Simplificada para Chão de Fábrica e Tablets/Celulares**
|
||||||
|
> *Versão:* 1.0
|
||||||
|
> *Data:* 21/09/2026
|
||||||
|
> *Status:* Planejamento (Aguardando aprovação para desenvolvimento)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visão Geral e Propósito
|
||||||
|
|
||||||
|
O **Modo Smart** é uma interface simplificada, de alto contraste e focada em toque (Touch-First), projetada para operadores de chão de fábrica e equipes de canteiro de obras.
|
||||||
|
|
||||||
|
### Pilares Fundamentais:
|
||||||
|
1. **Foco Total em Celular (Mobile-First & Touch-First):**
|
||||||
|
- Botões gigantes com altura mínima de 56px a 72px (área de toque perfeita para polegares, mesmo usando luvas).
|
||||||
|
- Ações principais concentradas na "zona do polegar" (metade inferior da tela).
|
||||||
|
- Zero tabelas largas com rolagem horizontal que quebram a tela do celular.
|
||||||
|
- Modais que abrem como *Bottom Sheets* (deslizam de baixo para cima, como nos apps de banco e entrega).
|
||||||
|
2. **Completude Total dos Dados (Zero Campos Faltantes):**
|
||||||
|
- Nenhuma ação (como criação de Romaneio ou Apontamento) terá campos essenciais cortados.
|
||||||
|
- Telas de formulário mobile organizadas com 4 a 5 campos inteligentes por bloco (pílulas de seleção para *Tipo de Transporte*, *Frete*, *Prioridade* e valores padrão automáticos para datas), garantindo que 100% dos dados exigidos pelo Supabase sejam gravados com perfeição.
|
||||||
|
3. **Sistema Inteligente de Memória e Repetição Rápida (Sticky Context / Fast Loop):**
|
||||||
|
- **Fluxo Contínuo:** Ao apontar a peça `P-01`, o app emite o BIP de confirmação e **permanece na mesma lista de peças daquela Fase/Processo**, permitindo apontar a próxima peça (`P-02`) com 1 toque, sem precisar voltar 4 telas atrás!
|
||||||
|
- **Breadcrumbs Interativos no Topo:** Barra compacta no topo `[ ⚡ Solda ▾ ] [ 🏢 Pilares ▾ ]` que permite trocar de processo ou fase instantaneamente com 1 toque quando o operador terminar aquele lote.
|
||||||
|
4. **Zero Curva de Aprendizado:** Navegação guiada em formato de esteira/passo a passo (Wizard de 1 ação por tela).
|
||||||
|
5. **Legibilidade no Sol / Fábrica:** Tipografia grande (16px a 24px), alto contraste e cartões visuais limpos.
|
||||||
|
6. **Eliminação de Ruído:** Esconde 90% das telas administrativas (BI, engenharia, configurações fiscais, cadastros pesados) e foca apenas nas ações essenciais do posto de trabalho.
|
||||||
|
7. **Integridade Absoluta do Banco de Dados:** O Modo Smart é 100% uma **camada visual (UI)**. Ele consome os mesmos hooks, serviços, validações e payloads existentes no app principal, garantindo risco zero de crash ou inconsistência no banco de dados.
|
||||||
|
8. **Sem Dependência de QR Code/Etiquetas:** Toda a seleção é baseada em listas limpas, botões grandes e buscas visuais diretas.
|
||||||
|
9. **Feedback Imediato:** Confirmações sonoras (BIP via Web Audio API) e vibração tátil para garantia de operação sem precisar ler letras pequenas.
|
||||||
|
10. **Relatórios e Compartilhamento:** Geração de relatórios em PDF com botões rápidos para envio via WhatsApp ou E-mail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Ponto de Entrada e Alternância de Modos
|
||||||
|
|
||||||
|
### 2.1. Tela de Login e Configurações
|
||||||
|
- No momento do login (ou na barra superior após logado), o usuário pode escolher:
|
||||||
|
- 🏢 **[ MODO COMPLETO ]** *(Dashboard, 3D, engenharia, relatórios, cadastros completos)*
|
||||||
|
- ⚡ **[ MODO SMART ]** *(Operação rápida, botões gigantes, esteira guiada)*
|
||||||
|
- **Persistência no Aparelho:** A escolha fica gravada no `localStorage` do dispositivo. Tablets fixados em bancadas da fábrica já abrirão diretamente no Modo Smart.
|
||||||
|
- **Botão de Alternância:** No canto superior direito do Modo Smart haverá sempre um botão discreto `[ 🏢 Modo Completo ]` para troca rápida por encarregados.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Arquitetura de Telas e Árvore Completa de Botões
|
||||||
|
|
||||||
|
```
|
||||||
|
[ TELA 0: SELEÇÃO DE OBRA ]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[ TELA 1: HUB PRINCIPAL SMART (4 BOTÕES PRINCIPAIS) ]
|
||||||
|
├── 1. 🔨 APONTAR PRODUÇÃO
|
||||||
|
├── 2. 🚚 APONTAR EMBARQUE (ROMANEIO)
|
||||||
|
├── 3. 📦 APONTAR MONTAGEM (OBRA)
|
||||||
|
└── 4. 🔍 CONSULTAR PEÇAS & RELATÓRIOS
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TELA 0: Seleção de Obra (Contexto Ativo)
|
||||||
|
- **Cabeçalho:** Nome do Usuário Logado | Botão `[ 🏢 Modo Completo ]` | Botão `[ 🚪 Sair ]`
|
||||||
|
- **Conteúdo Central:**
|
||||||
|
- Campo de busca rápida de Obra (Grande);
|
||||||
|
- Cards de Obras Ativas em botões retangulares grandes:
|
||||||
|
- `[ 🏗️ Obra Galpão Industrial 01 - Cliente ABC ]`
|
||||||
|
- `[ 🏗️ Obra Ponte Metálica Sul - Cliente XYZ ]`
|
||||||
|
- *(Ao tocar na obra, entra no Hub Principal carregando automaticamente os dados daquela obra).*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TELA 1: Hub Principal Smart (Os 4 Botões Principais)
|
||||||
|
- **Barra Superior:** `[ ⬅️ Trocar Obra ]` | Obra Selecionada: **Galpão 01** | `[ 🔊 Som Ativo/Mudo ]`
|
||||||
|
- **Centro da Tela (Grade 2x2 ou Lista Vertical de Botões Gigantes):**
|
||||||
|
1. 🔨 **[ 1. APONTAR PRODUÇÃO ]** *(Corte, Montagem, Solda, Pintura, etc.)*
|
||||||
|
2. 🚚 **[ 2. APONTAR EMBARQUE ]** *(Romaneios e saída da fábrica)*
|
||||||
|
3. 📦 **[ 3. APONTAR MONTAGEM ]** *(Conferência e avanço na obra)*
|
||||||
|
4. 🔍 **[ 4. CONSULTAR PEÇAS & PDF ]** *(Status da peça e emissão de relatórios)*
|
||||||
|
- **Rodapé Informativo:**
|
||||||
|
- Badge grande: `📊 Minha Produção Hoje: 42 peças apontadas (1.850 kg)`
|
||||||
|
- Botão `[ 📄 Gerar Relatório do Meu Turno ]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Detalhamento dos Botões Aninhados (Passo a Passo por Tela)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔨 BOTÃO 1: APONTAR PRODUÇÃO
|
||||||
|
|
||||||
|
#### Tela 1.1 — Seleção do Processo / Etapa
|
||||||
|
- **Botões Grandes de Processo:**
|
||||||
|
- `[ ✂️ CORTE ]`
|
||||||
|
- `[ 📐 DOBRA / FURACÃO ]`
|
||||||
|
- `[ 🧩 MONTAGEM ]`
|
||||||
|
- `[ ⚡ SOLDA ]`
|
||||||
|
- `[ 🎨 PINTURA ]`
|
||||||
|
- `[ 🛡️ JATEAMENTO / GALVANIZAÇÃO ]`
|
||||||
|
- `[ ⬅️ Voltar ao Menu ]`
|
||||||
|
|
||||||
|
#### Tela 1.2 — Seleção da Fase / Subconjunto
|
||||||
|
- Lista de Fases da Obra:
|
||||||
|
- `[ 🏢 Pilares ]`
|
||||||
|
- `[ 🏗️ Vigas Principais ]`
|
||||||
|
- `[ 📐 Treliças ]`
|
||||||
|
- `[ 📏 Terças / Fechamentos ]`
|
||||||
|
- `[ 🔩 Placas de Base / Chumbadores ]`
|
||||||
|
- `[ ⬅️ Voltar aos Processos ]`
|
||||||
|
|
||||||
|
#### Tela 1.3 — Lista de Peças Pendentes
|
||||||
|
- Cards de Peças com informações essenciais destacadas em fonte grande:
|
||||||
|
- Card 1: **Marca: V-101** | Perfil: W250x32 | **Saldo: Falta 4 de 10** ➔ Botão `[ APONTAR ]`
|
||||||
|
- Card 2: **Marca: P-205** | Perfil: CS300x50 | **Saldo: Falta 2 de 8** ➔ Botão `[ APONTAR ]`
|
||||||
|
- `[ ⬅️ Voltar às Fases ]`
|
||||||
|
|
||||||
|
#### Tela 1.4 — Apontamento da Quantidade
|
||||||
|
- Resumo no Topo: **V-101** | Processo: **SOLDA** | Saldo Pendente: **4 peças**
|
||||||
|
- **Teclado de Ações Rápidas (Botões Gigantes):**
|
||||||
|
- `[ +1 ]` `[ +2 ]` `[ +5 ]` `[ TODAS (4) ]`
|
||||||
|
- Display da Quantidade Selecionada: **[ 4 ]**
|
||||||
|
- Botão `[ 🔄 Zerar ]`
|
||||||
|
- **Botões de Confirmação:**
|
||||||
|
- `[ ✅ CONFIRMAR APONTAMENTO ]` *(Gera BIP Sonoro de sucesso, vibração, salva no Supabase e volta para a lista de peças já atualizada)*
|
||||||
|
- `[ ❌ Cancelar ]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🚚 BOTÃO 2: APONTAR EMBARQUE (ROMANEIO)
|
||||||
|
|
||||||
|
#### Tela 2.1 — Escolha da Operação de Expedição
|
||||||
|
- `[ ➕ Criar Novo Romaneio Rápido ]`
|
||||||
|
- `[ 📋 Continuar Carregamento em Romaneio Aberto ]`
|
||||||
|
- `[ ⬅️ Voltar ao Menu ]`
|
||||||
|
|
||||||
|
#### Tela 2.2 — Seleção do Romaneio / Veículo
|
||||||
|
- Cards de Romaneios Abertos:
|
||||||
|
- `[ 🚚 Romaneio #045 | Placa: ABC-1234 | 12/28 Peças Carregadas ]`
|
||||||
|
- `[ 🚚 Romaneio #046 | Placa: XYZ-9876 | 0/15 Peças Carregadas ]`
|
||||||
|
- `[ ⬅️ Voltar ]`
|
||||||
|
|
||||||
|
#### Tela 2.3 — Seleção e Embarque das Peças Prontas
|
||||||
|
- Lista apenas as peças que já tiveram todos os processos de fábrica concluídos e possuem saldo de embarque:
|
||||||
|
- Card: **Viga V-101** | Prontas no Pátio: **10** | Já Embarcadas: **6** | Saldo: **4**
|
||||||
|
- Botões de Quantidade a Embarcar: `[ +1 ]` `[ +2 ]` `[ TODAS (4) ]`
|
||||||
|
- Botão `[ 📦 INCLUIR NO CAMINHÃO ]`
|
||||||
|
- **Botão Finalizador:**
|
||||||
|
- `[ 🚚 FINALIZAR CARREGAMENTO DO ROMANEIO ]` *(BIP de sucesso + emite PDF do Romaneio se desejado)*
|
||||||
|
- `[ ⬅️ Voltar ]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📦 BOTÃO 3: APONTAR MONTAGEM (OBRA)
|
||||||
|
|
||||||
|
#### Tela 3.1 — Seleção do Setor de Montagem
|
||||||
|
- `[ 🏗️ Estrutura Principal / Pilares ]`
|
||||||
|
- `[ 📐 Cobertura / Tesouras / Terças ]`
|
||||||
|
- `[ 🪜 Mezaninos e Escadas ]`
|
||||||
|
- `[ ⬅️ Voltar ao Menu ]`
|
||||||
|
|
||||||
|
#### Tela 3.2 — Lista de Peças Recebidas na Obra
|
||||||
|
- Lista peças que já foram enviadas via Romaneio e estão no canteiro aguardando montagem:
|
||||||
|
- Card: **Pilar P-01** | Entregues na Obra: **8** | Montados: **5** | Pendentes: **3**
|
||||||
|
- Botões de Apontamento: `[ +1 ]` `[ +2 ]` `[ TODAS (3) ]`
|
||||||
|
- **Botão de Confirmação:**
|
||||||
|
- `[ 🏗️ CONFIRMAR MONTAGEM ]` *(BIP sonoro + atualização imediata do status de montagem)*
|
||||||
|
- `[ ⬅️ Voltar ]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔍 BOTÃO 4: CONSULTAR PEÇAS & RELATÓRIOS PDF
|
||||||
|
|
||||||
|
#### Tela 4.1 — Consulta Rápida de Peças
|
||||||
|
- **Campo de Busca Gigante:** `[ Digite a Marca, ex: V101 ]`
|
||||||
|
- **Filtros Rápidos por Toque:**
|
||||||
|
- `[ ⚪ Todos ]` `[ 🔴 Fabricando ]` `[ 🟢 Pronto ]` `[ 🚚 Embarcado ]` `[ 🏗️ Montado ]`
|
||||||
|
- **Card da Peça com Linha do Tempo Visual:**
|
||||||
|
- Mostra claramente em qual estágio a peça está:
|
||||||
|
`[ Corte: ✅ ] ➔ [ Montagem: ✅ ] ➔ [ Solda: ✅ ] ➔ [ Pintura: ⏳ ] ➔ [ Embarque: ⭕ ] ➔ [ Obra: ⭕ ]`
|
||||||
|
|
||||||
|
#### Tela 4.2 — Emissão de Relatório em PDF & Compartilhamento
|
||||||
|
- **Opções de Relatórios em Botões Grandes:**
|
||||||
|
1. `[ 📄 1. Relatório do Meu Turno (O que apontei hoje) ]`
|
||||||
|
2. `[ 📋 2. Relatório de Produção Geral da Obra ]`
|
||||||
|
3. `[ 🚚 3. Relatório de Peças Embarcadas / Romaneios ]`
|
||||||
|
4. `[ ⚠️ 4. Relatório de Peças Pendentes de Fabricação ]`
|
||||||
|
|
||||||
|
- **Ações Imediatas após gerar o PDF:**
|
||||||
|
- `[ 👁️ Visualizar na Tela ]`
|
||||||
|
- `[ 📥 Baixar Arquivo PDF ]`
|
||||||
|
- `[ 💬 Compartilhar no WhatsApp ]` *(Gera mensagem formatada com o resumo do dia + link/documento para envio direto no zap do encarregado/diretoria)*
|
||||||
|
- `[ ✉️ Enviar por E-mail ]` *(Abre o cliente de e-mail com assunto e corpo pré-formatados)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Especificação Técnica do Sistema de Som (BIP) e Áudio
|
||||||
|
|
||||||
|
Para não pesar o app e funcionar 100% offline em qualquer celular/tablet, utilizaremos a **Web Audio API** nativa do navegador:
|
||||||
|
|
||||||
|
- **Bip de Sucesso (Apontamento Gravado):** Frequência dupla suave e ascendente (587Hz -> 880Hz, duração de 120ms).
|
||||||
|
- **Bip de Alerta/Erro (Saldo Insuficiente):** Frequência baixa (220Hz, duração de 200ms).
|
||||||
|
- **Vibração Háptica:** `navigator.vibrate([70, 40, 70])` acionada em conjunto com o som nos celulares compatíveis.
|
||||||
|
- **Controle de Usuário:** Botão de Mudo/Ativo no cabeçalho para ambientes de escritório ou preferência pessoal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Matriz de Auditoria Técnica e Mapeamento de Banco de Dados
|
||||||
|
|
||||||
|
Após varredura minuciosa em todos os hooks e serviços do aplicativo, mapeamos a integridade exata exigida por cada etapa:
|
||||||
|
|
||||||
|
### 6.1. Mapeamento: Apontamento de Produção (`apontamentos_producao`)
|
||||||
|
- **Tabela Principal:** `apontamentos_producao`
|
||||||
|
- **Tabelas Relacionadas:** `pecas`, `componentes_peca`, `processos_fabricacao`
|
||||||
|
- **Campos Obrigatórios e Tipagem:**
|
||||||
|
- `of_number` (string) — OF vinculada à obra ativa
|
||||||
|
- `tipo_apontamento` ('peca' | 'componente')
|
||||||
|
- `peca_id` (UUID) — ID da peça no Supabase (ou ID da peça-pai no caso de componentes)
|
||||||
|
- `componente_id` (UUID opcional) — ID do componente quando aplicável
|
||||||
|
- `processo_id` (UUID) — ID da etapa ativa (`processos_fabricacao`)
|
||||||
|
- `quantidade_produzida` (number) — Quantidade informada
|
||||||
|
- `data_apontamento` (YYYY-MM-DD) — Data do turno
|
||||||
|
- `created_by` (UUID) — ID do operador autenticado via `useAuth`
|
||||||
|
- `observacoes` (string opcional)
|
||||||
|
- **Regras de Validação Auditadas:**
|
||||||
|
- Validação sequencial de processos via `useValidacaoSequencialProcessos`: O operador não pode apontar quantidade maior do que a aprovada no processo imediatamente anterior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.2. Mapeamento: Expedição e Romaneios (`romaneios_expedicao` & `itens_romaneio_pecas`)
|
||||||
|
- **Tabelas Principais:** `romaneios_expedicao`, `itens_romaneio_pecas`, `itens_romaneio_insumos`
|
||||||
|
- **Campos Obrigatórios de Criação do Romaneio:**
|
||||||
|
- `numero_romaneio` (string gerada automaticamente)
|
||||||
|
- `of_number` (string)
|
||||||
|
- `data_romaneio` (YYYY-MM-DD)
|
||||||
|
- `data_criacao` (YYYY-MM-DD)
|
||||||
|
- `revisao` (number, padrão 0)
|
||||||
|
- `prioridade` ('Normal' | 'Urgente')
|
||||||
|
- `status` ('Em planejamento' | 'Entregue' | 'Conferido em Obra')
|
||||||
|
- `tipo_transporte` ('carro' | 'utilitario' | 'caminhao_pequeno' | 'caminhao_trucado' | 'caminhao_munck' | 'carreta_12m' | 'carreta_15m' | 'especial')
|
||||||
|
- `frete_tipo` ('proprio' | 'terceiros')
|
||||||
|
- `nome_motorista` (string)
|
||||||
|
- `peso_total_romaneio` (number calculado com base nas peças adicionadas)
|
||||||
|
- `created_by` (UUID)
|
||||||
|
- **Regras de Validação de Itens:**
|
||||||
|
- Apenas peças com status final de fábrica (após pintura/inspeção) e com saldo positivo (`quantidade_produzida_pintura - quantidade_ja_expedida > 0`) podem ser embarcadas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.3. Mapeamento: Montagem em Obra (`apontamentos_peca_obra` & `diario_obra_rdo`)
|
||||||
|
- **Tabelas Principais:** `apontamentos_peca_obra`, `diario_obra_rdo`
|
||||||
|
- **Campos Obrigatórios:**
|
||||||
|
- `rdo_id` (UUID do RDO da data da obra)
|
||||||
|
- `marca_peca` (string)
|
||||||
|
- `quantidade` (number)
|
||||||
|
- **Cálculo de Saldo de Montagem:**
|
||||||
|
- Saldo disponível para montar = `Total Expedido nos Romaneios` - `Total já Apontado no RDO` (auditado conforme `usePecasExpedidas`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.4. Mapeamento: Relatórios e Compartilhamento
|
||||||
|
- **Bibliotecas:** `jspdf` (v4.2.0) e `jspdf-autotable` (v5.0.7)
|
||||||
|
- **Saídas:**
|
||||||
|
- PDF do Turno (peças apontadas pelo operador naquele dia)
|
||||||
|
- PDF Geral da Obra (avanço percentual por processo)
|
||||||
|
- Exportação direta via WhatsApp com resumo em texto e link do PDF.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Próximos Passos (Aguardando Aprovação)
|
||||||
|
|
||||||
|
1. Revisão final da auditoria pelo usuário;
|
||||||
|
2. Desenvolvimento da casca visual do **Modo Smart** no padrão Mobile-First;
|
||||||
|
3. Testes em emulador mobile e validação de gravação no banco de dados.
|
||||||
@@ -67,6 +67,13 @@ function wrapWithGuard(
|
|||||||
</ProtectedRouteByResource>
|
</ProtectedRouteByResource>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'smart':
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
{element}
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
|
|
||||||
import React, { useState, useMemo } from "react";
|
import React, { useState, useMemo } from "react";
|
||||||
import { logger } from "@/utils/logger";
|
import { logger } from "@/utils/logger";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu } from "@/components/ui/sidebar";
|
import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu } from "@/components/ui/sidebar";
|
||||||
import { useSidebar } from "@/components/ui/sidebar";
|
import { useSidebar } from "@/components/ui/sidebar";
|
||||||
import { useUserRole } from "@/hooks/useUserRole";
|
import { useUserRole } from "@/hooks/useUserRole";
|
||||||
import { useIconStyle } from "@/hooks/useIconStyle";
|
import { useIconStyle } from "@/hooks/useIconStyle";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { useUserPermissions } from "@/hooks/useUserPermissions";
|
import { useUserPermissions } from "@/hooks/useUserPermissions";
|
||||||
|
import { Zap } from "lucide-react";
|
||||||
import { usePermissionControl } from "@/hooks/usePermissionControl";
|
import { usePermissionControl } from "@/hooks/usePermissionControl";
|
||||||
import { menuGroups } from "./sidebar/menuConfig";
|
import { menuGroups } from "./sidebar/menuConfig";
|
||||||
import { AppSidebarMenuItem } from "./sidebar/SidebarMenuItem";
|
import { AppSidebarMenuItem } from "./sidebar/SidebarMenuItem";
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { setOpenMobile } = useSidebar();
|
const { setOpenMobile } = useSidebar();
|
||||||
const { isAdmin, loading: roleLoading } = useUserRole();
|
const { isAdmin, loading: roleLoading } = useUserRole();
|
||||||
const { iconStyle } = useIconStyle();
|
const { iconStyle } = useIconStyle();
|
||||||
@@ -140,6 +142,41 @@ export function AppSidebar() {
|
|||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
|
{/* Banner de atalho para Modo Smart */}
|
||||||
|
<div className="px-3 pt-3 pb-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('tracksteel_app_mode', 'smart');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (isMobile) setOpenMobile(false);
|
||||||
|
navigate('/smart');
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center justify-between p-2.5 rounded-xl bg-gradient-to-r from-amber-500/15 via-amber-500/10 to-orange-500/10 border border-amber-500/30 hover:border-amber-500/60 transition-all text-left group shadow-sm active:scale-[0.98]"
|
||||||
|
title="Alternar para o Modo Smart (Chão de Fábrica Touch-First)"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="p-1.5 rounded-lg bg-amber-500/20 text-amber-500 group-hover:bg-amber-500 group-hover:text-white transition-colors">
|
||||||
|
<Zap className="h-4 w-4 fill-amber-500 group-hover:fill-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold text-amber-600 dark:text-amber-400 tracking-wide uppercase">
|
||||||
|
Modo Smart
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground leading-tight">
|
||||||
|
Chão de Fábrica Touch
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-600 dark:text-amber-300">
|
||||||
|
RÁPIDO
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{menuGroups.map(group => {
|
{menuGroups.map(group => {
|
||||||
// Filter admin groups for non-admin users
|
// Filter admin groups for non-admin users
|
||||||
if (!isAdmin && group.name === 'Administração') {
|
if (!isAdmin && group.name === 'Administração') {
|
||||||
|
|||||||
@@ -1,26 +1,52 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
|
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
|
||||||
import { AppSidebar } from '@/components/AppSidebar';
|
import { AppSidebar } from '@/components/AppSidebar';
|
||||||
import { ApontamentoAutomaticoListener } from '@/components/expedicao/ApontamentoAutomaticoListener';
|
import { ApontamentoAutomaticoListener } from '@/components/expedicao/ApontamentoAutomaticoListener';
|
||||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Zap } from 'lucide-react';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Layout = ({ children }: LayoutProps) => {
|
export const Layout = ({ children }: LayoutProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleIrParaModoSmart = () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('tracksteel_app_mode', 'smart');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
navigate('/smart');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<div className="min-h-screen flex w-full">
|
<div className="min-h-screen flex w-full">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<main className="flex-1 overflow-hidden flex flex-col">
|
<main className="flex-1 overflow-hidden flex flex-col">
|
||||||
{/* Header fixo com SidebarTrigger e ThemeToggle */}
|
{/* 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">
|
<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">
|
<div className="flex items-center">
|
||||||
<SidebarTrigger />
|
<SidebarTrigger />
|
||||||
</div>
|
</div>
|
||||||
<ThemeToggle />
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleIrParaModoSmart}
|
||||||
|
className="h-9 px-3 gap-2 font-semibold border-amber-500/40 bg-amber-500/10 hover:bg-amber-500/20 text-amber-600 dark:text-amber-400 hover:text-amber-600 dark:hover:text-amber-300 transition-all rounded-full shadow-sm hover:shadow active:scale-95"
|
||||||
|
title="Alternar para o Modo Smart (Chão de Fábrica Touch-First)"
|
||||||
|
>
|
||||||
|
<Zap className="h-4 w-4 fill-amber-500 text-amber-500 animate-pulse" />
|
||||||
|
<span className="text-xs sm:text-sm font-bold tracking-wide">Modo Smart</span>
|
||||||
|
</Button>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{/* Conteúdo principal */}
|
{/* Conteúdo principal */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ComponentType, lazy } from 'react';
|
|||||||
// Definição de tipos para o sistema de rotas
|
// Definição de tipos para o sistema de rotas
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type RouteGuard = 'public' | 'protected' | 'admin' | 'resource';
|
export type RouteGuard = 'public' | 'protected' | 'admin' | 'resource' | 'smart';
|
||||||
|
|
||||||
export interface RouteDefinition {
|
export interface RouteDefinition {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -292,4 +292,11 @@ export const routeConfig: RouteDefinition[] = [
|
|||||||
component: () => import('@/pages/PrioridadesFabricacao'),
|
component: () => import('@/pages/PrioridadesFabricacao'),
|
||||||
guard: 'protected',
|
guard: 'protected',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Modo Smart (Chão de Fábrica Touch-First) ──────────────
|
||||||
|
{
|
||||||
|
path: '/smart',
|
||||||
|
component: () => import('@/pages/ModoSmart'),
|
||||||
|
guard: 'smart',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { SmartHeader } from './smart/components/SmartHeader';
|
||||||
|
import { SmartObraSelector } from './smart/components/SmartObraSelector';
|
||||||
|
import { SmartHub } from './smart/components/SmartHub';
|
||||||
|
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 { OFAtiva } from '@/hooks/useOFsAtivas';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
|
||||||
|
export const ModoSmart: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
// Obra ativa selecionada
|
||||||
|
const [obraAtiva, setObraAtiva] = useState<OFAtiva | null>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('tracksteel_smart_last_of');
|
||||||
|
return saved ? JSON.parse(saved) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tela ativa: 'hub' | 'producao' | 'embarque' | 'montagem' | 'consulta' | 'turno_pdf'
|
||||||
|
const [telaAtiva, setTelaAtiva] = useState<
|
||||||
|
'hub' | 'producao' | 'embarque' | 'montagem' | 'consulta' | 'turno_pdf'
|
||||||
|
>('hub');
|
||||||
|
|
||||||
|
// Produção do operador hoje
|
||||||
|
const [producaoHoje, setProducaoHoje] = useState<{ totalPecas: number; totalKg: number }>({
|
||||||
|
totalPecas: 0,
|
||||||
|
totalKg: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Carregar produção de hoje do operador
|
||||||
|
const carregarProducaoHoje = React.useCallback(async () => {
|
||||||
|
if (!user || !obraAtiva) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hoje = new Date().toISOString().split('T')[0];
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('apontamentos_producao')
|
||||||
|
.select(`
|
||||||
|
quantidade_produzida,
|
||||||
|
pecas (
|
||||||
|
peso_unitario
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('of_number', obraAtiva.of_number)
|
||||||
|
.eq('data_apontamento', hoje)
|
||||||
|
.eq('created_by', user.id);
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
let pecas = 0;
|
||||||
|
let kg = 0;
|
||||||
|
data.forEach((ap: any) => {
|
||||||
|
const q = Number(ap.quantidade_produzida || 0);
|
||||||
|
const p = Number(ap.pecas?.peso_unitario || 0);
|
||||||
|
pecas += q;
|
||||||
|
kg += q * p;
|
||||||
|
});
|
||||||
|
setProducaoHoje({ totalPecas: pecas, totalKg: kg });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erro ao carregar produção hoje:', e);
|
||||||
|
}
|
||||||
|
}, [user, obraAtiva]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
carregarProducaoHoje();
|
||||||
|
}, [carregarProducaoHoje]);
|
||||||
|
|
||||||
|
const handleSelectObra = (of: OFAtiva) => {
|
||||||
|
setObraAtiva(of);
|
||||||
|
setTelaAtiva('hub');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrocarObra = () => {
|
||||||
|
setObraAtiva(null);
|
||||||
|
setTelaAtiva('hub');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApontamentoRealizado = (pecasCount: number, pesoKg: number) => {
|
||||||
|
setProducaoHoje((prev) => ({
|
||||||
|
totalPecas: prev.totalPecas + pecasCount,
|
||||||
|
totalKg: prev.totalKg + pesoKg,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determinar título do cabeçalho
|
||||||
|
const getHeaderInfo = () => {
|
||||||
|
if (!obraAtiva) {
|
||||||
|
return {
|
||||||
|
titulo: 'MODO SMART',
|
||||||
|
subtitulo: 'Chão de Fábrica Touch-First',
|
||||||
|
mostrarVoltar: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (telaAtiva === 'hub') {
|
||||||
|
return {
|
||||||
|
titulo: `OF ${obraAtiva.of_number}`,
|
||||||
|
subtitulo: obraAtiva.cliente || 'Central de Operação',
|
||||||
|
mostrarVoltar: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapaTitulos: Record<string, string> = {
|
||||||
|
producao: '1. Apontar Produção',
|
||||||
|
embarque: '2. Apontar Embarque',
|
||||||
|
montagem: '3. Apontar Montagem',
|
||||||
|
consulta: '4. Consultar Peças',
|
||||||
|
turno_pdf: 'Relatório do Turno',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
titulo: mapaTitulos[telaAtiva] || 'Modo Smart',
|
||||||
|
subtitulo: `OF ${obraAtiva.of_number}`,
|
||||||
|
mostrarVoltar: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerInfo = getHeaderInfo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col font-sans select-none antialiased">
|
||||||
|
{/* Cabeçalho Fixo do Modo Smart */}
|
||||||
|
<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">
|
||||||
|
{!obraAtiva ? (
|
||||||
|
<SmartObraSelector onSelectObra={handleSelectObra} />
|
||||||
|
) : telaAtiva === 'hub' ? (
|
||||||
|
<SmartHub
|
||||||
|
obra={obraAtiva}
|
||||||
|
onTrocarObra={handleTrocarObra}
|
||||||
|
onNavigate={(destino) => setTelaAtiva(destino)}
|
||||||
|
producaoHoje={producaoHoje}
|
||||||
|
onGerarRelatorioTurno={() => setTelaAtiva('turno_pdf')}
|
||||||
|
/>
|
||||||
|
) : telaAtiva === 'producao' ? (
|
||||||
|
<SmartProducaoFlow
|
||||||
|
obra={obraAtiva}
|
||||||
|
onApontamentoRealizado={handleApontamentoRealizado}
|
||||||
|
onVoltarHub={() => setTelaAtiva('hub')}
|
||||||
|
/>
|
||||||
|
) : telaAtiva === 'embarque' ? (
|
||||||
|
<SmartEmbarqueFlow
|
||||||
|
obra={obraAtiva}
|
||||||
|
onVoltarHub={() => setTelaAtiva('hub')}
|
||||||
|
/>
|
||||||
|
) : telaAtiva === 'montagem' ? (
|
||||||
|
<SmartMontagemFlow
|
||||||
|
obra={obraAtiva}
|
||||||
|
onVoltarHub={() => setTelaAtiva('hub')}
|
||||||
|
/>
|
||||||
|
) : telaAtiva === 'consulta' ? (
|
||||||
|
<SmartConsultaRelatoriosFlow
|
||||||
|
obra={obraAtiva}
|
||||||
|
onVoltarHub={() => setTelaAtiva('hub')}
|
||||||
|
/>
|
||||||
|
) : telaAtiva === 'turno_pdf' ? (
|
||||||
|
<SmartConsultaRelatoriosFlow
|
||||||
|
obra={obraAtiva}
|
||||||
|
onVoltarHub={() => setTelaAtiva('hub')}
|
||||||
|
abrirDiretoTurno={true}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModoSmart;
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
FileText,
|
||||||
|
Share2,
|
||||||
|
Download,
|
||||||
|
Mail,
|
||||||
|
X,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Circle,
|
||||||
|
AlertCircle,
|
||||||
|
Eye,
|
||||||
|
MessageCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import jsPDF from 'jspdf';
|
||||||
|
import autoTable from 'jspdf-autotable';
|
||||||
|
import { OFAtiva } from '@/hooks/useOFsAtivas';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { smartAudio } from '@/utils/smartAudio';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface SmartConsultaRelatoriosFlowProps {
|
||||||
|
obra: OFAtiva;
|
||||||
|
onVoltarHub: () => void;
|
||||||
|
abrirDiretoTurno?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PecaStatus {
|
||||||
|
id: string;
|
||||||
|
marca: string;
|
||||||
|
descricao: string;
|
||||||
|
etapa_fase: string;
|
||||||
|
quantidade: number;
|
||||||
|
peso_unitario: number;
|
||||||
|
corte: number;
|
||||||
|
montagem: number;
|
||||||
|
solda: number;
|
||||||
|
pintura: number;
|
||||||
|
expedido: number;
|
||||||
|
montado_obra: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmartConsultaRelatoriosFlow: React.FC<SmartConsultaRelatoriosFlowProps> = ({
|
||||||
|
obra,
|
||||||
|
onVoltarHub,
|
||||||
|
abrirDiretoTurno = false,
|
||||||
|
}) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
// Aba ativa: 'consulta' | 'relatorios'
|
||||||
|
const [abaAtiva, setAbaAtiva] = useState<'consulta' | 'relatorios'>(
|
||||||
|
abrirDiretoTurno ? 'relatorios' : 'consulta'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Estados da Consulta
|
||||||
|
const [busca, setBusca] = useState('');
|
||||||
|
const [filtroStatus, setFiltroStatus] = useState<'todos' | 'fabricando' | 'pronto' | 'embarcado' | 'montado'>('todos');
|
||||||
|
const [pecasStatus, setPecasStatus] = useState<PecaStatus[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Estados de Relatórios
|
||||||
|
const [gerandoPDF, setGerandoPDF] = useState(false);
|
||||||
|
|
||||||
|
// Carregar dados de peças e progresso
|
||||||
|
const carregarDadosCompletos = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const [resPecas, resApontamentos, resExpedidos, resMontados] = await Promise.all([
|
||||||
|
supabase
|
||||||
|
.from('pecas')
|
||||||
|
.select('id, marca, descricao, etapa_fase, quantidade, peso_unitario')
|
||||||
|
.eq('of_number', obra.of_number),
|
||||||
|
supabase
|
||||||
|
.from('apontamentos_producao')
|
||||||
|
.select(`
|
||||||
|
peca_id,
|
||||||
|
quantidade_produzida,
|
||||||
|
processos_fabricacao (
|
||||||
|
nome
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('of_number', obra.of_number),
|
||||||
|
supabase
|
||||||
|
.from('itens_romaneio_pecas')
|
||||||
|
.select(`
|
||||||
|
marca,
|
||||||
|
quantidade_expedida,
|
||||||
|
romaneios_expedicao!inner(of_number)
|
||||||
|
`)
|
||||||
|
.eq('romaneios_expedicao.of_number', obra.of_number),
|
||||||
|
supabase
|
||||||
|
.from('apontamentos_peca_obra')
|
||||||
|
.select(`
|
||||||
|
marca_peca,
|
||||||
|
quantidade,
|
||||||
|
diario_obra_rdo!inner(of_number)
|
||||||
|
`)
|
||||||
|
.eq('diario_obra_rdo.of_number', obra.of_number),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mapear apontamentos por peça e nome de processo
|
||||||
|
const mapaProc = new Map<string, { corte: number; montagem: number; solda: number; pintura: number }>();
|
||||||
|
(resApontamentos.data || []).forEach((ap: any) => {
|
||||||
|
const pecaId = ap.peca_id;
|
||||||
|
const nomeProc = (ap.processos_fabricacao?.nome || '').toLowerCase();
|
||||||
|
const qtd = Number(ap.quantidade_produzida || 0);
|
||||||
|
|
||||||
|
const atual = mapaProc.get(pecaId) || { corte: 0, montagem: 0, solda: 0, pintura: 0 };
|
||||||
|
if (nomeProc.includes('corte')) atual.corte += qtd;
|
||||||
|
else if (nomeProc.includes('montag')) atual.montagem += qtd;
|
||||||
|
else if (nomeProc.includes('solda')) atual.solda += qtd;
|
||||||
|
else if (nomeProc.includes('pintura')) atual.pintura += qtd;
|
||||||
|
|
||||||
|
mapaProc.set(pecaId, atual);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mapear expedição por marca
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mapear montagem por marca
|
||||||
|
const mapaMont = new Map<string, number>();
|
||||||
|
(resMontados.data || []).forEach((item: any) => {
|
||||||
|
const marca = item.marca_peca;
|
||||||
|
mapaMont.set(marca, (mapaMont.get(marca) || 0) + Number(item.quantidade || 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Montar PecaStatus
|
||||||
|
const lista: PecaStatus[] = (resPecas.data || []).map((p) => {
|
||||||
|
const procs = mapaProc.get(p.id) || { corte: 0, montagem: 0, solda: 0, pintura: 0 };
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
marca: p.marca,
|
||||||
|
descricao: p.descricao,
|
||||||
|
etapa_fase: p.etapa_fase,
|
||||||
|
quantidade: p.quantidade,
|
||||||
|
peso_unitario: p.peso_unitario,
|
||||||
|
corte: procs.corte,
|
||||||
|
montagem: procs.montagem,
|
||||||
|
solda: procs.solda,
|
||||||
|
pintura: procs.pintura,
|
||||||
|
expedido: mapaExp.get(p.marca) || 0,
|
||||||
|
montado_obra: mapaMont.get(p.marca) || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setPecasStatus(lista);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erro ao carregar dados de status:', e);
|
||||||
|
toast.error('Erro ao consultar status das peças');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [obra.of_number]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
carregarDadosCompletos();
|
||||||
|
}, [carregarDadosCompletos]);
|
||||||
|
|
||||||
|
// Filtragem da lista
|
||||||
|
const pecasFiltradas = useMemo(() => {
|
||||||
|
return pecasStatus.filter((p) => {
|
||||||
|
// Filtro texto
|
||||||
|
if (busca.trim()) {
|
||||||
|
const t = busca.toLowerCase();
|
||||||
|
const match = p.marca.toLowerCase().includes(t) || p.descricao?.toLowerCase().includes(t);
|
||||||
|
if (!match) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtro status
|
||||||
|
if (filtroStatus === 'todos') return true;
|
||||||
|
if (filtroStatus === 'montado') return p.montado_obra >= p.quantidade;
|
||||||
|
if (filtroStatus === 'embarcado') return p.expedido >= p.quantidade;
|
||||||
|
if (filtroStatus === 'pronto') return p.pintura >= p.quantidade || p.solda >= p.quantidade;
|
||||||
|
if (filtroStatus === 'fabricando') return p.corte > 0 && p.pintura < p.quantidade;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [pecasStatus, busca, filtroStatus]);
|
||||||
|
|
||||||
|
// Status visual de cada etapa
|
||||||
|
const getBadgeEtapa = (concluido: number, total: number) => {
|
||||||
|
if (concluido >= total && total > 0) {
|
||||||
|
return { icon: <CheckCircle2 className="h-4 w-4 text-emerald-400" />, cor: 'text-emerald-400', label: 'OK' };
|
||||||
|
}
|
||||||
|
if (concluido > 0) {
|
||||||
|
return { icon: <Clock className="h-4 w-4 text-amber-400" />, cor: 'text-amber-400', label: `${concluido}/${total}` };
|
||||||
|
}
|
||||||
|
return { icon: <Circle className="h-4 w-4 text-slate-600" />, cor: 'text-slate-500', label: '0' };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// GERAÇÃO DE RELATÓRIOS PDF (jsPDF)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 1. Relatório do Turno de Hoje do Operador
|
||||||
|
const gerarRelatorioTurnoPDF = async (acao: 'download' | 'whatsapp' | 'email') => {
|
||||||
|
try {
|
||||||
|
setGerandoPDF(true);
|
||||||
|
const hoje = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Buscar apontamentos feitos hoje pelo operador autenticado
|
||||||
|
const { data: meusApontamentos, error } = await supabase
|
||||||
|
.from('apontamentos_producao')
|
||||||
|
.select(`
|
||||||
|
quantidade_produzida,
|
||||||
|
data_apontamento,
|
||||||
|
created_at,
|
||||||
|
pecas (
|
||||||
|
marca,
|
||||||
|
descricao,
|
||||||
|
peso_unitario,
|
||||||
|
etapa_fase
|
||||||
|
),
|
||||||
|
processos_fabricacao (
|
||||||
|
nome
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('of_number', obra.of_number)
|
||||||
|
.eq('data_apontamento', hoje)
|
||||||
|
.eq('created_by', user?.id || '');
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const doc = new jsPDF();
|
||||||
|
const titulo = `RELATÓRIO DO TURNO - MODO SMART`;
|
||||||
|
const dataFormatada = new Date().toLocaleDateString('pt-BR');
|
||||||
|
const horaFormatada = new Date().toLocaleTimeString('pt-BR');
|
||||||
|
|
||||||
|
// Cabeçalho
|
||||||
|
doc.setFillColor(15, 23, 42); // Slate-900
|
||||||
|
doc.rect(0, 0, 210, 30, 'F');
|
||||||
|
doc.setTextColor(251, 191, 36); // Amber-400
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(titulo, 14, 15);
|
||||||
|
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(255, 255, 255);
|
||||||
|
doc.text(`OF: ${obra.of_number} | Cliente: ${obra.cliente || 'Industrial'}`, 14, 23);
|
||||||
|
doc.text(`Data: ${dataFormatada} às ${horaFormatada}`, 145, 23);
|
||||||
|
|
||||||
|
let totalPecas = 0;
|
||||||
|
let totalKg = 0;
|
||||||
|
|
||||||
|
const tableRows = (meusApontamentos || []).map((ap: any) => {
|
||||||
|
const marca = ap.pecas?.marca || 'N/A';
|
||||||
|
const fase = ap.pecas?.etapa_fase || 'Geral';
|
||||||
|
const processo = ap.processos_fabricacao?.nome || 'Produção';
|
||||||
|
const qtd = Number(ap.quantidade_produzida || 0);
|
||||||
|
const pesoUnit = Number(ap.pecas?.peso_unitario || 0);
|
||||||
|
const pesoTotal = qtd * pesoUnit;
|
||||||
|
|
||||||
|
totalPecas += qtd;
|
||||||
|
totalKg += pesoTotal;
|
||||||
|
|
||||||
|
return [
|
||||||
|
marca,
|
||||||
|
fase,
|
||||||
|
processo,
|
||||||
|
qtd.toString(),
|
||||||
|
`${pesoUnit.toFixed(1)} kg`,
|
||||||
|
`${pesoTotal.toFixed(1)} kg`,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tabela de Apontamentos
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: 38,
|
||||||
|
head: [['Marca', 'Fase', 'Processo', 'Qtd', 'Peso Unit.', 'Peso Total']],
|
||||||
|
body: tableRows,
|
||||||
|
theme: 'striped',
|
||||||
|
headStyles: { fillColor: [30, 41, 59], textColor: [251, 191, 36], fontStyle: 'bold' },
|
||||||
|
styles: { fontSize: 9 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rodapé Resumo
|
||||||
|
const finalY = (doc as any).lastAutoTable?.finalY || 80;
|
||||||
|
doc.setFillColor(241, 245, 249);
|
||||||
|
doc.rect(14, finalY + 5, 182, 20, 'F');
|
||||||
|
doc.setTextColor(15, 23, 42);
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(`RESUMO DO TURNO:`, 20, finalY + 14);
|
||||||
|
doc.text(`Total de Peças: ${totalPecas} un | Peso Total: ${totalKg.toFixed(1)} kg`, 20, finalY + 20);
|
||||||
|
|
||||||
|
// Tratamento da Ação
|
||||||
|
const nomeArquivo = `Turno_${obra.of_number}_${hoje}.pdf`;
|
||||||
|
|
||||||
|
if (acao === 'download') {
|
||||||
|
doc.save(nomeArquivo);
|
||||||
|
smartAudio.playSuccess();
|
||||||
|
toast.success('Relatório em PDF baixado com sucesso!');
|
||||||
|
} else if (acao === 'whatsapp') {
|
||||||
|
smartAudio.playClick();
|
||||||
|
const textoMsg = encodeURIComponent(
|
||||||
|
`*TRACKSTEEL - RELATÓRIO DO TURNO*\n` +
|
||||||
|
`*Obra:* OF ${obra.of_number} - ${obra.cliente || ''}\n` +
|
||||||
|
`*Data:* ${dataFormatada}\n` +
|
||||||
|
`*Operador:* ${user?.email || 'Chão de Fábrica'}\n` +
|
||||||
|
`*Produção:* ${totalPecas} peças apontadas (${totalKg.toFixed(1)} kg)\n\n` +
|
||||||
|
`_Registrado com sucesso via Modo Smart TrackSteel._`
|
||||||
|
);
|
||||||
|
window.open(`https://wa.me/?text=${textoMsg}`, '_blank');
|
||||||
|
} else if (acao === 'email') {
|
||||||
|
smartAudio.playClick();
|
||||||
|
const assunto = encodeURIComponent(`Relatório de Turno - OF ${obra.of_number}`);
|
||||||
|
const corpo = encodeURIComponent(
|
||||||
|
`Segue o resumo do turno operacional:\n\n` +
|
||||||
|
`Obra: OF ${obra.of_number}\n` +
|
||||||
|
`Data: ${dataFormatada}\n` +
|
||||||
|
`Total de Peças Apontadas: ${totalPecas} un\n` +
|
||||||
|
`Peso Total: ${totalKg.toFixed(1)} kg\n`
|
||||||
|
);
|
||||||
|
window.open(`mailto:?subject=${assunto}&body=${corpo}`, '_blank');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
smartAudio.playAlert();
|
||||||
|
console.error('Erro ao gerar relatório:', e);
|
||||||
|
toast.error('Erro ao gerar relatório do turno: ' + (e?.message || ''));
|
||||||
|
} finally {
|
||||||
|
setGerandoPDF(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Relatório Geral da Obra
|
||||||
|
const gerarRelatorioGeralObraPDF = () => {
|
||||||
|
try {
|
||||||
|
setGerandoPDF(true);
|
||||||
|
const doc = new jsPDF();
|
||||||
|
const dataFormatada = new Date().toLocaleDateString('pt-BR');
|
||||||
|
|
||||||
|
doc.setFillColor(15, 23, 42);
|
||||||
|
doc.rect(0, 0, 210, 30, 'F');
|
||||||
|
doc.setTextColor(251, 191, 36);
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(`RELATÓRIO GERAL DE FABRICAÇÃO`, 14, 15);
|
||||||
|
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(255, 255, 255);
|
||||||
|
doc.text(`OF: ${obra.of_number} | Cliente: ${obra.cliente || 'Industrial'} | Emissão: ${dataFormatada}`, 14, 23);
|
||||||
|
|
||||||
|
const tableRows = pecasStatus.map((p) => [
|
||||||
|
p.marca,
|
||||||
|
p.etapa_fase || 'Geral',
|
||||||
|
p.quantidade.toString(),
|
||||||
|
p.corte.toString(),
|
||||||
|
p.montagem.toString(),
|
||||||
|
p.solda.toString(),
|
||||||
|
p.pintura.toString(),
|
||||||
|
p.expedido.toString(),
|
||||||
|
p.montado_obra.toString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: 38,
|
||||||
|
head: [['Marca', 'Fase', 'Total', 'Corte', 'Mont.', 'Solda', 'Pint.', 'Exped.', 'Obra']],
|
||||||
|
body: tableRows,
|
||||||
|
theme: 'grid',
|
||||||
|
headStyles: { fillColor: [30, 41, 59], textColor: [251, 191, 36], fontStyle: 'bold' },
|
||||||
|
styles: { fontSize: 8 },
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.save(`Progresso_Geral_OF_${obra.of_number}.pdf`);
|
||||||
|
smartAudio.playSuccess();
|
||||||
|
toast.success('Relatório Geral da Obra gerado!');
|
||||||
|
} catch (e: any) {
|
||||||
|
smartAudio.playAlert();
|
||||||
|
toast.error('Erro ao gerar relatório: ' + e?.message);
|
||||||
|
} finally {
|
||||||
|
setGerandoPDF(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||||
|
{/* Navegação entre Abas: Consulta Rápida vs Relatórios */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-4 bg-slate-800 p-1.5 rounded-2xl border border-slate-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setAbaAtiva('consulta');
|
||||||
|
}}
|
||||||
|
className={`h-11 rounded-xl font-bold text-xs sm:text-sm flex items-center justify-center gap-1.5 transition-all ${
|
||||||
|
abaAtiva === 'consulta'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-md font-black'
|
||||||
|
: 'text-slate-300 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
<span>Consultar Peças</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setAbaAtiva('relatorios');
|
||||||
|
}}
|
||||||
|
className={`h-11 rounded-xl font-bold text-xs sm:text-sm flex items-center justify-center gap-1.5 transition-all ${
|
||||||
|
abaAtiva === 'relatorios'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-md font-black'
|
||||||
|
: 'text-slate-300 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
<span>Relatórios & PDF</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─────────────────────────────────────────────────────────────
|
||||||
|
ABA 1: CONSULTA RÁPIDA DE PEÇAS COM TIMELINE
|
||||||
|
───────────────────────────────────────────────────────────── */}
|
||||||
|
{abaAtiva === 'consulta' && (
|
||||||
|
<div className="flex flex-col flex-1 overflow-hidden">
|
||||||
|
{/* Campo de Busca Gigante Touch */}
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
||||||
|
<Search className="h-5 w-5 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={busca}
|
||||||
|
onChange={(e) => setBusca(e.target.value)}
|
||||||
|
placeholder="Digite a marca (ex: V101, P205)..."
|
||||||
|
className="h-14 pl-11 pr-11 bg-slate-800/90 border-slate-700 text-slate-100 text-base font-medium rounded-2xl"
|
||||||
|
/>
|
||||||
|
{busca && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBusca('')}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-slate-400"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pílulas de Filtro Rápido */}
|
||||||
|
<div className="flex items-center gap-1.5 overflow-x-auto pb-2 mb-2 no-scrollbar">
|
||||||
|
{[
|
||||||
|
{ id: 'todos', label: 'Todos' },
|
||||||
|
{ id: 'fabricando', label: 'Fabricando' },
|
||||||
|
{ id: 'pronto', label: 'Pronto Pátio' },
|
||||||
|
{ id: 'embarcado', label: 'Embarcado' },
|
||||||
|
{ id: 'montado', label: 'Montado' },
|
||||||
|
].map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setFiltroStatus(f.id as any);
|
||||||
|
}}
|
||||||
|
className={`h-9 px-3 rounded-xl font-bold text-xs whitespace-nowrap transition-all border ${
|
||||||
|
filtroStatus === f.id
|
||||||
|
? 'bg-amber-500 text-slate-950 border-amber-400'
|
||||||
|
: 'bg-slate-800 text-slate-300 border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista de Peças com Linha do Tempo Visual */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-3 pb-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-12 flex flex-col items-center justify-center text-slate-400 gap-2">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-amber-500 border-t-transparent" />
|
||||||
|
<span>Carregando timeline das peças...</span>
|
||||||
|
</div>
|
||||||
|
) : pecasFiltradas.length === 0 ? (
|
||||||
|
<div className="p-8 text-center bg-slate-800/40 rounded-2xl border border-slate-800">
|
||||||
|
<AlertCircle className="h-10 w-10 text-slate-500 mx-auto mb-2" />
|
||||||
|
<h3 className="text-base font-bold text-slate-200">Nenhuma peça encontrada</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">Verifique o termo de busca ou filtro</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
pecasFiltradas.map((peca) => (
|
||||||
|
<div
|
||||||
|
key={peca.id}
|
||||||
|
className="p-4 rounded-2xl bg-slate-800/95 border border-slate-700 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-xl font-black text-amber-400 tracking-wider">
|
||||||
|
{peca.marca}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-xs px-2 py-0.5 rounded bg-slate-700 text-slate-300 font-semibold">
|
||||||
|
{peca.etapa_fase || 'Estrutural'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-slate-300">
|
||||||
|
Total: <span className="font-black text-white">{peca.quantidade} un</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-slate-400 mt-0.5 truncate">
|
||||||
|
{peca.descricao || 'Peça Estrutural'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Linha do Tempo Visual da Peça */}
|
||||||
|
<div className="grid grid-cols-6 gap-1 mt-3.5 pt-3 border-t border-slate-700/80 text-center">
|
||||||
|
{[
|
||||||
|
{ nome: 'Corte', valor: peca.corte },
|
||||||
|
{ nome: 'Mont.', valor: peca.montagem },
|
||||||
|
{ nome: 'Solda', valor: peca.solda },
|
||||||
|
{ nome: 'Pint.', valor: peca.pintura },
|
||||||
|
{ nome: 'Emb.', valor: peca.expedido },
|
||||||
|
{ nome: 'Obra', valor: peca.montado_obra },
|
||||||
|
].map((etapa, idx) => {
|
||||||
|
const badge = getBadgeEtapa(etapa.valor, peca.quantidade);
|
||||||
|
return (
|
||||||
|
<div key={idx} className="flex flex-col items-center">
|
||||||
|
<div className="text-[10px] font-bold text-slate-400 mb-1">
|
||||||
|
{etapa.nome}
|
||||||
|
</div>
|
||||||
|
<div className="p-1 rounded-lg bg-slate-900 border border-slate-700/60 mb-1">
|
||||||
|
{badge.icon}
|
||||||
|
</div>
|
||||||
|
<span className={`text-[10px] font-black ${badge.cor}`}>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─────────────────────────────────────────────────────────────
|
||||||
|
ABA 2: EMISSÃO DE RELATÓRIOS EM PDF & COMPARTILHAMENTO
|
||||||
|
───────────────────────────────────────────────────────────── */}
|
||||||
|
{abaAtiva === 'relatorios' && (
|
||||||
|
<div className="flex flex-col flex-1 overflow-y-auto space-y-4 pb-4">
|
||||||
|
<div className="text-center mb-1">
|
||||||
|
<h3 className="text-base font-black text-slate-100 uppercase">
|
||||||
|
Selecione o Relatório Desejado
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Gere documentos oficiais para download ou envio imediato
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CARD 1: RELATÓRIO DO MEU TURNO */}
|
||||||
|
<div className="p-4 rounded-2xl bg-gradient-to-br from-amber-500/15 via-slate-800 to-slate-800 border border-amber-500/30 shadow-md">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-black uppercase text-amber-400 tracking-wider">
|
||||||
|
Opção 1
|
||||||
|
</span>
|
||||||
|
<h4 className="text-base font-black text-slate-100">
|
||||||
|
Relatório do Meu Turno (Hoje)
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-300 mt-0.5">
|
||||||
|
Lista todas as peças apontadas por você no turno de hoje
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2.5 rounded-xl bg-amber-500/20 text-amber-400">
|
||||||
|
<FileText className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2 mt-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={gerandoPDF}
|
||||||
|
onClick={() => gerarRelatorioTurnoPDF('download')}
|
||||||
|
className="h-12 bg-amber-500 hover:bg-amber-400 text-slate-950 font-black text-xs rounded-xl gap-1.5 active:scale-95"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
<span>Baixar PDF</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={gerandoPDF}
|
||||||
|
onClick={() => gerarRelatorioTurnoPDF('whatsapp')}
|
||||||
|
className="h-12 bg-emerald-600 hover:bg-emerald-500 text-white font-black text-xs rounded-xl gap-1.5 active:scale-95"
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-4 w-4" />
|
||||||
|
<span>WhatsApp</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={gerandoPDF}
|
||||||
|
onClick={() => gerarRelatorioTurnoPDF('email')}
|
||||||
|
className="h-12 bg-slate-700 hover:bg-slate-650 text-white font-black text-xs rounded-xl gap-1.5 active:scale-95"
|
||||||
|
>
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
<span>E-mail</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CARD 2: RELATÓRIO GERAL DA OBRA */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-800 border border-slate-700 shadow-md">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-black uppercase text-blue-400 tracking-wider">
|
||||||
|
Opção 2
|
||||||
|
</span>
|
||||||
|
<h4 className="text-base font-black text-slate-100">
|
||||||
|
Relatório de Produção Geral da Obra
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-300 mt-0.5">
|
||||||
|
Quadro completo de todas as marcas com avanço por etapa
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2.5 rounded-xl bg-blue-500/20 text-blue-400">
|
||||||
|
<FileText className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={gerandoPDF}
|
||||||
|
onClick={gerarRelatorioGeralObraPDF}
|
||||||
|
className="w-full h-12 bg-blue-600 hover:bg-blue-500 text-white font-black text-xs rounded-xl gap-2 active:scale-95"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
<span>Gerar e Baixar PDF Geral</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Botão de Voltar ao Hub */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
onVoltarHub();
|
||||||
|
}}
|
||||||
|
className="w-full h-14 bg-slate-800 border-slate-700 text-slate-200 text-sm font-bold rounded-2xl mt-2 active:scale-98"
|
||||||
|
>
|
||||||
|
⬅️ Voltar ao Menu Principal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,723 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Truck,
|
||||||
|
Plus,
|
||||||
|
ListChecks,
|
||||||
|
ChevronRight,
|
||||||
|
Check,
|
||||||
|
Package,
|
||||||
|
User,
|
||||||
|
FileCheck,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { OFAtiva } from '@/hooks/useOFsAtivas';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { smartAudio } from '@/utils/smartAudio';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface SmartEmbarqueFlowProps {
|
||||||
|
obra: OFAtiva;
|
||||||
|
onVoltarHub: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RomaneioAberto {
|
||||||
|
id: string;
|
||||||
|
numero_romaneio: string;
|
||||||
|
nome_motorista?: string;
|
||||||
|
tipo_transporte?: string;
|
||||||
|
peso_total_romaneio?: number;
|
||||||
|
status?: string;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PecaPronta {
|
||||||
|
id: string;
|
||||||
|
marca: string;
|
||||||
|
descricao: string;
|
||||||
|
etapa_fase: string;
|
||||||
|
quantidade: number;
|
||||||
|
peso_unitario: number;
|
||||||
|
quantidadeJaExpedida: number;
|
||||||
|
saldoDisponivel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmartEmbarqueFlow: React.FC<SmartEmbarqueFlowProps> = ({ obra, onVoltarHub }) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
// Estados: 'menu' | 'novo_romaneio' | 'selecionar_romaneio' | 'carregando_pecas'
|
||||||
|
const [subTela, setSubTela] = useState<'menu' | 'novo_romaneio' | 'selecionar_romaneio' | 'carregando_pecas'>('menu');
|
||||||
|
|
||||||
|
// Romaneio ativo para carregamento
|
||||||
|
const [romaneioAtivo, setRomaneioAtivo] = useState<RomaneioAberto | null>(null);
|
||||||
|
|
||||||
|
// Lista de romaneios da OF
|
||||||
|
const [romaneios, setRomaneios] = useState<RomaneioAberto[]>([]);
|
||||||
|
const [loadingRomaneios, setLoadingRomaneios] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Formulário de Novo Romaneio Rápido
|
||||||
|
const [nomeMotorista, setNomeMotorista] = useState('');
|
||||||
|
const [tipoTransporte, setTipoTransporte] = useState<'caminhao_pequeno' | 'caminhao_trucado' | 'caminhao_munck' | 'carreta_12m'>('caminhao_trucado');
|
||||||
|
const [freteTipo, setFreteTipo] = useState<'proprio' | 'terceiros'>('proprio');
|
||||||
|
const [criandoRomaneio, setCriandoRomaneio] = useState(false);
|
||||||
|
|
||||||
|
// Lista de peças prontas da OF e seus saldos de embarque
|
||||||
|
const [pecasProntas, setPecasProntas] = useState<PecaPronta[]>([]);
|
||||||
|
const [loadingPecas, setLoadingPecas] = useState(false);
|
||||||
|
|
||||||
|
// Peça selecionada para adicionar ao caminhão
|
||||||
|
const [pecaEmbarque, setPecaEmbarque] = useState<PecaPronta | null>(null);
|
||||||
|
const [qtdEmbarcar, setQtdEmbarcar] = useState<number>(1);
|
||||||
|
const [salvandoItem, setSalvandoItem] = useState(false);
|
||||||
|
|
||||||
|
// Carregar Romaneios Abertos da OF
|
||||||
|
const carregarRomaneios = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoadingRomaneios(true);
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('romaneios_expedicao')
|
||||||
|
.select('*')
|
||||||
|
.eq('of_number', obra.of_number)
|
||||||
|
.neq('status', 'Entregue')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setRomaneios(data || []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erro ao buscar romaneios:', e);
|
||||||
|
} finally {
|
||||||
|
setLoadingRomaneios(false);
|
||||||
|
}
|
||||||
|
}, [obra.of_number]);
|
||||||
|
|
||||||
|
// Carregar Peças com Saldo para Embarque
|
||||||
|
const carregarPecasParaEmbarque = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoadingPecas(true);
|
||||||
|
|
||||||
|
const [resPecas, resItensExpedidos] = await Promise.all([
|
||||||
|
supabase
|
||||||
|
.from('pecas')
|
||||||
|
.select('id, marca, descricao, etapa_fase, quantidade, peso_unitario')
|
||||||
|
.eq('of_number', obra.of_number),
|
||||||
|
supabase
|
||||||
|
.from('itens_romaneio_pecas')
|
||||||
|
.select('peca_id, quantidade_expedida'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mapaExpedidas = new Map<string, number>();
|
||||||
|
if (resItensExpedidos.data) {
|
||||||
|
resItensExpedidos.data.forEach((item) => {
|
||||||
|
const atual = mapaExpedidas.get(item.peca_id) || 0;
|
||||||
|
mapaExpedidas.set(item.peca_id, atual + Number(item.quantidade_expedida || 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resPecas.data) {
|
||||||
|
const formatadas: PecaPronta[] = resPecas.data.map((p) => {
|
||||||
|
const jaExp = mapaExpedidas.get(p.id) || 0;
|
||||||
|
const saldo = Math.max(0, p.quantidade - jaExp);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
quantidadeJaExpedida: jaExp,
|
||||||
|
saldoDisponivel: saldo,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPecasProntas(formatadas);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erro ao carregar peças para embarque:', e);
|
||||||
|
} finally {
|
||||||
|
setLoadingPecas(false);
|
||||||
|
}
|
||||||
|
}, [obra.of_number]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
carregarRomaneios();
|
||||||
|
carregarPecasParaEmbarque();
|
||||||
|
}, [carregarRomaneios, carregarPecasParaEmbarque]);
|
||||||
|
|
||||||
|
// Criar Novo Romaneio
|
||||||
|
const handleCriarRomaneio = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
setCriandoRomaneio(true);
|
||||||
|
const hoje = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Gerar número de romaneio sequencial RO-XX
|
||||||
|
const seq = (romaneios.length + 1).toString().padStart(2, '0');
|
||||||
|
const numRomaneio = `RO${obra.of_number.replace(/\D/g, '')}-${seq}`;
|
||||||
|
|
||||||
|
const novo = {
|
||||||
|
of_number: obra.of_number,
|
||||||
|
numero_romaneio: numRomaneio,
|
||||||
|
data_romaneio: hoje,
|
||||||
|
data_criacao: hoje,
|
||||||
|
revisao: 0,
|
||||||
|
prioridade: 'Normal' as const,
|
||||||
|
status: 'Em planejamento' as const,
|
||||||
|
tipo_transporte: tipoTransporte,
|
||||||
|
frete_tipo: freteTipo,
|
||||||
|
nome_motorista: nomeMotorista || 'Motorista a definir',
|
||||||
|
peso_total_romaneio: 0,
|
||||||
|
created_by: user.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('romaneios_expedicao')
|
||||||
|
.insert(novo)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
smartAudio.playSuccess();
|
||||||
|
toast.success(`Romaneio ${numRomaneio} criado!`);
|
||||||
|
setRomaneioAtivo(data);
|
||||||
|
setSubTela('carregando_pecas');
|
||||||
|
carregarRomaneios();
|
||||||
|
} catch (e: any) {
|
||||||
|
smartAudio.playAlert();
|
||||||
|
console.error('Erro ao criar romaneio:', e);
|
||||||
|
toast.error('Erro ao criar romaneio: ' + (e?.message || 'Falha de conexão'));
|
||||||
|
} finally {
|
||||||
|
setCriandoRomaneio(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Incluir Peça no Romaneio
|
||||||
|
const handleIncluirPeca = async () => {
|
||||||
|
if (!romaneioAtivo || !pecaEmbarque || qtdEmbarcar <= 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSalvandoItem(true);
|
||||||
|
const pesoTotalItem = qtdEmbarcar * (pecaEmbarque.peso_unitario || 0);
|
||||||
|
|
||||||
|
const novoItem = {
|
||||||
|
romaneio_id: romaneioAtivo.id,
|
||||||
|
peca_id: pecaEmbarque.id,
|
||||||
|
marca: pecaEmbarque.marca,
|
||||||
|
descricao: pecaEmbarque.descricao,
|
||||||
|
fase: pecaEmbarque.etapa_fase || 'Geral',
|
||||||
|
quantidade_expedida: qtdEmbarcar,
|
||||||
|
peso_unitario: pecaEmbarque.peso_unitario || 0,
|
||||||
|
peso_total: pesoTotalItem,
|
||||||
|
quantidade_faltante: Math.max(0, pecaEmbarque.saldoDisponivel - qtdEmbarcar),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { error } = await supabase.from('itens_romaneio_pecas').insert(novoItem);
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Atualizar o peso total no cabeçalho do romaneio
|
||||||
|
const novoPesoRomaneio = (romaneioAtivo.peso_total_romaneio || 0) + pesoTotalItem;
|
||||||
|
await supabase
|
||||||
|
.from('romaneios_expedicao')
|
||||||
|
.update({ peso_total_romaneio: novoPesoRomaneio })
|
||||||
|
.eq('id', romaneioAtivo.id);
|
||||||
|
|
||||||
|
smartAudio.playSuccess();
|
||||||
|
toast.success(`Adicionado: ${qtdEmbarcar}x ${pecaEmbarque.marca} ao caminhão!`);
|
||||||
|
|
||||||
|
// Atualizar estado local
|
||||||
|
setRomaneioAtivo((prev) => (prev ? { ...prev, peso_total_romaneio: novoPesoRomaneio } : null));
|
||||||
|
setPecaEmbarque(null);
|
||||||
|
carregarPecasParaEmbarque();
|
||||||
|
} catch (e: any) {
|
||||||
|
smartAudio.playAlert();
|
||||||
|
console.error('Erro ao adicionar peça ao romaneio:', e);
|
||||||
|
toast.error('Erro ao incluir no caminhão: ' + (e?.message || ''));
|
||||||
|
} finally {
|
||||||
|
setSalvandoItem(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// RENDER: SUBTELA 'MENU'
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
if (subTela === 'menu') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<div className="inline-flex p-3 rounded-2xl bg-blue-500/10 text-blue-400 border border-blue-500/20 mb-2">
|
||||||
|
<Truck className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-black uppercase text-blue-400 tracking-wider block">
|
||||||
|
ETAPA 2 — EXPEDIÇÃO
|
||||||
|
</span>
|
||||||
|
<h2 className="text-xl font-black text-slate-100 uppercase mt-0.5">
|
||||||
|
Apontar Embarque & Romaneio
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Carregue caminhões com as peças prontas no pátio
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3.5 my-auto">
|
||||||
|
{/* Opção 1: Criar Novo Romaneio */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setSubTela('novo_romaneio');
|
||||||
|
}}
|
||||||
|
className="w-full min-h-[90px] p-5 rounded-3xl bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-500 hover:to-blue-600 active:scale-[0.98] text-white flex items-center justify-between text-left shadow-lg shadow-blue-950/40 border-2 border-blue-400/30 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3.5">
|
||||||
|
<div className="p-3 rounded-2xl bg-white/20">
|
||||||
|
<Plus className="h-7 w-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-black tracking-tight">
|
||||||
|
Criar Novo Romaneio Rápido
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-blue-100 font-medium">
|
||||||
|
Informar veículo, motorista e iniciar carga
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-6 w-6 text-blue-200 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Opção 2: Romaneios em Aberto */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setSubTela('selecionar_romaneio');
|
||||||
|
}}
|
||||||
|
className="w-full min-h-[90px] p-5 rounded-3xl bg-slate-800 hover:bg-slate-750 active:scale-[0.98] text-slate-100 border-2 border-slate-700 hover:border-blue-500/60 flex items-center justify-between text-left shadow-md transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3.5">
|
||||||
|
<div className="p-3 rounded-2xl bg-slate-700 text-blue-400">
|
||||||
|
<ListChecks className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-black tracking-tight">
|
||||||
|
Continuar em Romaneio Aberto
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 font-medium">
|
||||||
|
{romaneios.length} romaneio(s) aberto(s) para esta obra
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-6 w-6 text-slate-400 group-hover:text-blue-400 group-hover:translate-x-1 transition-all" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
onVoltarHub();
|
||||||
|
}}
|
||||||
|
className="w-full h-14 bg-slate-800 border-slate-700 text-slate-200 text-sm font-bold rounded-2xl mt-4 active:scale-98"
|
||||||
|
>
|
||||||
|
Voltar ao Menu Principal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// RENDER: SUBTELA 'NOVO_ROMANEIO' (FORMULÁRIO TOUCH RÁPIDO)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
if (subTela === 'novo_romaneio') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||||
|
<div className="mb-4 text-center">
|
||||||
|
<span className="text-xs font-black uppercase text-blue-400 tracking-wider">
|
||||||
|
NOVO CARREGAMENTO
|
||||||
|
</span>
|
||||||
|
<h2 className="text-xl font-black text-slate-100 uppercase mt-0.5">
|
||||||
|
Dados do Veículo & Frete
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-4 pb-4">
|
||||||
|
{/* Nome / Placa do Motorista */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-black uppercase text-slate-300 tracking-wider block mb-1.5">
|
||||||
|
Motorista ou Identificação do Veículo
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
||||||
|
<User className="h-5 w-5 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={nomeMotorista}
|
||||||
|
onChange={(e) => setNomeMotorista(e.target.value)}
|
||||||
|
placeholder="Ex: João Silva / Placa ABC-1234"
|
||||||
|
className="h-14 pl-11 bg-slate-800/90 border-slate-700 text-slate-100 text-base font-medium rounded-2xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pílulas Touch: Tipo de Transporte */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-black uppercase text-slate-300 tracking-wider block mb-1.5">
|
||||||
|
Tipo de Veículo
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{[
|
||||||
|
{ id: 'caminhao_pequeno', label: 'Caminhão 3/4' },
|
||||||
|
{ id: 'caminhao_trucado', label: 'Toco / Trucado' },
|
||||||
|
{ id: 'caminhao_munck', label: 'Munck' },
|
||||||
|
{ id: 'carreta_12m', label: 'Carreta 12m/15m' },
|
||||||
|
].map((tipo) => (
|
||||||
|
<button
|
||||||
|
key={tipo.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setTipoTransporte(tipo.id as any);
|
||||||
|
}}
|
||||||
|
className={`h-14 rounded-2xl font-bold text-xs sm:text-sm transition-all border-2 active:scale-95 flex items-center justify-center text-center p-2 ${
|
||||||
|
tipoTransporte === tipo.id
|
||||||
|
? 'bg-blue-600 text-white border-blue-400 shadow-md'
|
||||||
|
: 'bg-slate-800 text-slate-300 border-slate-700 hover:border-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tipo.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pílulas Touch: Tipo de Frete */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-black uppercase text-slate-300 tracking-wider block mb-1.5">
|
||||||
|
Tipo de Frete
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{[
|
||||||
|
{ id: 'proprio', label: 'Frota Própria' },
|
||||||
|
{ id: 'terceiros', label: 'Terceirizado' },
|
||||||
|
].map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setFreteTipo(f.id as any);
|
||||||
|
}}
|
||||||
|
className={`h-12 rounded-2xl font-bold text-xs sm:text-sm transition-all border-2 active:scale-95 flex items-center justify-center text-center ${
|
||||||
|
freteTipo === f.id
|
||||||
|
? 'bg-blue-600 text-white border-blue-400 shadow-md'
|
||||||
|
: 'bg-slate-800 text-slate-300 border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={criandoRomaneio}
|
||||||
|
onClick={handleCriarRomaneio}
|
||||||
|
className="w-full h-15 bg-blue-600 hover:bg-blue-500 text-white font-black text-base rounded-2xl shadow-lg uppercase tracking-wider active:scale-98"
|
||||||
|
>
|
||||||
|
{criandoRomaneio ? 'Criando...' : 'Criar e Começar a Carregar'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setSubTela('menu');
|
||||||
|
}}
|
||||||
|
className="w-full h-12 bg-slate-800 border-slate-700 text-slate-300 text-xs font-bold rounded-2xl"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// RENDER: SUBTELA 'SELECIONAR_ROMANEIO'
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
if (subTela === 'selecionar_romaneio') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||||
|
<div className="mb-4 text-center">
|
||||||
|
<span className="text-xs font-black uppercase text-blue-400 tracking-wider">
|
||||||
|
CONTINUAR CARGA
|
||||||
|
</span>
|
||||||
|
<h2 className="text-xl font-black text-slate-100 uppercase mt-0.5">
|
||||||
|
Selecione o Romaneio
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-3 pb-4">
|
||||||
|
{loadingRomaneios ? (
|
||||||
|
<div className="py-12 flex flex-col items-center justify-center text-slate-400 gap-2">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
|
||||||
|
<span>Buscando romaneios abertos...</span>
|
||||||
|
</div>
|
||||||
|
) : romaneios.length === 0 ? (
|
||||||
|
<div className="p-8 text-center bg-slate-800/40 rounded-2xl border border-slate-800">
|
||||||
|
<AlertCircle className="h-10 w-10 text-slate-500 mx-auto mb-2" />
|
||||||
|
<h3 className="text-base font-bold text-slate-200">Nenhum romaneio aberto</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Crie um novo romaneio para iniciar a expedição
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
romaneios.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setRomaneioAtivo(r);
|
||||||
|
setSubTela('carregando_pecas');
|
||||||
|
}}
|
||||||
|
className="w-full min-h-[72px] p-4 rounded-2xl bg-slate-800/90 hover:bg-slate-750 active:bg-slate-700 border-2 border-slate-700 hover:border-blue-500/80 flex items-center justify-between text-left transition-all active:scale-[0.98] group shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-12 w-12 rounded-xl bg-blue-500/20 text-blue-400 flex items-center justify-center shrink-0">
|
||||||
|
<Truck className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-black text-blue-400">
|
||||||
|
{r.numero_romaneio}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-200 font-semibold">
|
||||||
|
{r.nome_motorista || 'Motorista padrão'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400">
|
||||||
|
Peso Atual: {(r.peso_total_romaneio || 0).toLocaleString('pt-BR')} kg
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-6 w-6 text-slate-400 group-hover:text-blue-400" />
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setSubTela('menu');
|
||||||
|
}}
|
||||||
|
className="w-full h-14 bg-slate-800 border-slate-700 text-slate-200 text-sm font-bold rounded-2xl mt-2 active:scale-98"
|
||||||
|
>
|
||||||
|
⬅️ Voltar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// RENDER: SUBTELA 'CARREGANDO_PECAS' (CARGA DE PEÇAS NO CAMINHÃO)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
if (subTela === 'carregando_pecas' && romaneioAtivo) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||||
|
{/* Banner do Romaneio Ativo */}
|
||||||
|
<div className="p-3.5 rounded-2xl bg-blue-950/60 border border-blue-700/60 mb-3 shadow-md flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-black uppercase text-blue-400 tracking-wider">
|
||||||
|
CAMINHÃO ATIVO
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold px-2 py-0.5 rounded bg-blue-500/20 text-blue-300">
|
||||||
|
{romaneioAtivo.numero_romaneio}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-300 mt-0.5">
|
||||||
|
Motorista: <span className="font-bold">{romaneioAtivo.nome_motorista}</span> | Peso:{' '}
|
||||||
|
<span className="font-bold text-amber-400">
|
||||||
|
{(romaneioAtivo.peso_total_romaneio || 0).toLocaleString('pt-BR')} kg
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setRomaneioAtivo(null);
|
||||||
|
setSubTela('menu');
|
||||||
|
}}
|
||||||
|
className="text-xs font-bold text-blue-400 underline px-2 py-1 shrink-0"
|
||||||
|
>
|
||||||
|
Trocar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal/Seção de Quantidade para o Item Selecionado */}
|
||||||
|
{pecaEmbarque ? (
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-800 border-2 border-blue-500/80 mb-3 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xl font-black text-amber-400">
|
||||||
|
{pecaEmbarque.marca}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-slate-400">
|
||||||
|
Saldo: {pecaEmbarque.saldoDisponivel} un
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-3 my-3">
|
||||||
|
<div className="h-16 px-6 rounded-2xl bg-slate-900 border border-slate-700 flex items-center justify-center">
|
||||||
|
<span className="text-3xl font-black text-blue-400">{qtdEmbarcar}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setQtdEmbarcar((p) => Math.min(pecaEmbarque.saldoDisponivel, p + 1));
|
||||||
|
}}
|
||||||
|
className="h-12 px-3 rounded-xl bg-slate-700 font-bold text-white active:scale-95"
|
||||||
|
>
|
||||||
|
+1
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setQtdEmbarcar((p) => Math.min(pecaEmbarque.saldoDisponivel, p + 2));
|
||||||
|
}}
|
||||||
|
className="h-12 px-3 rounded-xl bg-slate-700 font-bold text-white active:scale-95"
|
||||||
|
>
|
||||||
|
+2
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setQtdEmbarcar(pecaEmbarque.saldoDisponivel);
|
||||||
|
}}
|
||||||
|
className="h-12 px-2.5 rounded-xl bg-blue-500/20 text-blue-300 font-black text-xs active:scale-95"
|
||||||
|
>
|
||||||
|
TODAS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={salvandoItem}
|
||||||
|
onClick={handleIncluirPeca}
|
||||||
|
className="h-13 bg-blue-600 hover:bg-blue-500 text-white font-black rounded-xl text-xs uppercase"
|
||||||
|
>
|
||||||
|
{salvandoItem ? 'Gravando...' : 'INCLUIR NO CAMINHÃO'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPecaEmbarque(null)}
|
||||||
|
className="h-13 bg-slate-700 text-slate-300 text-xs font-bold rounded-xl"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Lista de Peças Prontas para Embarcar */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-2.5 pb-4">
|
||||||
|
<div className="text-xs font-black uppercase text-slate-400 tracking-wider mb-1">
|
||||||
|
Peças com Saldo para Embarque
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingPecas ? (
|
||||||
|
<div className="py-12 flex flex-col items-center justify-center text-slate-400 gap-2">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
|
||||||
|
<span>Carregando peças prontas...</span>
|
||||||
|
</div>
|
||||||
|
) : pecasProntas.filter((p) => p.saldoDisponivel > 0).length === 0 ? (
|
||||||
|
<div className="p-8 text-center bg-slate-800/40 rounded-2xl border border-slate-800">
|
||||||
|
<Package className="h-10 w-10 text-slate-500 mx-auto mb-2" />
|
||||||
|
<h3 className="text-base font-bold text-slate-200">
|
||||||
|
Nenhuma peça com saldo de embarque
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Todas as peças prontas já foram embarcadas em romaneios anteriores
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
pecasProntas
|
||||||
|
.filter((p) => p.saldoDisponivel > 0)
|
||||||
|
.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="p-3.5 rounded-2xl bg-slate-800/95 border border-slate-700 flex items-center justify-between gap-2 shadow-sm"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg font-black text-amber-400">{p.marca}</span>
|
||||||
|
<span className="text-[11px] px-2 py-0.5 rounded bg-slate-700 text-slate-300 font-semibold">
|
||||||
|
{p.etapa_fase || 'Estrutural'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-300 mt-0.5">
|
||||||
|
Saldo p/ Embarque:{' '}
|
||||||
|
<span className="font-bold text-blue-400">{p.saldoDisponivel} un</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setPecaEmbarque(p);
|
||||||
|
setQtdEmbarcar(1);
|
||||||
|
}}
|
||||||
|
className="h-11 px-3.5 bg-blue-600 hover:bg-blue-500 text-white font-bold rounded-xl active:scale-95 text-xs uppercase shrink-0"
|
||||||
|
>
|
||||||
|
Embarcar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botão Finalizador do Romaneio */}
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playSuccess();
|
||||||
|
toast.success('Carregamento do Romaneio registrado com sucesso!');
|
||||||
|
setSubTela('menu');
|
||||||
|
}}
|
||||||
|
className="w-full h-15 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-black rounded-2xl shadow-lg uppercase active:scale-98 gap-2"
|
||||||
|
>
|
||||||
|
<FileCheck className="h-5 w-5" />
|
||||||
|
<span>FINALIZAR CARREGAMENTO DO ROMANEIO</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setSubTela('menu');
|
||||||
|
}}
|
||||||
|
className="w-full h-12 bg-slate-800 border-slate-700 text-slate-300 text-xs font-bold rounded-2xl"
|
||||||
|
>
|
||||||
|
⬅️ Voltar à Lista
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Building2, Volume2, VolumeX, LogOut, ArrowLeft } from 'lucide-react';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { smartAudio } from '@/utils/smartAudio';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface SmartHeaderProps {
|
||||||
|
titulo?: string;
|
||||||
|
subtitulo?: string;
|
||||||
|
mostrarVoltar?: boolean;
|
||||||
|
onVoltar?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmartHeader: React.FC<SmartHeaderProps> = ({
|
||||||
|
titulo = 'MODO SMART',
|
||||||
|
subtitulo,
|
||||||
|
mostrarVoltar = false,
|
||||||
|
onVoltar,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, signOut } = useAuth();
|
||||||
|
const [soundOn, setSoundOn] = useState<boolean>(() => smartAudio.isSoundEnabled());
|
||||||
|
|
||||||
|
const handleToggleSound = () => {
|
||||||
|
const novoEstado = smartAudio.toggleSound();
|
||||||
|
setSoundOn(novoEstado);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVoltarAoModoCompleto = () => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
try {
|
||||||
|
localStorage.setItem('tracksteel_app_mode', 'complete');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
navigate('/dashboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
if (window.confirm('Deseja realmente encerrar a sessão?')) {
|
||||||
|
await signOut();
|
||||||
|
navigate('/auth');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 w-full bg-slate-900 border-b border-slate-800 text-white shadow-md">
|
||||||
|
<div className="max-w-4xl mx-auto px-3 py-2.5 flex items-center justify-between gap-2">
|
||||||
|
{/* Lado Esquerdo: Botão Voltar ou Logo/Status */}
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{mostrarVoltar && onVoltar ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
onVoltar();
|
||||||
|
}}
|
||||||
|
className="h-11 px-3 bg-slate-800/80 hover:bg-slate-700 text-slate-100 rounded-xl border border-slate-700 active:scale-95 text-sm font-bold flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5 text-amber-400" />
|
||||||
|
<span>Voltar</span>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-3 w-3 relative">
|
||||||
|
<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-3 w-3 bg-amber-500"></span>
|
||||||
|
</span>
|
||||||
|
<div className="leading-tight">
|
||||||
|
<h1 className="text-base font-black tracking-wider text-amber-400 uppercase">
|
||||||
|
{titulo}
|
||||||
|
</h1>
|
||||||
|
{subtitulo && (
|
||||||
|
<p className="text-[11px] text-slate-300 font-medium truncate max-w-[180px] sm:max-w-xs">
|
||||||
|
{subtitulo}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mostrarVoltar && (
|
||||||
|
<div className="leading-tight truncate">
|
||||||
|
<h1 className="text-sm sm:text-base font-black text-slate-100 uppercase truncate">
|
||||||
|
{titulo}
|
||||||
|
</h1>
|
||||||
|
{subtitulo && (
|
||||||
|
<p className="text-[11px] text-amber-400 font-semibold truncate">
|
||||||
|
{subtitulo}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lado Direito: Ações de Controle e Comutação */}
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{/* Controle de Som / Áudio */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleToggleSound}
|
||||||
|
className={`h-10 w-10 rounded-xl flex items-center justify-center border transition-all active:scale-90 ${
|
||||||
|
soundOn
|
||||||
|
? 'bg-emerald-950/60 border-emerald-600/50 text-emerald-400'
|
||||||
|
: 'bg-slate-800/80 border-slate-700 text-slate-400'
|
||||||
|
}`}
|
||||||
|
title={soundOn ? 'Som ativo (BIP habilitado)' : 'Mudo'}
|
||||||
|
>
|
||||||
|
{soundOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* BOTÃO CRUCIAL DE COMUTAÇÃO: VOLTAR AO MODO COMPLETO */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleVoltarAoModoCompleto}
|
||||||
|
className="h-10 px-3 gap-1.5 bg-blue-600 hover:bg-blue-500 text-white border-blue-500 hover:border-blue-400 font-bold text-xs sm:text-sm rounded-xl shadow-sm active:scale-95 transition-all"
|
||||||
|
title="Sair do Modo Smart e abrir o sistema completo (Dashboard / 3D / Cadastros)"
|
||||||
|
>
|
||||||
|
<Building2 className="h-4 w-4" />
|
||||||
|
<span className="hidden xs:inline">Modo Completo</span>
|
||||||
|
<span className="xs:hidden">Completo</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Botão de Logout */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="h-10 w-10 rounded-xl flex items-center justify-center bg-slate-800 hover:bg-red-900/60 border border-slate-700 hover:border-red-600/50 text-slate-400 hover:text-red-300 transition-colors active:scale-90"
|
||||||
|
title={`Sair do sistema (${user?.email || 'Operador'})`}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Hammer,
|
||||||
|
Truck,
|
||||||
|
PackageCheck,
|
||||||
|
Search,
|
||||||
|
ArrowLeftRight,
|
||||||
|
FileText,
|
||||||
|
TrendingUp,
|
||||||
|
Sparkles
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { OFAtiva } from '@/hooks/useOFsAtivas';
|
||||||
|
import { smartAudio } from '@/utils/smartAudio';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface SmartHubProps {
|
||||||
|
obra: OFAtiva;
|
||||||
|
onTrocarObra: () => void;
|
||||||
|
onNavigate: (destino: 'producao' | 'embarque' | 'montagem' | 'consulta') => void;
|
||||||
|
producaoHoje: { totalPecas: number; totalKg: number };
|
||||||
|
onGerarRelatorioTurno: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmartHub: React.FC<SmartHubProps> = ({
|
||||||
|
obra,
|
||||||
|
onTrocarObra,
|
||||||
|
onNavigate,
|
||||||
|
producaoHoje,
|
||||||
|
onGerarRelatorioTurno,
|
||||||
|
}) => {
|
||||||
|
const handleAcao = (destino: 'producao' | 'embarque' | 'montagem' | 'consulta') => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
onNavigate(destino);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||||
|
{/* Barra de Contexto da Obra */}
|
||||||
|
<div className="mb-4 p-3.5 rounded-2xl bg-slate-800/80 border border-slate-700/80 flex items-center justify-between gap-2 shadow-sm">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-[11px] font-black uppercase text-amber-400 tracking-wider">
|
||||||
|
OBRA ATIVA
|
||||||
|
</span>
|
||||||
|
<div className="text-base font-black text-slate-100 truncate">
|
||||||
|
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-600 bg-slate-700/60 hover:bg-slate-650 text-slate-200 hover:text-white rounded-xl text-xs font-bold shrink-0 active:scale-95 gap-1.5"
|
||||||
|
>
|
||||||
|
<ArrowLeftRight className="h-4 w-4 text-amber-400" />
|
||||||
|
<span>Trocar</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grade dos 4 Botões Principais Gigantes */}
|
||||||
|
<div className="grid grid-cols-1 xs:grid-cols-2 gap-3.5 my-auto py-2">
|
||||||
|
{/* BOTÃO 1: PRODUÇÃO */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAcao('producao')}
|
||||||
|
className="min-h-[110px] sm:min-h-[125px] p-4 rounded-3xl bg-gradient-to-br from-amber-600 to-amber-700 hover:from-amber-500 hover:to-amber-600 active:from-amber-700 active:to-amber-800 text-white shadow-lg shadow-amber-950/40 border-2 border-amber-400/40 flex flex-col justify-between text-left transition-all active:scale-[0.97] group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="p-2.5 rounded-2xl bg-white/15 text-white backdrop-blur-sm">
|
||||||
|
<Hammer className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-black uppercase tracking-wider px-2 py-0.5 rounded-full bg-black/25 text-amber-100">
|
||||||
|
Passo 1
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg sm:text-xl font-black tracking-tight leading-tight">
|
||||||
|
1. PRODUÇÃO
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-amber-100 font-medium opacity-90 mt-0.5">
|
||||||
|
Corte, Dobra, Solda, Pintura...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* BOTÃO 2: EMBARQUE (ROMANEIO) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAcao('embarque')}
|
||||||
|
className="min-h-[110px] sm:min-h-[125px] p-4 rounded-3xl bg-gradient-to-br from-blue-600 to-blue-700 hover:from-blue-500 hover:to-blue-600 active:from-blue-700 active:to-blue-800 text-white shadow-lg shadow-blue-950/40 border-2 border-blue-400/40 flex flex-col justify-between text-left transition-all active:scale-[0.97] group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="p-2.5 rounded-2xl bg-white/15 text-white backdrop-blur-sm">
|
||||||
|
<Truck className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-black uppercase tracking-wider px-2 py-0.5 rounded-full bg-black/25 text-blue-100">
|
||||||
|
Passo 2
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg sm:text-xl font-black tracking-tight leading-tight">
|
||||||
|
2. EMBARQUE
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-blue-100 font-medium opacity-90 mt-0.5">
|
||||||
|
Romaneios e saída da fábrica
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* BOTÃO 3: MONTAGEM (OBRA) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAcao('montagem')}
|
||||||
|
className="min-h-[110px] sm:min-h-[125px] p-4 rounded-3xl bg-gradient-to-br from-emerald-600 to-emerald-700 hover:from-emerald-500 hover:to-emerald-600 active:from-emerald-700 active:to-emerald-800 text-white shadow-lg shadow-emerald-950/40 border-2 border-emerald-400/40 flex flex-col justify-between text-left transition-all active:scale-[0.97] group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="p-2.5 rounded-2xl bg-white/15 text-white backdrop-blur-sm">
|
||||||
|
<PackageCheck className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-black uppercase tracking-wider px-2 py-0.5 rounded-full bg-black/25 text-emerald-100">
|
||||||
|
Passo 3
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg sm:text-xl font-black tracking-tight leading-tight">
|
||||||
|
3. MONTAGEM
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-emerald-100 font-medium opacity-90 mt-0.5">
|
||||||
|
Conferência e avanço na obra
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* BOTÃO 4: CONSULTAR PEÇAS & RELATÓRIOS */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAcao('consulta')}
|
||||||
|
className="min-h-[110px] sm:min-h-[125px] p-4 rounded-3xl bg-gradient-to-br from-purple-600 to-purple-700 hover:from-purple-500 hover:to-purple-600 active:from-purple-700 active:to-purple-800 text-white shadow-lg shadow-purple-950/40 border-2 border-purple-400/40 flex flex-col justify-between text-left transition-all active:scale-[0.97] group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="p-2.5 rounded-2xl bg-white/15 text-white backdrop-blur-sm">
|
||||||
|
<Search className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-black uppercase tracking-wider px-2 py-0.5 rounded-full bg-black/25 text-purple-100">
|
||||||
|
Info
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg sm:text-xl font-black tracking-tight leading-tight">
|
||||||
|
4. PEÇAS & PDF
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-purple-100 font-medium opacity-90 mt-0.5">
|
||||||
|
Status da peça e relatórios
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Informativo de Produção do Operador Hoje */}
|
||||||
|
<div className="mt-4 p-4 rounded-2xl bg-slate-800/90 border border-slate-700/80 flex flex-col gap-3 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-slate-300">
|
||||||
|
<TrendingUp className="h-5 w-5 text-amber-400" />
|
||||||
|
<span className="text-xs font-black uppercase tracking-wider">
|
||||||
|
Minha Produção Hoje
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] font-bold px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
||||||
|
<Sparkles className="h-3 w-3" />
|
||||||
|
Turno Ativo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 bg-slate-900/60 p-3 rounded-xl border border-slate-800">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl sm:text-3xl font-black text-amber-400 leading-tight">
|
||||||
|
{producaoHoje.totalPecas}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 font-medium">Peças Apontadas</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-l border-slate-800 pl-3">
|
||||||
|
<div className="text-2xl sm:text-3xl font-black text-slate-100 leading-tight">
|
||||||
|
{producaoHoje.totalKg.toLocaleString('pt-BR', { maximumFractionDigits: 1 })}{' '}
|
||||||
|
<span className="text-sm text-slate-400">kg</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 font-medium">Peso Total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
onGerarRelatorioTurno();
|
||||||
|
}}
|
||||||
|
className="w-full h-12 bg-slate-700 hover:bg-slate-650 active:bg-slate-600 text-slate-100 font-bold rounded-xl gap-2 border border-slate-600 text-sm active:scale-98"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 text-amber-400" />
|
||||||
|
<span>Gerar Relatório do Meu Turno (PDF)</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
PackageCheck,
|
||||||
|
Building2,
|
||||||
|
Check,
|
||||||
|
RotateCcw,
|
||||||
|
Layers,
|
||||||
|
ChevronRight,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { OFAtiva } from '@/hooks/useOFsAtivas';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { smartAudio } from '@/utils/smartAudio';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface SmartMontagemFlowProps {
|
||||||
|
obra: OFAtiva;
|
||||||
|
onVoltarHub: () => void;
|
||||||
|
onApontamentoMontagem?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemMontagem {
|
||||||
|
marca: string;
|
||||||
|
descricao?: string;
|
||||||
|
peso_unitario?: number;
|
||||||
|
quantidade_expedida: number;
|
||||||
|
quantidade_ja_apontada: number;
|
||||||
|
saldo_disponivel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmartMontagemFlow: React.FC<SmartMontagemFlowProps> = ({
|
||||||
|
obra,
|
||||||
|
onVoltarHub,
|
||||||
|
onApontamentoMontagem,
|
||||||
|
}) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [itens, setItens] = useState<ItemMontagem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Peça selecionada para apontamento
|
||||||
|
const [itemSelecionado, setItemSelecionado] = useState<ItemMontagem | null>(null);
|
||||||
|
const [qtdApontar, setQtdApontar] = useState<number>(1);
|
||||||
|
const [salvando, setSalvando] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Carregar saldo de peças expedidas vs já montadas
|
||||||
|
const carregarDadosMontagem = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// 1. Buscar peças expedidas em romaneios desta OF
|
||||||
|
const { data: pecasExpedidas, error: errorExp } = await supabase
|
||||||
|
.from('itens_romaneio_pecas')
|
||||||
|
.select(`
|
||||||
|
marca,
|
||||||
|
descricao,
|
||||||
|
peso_unitario,
|
||||||
|
quantidade_expedida,
|
||||||
|
romaneios_expedicao!inner(
|
||||||
|
of_number
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('romaneios_expedicao.of_number', obra.of_number);
|
||||||
|
|
||||||
|
if (errorExp) throw errorExp;
|
||||||
|
|
||||||
|
const mapaExpedidas = new Map<string, { marca: string; descricao: string; peso_unitario: number; expedido: number }>();
|
||||||
|
(pecasExpedidas || []).forEach((item: any) => {
|
||||||
|
const atual = mapaExpedidas.get(item.marca) || {
|
||||||
|
marca: item.marca,
|
||||||
|
descricao: item.descricao || '',
|
||||||
|
peso_unitario: item.peso_unitario || 0,
|
||||||
|
expedido: 0,
|
||||||
|
};
|
||||||
|
atual.expedido += Number(item.quantidade_expedida || 0);
|
||||||
|
mapaExpedidas.set(item.marca, atual);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Buscar apontamentos já feitos no RDO para esta OF
|
||||||
|
const { data: apontamentosRDO, error: errorRDO } = await supabase
|
||||||
|
.from('apontamentos_peca_obra')
|
||||||
|
.select(`
|
||||||
|
marca_peca,
|
||||||
|
quantidade,
|
||||||
|
diario_obra_rdo!inner(
|
||||||
|
of_number
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('diario_obra_rdo.of_number', obra.of_number);
|
||||||
|
|
||||||
|
if (errorRDO) throw errorRDO;
|
||||||
|
|
||||||
|
const mapaMontadas = new Map<string, number>();
|
||||||
|
(apontamentosRDO || []).forEach((ap: any) => {
|
||||||
|
const atual = mapaMontadas.get(ap.marca_peca) || 0;
|
||||||
|
mapaMontadas.set(ap.marca_peca, atual + Number(ap.quantidade || 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Montar lista de saldos
|
||||||
|
const lista: ItemMontagem[] = Array.from(mapaExpedidas.values()).map((exp) => {
|
||||||
|
const jaMontado = mapaMontadas.get(exp.marca) || 0;
|
||||||
|
const saldo = Math.max(0, exp.expedido - jaMontado);
|
||||||
|
return {
|
||||||
|
marca: exp.marca,
|
||||||
|
descricao: exp.descricao,
|
||||||
|
peso_unitario: exp.peso_unitario,
|
||||||
|
quantidade_expedida: exp.expedido,
|
||||||
|
quantidade_ja_apontada: jaMontado,
|
||||||
|
saldo_disponivel: saldo,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ordenar: primeiro os com saldo > 0
|
||||||
|
lista.sort((a, b) => {
|
||||||
|
if (a.saldo_disponivel > 0 && b.saldo_disponivel === 0) return -1;
|
||||||
|
if (a.saldo_disponivel === 0 && b.saldo_disponivel > 0) return 1;
|
||||||
|
return a.marca.localeCompare(b.marca);
|
||||||
|
});
|
||||||
|
|
||||||
|
setItens(lista);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erro ao carregar dados de montagem:', e);
|
||||||
|
toast.error('Erro ao buscar peças da obra');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [obra.of_number]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
carregarDadosMontagem();
|
||||||
|
}, [carregarDadosMontagem]);
|
||||||
|
|
||||||
|
// Obter ou criar RDO de hoje para a OF
|
||||||
|
const obterOuCriarRDO = async (): Promise<string> => {
|
||||||
|
const hoje = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Buscar se já existe RDO hoje
|
||||||
|
const { data: rdoExistente } = await supabase
|
||||||
|
.from('diario_obra_rdo')
|
||||||
|
.select('id')
|
||||||
|
.eq('of_number', obra.of_number)
|
||||||
|
.eq('data', hoje)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (rdoExistente?.id) {
|
||||||
|
return rdoExistente.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar novo RDO para o dia
|
||||||
|
const { data: novoRDO, error } = await supabase
|
||||||
|
.from('diario_obra_rdo')
|
||||||
|
.insert({
|
||||||
|
of_number: obra.of_number,
|
||||||
|
data: hoje,
|
||||||
|
responsavel_obra: user?.email || 'Operador Smart',
|
||||||
|
status: 'Em andamento',
|
||||||
|
observacoes: 'Criado via Modo Smart',
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return novoRDO.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Confirmar Apontamento de Montagem
|
||||||
|
const handleConfirmarMontagem = async () => {
|
||||||
|
if (!itemSelecionado || qtdApontar <= 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSalvando(true);
|
||||||
|
const rdoId = await obterOuCriarRDO();
|
||||||
|
|
||||||
|
const { error } = await supabase.from('apontamentos_peca_obra').insert({
|
||||||
|
rdo_id: rdoId,
|
||||||
|
marca_peca: itemSelecionado.marca,
|
||||||
|
quantidade: qtdApontar,
|
||||||
|
periodo: 'Normal',
|
||||||
|
status: 'Montado',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
smartAudio.playSuccess();
|
||||||
|
toast.success(`🏗️ Montagem registrada: ${qtdApontar}x ${itemSelecionado.marca}!`);
|
||||||
|
|
||||||
|
if (onApontamentoMontagem) {
|
||||||
|
onApontamentoMontagem();
|
||||||
|
}
|
||||||
|
|
||||||
|
setItemSelecionado(null);
|
||||||
|
carregarDadosMontagem();
|
||||||
|
} catch (e: any) {
|
||||||
|
smartAudio.playAlert();
|
||||||
|
console.error('Erro ao registrar montagem:', e);
|
||||||
|
toast.error('Erro ao registrar montagem: ' + (e?.message || ''));
|
||||||
|
} finally {
|
||||||
|
setSalvando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// RENDER: TECLADO DE APONTAMENTO
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
if (itemSelecionado) {
|
||||||
|
const saldo = itemSelecionado.saldo_disponivel;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||||
|
{/* Resumo da Peça */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-800/90 border border-slate-700 mb-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-2xl font-black text-amber-400">
|
||||||
|
{itemSelecionado.marca}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-black px-2.5 py-1 rounded-lg bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 uppercase">
|
||||||
|
Montagem em Obra
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-slate-200 mt-1">
|
||||||
|
{itemSelecionado.descricao || 'Peça Estrutural'}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400 mt-2 pt-2 border-t border-slate-700/80">
|
||||||
|
<span>Recebidas no Canteiro: {itemSelecionado.quantidade_expedida} un</span>
|
||||||
|
<span className="text-emerald-400 font-bold">
|
||||||
|
Disponíveis p/ Montar: {saldo} un
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display da Quantidade */}
|
||||||
|
<div className="my-auto py-3 text-center">
|
||||||
|
<span className="text-xs font-black uppercase text-slate-400 tracking-wider">
|
||||||
|
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-900 border-2 border-emerald-500 flex items-center justify-center shadow-inner">
|
||||||
|
<span className="text-4xl sm:text-5xl font-black text-emerald-400 tracking-tight">
|
||||||
|
{qtdApontar}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setQtdApontar(1);
|
||||||
|
}}
|
||||||
|
className="h-14 px-4 rounded-2xl bg-slate-800 hover:bg-slate-700 active:bg-slate-650 border border-slate-700 text-slate-300 text-xs font-bold flex items-center gap-1.5 active:scale-95"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
<span>Zerar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Teclado Rápido */}
|
||||||
|
<div className="grid grid-cols-4 gap-2.5 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setQtdApontar((p) => Math.min(saldo, p + 1));
|
||||||
|
}}
|
||||||
|
className="h-16 rounded-2xl bg-slate-800 hover:bg-slate-750 active:bg-slate-700 border-2 border-slate-700 text-white text-xl font-black active:scale-95 shadow-sm"
|
||||||
|
>
|
||||||
|
+1
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setQtdApontar((p) => Math.min(saldo, p + 2));
|
||||||
|
}}
|
||||||
|
className="h-16 rounded-2xl bg-slate-800 hover:bg-slate-750 active:bg-slate-700 border-2 border-slate-700 text-white text-xl font-black active:scale-95 shadow-sm"
|
||||||
|
>
|
||||||
|
+2
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setQtdApontar((p) => Math.min(saldo, p + 5));
|
||||||
|
}}
|
||||||
|
className="h-16 rounded-2xl bg-slate-800 hover:bg-slate-750 active:bg-slate-700 border-2 border-slate-700 text-white text-xl font-black active:scale-95 shadow-sm"
|
||||||
|
>
|
||||||
|
+5
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setQtdApontar(saldo > 0 ? saldo : 1);
|
||||||
|
}}
|
||||||
|
className="h-16 rounded-2xl bg-emerald-500/20 hover:bg-emerald-500/30 border-2 border-emerald-500/60 text-emerald-400 text-sm font-black active:scale-95 flex flex-col items-center justify-center leading-tight"
|
||||||
|
>
|
||||||
|
<span>TODAS</span>
|
||||||
|
<span className="text-[11px] opacity-80">({saldo})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmação */}
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={salvando || qtdApontar <= 0}
|
||||||
|
onClick={handleConfirmarMontagem}
|
||||||
|
className="w-full h-16 bg-emerald-600 hover:bg-emerald-500 text-white text-base font-black rounded-2xl shadow-lg uppercase tracking-wide active:scale-98 gap-2"
|
||||||
|
>
|
||||||
|
{salvando ? (
|
||||||
|
<>
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-3 border-white border-t-transparent" />
|
||||||
|
<span>Registrando...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check className="h-6 w-6" />
|
||||||
|
<span>CONFIRMAR MONTAGEM</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setItemSelecionado(null);
|
||||||
|
}}
|
||||||
|
className="w-full h-13 bg-slate-800 border-slate-700 text-slate-300 text-sm font-bold rounded-2xl active:scale-98"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// RENDER: LISTA DE PEÇAS DISPONÍVEIS NA OBRA
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||||
|
<div className="mb-4 text-center">
|
||||||
|
<span className="text-xs font-black uppercase text-emerald-400 tracking-wider">
|
||||||
|
ETAPA 3 — CANTEIRO DE OBRAS
|
||||||
|
</span>
|
||||||
|
<h2 className="text-xl font-black text-slate-100 uppercase mt-0.5">
|
||||||
|
Apontar Peças Montadas
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Peças entregues no canteiro aguardando fixação/montagem
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-3 pb-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-12 flex flex-col items-center justify-center text-slate-400 gap-2">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-emerald-500 border-t-transparent" />
|
||||||
|
<span>Consultando entregas no canteiro...</span>
|
||||||
|
</div>
|
||||||
|
) : itens.length === 0 ? (
|
||||||
|
<div className="p-8 text-center bg-slate-800/40 rounded-2xl border border-slate-800">
|
||||||
|
<AlertCircle className="h-10 w-10 text-slate-500 mx-auto mb-2" />
|
||||||
|
<h3 className="text-base font-bold text-slate-200">Nenhuma peça recebida na obra</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
É necessário gerar e enviar romaneios de fábrica antes de apontar montagem
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
itens.map((item) => {
|
||||||
|
const concluida = item.saldo_disponivel <= 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.marca}
|
||||||
|
className={`p-4 rounded-2xl border-2 transition-all shadow-sm ${
|
||||||
|
concluida
|
||||||
|
? 'bg-slate-900/60 border-slate-800 opacity-75'
|
||||||
|
: 'bg-slate-800/95 border-slate-700/90 hover:border-emerald-500/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-xl font-black text-amber-400 tracking-wider">
|
||||||
|
{item.marca}
|
||||||
|
</span>
|
||||||
|
<div className="text-xs text-slate-300 mt-0.5 line-clamp-1">
|
||||||
|
{item.descricao || 'Peça Estrutural'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<div className="text-xs font-semibold text-slate-400">
|
||||||
|
Entregues: {item.quantidade_expedida}
|
||||||
|
</div>
|
||||||
|
{concluida ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs font-black text-emerald-400 px-2 py-0.5 rounded-full bg-emerald-950/80 border border-emerald-700">
|
||||||
|
<Check className="h-3 w-3" /> 100% Montada
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs font-black text-emerald-300 px-2 py-0.5 rounded-full bg-emerald-950/80 border border-emerald-700">
|
||||||
|
Saldo: {item.saldo_disponivel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={concluida}
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setItemSelecionado(item);
|
||||||
|
setQtdApontar(item.saldo_disponivel > 0 ? 1 : 1);
|
||||||
|
}}
|
||||||
|
className={`w-full h-13 text-sm font-black rounded-xl uppercase tracking-wider transition-all active:scale-[0.98] ${
|
||||||
|
concluida
|
||||||
|
? 'bg-slate-800 text-slate-500 border border-slate-700'
|
||||||
|
: 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-md shadow-emerald-950/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{concluida ? 'Totalmente Montada' : `APONTAR MONTAGEM (${item.saldo_disponivel} PENDENTES)`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
onVoltarHub();
|
||||||
|
}}
|
||||||
|
className="w-full h-14 bg-slate-800 border-slate-700 text-slate-200 text-sm font-bold rounded-2xl mt-2 active:scale-98"
|
||||||
|
>
|
||||||
|
⬅️ Voltar ao Menu Principal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { Search, HardHat, Building, ChevronRight, X, AlertCircle } from 'lucide-react';
|
||||||
|
import { useOFsAtivas, OFAtiva } from '@/hooks/useOFsAtivas';
|
||||||
|
import { smartAudio } from '@/utils/smartAudio';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
interface SmartObraSelectorProps {
|
||||||
|
onSelectObra: (of: OFAtiva) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmartObraSelector: React.FC<SmartObraSelectorProps> = ({ onSelectObra }) => {
|
||||||
|
const { ofsAtivas, isLoading } = useOFsAtivas();
|
||||||
|
const [busca, setBusca] = useState('');
|
||||||
|
|
||||||
|
const filteredOFs = useMemo(() => {
|
||||||
|
if (!busca.trim()) return ofsAtivas;
|
||||||
|
const term = busca.toLowerCase();
|
||||||
|
return ofsAtivas.filter(
|
||||||
|
(of) =>
|
||||||
|
of.of_number?.toLowerCase().includes(term) ||
|
||||||
|
of.cliente?.toLowerCase().includes(term) ||
|
||||||
|
of.descricao_resumida?.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
}, [ofsAtivas, busca]);
|
||||||
|
|
||||||
|
const handleSelect = (of: OFAtiva) => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
try {
|
||||||
|
localStorage.setItem('tracksteel_smart_last_of', JSON.stringify(of));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
onSelectObra(of);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||||
|
{/* Título de Instrução Touch */}
|
||||||
|
<div className="mb-4 text-center">
|
||||||
|
<div className="inline-flex p-3 rounded-2xl bg-amber-500/10 text-amber-400 border border-amber-500/20 mb-2">
|
||||||
|
<HardHat className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-black text-slate-100 uppercase tracking-wide">
|
||||||
|
Selecione a Obra / OF
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-400 font-medium">
|
||||||
|
Escolha a obra em que você está operando hoje
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Campo de Busca Grande para Touch */}
|
||||||
|
<div className="relative mb-5">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
||||||
|
<Search className="h-5 w-5 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={busca}
|
||||||
|
onChange={(e) => setBusca(e.target.value)}
|
||||||
|
placeholder="Buscar por OF ou Cliente..."
|
||||||
|
className="h-14 pl-11 pr-11 bg-slate-800/90 border-slate-700 text-slate-100 placeholder:text-slate-500 text-base font-medium rounded-2xl focus-visible:ring-amber-500 focus-visible:border-amber-500"
|
||||||
|
/>
|
||||||
|
{busca && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBusca('')}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-slate-400 hover:text-slate-200"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista de Obras em Cards Touch */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-3 pb-8">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-12 flex flex-col items-center justify-center text-slate-400 gap-3">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-amber-500 border-t-transparent" />
|
||||||
|
<span className="text-sm font-semibold">Carregando obras ativas...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredOFs.length === 0 ? (
|
||||||
|
<div className="p-8 text-center bg-slate-800/40 rounded-2xl border border-slate-800">
|
||||||
|
<AlertCircle className="h-10 w-10 text-amber-500/60 mx-auto mb-2" />
|
||||||
|
<h3 className="text-base font-bold text-slate-200">Nenhuma obra encontrada</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
{busca ? 'Tente buscar com outro termo' : 'Nenhuma ordem de fabricação ativa no momento'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredOFs.map((of) => (
|
||||||
|
<button
|
||||||
|
key={of.of_number}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(of)}
|
||||||
|
className="w-full min-h-[72px] p-4 rounded-2xl bg-slate-800/90 hover:bg-slate-750 active:bg-slate-700 border-2 border-slate-700/80 hover:border-amber-500/60 transition-all flex items-center justify-between text-left gap-3 shadow-sm active:scale-[0.98] group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3.5 min-w-0">
|
||||||
|
<div className="h-12 w-12 rounded-xl bg-slate-700/80 flex items-center justify-center text-amber-400 group-hover:bg-amber-500 group-hover:text-slate-950 transition-colors shrink-0">
|
||||||
|
<Building className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-base font-black text-amber-400 tracking-wider">
|
||||||
|
OF {of.of_number}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-slate-100 truncate">
|
||||||
|
{of.cliente || of.descricao_resumida || 'Cliente não especificado'}
|
||||||
|
</div>
|
||||||
|
{of.descricao_resumida && of.cliente && (
|
||||||
|
<div className="text-xs text-slate-400 truncate">
|
||||||
|
{of.descricao_resumida}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-6 w-6 text-slate-500 group-hover:text-amber-400 transition-colors shrink-0" />
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,705 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Scissors,
|
||||||
|
Ruler,
|
||||||
|
Puzzle,
|
||||||
|
Zap,
|
||||||
|
Paintbrush,
|
||||||
|
ShieldAlert,
|
||||||
|
Check,
|
||||||
|
Layers,
|
||||||
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronRight,
|
||||||
|
Filter
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { OFAtiva } from '@/hooks/useOFsAtivas';
|
||||||
|
import { useProcessosFabricacao, ProcessoFabricacao } from '@/hooks/useProcessosFabricacao';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { smartAudio } from '@/utils/smartAudio';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface SmartProducaoFlowProps {
|
||||||
|
obra: OFAtiva;
|
||||||
|
onApontamentoRealizado: (pecasCount: number, pesoKg: number) => void;
|
||||||
|
onVoltarHub: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PecaData {
|
||||||
|
id: string;
|
||||||
|
marca: string;
|
||||||
|
descricao: string;
|
||||||
|
etapa_fase: string;
|
||||||
|
quantidade: number;
|
||||||
|
peso_unitario: number;
|
||||||
|
perfil_principal?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApontamentoItem {
|
||||||
|
peca_id: string;
|
||||||
|
processo_id: string;
|
||||||
|
quantidade_produzida: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
|
||||||
|
obra,
|
||||||
|
onApontamentoRealizado,
|
||||||
|
onVoltarHub,
|
||||||
|
}) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { data: processos = [], isLoading: loadingProcessos } = useProcessosFabricacao();
|
||||||
|
|
||||||
|
// Estados da esteira guiada
|
||||||
|
const [etapaAtual, setEtapaAtual] = useState<'processo' | 'fase' | 'pecas' | 'quantidade'>('processo');
|
||||||
|
const [processoSelecionado, setProcessoSelecionado] = useState<ProcessoFabricacao | null>(null);
|
||||||
|
const [faseSelecionada, setFaseSelecionada] = useState<string>('');
|
||||||
|
const [pecaSelecionada, setPecaSelecionada] = useState<PecaData | null>(null);
|
||||||
|
|
||||||
|
// Quantidade selecionada no teclado rápido
|
||||||
|
const [qtdApontar, setQtdApontar] = useState<number>(1);
|
||||||
|
const [salvando, setSalvando] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Dados carregados da OF
|
||||||
|
const [pecas, setPecas] = useState<PecaData[]>([]);
|
||||||
|
const [apontamentosExistentes, setApontamentosExistentes] = useState<ApontamentoItem[]>([]);
|
||||||
|
const [carregandoDados, setCarregandoDados] = useState<boolean>(true);
|
||||||
|
|
||||||
|
// Carregar peças e apontamentos da OF
|
||||||
|
const carregarDadosOF = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setCarregandoDados(true);
|
||||||
|
|
||||||
|
const [resPecas, resApontamentos] = await Promise.all([
|
||||||
|
supabase
|
||||||
|
.from('pecas')
|
||||||
|
.select('id, marca, descricao, etapa_fase, quantidade, peso_unitario, perfil_principal')
|
||||||
|
.eq('of_number', obra.of_number),
|
||||||
|
supabase
|
||||||
|
.from('apontamentos_producao')
|
||||||
|
.select('peca_id, processo_id, quantidade_produzida')
|
||||||
|
.eq('of_number', obra.of_number),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (resPecas.data) {
|
||||||
|
setPecas(resPecas.data);
|
||||||
|
}
|
||||||
|
if (resApontamentos.data) {
|
||||||
|
setApontamentosExistentes(resApontamentos.data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erro ao carregar dados da OF:', e);
|
||||||
|
toast.error('Erro ao buscar peças da obra');
|
||||||
|
} finally {
|
||||||
|
setCarregandoDados(false);
|
||||||
|
}
|
||||||
|
}, [obra.of_number]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
carregarDadosOF();
|
||||||
|
}, [carregarDadosOF]);
|
||||||
|
|
||||||
|
// Lista de fases únicas da OF
|
||||||
|
const fasesDisponiveis = useMemo(() => {
|
||||||
|
const setFases = new Set<string>();
|
||||||
|
pecas.forEach((p) => {
|
||||||
|
if (p.etapa_fase && p.etapa_fase.trim()) {
|
||||||
|
setFases.add(p.etapa_fase.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const lista = Array.from(setFases).sort();
|
||||||
|
return lista.length > 0 ? lista : ['Geral'];
|
||||||
|
}, [pecas]);
|
||||||
|
|
||||||
|
// Mapa de produção acumulada por peca_id e processo_id
|
||||||
|
const mapaProducao = useMemo(() => {
|
||||||
|
const mapa = new Map<string, number>();
|
||||||
|
apontamentosExistentes.forEach((ap) => {
|
||||||
|
const key = `${ap.peca_id}_${ap.processo_id}`;
|
||||||
|
const atual = mapa.get(key) || 0;
|
||||||
|
mapa.set(key, atual + Number(ap.quantidade_produzida || 0));
|
||||||
|
});
|
||||||
|
return mapa;
|
||||||
|
}, [apontamentosExistentes]);
|
||||||
|
|
||||||
|
// Peças filtradas pela fase selecionada com cálculo do saldo
|
||||||
|
const pecasComSaldo = useMemo(() => {
|
||||||
|
if (!processoSelecionado) return [];
|
||||||
|
|
||||||
|
return pecas
|
||||||
|
.filter((p) => {
|
||||||
|
if (!faseSelecionada || faseSelecionada === 'Geral') return true;
|
||||||
|
return p.etapa_fase?.trim() === faseSelecionada;
|
||||||
|
})
|
||||||
|
.map((p) => {
|
||||||
|
const key = `${p.id}_${processoSelecionado.id}`;
|
||||||
|
const jaProduzido = mapaProducao.get(key) || 0;
|
||||||
|
const saldo = Math.max(0, p.quantidade - jaProduzido);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
jaProduzido,
|
||||||
|
saldo,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Colocar primeiro as que têm saldo pendente
|
||||||
|
if (a.saldo > 0 && b.saldo === 0) return -1;
|
||||||
|
if (a.saldo === 0 && b.saldo > 0) return 1;
|
||||||
|
return a.marca.localeCompare(b.marca);
|
||||||
|
});
|
||||||
|
}, [pecas, faseSelecionada, processoSelecionado, mapaProducao]);
|
||||||
|
|
||||||
|
// Helper de ícone por processo
|
||||||
|
const getIconeProcesso = (nome: string) => {
|
||||||
|
const n = nome.toLowerCase();
|
||||||
|
if (n.includes('corte')) return <Scissors className="h-6 w-6" />;
|
||||||
|
if (n.includes('dobra') || n.includes('fura')) return <Ruler className="h-6 w-6" />;
|
||||||
|
if (n.includes('montag')) return <Puzzle className="h-6 w-6" />;
|
||||||
|
if (n.includes('solda')) return <Zap className="h-6 w-6" />;
|
||||||
|
if (n.includes('pintura')) return <Paintbrush className="h-6 w-6" />;
|
||||||
|
return <ShieldAlert className="h-6 w-6" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── AÇÕES DE TRANSIÇÃO ──
|
||||||
|
|
||||||
|
const handleEscolherProcesso = (proc: ProcessoFabricacao) => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setProcessoSelecionado(proc);
|
||||||
|
setEtapaAtual('fase');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscolherFase = (fase: string) => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setFaseSelecionada(fase);
|
||||||
|
setEtapaAtual('pecas');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIniciarApontamentoPeca = (peca: PecaData, saldo: number) => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setPecaSelecionada(peca);
|
||||||
|
setQtdApontar(saldo > 0 ? (saldo >= 1 ? 1 : saldo) : 1);
|
||||||
|
setEtapaAtual('quantidade');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Salvar apontamento no Supabase
|
||||||
|
const handleConfirmarApontamento = async () => {
|
||||||
|
if (!pecaSelecionada || !processoSelecionado || !user) return;
|
||||||
|
|
||||||
|
if (qtdApontar <= 0) {
|
||||||
|
smartAudio.playAlert();
|
||||||
|
toast.error('Informe uma quantidade maior que zero');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSalvando(true);
|
||||||
|
const hoje = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
of_number: obra.of_number,
|
||||||
|
peca_id: pecaSelecionada.id,
|
||||||
|
tipo_apontamento: 'peca' as const,
|
||||||
|
processo_id: processoSelecionado.id,
|
||||||
|
quantidade_produzida: qtdApontar,
|
||||||
|
data_apontamento: hoje,
|
||||||
|
created_by: user.id,
|
||||||
|
observacoes: 'Apontado via Modo Smart',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { error } = await supabase.from('apontamentos_producao').insert(payload);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BIP sonoro de sucesso e vibração háptica!
|
||||||
|
smartAudio.playSuccess();
|
||||||
|
toast.success(`✅ Apontado: ${qtdApontar}x ${pecaSelecionada.marca} (${processoSelecionado.nome})`);
|
||||||
|
|
||||||
|
// Notificar estatísticas do operador
|
||||||
|
const pesoTotalKg = qtdApontar * (pecaSelecionada.peso_unitario || 0);
|
||||||
|
onApontamentoRealizado(qtdApontar, pesoTotalKg);
|
||||||
|
|
||||||
|
// FAST LOOP / STICKY CONTEXT:
|
||||||
|
// Atualiza o estado local imediatamente e volta para a lista de peças daquela Fase/Processo!
|
||||||
|
setApontamentosExistentes((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
peca_id: pecaSelecionada.id,
|
||||||
|
processo_id: processoSelecionado.id,
|
||||||
|
quantidade_produzida: qtdApontar,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Permanece na mesma fase para apontar a próxima peça sem voltar tudo!
|
||||||
|
setEtapaAtual('pecas');
|
||||||
|
setPecaSelecionada(null);
|
||||||
|
} catch (e: any) {
|
||||||
|
smartAudio.playAlert();
|
||||||
|
console.error('Erro ao salvar apontamento:', e);
|
||||||
|
toast.error('Falha ao registrar apontamento: ' + (e?.message || 'Erro de conexão'));
|
||||||
|
} finally {
|
||||||
|
setSalvando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// RENDER: ETAPA 1.1 — SELEÇÃO DE PROCESSO
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
if (etapaAtual === 'processo') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||||
|
<div className="mb-4 text-center">
|
||||||
|
<span className="text-xs font-black uppercase text-amber-400 tracking-wider">
|
||||||
|
ETAPA 1.1 — FABRICAÇÃO
|
||||||
|
</span>
|
||||||
|
<h2 className="text-xl font-black text-slate-100 uppercase mt-0.5">
|
||||||
|
Qual é o seu Processo?
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Selecione o posto de trabalho em que você está operando
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 flex-1 overflow-y-auto pb-4">
|
||||||
|
{loadingProcessos ? (
|
||||||
|
<div className="col-span-full py-12 flex flex-col items-center justify-center text-slate-400 gap-2">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-amber-500 border-t-transparent" />
|
||||||
|
<span>Carregando etapas...</span>
|
||||||
|
</div>
|
||||||
|
) : processos.length === 0 ? (
|
||||||
|
// Fallback com processos industriais padrões caso a tabela esteja vazia
|
||||||
|
[
|
||||||
|
{ id: '1', nome: 'Corte', ordem: 1 },
|
||||||
|
{ id: '2', nome: 'Dobra / Furação', ordem: 2 },
|
||||||
|
{ id: '3', nome: 'Montagem', ordem: 3 },
|
||||||
|
{ id: '4', nome: 'Solda', ordem: 4 },
|
||||||
|
{ id: '5', nome: 'Pintura', ordem: 5 },
|
||||||
|
{ id: '6', nome: 'Galvanização', ordem: 6 },
|
||||||
|
].map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleEscolherProcesso(p as ProcessoFabricacao)}
|
||||||
|
className="min-h-[72px] p-4 rounded-2xl bg-slate-800/90 hover:bg-slate-750 active:bg-slate-700 border-2 border-slate-700 hover:border-amber-500/80 flex items-center justify-between text-left transition-all active:scale-[0.98] group shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-12 w-12 rounded-xl bg-amber-500/15 text-amber-400 flex items-center justify-center shrink-0">
|
||||||
|
{getIconeProcesso(p.nome)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-black text-slate-100 uppercase tracking-wide">
|
||||||
|
{p.nome}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 font-medium">Toque para selecionar</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-6 w-6 text-slate-500 group-hover:text-amber-400" />
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
processos.map((proc) => (
|
||||||
|
<button
|
||||||
|
key={proc.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleEscolherProcesso(proc)}
|
||||||
|
className="min-h-[72px] p-4 rounded-2xl bg-slate-800/90 hover:bg-slate-750 active:bg-slate-700 border-2 border-slate-700 hover:border-amber-500/80 flex items-center justify-between text-left transition-all active:scale-[0.98] group shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-12 w-12 rounded-xl bg-amber-500/15 text-amber-400 flex items-center justify-center shrink-0">
|
||||||
|
{getIconeProcesso(proc.nome)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-black text-slate-100 uppercase tracking-wide">
|
||||||
|
{proc.nome}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 font-medium">Etapa #{proc.ordem}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-6 w-6 text-slate-500 group-hover:text-amber-400" />
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
onVoltarHub();
|
||||||
|
}}
|
||||||
|
className="w-full h-14 bg-slate-800 border-slate-700 text-slate-200 text-sm font-bold rounded-2xl mt-3 active:scale-98"
|
||||||
|
>
|
||||||
|
Voltar ao Menu Principal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// RENDER: ETAPA 1.2 — SELEÇÃO DE FASE / SUBCONJUNTO
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
if (etapaAtual === 'fase') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||||
|
{/* Breadcrumb de Processo */}
|
||||||
|
<div className="mb-4 flex items-center justify-between p-3 rounded-2xl bg-amber-500/15 border border-amber-500/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-amber-400">{getIconeProcesso(processoSelecionado?.nome || '')}</span>
|
||||||
|
<span className="text-sm font-black text-amber-300 uppercase">
|
||||||
|
PROCESSO: {processoSelecionado?.nome}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setEtapaAtual('processo');
|
||||||
|
}}
|
||||||
|
className="text-xs font-bold text-amber-400 underline hover:text-amber-300 px-2 py-1"
|
||||||
|
>
|
||||||
|
Trocar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 text-center">
|
||||||
|
<span className="text-xs font-black uppercase text-slate-400 tracking-wider">
|
||||||
|
ETAPA 1.2 — SUBCONJUNTO
|
||||||
|
</span>
|
||||||
|
<h2 className="text-xl font-black text-slate-100 uppercase mt-0.5">
|
||||||
|
Qual é a Fase das Peças?
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-2.5 pb-4">
|
||||||
|
{fasesDisponiveis.map((fase) => {
|
||||||
|
const countFase = pecas.filter((p) => (p.etapa_fase || 'Geral').trim() === fase).length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={fase}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleEscolherFase(fase)}
|
||||||
|
className="w-full min-h-[68px] p-4 rounded-2xl bg-slate-800/90 hover:bg-slate-750 active:bg-slate-700 border-2 border-slate-700 hover:border-amber-500/80 flex items-center justify-between text-left transition-all active:scale-[0.98] group shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-11 w-11 rounded-xl bg-slate-700 text-slate-200 flex items-center justify-center shrink-0">
|
||||||
|
<Layers className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-black text-slate-100 uppercase">{fase}</div>
|
||||||
|
<div className="text-xs text-slate-400">{countFase} peças cadastradas</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-6 w-6 text-slate-500 group-hover:text-amber-400" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setEtapaAtual('processo');
|
||||||
|
}}
|
||||||
|
className="w-full h-14 bg-slate-800 border-slate-700 text-slate-200 text-sm font-bold rounded-2xl mt-2 active:scale-98"
|
||||||
|
>
|
||||||
|
⬅️ Voltar aos Processos
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// RENDER: ETAPA 1.3 — LISTA DE PEÇAS (FAST LOOP / STICKY CONTEXT)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
if (etapaAtual === 'pecas') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||||
|
{/* Sticky Context: Breadcrumbs Interativos no Topo */}
|
||||||
|
<div className="mb-3 p-3 rounded-2xl bg-slate-800/95 border border-slate-700 flex items-center justify-between gap-2 shadow-md">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setEtapaAtual('processo');
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-amber-500/20 text-amber-300 font-bold text-xs flex items-center gap-1 border border-amber-500/30 hover:bg-amber-500/30 shrink-0"
|
||||||
|
title="Trocar Processo"
|
||||||
|
>
|
||||||
|
<span>⚡ {processoSelecionado?.nome}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setEtapaAtual('fase');
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-blue-500/20 text-blue-300 font-bold text-xs flex items-center gap-1 border border-blue-500/30 hover:bg-blue-500/30 truncate"
|
||||||
|
title="Trocar Fase"
|
||||||
|
>
|
||||||
|
<span>🏢 {faseSelecionada}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-[11px] font-bold text-slate-400 shrink-0">
|
||||||
|
{pecasComSaldo.length} itens
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista de Peças */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-3 pb-4">
|
||||||
|
{carregandoDados ? (
|
||||||
|
<div className="py-12 flex flex-col items-center justify-center text-slate-400 gap-2">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-amber-500 border-t-transparent" />
|
||||||
|
<span>Carregando peças...</span>
|
||||||
|
</div>
|
||||||
|
) : pecasComSaldo.length === 0 ? (
|
||||||
|
<div className="p-8 text-center bg-slate-800/40 rounded-2xl border border-slate-800">
|
||||||
|
<Filter className="h-10 w-10 text-slate-500 mx-auto mb-2" />
|
||||||
|
<h3 className="text-base font-bold text-slate-200">Nenhuma peça nesta fase</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Selecione outra fase ou processo para apontar
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
pecasComSaldo.map((item) => {
|
||||||
|
const concluida = item.saldo <= 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`p-4 rounded-2xl border-2 transition-all shadow-sm ${
|
||||||
|
concluida
|
||||||
|
? 'bg-slate-900/60 border-slate-800 opacity-75'
|
||||||
|
: 'bg-slate-800/95 border-slate-700/90 hover:border-amber-500/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl font-black text-amber-400 tracking-wider">
|
||||||
|
{item.marca}
|
||||||
|
</span>
|
||||||
|
{item.perfil_principal && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-md bg-slate-700/80 text-slate-300 font-semibold">
|
||||||
|
{item.perfil_principal}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-300 mt-0.5 line-clamp-1">
|
||||||
|
{item.descricao || 'Peça Estrutural'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<div className="text-xs font-semibold text-slate-400">Total: {item.quantidade}</div>
|
||||||
|
{concluida ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs font-black text-emerald-400 px-2 py-0.5 rounded-full bg-emerald-950/80 border border-emerald-700">
|
||||||
|
<Check className="h-3 w-3" /> 100% Pronto
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs font-black text-amber-300 px-2 py-0.5 rounded-full bg-amber-950/80 border border-amber-700">
|
||||||
|
Falta: {item.saldo}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barra de Progresso Visual da Peça */}
|
||||||
|
<div className="w-full bg-slate-700/60 h-2 rounded-full mt-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-300 ${
|
||||||
|
concluida ? 'bg-emerald-500' : 'bg-amber-500'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (item.jaProduzido / item.quantidade) * 100)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botão Gigante de Ação de Apontar */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={concluida}
|
||||||
|
onClick={() => handleIniciarApontamentoPeca(item, item.saldo)}
|
||||||
|
className={`w-full h-13 text-sm sm:text-base font-black rounded-xl uppercase tracking-wider transition-all active:scale-[0.98] ${
|
||||||
|
concluida
|
||||||
|
? 'bg-slate-800 text-slate-500 border border-slate-700'
|
||||||
|
: 'bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 shadow-md shadow-amber-950/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{concluida ? 'Processo Concluído' : `APONTAR PEÇA (${item.saldo} PENDENTES)`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setEtapaAtual('fase');
|
||||||
|
}}
|
||||||
|
className="w-full h-14 bg-slate-800 border-slate-700 text-slate-200 text-sm font-bold rounded-2xl mt-2 active:scale-98"
|
||||||
|
>
|
||||||
|
⬅️ Voltar às Fases
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// RENDER: ETAPA 1.4 — APONTAMENTO DA QUANTIDADE (TECLADO RÁPIDO)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
if (etapaAtual === 'quantidade' && pecaSelecionada && processoSelecionado) {
|
||||||
|
const key = `${pecaSelecionada.id}_${processoSelecionado.id}`;
|
||||||
|
const jaProduzido = mapaProducao.get(key) || 0;
|
||||||
|
const saldoPendente = Math.max(0, pecaSelecionada.quantidade - jaProduzido);
|
||||||
|
|
||||||
|
const somarQtd = (valor: number) => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setQtdApontar((prev) => {
|
||||||
|
const nova = prev + valor;
|
||||||
|
return saldoPendente > 0 ? Math.min(saldoPendente, nova) : nova;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const definirTodas = () => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setQtdApontar(saldoPendente > 0 ? saldoPendente : 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const zerar = () => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setQtdApontar(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 p-4 max-w-xl mx-auto w-full">
|
||||||
|
{/* Resumo da Peça no Topo */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-800/90 border border-slate-700 mb-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-2xl font-black text-amber-400">
|
||||||
|
{pecaSelecionada.marca}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-black px-2.5 py-1 rounded-lg bg-amber-500/20 text-amber-300 border border-amber-500/30 uppercase">
|
||||||
|
{processoSelecionado.nome}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-slate-200 mt-1">
|
||||||
|
{pecaSelecionada.descricao}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400 mt-2 pt-2 border-t border-slate-700/80">
|
||||||
|
<span>Total da Peça: {pecaSelecionada.quantidade} un</span>
|
||||||
|
<span className="text-amber-400 font-bold">
|
||||||
|
Saldo Pendente: {saldoPendente} un
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display da Quantidade Selecionada */}
|
||||||
|
<div className="my-auto py-3 text-center">
|
||||||
|
<span className="text-xs font-black uppercase text-slate-400 tracking-wider">
|
||||||
|
Quantidade a Apontar 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-900 border-2 border-amber-500 flex items-center justify-center shadow-inner">
|
||||||
|
<span className="text-4xl sm:text-5xl font-black text-amber-400 tracking-tight">
|
||||||
|
{qtdApontar}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={zerar}
|
||||||
|
className="h-14 px-4 rounded-2xl bg-slate-800 hover:bg-slate-700 active:bg-slate-650 border border-slate-700 text-slate-300 text-xs font-bold flex items-center gap-1.5 active:scale-95"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
<span>Zerar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Teclado de Ações Rápidas (Botões Gigantes) */}
|
||||||
|
<div className="grid grid-cols-4 gap-2.5 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => somarQtd(1)}
|
||||||
|
className="h-16 rounded-2xl bg-slate-800 hover:bg-slate-750 active:bg-slate-700 border-2 border-slate-700 text-white text-xl font-black active:scale-95 shadow-sm"
|
||||||
|
>
|
||||||
|
+1
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => somarQtd(2)}
|
||||||
|
className="h-16 rounded-2xl bg-slate-800 hover:bg-slate-750 active:bg-slate-700 border-2 border-slate-700 text-white text-xl font-black active:scale-95 shadow-sm"
|
||||||
|
>
|
||||||
|
+2
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => somarQtd(5)}
|
||||||
|
className="h-16 rounded-2xl bg-slate-800 hover:bg-slate-750 active:bg-slate-700 border-2 border-slate-700 text-white text-xl font-black active:scale-95 shadow-sm"
|
||||||
|
>
|
||||||
|
+5
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={definirTodas}
|
||||||
|
className="h-16 rounded-2xl bg-amber-500/20 hover:bg-amber-500/30 active:bg-amber-500/40 border-2 border-amber-500/60 text-amber-400 text-sm font-black active:scale-95 flex flex-col items-center justify-center leading-tight"
|
||||||
|
>
|
||||||
|
<span>TODAS</span>
|
||||||
|
<span className="text-[11px] opacity-80">({saldoPendente})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botão Gigante de Confirmação com Feedback Imediato */}
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={salvando || qtdApontar <= 0}
|
||||||
|
onClick={handleConfirmarApontamento}
|
||||||
|
className="w-full h-16 bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 text-white text-base sm:text-lg font-black rounded-2xl shadow-lg shadow-emerald-950/40 uppercase tracking-wide active:scale-98 gap-2"
|
||||||
|
>
|
||||||
|
{salvando ? (
|
||||||
|
<>
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-3 border-white border-t-transparent" />
|
||||||
|
<span>Registrando...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check className="h-6 w-6" />
|
||||||
|
<span>CONFIRMAR APONTAMENTO</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={salvando}
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
setEtapaAtual('pecas');
|
||||||
|
}}
|
||||||
|
className="w-full h-13 bg-slate-800 border-slate-700 text-slate-300 text-sm font-bold rounded-2xl active:scale-98"
|
||||||
|
>
|
||||||
|
Cancelar e Voltar à Lista
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Utilitário de Feedback Sonoro (Web Audio API) e Tátil para o Modo Smart
|
||||||
|
// Funciona 100% offline, sem carregar arquivos de áudio externos pesados.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class SmartAudioManager {
|
||||||
|
private audioCtx: AudioContext | null = null;
|
||||||
|
private soundEnabled: boolean = true;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Carregar preferência salva
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('tracksteel_smart_sound');
|
||||||
|
if (saved !== null) {
|
||||||
|
this.soundEnabled = saved === 'true';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.soundEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getContext(): AudioContext | null {
|
||||||
|
try {
|
||||||
|
if (!this.audioCtx) {
|
||||||
|
const AudioContextClass = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||||
|
if (AudioContextClass) {
|
||||||
|
this.audioCtx = new AudioContextClass();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.audioCtx && this.audioCtx.state === 'suspended') {
|
||||||
|
this.audioCtx.resume();
|
||||||
|
}
|
||||||
|
return this.audioCtx;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Web Audio API não suportada neste dispositivo', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isSoundEnabled(): boolean {
|
||||||
|
return this.soundEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setSoundEnabled(enabled: boolean): void {
|
||||||
|
this.soundEnabled = enabled;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('tracksteel_smart_sound', String(enabled));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleSound(): boolean {
|
||||||
|
const newState = !this.soundEnabled;
|
||||||
|
this.setSoundEnabled(newState);
|
||||||
|
if (newState) {
|
||||||
|
this.playClick();
|
||||||
|
}
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bip de Sucesso (Apontamento Concluído)
|
||||||
|
* Frequência harmônica dupla suave e ascendente (587Hz -> 880Hz, 120ms)
|
||||||
|
*/
|
||||||
|
public playSuccess(): void {
|
||||||
|
this.vibrate([70, 40, 70]);
|
||||||
|
if (!this.soundEnabled) return;
|
||||||
|
|
||||||
|
const ctx = this.getContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
|
||||||
|
// Inicia em Ré5 (587Hz) e sobe para Lá5 (880Hz)
|
||||||
|
osc.frequency.setValueAtTime(587.33, now);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(880, now + 0.08);
|
||||||
|
|
||||||
|
// Fade out suave para evitar estalo
|
||||||
|
gain.gain.setValueAtTime(0.18, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.13);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.14);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Erro ao tocar som de sucesso:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bip de Alerta ou Erro (Saldo insuficiente, conflito)
|
||||||
|
* Frequência grave de aviso (220Hz, 180ms)
|
||||||
|
*/
|
||||||
|
public playAlert(): void {
|
||||||
|
this.vibrate([120, 80, 120]);
|
||||||
|
if (!this.soundEnabled) return;
|
||||||
|
|
||||||
|
const ctx = this.getContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'triangle';
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
|
||||||
|
osc.frequency.setValueAtTime(220, now);
|
||||||
|
osc.frequency.linearRampToValueAtTime(180, now + 0.18);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.2, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.19);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.2);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Erro ao tocar som de alerta:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clique sonoro rápido para botões touch
|
||||||
|
*/
|
||||||
|
public playClick(): void {
|
||||||
|
this.vibrate(30);
|
||||||
|
if (!this.soundEnabled) return;
|
||||||
|
|
||||||
|
const ctx = this.getContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
|
||||||
|
osc.frequency.setValueAtTime(520, now);
|
||||||
|
gain.gain.setValueAtTime(0.08, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.04);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 0.05);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vibração Háptica para smartphones e tablets
|
||||||
|
*/
|
||||||
|
public vibrate(pattern: number | number[]): void {
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined' && 'vibrate' in navigator) {
|
||||||
|
navigator.vibrate(pattern);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Navegadores sem suporte a vibração
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const smartAudio = new SmartAudioManager();
|
||||||
Reference in New Issue
Block a user