From 1ce4455f378fe2d3b5915d60acab31df4781b312 Mon Sep 17 00:00:00 2001 From: Marcos Reifonas Date: Mon, 21 Sep 2026 22:33:22 +0000 Subject: [PATCH] feat: implementar Modo Smart touch-first com comutador bidirecional sem logout --- docs/PLANO_MODO_SMART.md | 273 +++++++ src/components/AppRoutes.tsx | 7 + src/components/AppSidebar.tsx | 39 +- src/components/Layout.tsx | 30 +- src/config/routeConfig.ts | 9 +- src/pages/ModoSmart.tsx | 182 +++++ .../SmartConsultaRelatoriosFlow.tsx | 658 ++++++++++++++++ .../smart/components/SmartEmbarqueFlow.tsx | 723 ++++++++++++++++++ src/pages/smart/components/SmartHeader.tsx | 141 ++++ src/pages/smart/components/SmartHub.tsx | 206 +++++ .../smart/components/SmartMontagemFlow.tsx | 446 +++++++++++ .../smart/components/SmartObraSelector.tsx | 124 +++ .../smart/components/SmartProducaoFlow.tsx | 705 +++++++++++++++++ src/utils/smartAudio.ts | 177 +++++ 14 files changed, 3716 insertions(+), 4 deletions(-) create mode 100644 docs/PLANO_MODO_SMART.md create mode 100644 src/pages/ModoSmart.tsx create mode 100644 src/pages/smart/components/SmartConsultaRelatoriosFlow.tsx create mode 100644 src/pages/smart/components/SmartEmbarqueFlow.tsx create mode 100644 src/pages/smart/components/SmartHeader.tsx create mode 100644 src/pages/smart/components/SmartHub.tsx create mode 100644 src/pages/smart/components/SmartMontagemFlow.tsx create mode 100644 src/pages/smart/components/SmartObraSelector.tsx create mode 100644 src/pages/smart/components/SmartProducaoFlow.tsx create mode 100644 src/utils/smartAudio.ts diff --git a/docs/PLANO_MODO_SMART.md b/docs/PLANO_MODO_SMART.md new file mode 100644 index 0000000..0d10a48 --- /dev/null +++ b/docs/PLANO_MODO_SMART.md @@ -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. diff --git a/src/components/AppRoutes.tsx b/src/components/AppRoutes.tsx index 75ab708..0014a8a 100644 --- a/src/components/AppRoutes.tsx +++ b/src/components/AppRoutes.tsx @@ -67,6 +67,13 @@ function wrapWithGuard( ); + case 'smart': + return ( + + {element} + + ); + default: return element; } diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 7e74064..db9a32c 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -1,19 +1,21 @@ import React, { useState, useMemo } from "react"; 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 { useSidebar } from "@/components/ui/sidebar"; import { useUserRole } from "@/hooks/useUserRole"; import { useIconStyle } from "@/hooks/useIconStyle"; import { useIsMobile } from "@/hooks/use-mobile"; import { useUserPermissions } from "@/hooks/useUserPermissions"; +import { Zap } from "lucide-react"; import { usePermissionControl } from "@/hooks/usePermissionControl"; import { menuGroups } from "./sidebar/menuConfig"; import { AppSidebarMenuItem } from "./sidebar/SidebarMenuItem"; export function AppSidebar() { const location = useLocation(); + const navigate = useNavigate(); const { setOpenMobile } = useSidebar(); const { isAdmin, loading: roleLoading } = useUserRole(); const { iconStyle } = useIconStyle(); @@ -140,6 +142,41 @@ export function AppSidebar() { return ( + {/* Banner de atalho para Modo Smart */} +
+ +
+ {menuGroups.map(group => { // Filter admin groups for non-admin users if (!isAdmin && group.name === 'Administração') { diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 090be15..071fa99 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,26 +1,52 @@ import React from 'react'; +import { useNavigate } from 'react-router-dom'; import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'; import { AppSidebar } from '@/components/AppSidebar'; import { ApontamentoAutomaticoListener } from '@/components/expedicao/ApontamentoAutomaticoListener'; import { ThemeToggle } from '@/components/ThemeToggle'; +import { Button } from '@/components/ui/button'; +import { Zap } from 'lucide-react'; interface LayoutProps { children: React.ReactNode; } export const Layout = ({ children }: LayoutProps) => { + const navigate = useNavigate(); + + const handleIrParaModoSmart = () => { + try { + localStorage.setItem('tracksteel_app_mode', 'smart'); + } catch { + // ignore + } + navigate('/smart'); + }; + return (
- {/* Header fixo com SidebarTrigger e ThemeToggle */} + {/* Header fixo com SidebarTrigger, Modo Smart e ThemeToggle */}
- +
+ + +
{/* Conteúdo principal */}
diff --git a/src/config/routeConfig.ts b/src/config/routeConfig.ts index 28f0af3..a59c459 100644 --- a/src/config/routeConfig.ts +++ b/src/config/routeConfig.ts @@ -4,7 +4,7 @@ import { ComponentType, lazy } from 'react'; // 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 { path: string; @@ -292,4 +292,11 @@ export const routeConfig: RouteDefinition[] = [ component: () => import('@/pages/PrioridadesFabricacao'), guard: 'protected', }, + + // ── Modo Smart (Chão de Fábrica Touch-First) ────────────── + { + path: '/smart', + component: () => import('@/pages/ModoSmart'), + guard: 'smart', + }, ]; diff --git a/src/pages/ModoSmart.tsx b/src/pages/ModoSmart.tsx new file mode 100644 index 0000000..2b2b154 --- /dev/null +++ b/src/pages/ModoSmart.tsx @@ -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(() => { + 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 = { + 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 ( +
+ {/* Cabeçalho Fixo do Modo Smart */} + setTelaAtiva('hub')} + /> + + {/* Conteúdo Principal Touch-First */} +
+ {!obraAtiva ? ( + + ) : telaAtiva === 'hub' ? ( + setTelaAtiva(destino)} + producaoHoje={producaoHoje} + onGerarRelatorioTurno={() => setTelaAtiva('turno_pdf')} + /> + ) : telaAtiva === 'producao' ? ( + setTelaAtiva('hub')} + /> + ) : telaAtiva === 'embarque' ? ( + setTelaAtiva('hub')} + /> + ) : telaAtiva === 'montagem' ? ( + setTelaAtiva('hub')} + /> + ) : telaAtiva === 'consulta' ? ( + setTelaAtiva('hub')} + /> + ) : telaAtiva === 'turno_pdf' ? ( + setTelaAtiva('hub')} + abrirDiretoTurno={true} + /> + ) : null} +
+
+ ); +}; + +export default ModoSmart; diff --git a/src/pages/smart/components/SmartConsultaRelatoriosFlow.tsx b/src/pages/smart/components/SmartConsultaRelatoriosFlow.tsx new file mode 100644 index 0000000..96abc3c --- /dev/null +++ b/src/pages/smart/components/SmartConsultaRelatoriosFlow.tsx @@ -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 = ({ + 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([]); + 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(); + (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(); + (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(); + (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: , cor: 'text-emerald-400', label: 'OK' }; + } + if (concluido > 0) { + return { icon: , cor: 'text-amber-400', label: `${concluido}/${total}` }; + } + return { icon: , 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 ( +
+ {/* Navegação entre Abas: Consulta Rápida vs Relatórios */} +
+ + + +
+ + {/* ───────────────────────────────────────────────────────────── + ABA 1: CONSULTA RÁPIDA DE PEÇAS COM TIMELINE + ───────────────────────────────────────────────────────────── */} + {abaAtiva === 'consulta' && ( +
+ {/* Campo de Busca Gigante Touch */} +
+
+ +
+ 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 && ( + + )} +
+ + {/* Pílulas de Filtro Rápido */} +
+ {[ + { id: 'todos', label: 'Todos' }, + { id: 'fabricando', label: 'Fabricando' }, + { id: 'pronto', label: 'Pronto Pátio' }, + { id: 'embarcado', label: 'Embarcado' }, + { id: 'montado', label: 'Montado' }, + ].map((f) => ( + + ))} +
+ + {/* Lista de Peças com Linha do Tempo Visual */} +
+ {loading ? ( +
+
+ Carregando timeline das peças... +
+ ) : pecasFiltradas.length === 0 ? ( +
+ +

Nenhuma peça encontrada

+

Verifique o termo de busca ou filtro

+
+ ) : ( + pecasFiltradas.map((peca) => ( +
+
+
+ + {peca.marca} + + + {peca.etapa_fase || 'Estrutural'} + +
+ + Total: {peca.quantidade} un + +
+ +
+ {peca.descricao || 'Peça Estrutural'} +
+ + {/* Linha do Tempo Visual da Peça */} +
+ {[ + { 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 ( +
+
+ {etapa.nome} +
+
+ {badge.icon} +
+ + {badge.label} + +
+ ); + })} +
+
+ )) + )} +
+
+ )} + + {/* ───────────────────────────────────────────────────────────── + ABA 2: EMISSÃO DE RELATÓRIOS EM PDF & COMPARTILHAMENTO + ───────────────────────────────────────────────────────────── */} + {abaAtiva === 'relatorios' && ( +
+
+

+ Selecione o Relatório Desejado +

+

+ Gere documentos oficiais para download ou envio imediato +

+
+ + {/* CARD 1: RELATÓRIO DO MEU TURNO */} +
+
+
+ + Opção 1 + +

+ Relatório do Meu Turno (Hoje) +

+

+ Lista todas as peças apontadas por você no turno de hoje +

+
+
+ +
+
+ +
+ + + + + +
+
+ + {/* CARD 2: RELATÓRIO GERAL DA OBRA */} +
+
+
+ + Opção 2 + +

+ Relatório de Produção Geral da Obra +

+

+ Quadro completo de todas as marcas com avanço por etapa +

+
+
+ +
+
+ + +
+
+ )} + + {/* Botão de Voltar ao Hub */} + +
+ ); +}; diff --git a/src/pages/smart/components/SmartEmbarqueFlow.tsx b/src/pages/smart/components/SmartEmbarqueFlow.tsx new file mode 100644 index 0000000..b951148 --- /dev/null +++ b/src/pages/smart/components/SmartEmbarqueFlow.tsx @@ -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 = ({ 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(null); + + // Lista de romaneios da OF + const [romaneios, setRomaneios] = useState([]); + const [loadingRomaneios, setLoadingRomaneios] = useState(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([]); + const [loadingPecas, setLoadingPecas] = useState(false); + + // Peça selecionada para adicionar ao caminhão + const [pecaEmbarque, setPecaEmbarque] = useState(null); + const [qtdEmbarcar, setQtdEmbarcar] = useState(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(); + 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 ( +
+
+
+ +
+ + ETAPA 2 — EXPEDIÇÃO + +

+ Apontar Embarque & Romaneio +

+

+ Carregue caminhões com as peças prontas no pátio +

+
+ +
+ {/* Opção 1: Criar Novo Romaneio */} + + + {/* Opção 2: Romaneios em Aberto */} + +
+ + +
+ ); + } + + // ───────────────────────────────────────────────────────────── + // RENDER: SUBTELA 'NOVO_ROMANEIO' (FORMULÁRIO TOUCH RÁPIDO) + // ───────────────────────────────────────────────────────────── + if (subTela === 'novo_romaneio') { + return ( +
+
+ + NOVO CARREGAMENTO + +

+ Dados do Veículo & Frete +

+
+ +
+ {/* Nome / Placa do Motorista */} +
+ +
+
+ +
+ 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" + /> +
+
+ + {/* Pílulas Touch: Tipo de Transporte */} +
+ +
+ {[ + { 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) => ( + + ))} +
+
+ + {/* Pílulas Touch: Tipo de Frete */} +
+ +
+ {[ + { id: 'proprio', label: 'Frota Própria' }, + { id: 'terceiros', label: 'Terceirizado' }, + ].map((f) => ( + + ))} +
+
+
+ +
+ + + +
+
+ ); + } + + // ───────────────────────────────────────────────────────────── + // RENDER: SUBTELA 'SELECIONAR_ROMANEIO' + // ───────────────────────────────────────────────────────────── + if (subTela === 'selecionar_romaneio') { + return ( +
+
+ + CONTINUAR CARGA + +

+ Selecione o Romaneio +

+
+ +
+ {loadingRomaneios ? ( +
+
+ Buscando romaneios abertos... +
+ ) : romaneios.length === 0 ? ( +
+ +

Nenhum romaneio aberto

+

+ Crie um novo romaneio para iniciar a expedição +

+
+ ) : ( + romaneios.map((r) => ( + + )) + )} +
+ + +
+ ); + } + + // ───────────────────────────────────────────────────────────── + // RENDER: SUBTELA 'CARREGANDO_PECAS' (CARGA DE PEÇAS NO CAMINHÃO) + // ───────────────────────────────────────────────────────────── + if (subTela === 'carregando_pecas' && romaneioAtivo) { + return ( +
+ {/* Banner do Romaneio Ativo */} +
+
+
+ + CAMINHÃO ATIVO + + + {romaneioAtivo.numero_romaneio} + +
+
+ Motorista: {romaneioAtivo.nome_motorista} | Peso:{' '} + + {(romaneioAtivo.peso_total_romaneio || 0).toLocaleString('pt-BR')} kg + +
+
+ +
+ + {/* Modal/Seção de Quantidade para o Item Selecionado */} + {pecaEmbarque ? ( +
+
+ + {pecaEmbarque.marca} + + + Saldo: {pecaEmbarque.saldoDisponivel} un + +
+ +
+
+ {qtdEmbarcar} +
+
+ + + +
+
+ +
+ + +
+
+ ) : null} + + {/* Lista de Peças Prontas para Embarcar */} +
+
+ Peças com Saldo para Embarque +
+ + {loadingPecas ? ( +
+
+ Carregando peças prontas... +
+ ) : pecasProntas.filter((p) => p.saldoDisponivel > 0).length === 0 ? ( +
+ +

+ Nenhuma peça com saldo de embarque +

+

+ Todas as peças prontas já foram embarcadas em romaneios anteriores +

+
+ ) : ( + pecasProntas + .filter((p) => p.saldoDisponivel > 0) + .map((p) => ( +
+
+
+ {p.marca} + + {p.etapa_fase || 'Estrutural'} + +
+
+ Saldo p/ Embarque:{' '} + {p.saldoDisponivel} un +
+
+ + +
+ )) + )} +
+ + {/* Botão Finalizador do Romaneio */} +
+ + + +
+
+ ); + } + + return null; +}; diff --git a/src/pages/smart/components/SmartHeader.tsx b/src/pages/smart/components/SmartHeader.tsx new file mode 100644 index 0000000..c8d4dfd --- /dev/null +++ b/src/pages/smart/components/SmartHeader.tsx @@ -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 = ({ + titulo = 'MODO SMART', + subtitulo, + mostrarVoltar = false, + onVoltar, +}) => { + const navigate = useNavigate(); + const { user, signOut } = useAuth(); + const [soundOn, setSoundOn] = useState(() => 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 ( +
+
+ {/* Lado Esquerdo: Botão Voltar ou Logo/Status */} +
+ {mostrarVoltar && onVoltar ? ( + + ) : ( +
+ + + + +
+

+ {titulo} +

+ {subtitulo && ( +

+ {subtitulo} +

+ )} +
+
+ )} + + {mostrarVoltar && ( +
+

+ {titulo} +

+ {subtitulo && ( +

+ {subtitulo} +

+ )} +
+ )} +
+ + {/* Lado Direito: Ações de Controle e Comutação */} +
+ {/* Controle de Som / Áudio */} + + + {/* BOTÃO CRUCIAL DE COMUTAÇÃO: VOLTAR AO MODO COMPLETO */} + + + {/* Botão de Logout */} + +
+
+
+ ); +}; diff --git a/src/pages/smart/components/SmartHub.tsx b/src/pages/smart/components/SmartHub.tsx new file mode 100644 index 0000000..e94fd5a --- /dev/null +++ b/src/pages/smart/components/SmartHub.tsx @@ -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 = ({ + obra, + onTrocarObra, + onNavigate, + producaoHoje, + onGerarRelatorioTurno, +}) => { + const handleAcao = (destino: 'producao' | 'embarque' | 'montagem' | 'consulta') => { + smartAudio.playClick(); + onNavigate(destino); + }; + + return ( +
+ {/* Barra de Contexto da Obra */} +
+
+ + OBRA ATIVA + +
+ OF {obra.of_number} — {obra.cliente || 'Obra Industrial'} +
+
+ +
+ + {/* Grade dos 4 Botões Principais Gigantes */} +
+ {/* BOTÃO 1: PRODUÇÃO */} + + + {/* BOTÃO 2: EMBARQUE (ROMANEIO) */} + + + {/* BOTÃO 3: MONTAGEM (OBRA) */} + + + {/* BOTÃO 4: CONSULTAR PEÇAS & RELATÓRIOS */} + +
+ + {/* Card Informativo de Produção do Operador Hoje */} +
+
+
+ + + Minha Produção Hoje + +
+ + + Turno Ativo + +
+ +
+
+
+ {producaoHoje.totalPecas} +
+
Peças Apontadas
+
+
+
+ {producaoHoje.totalKg.toLocaleString('pt-BR', { maximumFractionDigits: 1 })}{' '} + kg +
+
Peso Total
+
+
+ + +
+
+ ); +}; diff --git a/src/pages/smart/components/SmartMontagemFlow.tsx b/src/pages/smart/components/SmartMontagemFlow.tsx new file mode 100644 index 0000000..fe6f141 --- /dev/null +++ b/src/pages/smart/components/SmartMontagemFlow.tsx @@ -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 = ({ + obra, + onVoltarHub, + onApontamentoMontagem, +}) => { + const { user } = useAuth(); + + const [itens, setItens] = useState([]); + const [loading, setLoading] = useState(true); + + // Peça selecionada para apontamento + const [itemSelecionado, setItemSelecionado] = useState(null); + const [qtdApontar, setQtdApontar] = useState(1); + const [salvando, setSalvando] = useState(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(); + (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(); + (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 => { + 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 ( +
+ {/* Resumo da Peça */} +
+
+ + {itemSelecionado.marca} + + + Montagem em Obra + +
+
+ {itemSelecionado.descricao || 'Peça Estrutural'} +
+
+ Recebidas no Canteiro: {itemSelecionado.quantidade_expedida} un + + Disponíveis p/ Montar: {saldo} un + +
+
+ + {/* Display da Quantidade */} +
+ + Quantidade Montada Agora + +
+
+ + {qtdApontar} + +
+ +
+
+ + {/* Teclado Rápido */} +
+ + + + +
+ + {/* Confirmação */} +
+ + + +
+
+ ); + } + + // ───────────────────────────────────────────────────────────── + // RENDER: LISTA DE PEÇAS DISPONÍVEIS NA OBRA + // ───────────────────────────────────────────────────────────── + return ( +
+
+ + ETAPA 3 — CANTEIRO DE OBRAS + +

+ Apontar Peças Montadas +

+

+ Peças entregues no canteiro aguardando fixação/montagem +

+
+ +
+ {loading ? ( +
+
+ Consultando entregas no canteiro... +
+ ) : itens.length === 0 ? ( +
+ +

Nenhuma peça recebida na obra

+

+ É necessário gerar e enviar romaneios de fábrica antes de apontar montagem +

+
+ ) : ( + itens.map((item) => { + const concluida = item.saldo_disponivel <= 0; + return ( +
+
+
+ + {item.marca} + +
+ {item.descricao || 'Peça Estrutural'} +
+
+ +
+
+ Entregues: {item.quantidade_expedida} +
+ {concluida ? ( + + 100% Montada + + ) : ( + + Saldo: {item.saldo_disponivel} + + )} +
+
+ +
+ +
+
+ ); + }) + )} +
+ + +
+ ); +}; diff --git a/src/pages/smart/components/SmartObraSelector.tsx b/src/pages/smart/components/SmartObraSelector.tsx new file mode 100644 index 0000000..cf0b18e --- /dev/null +++ b/src/pages/smart/components/SmartObraSelector.tsx @@ -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 = ({ 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 ( +
+ {/* Título de Instrução Touch */} +
+
+ +
+

+ Selecione a Obra / OF +

+

+ Escolha a obra em que você está operando hoje +

+
+ + {/* Campo de Busca Grande para Touch */} +
+
+ +
+ 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 && ( + + )} +
+ + {/* Lista de Obras em Cards Touch */} +
+ {isLoading ? ( +
+
+ Carregando obras ativas... +
+ ) : filteredOFs.length === 0 ? ( +
+ +

Nenhuma obra encontrada

+

+ {busca ? 'Tente buscar com outro termo' : 'Nenhuma ordem de fabricação ativa no momento'} +

+
+ ) : ( + filteredOFs.map((of) => ( + + )) + )} +
+
+ ); +}; diff --git a/src/pages/smart/components/SmartProducaoFlow.tsx b/src/pages/smart/components/SmartProducaoFlow.tsx new file mode 100644 index 0000000..1f7c46f --- /dev/null +++ b/src/pages/smart/components/SmartProducaoFlow.tsx @@ -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 = ({ + 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(null); + const [faseSelecionada, setFaseSelecionada] = useState(''); + const [pecaSelecionada, setPecaSelecionada] = useState(null); + + // Quantidade selecionada no teclado rápido + const [qtdApontar, setQtdApontar] = useState(1); + const [salvando, setSalvando] = useState(false); + + // Dados carregados da OF + const [pecas, setPecas] = useState([]); + const [apontamentosExistentes, setApontamentosExistentes] = useState([]); + const [carregandoDados, setCarregandoDados] = useState(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(); + 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(); + 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 ; + if (n.includes('dobra') || n.includes('fura')) return ; + if (n.includes('montag')) return ; + if (n.includes('solda')) return ; + if (n.includes('pintura')) return ; + return ; + }; + + // ── 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 ( +
+
+ + ETAPA 1.1 — FABRICAÇÃO + +

+ Qual é o seu Processo? +

+

+ Selecione o posto de trabalho em que você está operando +

+
+ +
+ {loadingProcessos ? ( +
+
+ Carregando etapas... +
+ ) : 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) => ( + + )) + ) : ( + processos.map((proc) => ( + + )) + )} +
+ + +
+ ); + } + + // ───────────────────────────────────────────────────────────── + // RENDER: ETAPA 1.2 — SELEÇÃO DE FASE / SUBCONJUNTO + // ───────────────────────────────────────────────────────────── + if (etapaAtual === 'fase') { + return ( +
+ {/* Breadcrumb de Processo */} +
+
+ {getIconeProcesso(processoSelecionado?.nome || '')} + + PROCESSO: {processoSelecionado?.nome} + +
+ +
+ +
+ + ETAPA 1.2 — SUBCONJUNTO + +

+ Qual é a Fase das Peças? +

+
+ +
+ {fasesDisponiveis.map((fase) => { + const countFase = pecas.filter((p) => (p.etapa_fase || 'Geral').trim() === fase).length; + return ( + + ); + })} +
+ + +
+ ); + } + + // ───────────────────────────────────────────────────────────── + // RENDER: ETAPA 1.3 — LISTA DE PEÇAS (FAST LOOP / STICKY CONTEXT) + // ───────────────────────────────────────────────────────────── + if (etapaAtual === 'pecas') { + return ( +
+ {/* Sticky Context: Breadcrumbs Interativos no Topo */} +
+
+ + + +
+ + + {pecasComSaldo.length} itens + +
+ + {/* Lista de Peças */} +
+ {carregandoDados ? ( +
+
+ Carregando peças... +
+ ) : pecasComSaldo.length === 0 ? ( +
+ +

Nenhuma peça nesta fase

+

+ Selecione outra fase ou processo para apontar +

+
+ ) : ( + pecasComSaldo.map((item) => { + const concluida = item.saldo <= 0; + return ( +
+
+
+
+ + {item.marca} + + {item.perfil_principal && ( + + {item.perfil_principal} + + )} +
+
+ {item.descricao || 'Peça Estrutural'} +
+
+ +
+
Total: {item.quantidade}
+ {concluida ? ( + + 100% Pronto + + ) : ( + + Falta: {item.saldo} + + )} +
+
+ + {/* Barra de Progresso Visual da Peça */} +
+
+
+ + {/* Botão Gigante de Ação de Apontar */} +
+ +
+
+ ); + }) + )} +
+ + +
+ ); + } + + // ───────────────────────────────────────────────────────────── + // 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 ( +
+ {/* Resumo da Peça no Topo */} +
+
+ + {pecaSelecionada.marca} + + + {processoSelecionado.nome} + +
+
+ {pecaSelecionada.descricao} +
+
+ Total da Peça: {pecaSelecionada.quantidade} un + + Saldo Pendente: {saldoPendente} un + +
+
+ + {/* Display da Quantidade Selecionada */} +
+ + Quantidade a Apontar Agora + +
+
+ + {qtdApontar} + +
+ +
+
+ + {/* Teclado de Ações Rápidas (Botões Gigantes) */} +
+ + + + +
+ + {/* Botão Gigante de Confirmação com Feedback Imediato */} +
+ + + +
+
+ ); + } + + return null; +}; diff --git a/src/utils/smartAudio.ts b/src/utils/smartAudio.ts new file mode 100644 index 0000000..7f1db70 --- /dev/null +++ b/src/utils/smartAudio.ts @@ -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();