commit 812ef26e9fd3fec8833e8acdfc3b34fec1d6567a Author: JuruSysadmin Date: Thu Jan 8 09:09:16 2026 -0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..7e1e5c7 --- /dev/null +++ b/App.tsx @@ -0,0 +1,214 @@ + +import React, { useState, useEffect } from 'react'; +import { View, Product, OrderItem } from './types'; +import LoginView from './views/LoginView'; +import HomeMenuView from './views/HomeMenuView'; +import SalesDashboardView from './views/SalesDashboardView'; +import ProductSearchView from './views/ProductSearchView'; +import CheckoutView from './views/CheckoutView'; +import Header from './components/Header'; +import CartDrawer from './components/CartDrawer'; +import ConfirmDialog from './components/ConfirmDialog'; +import { useAuth } from './src/contexts/AuthContext'; +import { useCart } from './src/hooks/useCart'; +import { shoppingService } from './src/services/shopping.service'; + +const App: React.FC = () => { + const { isAuthenticated, isLoading, logout: authLogout, user } = useAuth(); + const { + cart, + isLoading: isCartLoading, + addToCart, + updateQuantity, + removeFromCart, + refreshCart, + clearCart, + } = useCart(); + const [currentView, setCurrentView] = useState(View.LOGIN); + const [isCartOpen, setIsCartOpen] = useState(false); + const [showLogoutConfirm, setShowLogoutConfirm] = useState(false); + + // Redirecionar baseado no estado de autenticação + useEffect(() => { + console.log('Auth state changed:', { isAuthenticated, isLoading, user: !!user }); + if (!isLoading) { + if (isAuthenticated && user) { + console.log('Redirecionando para HOME_MENU'); + setCurrentView(View.HOME_MENU); + } else if (!isAuthenticated) { + console.log('Redirecionando para LOGIN'); + setCurrentView(View.LOGIN); + } + } + }, [isAuthenticated, isLoading, user]); + + // Recarregar carrinho quando navegar para a página de produtos (como no Angular) + useEffect(() => { + if (currentView === View.PRODUCT_SEARCH && isAuthenticated && user) { + console.log('🛒 [APP] Navegando para ProductSearchView, recarregando carrinho...'); + const cartId = shoppingService.getCart(); + if (cartId) { + console.log('🛒 [APP] CartId encontrado, chamando refreshCart:', cartId); + refreshCart(); + } else { + console.log('🛒 [APP] Nenhum cartId encontrado no localStorage'); + } + } + }, [currentView, isAuthenticated, user, refreshCart]); + + const handleLogin = () => { + // Login é gerenciado pelo LoginView através do contexto + if (isAuthenticated) { + setCurrentView(View.HOME_MENU); + } + }; + + const handleLogout = () => { + // Mostrar confirmação antes de fazer logout + setShowLogoutConfirm(true); + }; + + const executeLogout = () => { + console.log("🚪 [APP] Iniciando processo de logout..."); + + // 1. Limpar carrinho usando o hook + clearCart(); + console.log("🚪 [APP] Estado do carrinho limpo"); + + // 2. Limpar dados de autenticação (token, user) do localStorage e contexto + authLogout(); + console.log("🚪 [APP] Dados de autenticação removidos"); + + // 3. Redirecionar para login + setCurrentView(View.LOGIN); + console.log("🚪 [APP] Redirecionado para tela de login"); + setShowLogoutConfirm(false); + }; + + // Handler para novo pedido - limpa todos os dados do pedido atual + const handleNewOrder = () => { + console.log("🆕 [APP] Iniciando novo pedido..."); + + // 1. Limpar carrinho usando o hook + clearCart(); + console.log("🆕 [APP] Estado do carrinho limpo"); + + // 2. Limpar todos os dados do localStorage relacionados ao pedido + shoppingService.clearShoppingData(); + console.log("🆕 [APP] Dados do pedido limpos do localStorage"); + + // 3. Fechar o carrinho se estiver aberto + setIsCartOpen(false); + + // 4. Redirecionar para a página de produtos se não estiver lá + if (currentView !== View.PRODUCT_SEARCH) { + setCurrentView(View.PRODUCT_SEARCH); + } + + console.log("🆕 [APP] Novo pedido iniciado com sucesso"); + }; + + // Handler para adicionar item ao carrinho + const handleAddToCart = async (product: Product | OrderItem) => { + try { + await addToCart(product); + // Abrir o carrinho automaticamente ao adicionar + setIsCartOpen(true); + } catch (error: any) { + console.error('🛒 [APP] Erro ao adicionar item ao carrinho:', error); + // O hook já trata o erro, apenas logamos aqui + } + }; + + const renderView = () => { + const isDashboardOrSearch = currentView === View.SALES_DASHBOARD || currentView === View.PRODUCT_SEARCH || currentView === View.CHECKOUT; + + return ( +
+ {isDashboardOrSearch && user && ( +
acc + i.quantity, 0)} + onCartClick={() => setIsCartOpen(true)} + onLogout={handleLogout} + /> + )} +
+ {(() => { + switch (currentView) { + case View.LOGIN: + return ; + case View.HOME_MENU: + return user ? ( + + ) : null; + case View.SALES_DASHBOARD: + return ; + case View.PRODUCT_SEARCH: + return ; + case View.CHECKOUT: + return ( + setCurrentView(View.PRODUCT_SEARCH)} + onCartUpdate={refreshCart} + onClearCart={clearCart} + /> + ); + default: + return ; + } + })()} +
+ + {/* Carrinho Global */} + setIsCartOpen(false)} + items={cart} + onUpdateQuantity={updateQuantity} + onRemove={removeFromCart} + onCheckout={() => { + setIsCartOpen(false); + setCurrentView(View.CHECKOUT); + }} + onNewOrder={handleNewOrder} + /> +
+ ); + }; + + return ( +
+ {/* Meta tag para mobile app-like experience */} + + {renderView()} + + {/* Confirmação de Logout */} + setShowLogoutConfirm(false)} + confirmText="Sair" + cancelText="Cancelar" + /> +
+ ); +}; + +export default App; diff --git a/MIGRATION_SUMMARY.md b/MIGRATION_SUMMARY.md new file mode 100644 index 0000000..8dd6015 --- /dev/null +++ b/MIGRATION_SUMMARY.md @@ -0,0 +1,156 @@ +# Resumo da Migração: Portal Angular → React + +## ✅ O que foi implementado: + +### 1. Arquivos de Configuração + +#### `.env` e `.env.example` +- Variáveis de ambiente extraídas do portal Angular +- URLs da API (principal e PIX) +- Configurações do Firebase +- Domínio padrão (@jurunense.com.br) + +#### `src/config/env.ts` +- Configuração centralizada de variáveis de ambiente +- Acesso tipado às configurações +- Valores padrão caso variáveis não estejam definidas + +### 2. Sistema de Autenticação + +#### `src/types/auth.ts` +- Tipos TypeScript para autenticação +- Interfaces: `AuthUser`, `User`, `LoginResponse`, `ResultApi`, `AuthContextType` + +#### `src/services/auth.service.ts` +- Serviço completo de autenticação +- Métodos: + - `login()`: Realiza login com processamento de email/senha + - `authenticate()`: Autenticação para autorizações especiais + - `saveToken()` / `getToken()`: Gerenciamento de token + - `saveUser()` / `getUser()`: Gerenciamento de usuário + - `clearAuth()`: Limpa dados de autenticação + - `isAuthenticated()`: Verifica autenticação + - `isManager()`: Verifica se é gerente (via JWT) + - `getAuthHeaders()`: Headers para requisições autenticadas + +#### `src/contexts/AuthContext.tsx` +- Contexto React para estado global de autenticação +- Provider: `AuthProvider` +- Hook: `useAuth()` +- Funcionalidades: + - Estado de autenticação + - Login/Logout + - Acesso a dados do usuário + - Carregamento automático do localStorage + +### 3. Integração com a Aplicação + +#### `index.tsx` +- Envolvido com `AuthProvider` para disponibilizar contexto global + +#### `App.tsx` +- Integrado com `useAuth()` +- Redirecionamento automático baseado em autenticação +- Uso de dados reais do usuário autenticado + +#### `views/LoginView.tsx` +- Formulário de login funcional +- Integração com `useAuth()` +- Validação de campos +- Tratamento de erros +- Loading state +- Toggle de visibilidade de senha + +## 🔄 Fluxo de Autenticação + +1. Usuário preenche email e senha no `LoginView` +2. Email é processado (domínio adicionado, UPPERCASE) +3. Senha é convertida para UPPERCASE +4. Requisição POST para `/auth/login` +5. Resposta contém token JWT e dados do usuário +6. Dados são salvos no localStorage +7. Context atualiza estado global +8. App redireciona para menu principal + +## 📋 Características Implementadas + +### Processamento de Credenciais +- ✅ Email: domínio `@jurunense.com.br` adicionado automaticamente +- ✅ Email e senha convertidos para UPPERCASE +- ✅ Validação mínima de senha (3 caracteres) + +### Armazenamento +- ✅ Token JWT no localStorage +- ✅ Dados do usuário no localStorage +- ✅ Limpeza de carrinho ao fazer login + +### Funcionalidades do Usuário +- ✅ Obter store, seller, supervisor, deliveryTime +- ✅ Verificar se é gerente (via JWT) +- ✅ Headers de autorização para requisições + +## 🔧 Como Usar + +### 1. Configurar Variáveis de Ambiente + +Copie `.env.example` para `.env` e ajuste se necessário: + +```bash +cp .env.example .env +``` + +### 2. Usar o Hook de Autenticação + +```tsx +import { useAuth } from './src/contexts/AuthContext'; + +function MyComponent() { + const { + user, + isAuthenticated, + isLoading, + login, + logout + } = useAuth(); + + // Seu código aqui +} +``` + +### 3. Fazer Requisições Autenticadas + +```tsx +import { authService } from './src/services/auth.service'; + +const headers = authService.getAuthHeaders(); +const response = await fetch(`${API_URL}endpoint`, { + method: 'GET', + headers +}); +``` + +## 📝 Próximos Passos Sugeridos + +1. **Interceptors HTTP**: Criar interceptor para adicionar token automaticamente +2. **Refresh Token**: Implementar renovação automática de token +3. **Rotas Protegidas**: Implementar proteção de rotas baseada em autenticação +4. **Tratamento de Expiração**: Detectar e tratar expiração de token +5. **Testes**: Adicionar testes unitários e de integração + +## ⚠️ Notas Importantes + +- O sistema mantém compatibilidade total com a API do portal Angular +- Token é armazenado no localStorage (considerar httpOnly cookies em produção) +- Todas as credenciais são enviadas em UPPERCASE (padrão do sistema) +- O arquivo `.env` não deve ser versionado + +## 🔗 Compatibilidade + +- ✅ Mesma API do portal Angular +- ✅ Mesmos endpoints de autenticação +- ✅ Mesmo formato de resposta +- ✅ Mesmo processamento de credenciais +- ✅ Mesmo armazenamento (localStorage) + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..c749c7b --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +
+GHBanner +
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/drive/1KAk8a_whjTSknWyYvpY3V6qXNqUgUTHk + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/README_AUTH.md b/README_AUTH.md new file mode 100644 index 0000000..e20ce82 --- /dev/null +++ b/README_AUTH.md @@ -0,0 +1,174 @@ +# Sistema de Autenticação - VendaWeb React + +## 📋 Visão Geral + +Este documento descreve o sistema de autenticação implementado no projeto React, baseado na implementação do portal Angular. + +## 🏗️ Estrutura + +``` +vendaweb_react/ +├── .env # Variáveis de ambiente (não versionado) +├── .env.example # Template de variáveis de ambiente +├── src/ +│ ├── config/ +│ │ └── env.ts # Configuração centralizada de variáveis +│ ├── contexts/ +│ │ └── AuthContext.tsx # Contexto React de autenticação +│ ├── services/ +│ │ └── auth.service.ts # Serviço de autenticação +│ └── types/ +│ └── auth.ts # Tipos TypeScript para autenticação +``` + +## 🔧 Configuração + +### 1. Variáveis de Ambiente + +Crie um arquivo `.env` na raiz do projeto com as seguintes variáveis: + +```env +# API Configuration +VITE_API_URL=http://vendaweb.jurunense.com.br/api/v1/ +VITE_API_URL_PIX=http://10.1.1.205:8078/api/v1/ + +# Default Domain +VITE_DEFAULT_DOMAIN=@jurunense.com.br + +# Firebase Configuration (opcional) +VITE_FIREBASE_API_KEY=your_key +VITE_FIREBASE_AUTH_DOMAIN=your_domain +# ... outras configurações do Firebase +``` + +**Importante:** No Vite, todas as variáveis de ambiente devem começar com `VITE_` para serem expostas ao código do cliente. + +### 2. Instalação + +O projeto já inclui todas as dependências necessárias. Não são necessárias dependências adicionais para autenticação. + +## 🔐 Funcionalidades + +### AuthService + +O serviço de autenticação (`auth.service.ts`) fornece: + +- **login(email, password)**: Realiza login do usuário +- **authenticate(email, password)**: Autentica para autorizações especiais +- **saveToken(token)**: Salva token no localStorage +- **saveUser(user)**: Salva usuário no localStorage +- **getToken()**: Obtém token do localStorage +- **getUser()**: Obtém usuário do localStorage +- **clearAuth()**: Remove dados de autenticação +- **isAuthenticated()**: Verifica se usuário está autenticado +- **isManager()**: Verifica se usuário é gerente (baseado no JWT) +- **getAuthHeaders()**: Retorna headers para requisições autenticadas + +### AuthContext + +O contexto React (`AuthContext.tsx`) fornece: + +- Estado global de autenticação +- Funções de login/logout +- Acesso a dados do usuário +- Hook `useAuth()` para usar em componentes + +### Uso do Hook useAuth + +```tsx +import { useAuth } from './src/contexts/AuthContext'; + +function MyComponent() { + const { + user, + isAuthenticated, + isLoading, + login, + logout + } = useAuth(); + + if (isLoading) return
Carregando...
; + if (!isAuthenticated) return
Faça login
; + + return
Olá, {user?.username}!
; +} +``` + +## 🔄 Fluxo de Autenticação + +1. **Login**: Usuário preenche email e senha +2. **Processamento**: Email e senha são convertidos para UPPERCASE e domínio é adicionado automaticamente +3. **Requisição**: POST para `/auth/login` com credenciais +4. **Resposta**: Recebe token JWT e dados do usuário +5. **Armazenamento**: Token e usuário são salvos no localStorage +6. **Estado**: Context atualiza estado global +7. **Redirecionamento**: Usuário é redirecionado para o menu principal + +## 📝 Características Especiais + +### Processamento de Email + +- Se o usuário digitar `usuario@jurunense.com.br`, o domínio é removido e readicionado +- Email é sempre convertido para UPPERCASE antes do envio +- Domínio padrão: `@jurunense.com.br` + +### Processamento de Senha + +- Senha é sempre convertida para UPPERCASE antes do envio +- Validação mínima: 3 caracteres + +### Token JWT + +- Token é armazenado no localStorage como `token` +- Token é usado no header `Authorization: Basic {token}` +- Token pode ser decodificado para verificar permissões (ex: isManager) + +### Dados do Usuário + +Armazenados no localStorage como `user` (JSON): +```json +{ + "id": 1, + "username": "USUARIO", + "name": "Nome do Usuário", + "store": "Loja Jurunense", + "seller": "Vendedor", + "supervisorId": 123, + "deliveryTime": "24h", + "token": "jwt_token_here" +} +``` + +## 🛡️ Segurança + +- Tokens são armazenados apenas no localStorage (considerar migração para httpOnly cookies em produção) +- Credenciais são sempre enviadas em UPPERCASE (conforme padrão do sistema) +- Validação de formulário no frontend +- Tratamento de erros de autenticação + +## 🔗 Integração com API + +O sistema está configurado para usar a mesma API do portal Angular: + +- **Base URL**: `http://vendaweb.jurunense.com.br/api/v1/` +- **Login Endpoint**: `POST /auth/login` +- **Authenticate Endpoint**: `POST /auth/authenticate` +- **Authorization Header**: `Basic {token}` + +## 📚 Próximos Passos + +1. Implementar refresh token (se necessário) +2. Implementar interceptors para requisições HTTP +3. Adicionar tratamento de expiração de token +4. Implementar roteamento protegido +5. Adicionar testes unitários + +## ⚠️ Notas Importantes + +- O arquivo `.env` não deve ser versionado (já está no .gitignore) +- Use `.env.example` como template +- Em produção, configure as variáveis de ambiente no servidor +- O sistema mantém compatibilidade com a API existente do portal Angular + + + diff --git a/assets/icone.svg b/assets/icone.svg new file mode 100644 index 0000000..f9e0991 --- /dev/null +++ b/assets/icone.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/loading.webp b/assets/loading.webp new file mode 100644 index 0000000..fff5750 Binary files /dev/null and b/assets/loading.webp differ diff --git a/assets/logo2.svg b/assets/logo2.svg new file mode 100644 index 0000000..e56f64c --- /dev/null +++ b/assets/logo2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/no-image-svgrepo-com.svg b/assets/no-image-svgrepo-com.svg new file mode 100644 index 0000000..8f12b17 --- /dev/null +++ b/assets/no-image-svgrepo-com.svg @@ -0,0 +1,2 @@ + +no-image \ No newline at end of file diff --git a/assets/pix-svgrepo-com.svg b/assets/pix-svgrepo-com.svg new file mode 100644 index 0000000..9194530 --- /dev/null +++ b/assets/pix-svgrepo-com.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/components/ArcGauge.tsx b/components/ArcGauge.tsx new file mode 100644 index 0000000..0f1ab5c --- /dev/null +++ b/components/ArcGauge.tsx @@ -0,0 +1,153 @@ +import React from "react"; +import GaugeComponent from "react-gauge-component"; + +interface ArcGaugeProps { + value: number; + max?: number; + colors: Array<{ + from?: number; + to?: number; + color: string; + }>; + label?: string; + title?: string; +} + +const ArcGauge: React.FC = ({ + value, + max = 100, + colors, + label, + title, +}) => { + // Normalizar valor (pode ser maior que max para exibir acima de 100%) + const normalizedValue = Math.max(value, 0); + // Para o gauge, usar o valor normalizado (limitado ao max visualmente) + const gaugeValue = Math.min(normalizedValue, max); + + // Converter faixas de cores para o formato do GaugeComponent + // O GaugeComponent usa subArcs com limites e colorArray + const sortedColors = [...colors].sort((a, b) => { + const aFrom = a.from ?? 0; + const bFrom = b.from ?? 0; + return aFrom - bFrom; + }); + + const colorArray = sortedColors.map((c) => c.color); + + // Criar subArcs baseado nas faixas de cores + // O GaugeComponent espera limites em valores absolutos entre minValue e maxValue + // As cores vêm em porcentagem (0-100), então precisamos converter para o range correto + const subArcs = sortedColors.map((colorRange) => { + const to = colorRange.to ?? 100; // Assume 100% se não especificado + // Converter porcentagem (0-100) para o valor absoluto no range (0-max) + const limit = (to / 100) * max; + return { limit }; + }); + + // Garantir que o último subArc vá até maxValue + if (subArcs.length > 0) { + subArcs[subArcs.length - 1].limit = max; + } + + // Determinar a cor atual baseada no valor + const getCurrentColor = (): string => { + for (const colorRange of sortedColors) { + const from = colorRange.from ?? 0; + const to = colorRange.to ?? max; + if (normalizedValue >= from && normalizedValue <= to) { + return colorRange.color; + } + } + return sortedColors[sortedColors.length - 1]?.color || "#0aac25"; + }; + + const currentColor = getCurrentColor(); + + return ( +
+ {title && ( +

{title}

+ )} +
+ { + // Converter valor absoluto para porcentagem para exibição + const percentage = (value / max) * 100; + return Math.round(percentage).toString(); + }, + style: { + fill: "#0066cc", + fontSize: "10px", + fontWeight: "600", + }, + }, + }, + valueLabel: { + formatTextValue: () => "", + style: { + fontSize: "0px", + fill: "transparent", + }, + }, + }} + arc={{ + colorArray: colorArray, + subArcs: subArcs, + padding: 0.02, + width: 0.3, + }} + pointer={{ + elastic: true, + animationDelay: 0, + }} + minValue={0} + maxValue={max} + /> + {/* Valor centralizado customizado */} +
+ + {normalizedValue.toFixed(2).replace(".", ",")}% + +
+
+ {label && ( + + {label} + + )} +
+ ); +}; + +export default ArcGauge; diff --git a/components/Baldinho.tsx b/components/Baldinho.tsx new file mode 100644 index 0000000..90e2e3e --- /dev/null +++ b/components/Baldinho.tsx @@ -0,0 +1,325 @@ +import React, { useState, useEffect } from "react"; +import { + shippingService, + DeliveryScheduleItem, +} from "../src/services/shipping.service"; + +export interface DeliveryDay { + date: string; + day: string; + cap: number; + sales: number; + capDisp: number; + available: "Sim" | "Não"; +} + +interface BaldinhoProps { + selectedDeliveryDate: string; + onDateChange: (date: string) => void; + deliveryDays?: DeliveryDay[]; // Opcional agora, pois será carregado dinamicamente +} + +const Baldinho: React.FC = ({ + selectedDeliveryDate, + onDateChange, + deliveryDays: deliveryDaysProp, +}) => { + const [deliveryDays, setDeliveryDays] = useState( + deliveryDaysProp || [] + ); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + /** + * Converte um item da API para o formato do componente + */ + const mapApiItemToDeliveryDay = (item: DeliveryScheduleItem): DeliveryDay => { + const date = new Date(item.dateDelivery); + const dayNames = [ + "domingo", + "segunda-feira", + "terça-feira", + "quarta-feira", + "quinta-feira", + "sexta-feira", + "sábado", + ]; + const dayName = dayNames[date.getUTCDay()]; + + // Formatar data como DD/MM/YYYY + const formattedDate = date.toLocaleDateString("pt-BR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + timeZone: "UTC", + }); + + return { + date: formattedDate, + day: dayName, + cap: item.deliverySize || 0, + sales: item.saleWeigth || 0, + capDisp: item.avaliableDelivery || 0, + available: item.delivery === "S" ? "Sim" : "Não", + }; + }; + + /** + * Carrega os dados do agendamento de entrega da API + * Segue o mesmo padrão do Angular: getScheduleDelivery() + */ + useEffect(() => { + const loadDeliverySchedule = async () => { + try { + setLoading(true); + setError(null); + + // Se já tiver dados via props, não carrega da API + if (deliveryDaysProp && deliveryDaysProp.length > 0) { + setDeliveryDays(deliveryDaysProp); + setLoading(false); + return; + } + + const response = await shippingService.getScheduleDelivery(); + + if ( + response && + response.deliveries && + Array.isArray(response.deliveries) + ) { + const mappedDays = response.deliveries.map(mapApiItemToDeliveryDay); + setDeliveryDays(mappedDays); + + // Se não houver data selecionada e houver dias disponíveis, selecionar o primeiro disponível + if (!selectedDeliveryDate && mappedDays.length > 0) { + const firstAvailable = mappedDays.find( + (d) => d.available === "Sim" + ); + if (firstAvailable) { + onDateChange(firstAvailable.date); + } + } + } else { + setDeliveryDays([]); + } + } catch (err: any) { + console.error("Erro ao carregar agendamento de entrega:", err); + setError(err.message || "Erro ao carregar dados de entrega"); + setDeliveryDays([]); + } finally { + setLoading(false); + } + }; + + loadDeliverySchedule(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Carrega apenas uma vez ao montar o componente + + return ( +
+
+ {/* Lateral: Seleção Atual */} +
+
+
+ + + +
+ + Fluxo de Logística + +

+ Agendamento de Entrega: +

+
+ + {selectedDeliveryDate} + +
+
+

+ Confira a grade ao lado. Dias em{" "} + VERMELHO estão + com capacidade esgotada ou sem operação. +

+
+
+ {/* Elementos decorativos */} +
+
+
+ + {/* Tabela de Disponibilidade */} +
+
+
+

+ Capacidade Operacional +

+

+ Selecione uma data disponível para continuar o pedido +

+
+
+
+ {" "} + Bloqueado +
+
+ {" "} + Liberado +
+
+
+ +
+ {loading ? ( +
+
+
+ + Carregando disponibilidade... + +
+
+ ) : error ? ( +
+

{error}

+
+ ) : deliveryDays.length === 0 ? ( +
+

+ Nenhum agendamento disponível +

+
+ ) : ( +
+ + + + + + + + + + + + {deliveryDays.map((d, i) => { + const occupancy = + d.cap > 0 ? (d.sales / d.cap) * 100 : 100; + const isFull = d.available === "Não"; + + return ( + !isFull && onDateChange(d.date)} + className={`transition-all duration-200 group cursor-pointer ${ + isFull + ? "bg-red-600 text-white hover:bg-red-700" + : selectedDeliveryDate === d.date + ? "bg-orange-50 text-orange-900 border-l-4 border-l-orange-500" + : "bg-white hover:bg-slate-50" + }`} + > + + + + + + + ); + })} + +
+ Data + + Dia + + Capacidade + + Carga Atual + Status
+ {d.date} + + {d.day} + + {d.cap} Ton + +
+ + {d.sales.toFixed(3)} Ton + +
+
85 + ? "bg-orange-500" + : "bg-emerald-500" + }`} + style={{ + width: `${Math.min(occupancy, 100)}%`, + }} + >
+
+
+
+
+ + {d.available === "Sim" + ? "• Disponível" + : "• Esgotado"} + + {!isFull && ( + + + + )} +
+
+
+ )} +
+
+
+
+ ); +}; + +export default Baldinho; diff --git a/components/CartDrawer.tsx b/components/CartDrawer.tsx new file mode 100644 index 0000000..df15b3d --- /dev/null +++ b/components/CartDrawer.tsx @@ -0,0 +1,469 @@ +import React, { useState, useEffect } from "react"; +import { OrderItem } from "../types"; +import ConfirmDialog from "./ConfirmDialog"; + +interface CartDrawerProps { + isOpen: boolean; + onClose: () => void; + items: OrderItem[]; + onUpdateQuantity: (id: string, delta: number) => void; + onRemove: (id: string) => void; + onCheckout: () => void; + onNewOrder?: () => void; + onNavigate?: (path: string) => void; +} + +const CartDrawer: React.FC = ({ + isOpen, + onClose, + items, + onUpdateQuantity, + onRemove, + onCheckout, + onNewOrder, + onNavigate, +}) => { + const [isAnimating, setIsAnimating] = useState(false); + const [shouldRender, setShouldRender] = useState(false); + const [confirmDialog, setConfirmDialog] = useState<{ + isOpen: boolean; + itemId: string; + productName: string; + }>({ + isOpen: false, + itemId: "", + productName: "", + }); + const [showNewOrderDialog, setShowNewOrderDialog] = useState(false); + const [showContinueOrNewDialog, setShowContinueOrNewDialog] = useState(false); + const total = items.reduce( + (acc, item) => acc + item.price * item.quantity, + 0 + ); + + // Controlar renderização e animação + useEffect(() => { + if (isOpen) { + setShouldRender(true); + // Pequeno delay para garantir que o DOM está pronto antes de iniciar a animação + setTimeout(() => setIsAnimating(true), 10); + } else { + // Iniciar animação de saída + setIsAnimating(false); + // Remover do DOM após a animação terminar + const timer = setTimeout(() => setShouldRender(false), 400); // Duração da animação + return () => clearTimeout(timer); + } + }, [isOpen]); + + // Não renderizar se não deve estar visível + if (!shouldRender) return null; + + const handleClose = () => { + // Chamar onClose imediatamente para atualizar o estado + // O useEffect vai cuidar da animação de saída + onClose(); + }; + + // Verificar se há carrinho (itens no carrinho) + const hasCart = () => { + return items.length > 0 || localStorage.getItem("cart"); + }; + + // Verificar se há dados do pedido atual + const hasOrderData = () => { + const hasCartItems = items.length > 0; + const hasCustomer = localStorage.getItem("customer"); + const hasAddress = localStorage.getItem("address"); + const hasPaymentPlan = localStorage.getItem("paymentPlan"); + const hasBilling = localStorage.getItem("billing"); + const hasDataDelivery = localStorage.getItem("dataDelivery"); + const hasInvoiceStore = localStorage.getItem("invoiceStore"); + const hasPartner = localStorage.getItem("partner"); + + return ( + hasCartItems || + hasCustomer || + hasAddress || + hasPaymentPlan || + hasBilling || + hasDataDelivery || + hasInvoiceStore || + hasPartner + ); + }; + + const handleNewOrderClick = () => { + // Se houver carrinho, perguntar se quer continuar ou iniciar novo + if (hasCart()) { + setShowContinueOrNewDialog(true); + return; + } + + // Se não houver carrinho mas houver outros dados, mostrar confirmação normal + if (hasOrderData()) { + setShowNewOrderDialog(true); + return; + } + + // Se não houver nenhum dado, limpar direto sem confirmação + if (onNewOrder) { + onNewOrder(); + handleClose(); + } + }; + + const handleContinueOrder = () => { + // Fechar o dialog e navegar para /sales/home + setShowContinueOrNewDialog(false); + handleClose(); // Fechar o drawer também + window.location.href = "/#/sales/home"; + }; + + const handleStartNewOrder = () => { + // Fechar o dialog de continuar/iniciar e abrir o de confirmação + setShowContinueOrNewDialog(false); + setShowNewOrderDialog(true); + }; + + const handleConfirmNewOrder = () => { + if (onNewOrder) { + onNewOrder(); + setShowNewOrderDialog(false); + handleClose(); + } + }; + + return ( +
+ {/* Overlay */} +
+ + {/* Panel - Fullscreen em mobile, sidebar em desktop */} +
+
+
+ + Seu Carrinho + +

+ {items.length} {items.length === 1 ? "Produto" : "Produtos"} +

+
+ +
+
+ +
+ {items.length === 0 ? ( +
+
+ + + +
+

Seu carrinho está vazio

+

Adicione produtos para começar.

+
+ ) : ( + items.map((item) => ( +
+
+ {item.image && item.image.trim() !== "" ? ( + {item.name} + ) : ( + + + + )} +
+
+
+ {item.name} +
+ + R$ {item.price.toFixed(2)} + +
+
+ + + {item.quantity} + + +
+ +
+
+
+ )) + )} +
+ + {items.length > 0 && ( +
+
+
+ + Total do Pedido + + + R$ {total.toFixed(2)} + +
+
+ +
+ + {onNewOrder && ( + + )} + +
+
+ )} +
+ + + + {/* Dialog de Confirmação - Remover Item */} + + setConfirmDialog({ isOpen: false, itemId: "", productName: "" }) + } + onConfirm={() => { + onRemove(confirmDialog.itemId); + setConfirmDialog({ isOpen: false, itemId: "", productName: "" }); + }} + type="delete" + message={ + <> + Deseja remover o produto{" "} + + "{confirmDialog.productName}" + {" "} + do carrinho? +
+ + Esta ação não pode ser desfeita. + + + } + /> + + {/* Dialog - Continuar ou Iniciar Novo Pedido */} + + Você já possui um carrinho com itens. +
+
+ Deseja iniciar um novo pedido e limpar todos os dados do pedido + atual? + + } + confirmText="Iniciar Novo Pedido" + cancelText="Cancelar" + /> + + {/* Dialog de Confirmação - Novo Pedido */} + setShowNewOrderDialog(false)} + onConfirm={handleConfirmNewOrder} + type="warning" + title="Novo Pedido" + message={ + <> + Deseja iniciar um novo pedido? +
+
+ + Todos os dados do pedido atual serão perdidos: + +
    +
  • Itens do carrinho
  • +
  • Dados do cliente
  • +
  • Endereço de entrega
  • +
  • Plano de pagamento
  • +
  • Dados financeiros
  • +
  • Informações de entrega
  • +
+
+ + Esta ação não pode ser desfeita. + + + } + confirmText="Sim, Iniciar Novo Pedido" + cancelText="Cancelar" + /> +
+ ); +}; + +export default CartDrawer; diff --git a/components/CategoryCard.tsx b/components/CategoryCard.tsx new file mode 100644 index 0000000..f28f2b7 --- /dev/null +++ b/components/CategoryCard.tsx @@ -0,0 +1,52 @@ +import React, { ReactNode } from "react"; + +interface CategoryCardProps { + label: string; + icon: ReactNode; + onClick?: () => void; +} + +/** + * CategoryCard Component + * Componente reutilizável para exibir cards de categorias em destaque + * + * @param label - Texto do card + * @param icon - Ícone ou elemento visual do card + * @param onClick - Callback quando o card é clicado + */ +const CategoryCard: React.FC = ({ + label, + icon, + onClick, +}) => { + return ( +
+ {/* Fundo Decorativo */} +
+ +
+ {icon} +
+ +
+ + Produtos + + + {label} + +
+ + {/* Indicador de Hover */} +
+
+ ); +}; + +export default CategoryCard; + + + diff --git a/components/ConfirmDialog.tsx b/components/ConfirmDialog.tsx new file mode 100644 index 0000000..9203ca9 --- /dev/null +++ b/components/ConfirmDialog.tsx @@ -0,0 +1,373 @@ +import React, { useState, useEffect } from "react"; + +export type DialogType = + | "info" + | "warning" + | "error" + | "success" + | "delete" + | "confirm"; + +interface DialogConfig { + title: string; + icon: React.ReactNode; + confirmButtonColor: "red" | "orange" | "blue" | "green"; + headerBgColor: string; + iconBgColor: string; + iconColor: string; + subtitle?: string; +} + +interface ConfirmDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + type?: DialogType; + title?: string; + message: string | React.ReactNode; + confirmText?: string; + cancelText?: string; + icon?: React.ReactNode; + showWarning?: boolean; +} + +/** + * ConfirmDialog Component + * Componente reutilizável para exibir diálogos de confirmação customizados + * Mantém o tema do projeto com cores e estilos consistentes + * Suporta diferentes tipos: info, warning, error, success, delete, confirm + * + * @param isOpen - Controla se o dialog está aberto + * @param onClose - Callback chamado ao fechar/cancelar + * @param onConfirm - Callback chamado ao confirmar + * @param type - Tipo do diálogo (info, warning, error, success, delete, confirm) + * @param title - Título do dialog (opcional, será definido pelo tipo se não fornecido) + * @param message - Mensagem principal do dialog + * @param confirmText - Texto do botão de confirmação (opcional, será definido pelo tipo se não fornecido) + * @param cancelText - Texto do botão de cancelamento (padrão: "Cancelar") + * @param icon - Ícone customizado (opcional, será definido pelo tipo se não fornecido) + * @param showWarning - Se deve mostrar subtítulo de atenção (padrão: true para warning/error/delete) + */ +const ConfirmDialog: React.FC = ({ + isOpen, + onClose, + onConfirm, + type = "confirm", + title, + message, + confirmText, + cancelText = "Cancelar", + icon, + showWarning, +}) => { + const [isAnimating, setIsAnimating] = useState(false); + const [shouldRender, setShouldRender] = useState(false); + + // Configurações por tipo de diálogo + const getDialogConfig = (dialogType: DialogType): DialogConfig => { + const configs: Record = { + info: { + title: "Informação", + icon: ( + + + + ), + confirmButtonColor: "blue", + headerBgColor: "bg-[#002147]", + iconBgColor: "bg-blue-500/20", + iconColor: "text-blue-400", + subtitle: "Informação", + }, + warning: { + title: "Atenção", + icon: ( + + + + ), + confirmButtonColor: "orange", + headerBgColor: "bg-[#002147]", + iconBgColor: "bg-orange-500/20", + iconColor: "text-orange-400", + subtitle: "Atenção", + }, + error: { + title: "Erro", + icon: ( + + + + ), + confirmButtonColor: "red", + headerBgColor: "bg-[#002147]", + iconBgColor: "bg-red-500/20", + iconColor: "text-red-400", + subtitle: "Erro", + }, + success: { + title: "Sucesso", + icon: ( + + + + ), + confirmButtonColor: "green", + headerBgColor: "bg-[#002147]", + iconBgColor: "bg-green-500/20", + iconColor: "text-green-400", + subtitle: "Sucesso", + }, + delete: { + title: "Confirmar Exclusão", + icon: ( + + + + ), + confirmButtonColor: "red", + headerBgColor: "bg-[#002147]", + iconBgColor: "bg-red-500/20", + iconColor: "text-red-400", + subtitle: "Atenção", + }, + confirm: { + title: "Confirmar Ação", + icon: ( + + + + ), + confirmButtonColor: "orange", + headerBgColor: "bg-[#002147]", + iconBgColor: "bg-orange-500/20", + iconColor: "text-orange-400", + subtitle: "Confirmação", + }, + }; + + return configs[dialogType]; + }; + + // Obter configuração do tipo + const config = getDialogConfig(type as DialogType); + const finalTitle = title || config.title; + const finalIcon = icon || config.icon; + const finalConfirmText = + confirmText || + (type === "delete" + ? "Excluir" + : type === "success" || type === "error" || type === "info" + ? "OK" + : "Confirmar"); + const shouldShowWarning = + showWarning !== undefined + ? showWarning + : ["warning", "error", "delete"].includes(type); + + useEffect(() => { + if (isOpen) { + setShouldRender(true); + // Pequeno delay para garantir que o DOM está pronto antes de iniciar a animação + setTimeout(() => setIsAnimating(true), 10); + } else { + // Iniciar animação de saída + setIsAnimating(false); + // Remover do DOM após a animação terminar + const timer = setTimeout(() => setShouldRender(false), 300); + return () => clearTimeout(timer); + } + }, [isOpen]); + + // Não renderizar se não deve estar visível + if (!shouldRender) return null; + + const handleConfirm = () => { + setIsAnimating(false); + setTimeout(() => { + onConfirm(); + onClose(); + }, 300); + }; + + const handleCancel = () => { + setIsAnimating(false); + setTimeout(() => { + onClose(); + }, 300); + }; + + // Cores do botão de confirmação + const confirmButtonClasses = { + red: "bg-red-500 hover:bg-red-600 shadow-red-500/20", + orange: "bg-orange-500 hover:bg-orange-600 shadow-orange-500/20", + blue: "bg-blue-500 hover:bg-blue-600 shadow-blue-500/20", + green: "bg-green-500 hover:bg-green-600 shadow-green-500/20", + }; + + return ( +
+ {/* Overlay */} +
+ + {/* Dialog - Altura consistente em todos os dispositivos */} +
+ {/* Header */} +
+
+
+ {finalIcon && ( +
+ {finalIcon} +
+ )} +
+

{finalTitle}

+ {shouldShowWarning && config.subtitle && ( +

+ {config.subtitle} +

+ )} +
+
+
+
+
+ + {/* Content */} +
+ {typeof message === "string" ? ( +

+ {message} +

+ ) : ( +
+ {message} +
+ )} +
+ + {/* Actions */} +
+ {/* Mostrar botão de cancelar se: + - For tipo info (permite dois botões) OU + - Não for tipo success/error E finalConfirmText não é "OK" + */} + {type === "info" || + (type !== "success" && + type !== "error" && + finalConfirmText !== "OK") ? ( + + ) : null} + +
+
+
+ ); +}; + +export default ConfirmDialog; diff --git a/components/CreateCustomerDialog.tsx b/components/CreateCustomerDialog.tsx new file mode 100644 index 0000000..ed0abd3 --- /dev/null +++ b/components/CreateCustomerDialog.tsx @@ -0,0 +1,520 @@ +import React, { useState, useEffect } from "react"; +import { Customer } from "../src/services/customer.service"; +import { customerService } from "../src/services/customer.service"; +import { + validateCustomerForm, + validateCPForCNPJ, + validateCEP, + validatePhone, +} from "../lib/utils"; +import ConfirmDialog from "./ConfirmDialog"; + +interface CreateCustomerDialogProps { + isOpen: boolean; + onClose: () => void; + onSuccess: (customer: Customer) => void; +} + +const CreateCustomerDialog: React.FC = ({ + isOpen, + onClose, + onSuccess, +}) => { + const [isAnimating, setIsAnimating] = useState(false); + const [shouldRender, setShouldRender] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSearchingCustomer, setIsSearchingCustomer] = useState(false); + const [showCustomerFoundDialog, setShowCustomerFoundDialog] = useState(false); + const [foundCustomer, setFoundCustomer] = useState(null); + + const [formData, setFormData] = useState({ + name: "", + document: "", + cellPhone: "", + cep: "", + address: "", + number: "", + city: "", + state: "", + complement: "", + }); + + const [errors, setErrors] = useState>({}); + + useEffect(() => { + if (isOpen) { + setShouldRender(true); + setTimeout(() => setIsAnimating(true), 10); + // Limpar formulário ao abrir + setFormData({ + name: "", + document: "", + cellPhone: "", + cep: "", + address: "", + number: "", + city: "", + state: "", + complement: "", + }); + setErrors({}); + } else { + setIsAnimating(false); + const timer = setTimeout(() => setShouldRender(false), 300); + return () => clearTimeout(timer); + } + }, [isOpen]); + + if (!shouldRender) return null; + + const handleInputChange = (field: string, value: string) => { + setFormData((prev) => ({ ...prev, [field]: value })); + // Limpar erro do campo ao digitar + if (errors[field]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[field]; + return newErrors; + }); + } + }; + + /** + * Busca cliente por CPF/CNPJ ao sair do campo (blur) + * Similar ao searchCustomerByDocument() do Angular + */ + const handleSearchCustomerByDocument = async () => { + const document = formData.document; + + // Remover caracteres não numéricos para a busca + const cleanDocument = document.replace(/[^\d]/g, ""); + + // Validar se tem pelo menos 11 dígitos (CPF mínimo) ou 14 (CNPJ) + if (!cleanDocument || cleanDocument.length < 11) { + return; + } + + setIsSearchingCustomer(true); + try { + const customer = await customerService.getCustomerByCpf(cleanDocument); + + if (customer) { + // Cliente encontrado - preencher formulário + setFoundCustomer(customer); + populateCustomerForm(customer); + setShowCustomerFoundDialog(true); + } + } catch (error) { + console.error("Erro ao buscar cliente por CPF/CNPJ:", error); + // Não mostrar erro se o cliente não foi encontrado (é esperado para novos clientes) + } finally { + setIsSearchingCustomer(false); + } + }; + + /** + * Preenche o formulário com os dados do cliente encontrado + * Similar ao populateCustomer() do Angular + */ + const populateCustomerForm = (customer: Customer) => { + setFormData({ + name: customer.name || "", + document: customer.cpfCnpj || customer.document || "", + cellPhone: customer.cellPhone || customer.phone || "", + cep: customer.zipCode || customer.cep || "", + address: customer.address || "", + number: customer.addressNumber || customer.number || "", + city: customer.city || "", + state: customer.state || "", + complement: customer.complement || "", + }); + }; + + /** + * Fecha o dialog de cliente encontrado + * O usuário pode continuar editando o formulário ou usar o cliente encontrado + */ + const handleCloseCustomerFoundDialog = () => { + setShowCustomerFoundDialog(false); + }; + + /** + * Usa o cliente encontrado diretamente (sem editar) + */ + const handleUseFoundCustomer = () => { + setShowCustomerFoundDialog(false); + if (foundCustomer) { + // Chamar onSuccess com o cliente encontrado + onSuccess(foundCustomer); + onClose(); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Validar formulário + const validation = validateCustomerForm(formData); + if (!validation.isValid) { + setErrors(validation.errors); + return; + } + + setIsSubmitting(true); + try { + const newCustomer = await customerService.createCustomer(formData); + if (newCustomer) { + setIsAnimating(false); + setTimeout(() => { + onSuccess(newCustomer); + onClose(); + }, 300); + } else { + setErrors({ + submit: "Não foi possível cadastrar o cliente. Tente novamente.", + }); + } + } catch (error: any) { + setErrors({ + submit: error.message || "Erro ao cadastrar cliente. Tente novamente.", + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + setIsAnimating(false); + setTimeout(() => { + onClose(); + setFormData({ + name: "", + document: "", + cellPhone: "", + cep: "", + address: "", + number: "", + city: "", + state: "", + complement: "", + }); + setErrors({}); + }, 300); + }; + + return ( +
+ {/* Overlay */} +
+ + {/* Dialog */} +
+ {/* Header */} +
+
+
+
+ + + +
+
+

Novo Cliente

+

+ Cadastro +

+
+ +
+
+
+
+ + {/* Content */} +
+
+ {/* Nome do Cliente */} +
+ + handleInputChange("name", e.target.value)} + className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${ + errors.name ? "border-red-500" : "border-slate-200" + } focus:outline-none focus:ring-2 focus:ring-orange-500/20`} + placeholder="Digite o nome completo" + /> + {errors.name && ( +

{errors.name}

+ )} +
+ + {/* CPF/CNPJ e Contato */} +
+
+ +
+ + handleInputChange("document", e.target.value) + } + onBlur={handleSearchCustomerByDocument} + className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${ + errors.document ? "border-red-500" : "border-slate-200" + } focus:outline-none focus:ring-2 focus:ring-orange-500/20`} + placeholder="000.000.000-00" + disabled={isSearchingCustomer} + /> + {isSearchingCustomer && ( +
+ + + + +
+ )} +
+ {errors.document && ( +

{errors.document}

+ )} +
+
+ + + handleInputChange("cellPhone", e.target.value) + } + className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${ + errors.cellPhone ? "border-red-500" : "border-slate-200" + } focus:outline-none focus:ring-2 focus:ring-orange-500/20`} + placeholder="(00) 00000-0000" + /> + {errors.cellPhone && ( +

+ {errors.cellPhone} +

+ )} +
+
+ + {/* CEP e Endereço */} +
+
+ + handleInputChange("cep", e.target.value)} + className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${ + errors.cep ? "border-red-500" : "border-slate-200" + } focus:outline-none focus:ring-2 focus:ring-orange-500/20`} + placeholder="00000-000" + /> + {errors.cep && ( +

{errors.cep}

+ )} +
+
+ + handleInputChange("address", e.target.value)} + className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${ + errors.address ? "border-red-500" : "border-slate-200" + } focus:outline-none focus:ring-2 focus:ring-orange-500/20`} + placeholder="Rua, Avenida, etc." + /> + {errors.address && ( +

{errors.address}

+ )} +
+
+ + {/* Número, Cidade e Estado */} +
+
+ + handleInputChange("number", e.target.value)} + className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${ + errors.number ? "border-red-500" : "border-slate-200" + } focus:outline-none focus:ring-2 focus:ring-orange-500/20`} + placeholder="123" + /> + {errors.number && ( +

{errors.number}

+ )} +
+
+ + handleInputChange("city", e.target.value)} + className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${ + errors.city ? "border-red-500" : "border-slate-200" + } focus:outline-none focus:ring-2 focus:ring-orange-500/20`} + placeholder="Nome da cidade" + /> + {errors.city && ( +

{errors.city}

+ )} +
+
+ + handleInputChange("state", e.target.value)} + maxLength={2} + className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${ + errors.state ? "border-red-500" : "border-slate-200" + } focus:outline-none focus:ring-2 focus:ring-orange-500/20 uppercase`} + placeholder="PA" + /> + {errors.state && ( +

{errors.state}

+ )} +
+
+ + {/* Complemento */} +
+ + + handleInputChange("complement", e.target.value) + } + className="w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border border-slate-200 focus:outline-none focus:ring-2 focus:ring-orange-500/20" + placeholder="Apto, Bloco, etc. (opcional)" + /> +
+ + {/* Erro de submit */} + {errors.submit && ( +
+

+ {errors.submit} +

+
+ )} +
+
+ + {/* Actions */} +
+ + +
+
+ + {/* Dialog de Cliente Encontrado */} + +
+ ); +}; + +export default CreateCustomerDialog; diff --git a/components/EditItemModal.tsx b/components/EditItemModal.tsx new file mode 100644 index 0000000..7552c68 --- /dev/null +++ b/components/EditItemModal.tsx @@ -0,0 +1,492 @@ +import React, { useState, useEffect } from "react"; +import { OrderItem, Product } from "../types"; +import { productService, SaleProduct } from "../src/services/product.service"; +import { authService } from "../src/services/auth.service"; +import { shoppingService } from "../src/services/shopping.service"; +import FilialSelector from "./FilialSelector"; +import { X, Minus, Plus, Edit2 } from "lucide-react"; +import { validateMinValue, validateRequired } from "../lib/utils"; + +interface EditItemModalProps { + item: OrderItem; + isOpen: boolean; + onClose: () => void; + onConfirm: (item: OrderItem) => Promise; +} + +const EditItemModal: React.FC = ({ + item, + isOpen, + onClose, + onConfirm, +}) => { + const [productDetail, setProductDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [quantity, setQuantity] = useState(item.quantity || 1); + const [selectedStore, setSelectedStore] = useState( + item.stockStore?.toString() || "" + ); + const [deliveryType, setDeliveryType] = useState(item.deliveryType || ""); + const [environment, setEnvironment] = useState(item.environment || ""); + const [stocks, setStocks] = useState< + Array<{ + store: string; + storeName: string; + quantity: number; + work: boolean; + blocked: string; + breakdown: number; + transfer: number; + allowDelivery: number; + }> + >([]); + const [showDescription, setShowDescription] = useState(false); + const [formErrors, setFormErrors] = useState<{ + quantity?: string; + deliveryType?: string; + selectedStore?: string; + }>({}); + + // Tipos de entrega + const deliveryTypes = [ + { type: "RI", description: "Retira Imediata" }, + { type: "RP", description: "Retira Posterior" }, + { type: "EN", description: "Entrega" }, + { type: "EF", description: "Encomenda" }, + ]; + + useEffect(() => { + if (isOpen && item) { + setQuantity(item.quantity || 1); + setSelectedStore(item.stockStore?.toString() || ""); + setDeliveryType(item.deliveryType || ""); + setEnvironment(item.environment || ""); + setFormErrors({}); + loadProductDetail(); + } else if (!isOpen) { + // Reset states when modal closes + setQuantity(1); + setSelectedStore(""); + setDeliveryType(""); + setEnvironment(""); + setFormErrors({}); + setProductDetail(null); + setStocks([]); + setShowDescription(false); + } + }, [isOpen, item]); + + const loadProductDetail = async () => { + if (!item.code) return; + + try { + setLoading(true); + const store = authService.getStore(); + if (!store) { + throw new Error("Loja não encontrada"); + } + + const productId = parseInt(item.code); + if (isNaN(productId)) { + throw new Error("ID do produto inválido"); + } + + // Buscar detalhes do produto + const detail = await productService.getProductDetail(store, productId); + setProductDetail(detail); + + // Buscar estoques + try { + const stocksData = await productService.getProductStocks( + store, + productId + ); + setStocks(stocksData); + } catch (err) { + console.warn("Erro ao carregar estoques:", err); + } + } catch (err: any) { + console.error("Erro ao carregar detalhes do produto:", err); + } finally { + setLoading(false); + } + }; + + const validateForm = (): boolean => { + const errors: typeof formErrors = {}; + + if (!validateRequired(quantity) || !validateMinValue(quantity, 0.01)) { + errors.quantity = "A quantidade deve ser maior que zero"; + } + + if (!validateRequired(deliveryType)) { + errors.deliveryType = "O tipo de entrega é obrigatório"; + } + + if (stocks.length > 0 && !validateRequired(selectedStore)) { + errors.selectedStore = "A filial de estoque é obrigatória"; + } + + setFormErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleConfirm = async () => { + if (!validateForm()) { + return; + } + + // Criar OrderItem atualizado + const updatedItem: OrderItem = { + ...item, + quantity, + deliveryType, + stockStore: selectedStore || item.stockStore, + environment: environment || undefined, + }; + + await onConfirm(updatedItem); + }; + + const calculateTotal = () => { + const price = productDetail?.salePrice || item.price || 0; + return price * quantity; + }; + + const calculateDiscount = () => { + const listPrice = productDetail?.listPrice || item.originalPrice || 0; + const salePrice = productDetail?.salePrice || item.price || 0; + if (listPrice > 0 && salePrice < listPrice) { + return Math.round(((listPrice - salePrice) / listPrice) * 100); + } + return 0; + }; + + const discount = calculateDiscount(); + const total = calculateTotal(); + const listPrice = productDetail?.listPrice || item.originalPrice || 0; + const salePrice = productDetail?.salePrice || item.price || 0; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+

+ Editar Item do Pedido +

+ +
+ + {/* Content */} +
+ {loading ? ( +
+
+
+ ) : ( +
+ {/* Left: Image */} +
+ {item.image && item.image.trim() !== "" ? ( + {item.name} + ) : ( +
+ + + +

Sem imagem

+
+ )} +
+ + {/* Right: Product Info */} +
+ {/* Product Title */} +
+

+ #{productDetail?.title || item.name} +

+

+ {productDetail?.smallDescription || + productDetail?.description || + item.description || + ""} +

+
+ + {productDetail?.brand || item.mark} + + + {productDetail?.idProduct || item.code} + {productDetail?.ean && ( + <> + + {productDetail.ean} + + )} +
+ {productDetail?.productType === "A" && ( + + AUTOSSERVIÇO + + )} +
+ + {/* Price */} +
+ {listPrice > 0 && listPrice > salePrice && ( +

+ de R$ {listPrice.toLocaleString("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}{" "} + por +

+ )} +
+

+ R$ {salePrice.toLocaleString("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+ {discount > 0 && ( + + -{discount}% + + )} +
+ {productDetail?.installments && + productDetail.installments.length > 0 && ( +

+ POR UN EM {productDetail.installments[0].installment}X DE + R${" "} + {productDetail.installments[0].installmentValue.toLocaleString( + "pt-BR", + { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + } + )} +

+ )} +
+ + {/* Store Selector */} + {stocks.length > 0 && ( +
+ + { + setSelectedStore(value); + if (formErrors.selectedStore) { + setFormErrors((prev) => ({ + ...prev, + selectedStore: undefined, + })); + } + }} + placeholder="Digite para buscar..." + /> + {formErrors.selectedStore && ( +

+ {formErrors.selectedStore} +

+ )} +
+ )} + + {/* Description (Collapsible) */} +
+
+ + DESCRIÇÃO DO PRODUTO + + +
+ {showDescription && ( +

+ {productDetail?.description || + item.description || + "Sem descrição disponível"} +

+ )} +
+ + {/* Quantity */} +
+ +
+ + { + const val = parseInt(e.target.value); + if (!isNaN(val) && val > 0) { + setQuantity(val); + if (formErrors.quantity) { + setFormErrors((prev) => ({ + ...prev, + quantity: undefined, + })); + } + } + }} + className="w-16 text-center text-sm font-bold border-0 focus:outline-none bg-white" + /> + +
+ {formErrors.quantity && ( +

+ {formErrors.quantity} +

+ )} +
+ + {/* Environment */} +
+ + setEnvironment(e.target.value)} + className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500/20 text-sm" + placeholder="" + /> +
+ + {/* Delivery Type */} +
+ + + {formErrors.deliveryType && ( +

+ {formErrors.deliveryType} +

+ )} +
+ + {/* Total Price and Confirm Button */} +
+
+ + R$ {total.toLocaleString("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + +
+ +
+
+
+ )} +
+
+
+ ); +}; + +export default EditItemModal; + diff --git a/components/FilialSelector.tsx b/components/FilialSelector.tsx new file mode 100644 index 0000000..50767dc --- /dev/null +++ b/components/FilialSelector.tsx @@ -0,0 +1,324 @@ +import React, { useState, useRef, useEffect } from "react"; + +interface Stock { + store: string; + storeName: string; + quantity: number; + work: boolean; + blocked: string; + breakdown: number; + transfer: number; + allowDelivery: number; +} + +interface FilialSelectorProps { + stocks: Stock[]; + value?: string; + onValueChange?: (value: string) => void; + placeholder?: string; + label?: string; + className?: string; +} + +const FilialSelector: React.FC = ({ + stocks, + value, + onValueChange, + placeholder = "Selecione a filial retira", + label = "Filial Retira", + className, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedStore, setSelectedStore] = useState(value || ""); + const [displayValue, setDisplayValue] = useState(""); + const containerRef = useRef(null); + const inputRef = useRef(null); + const tableRef = useRef(null); + + // Atualizar selectedStore e displayValue quando value mudar externamente + useEffect(() => { + if (value !== undefined) { + setSelectedStore(value); + const selected = stocks.find((s) => s.store === value); + if (selected) { + setDisplayValue(selected.storeName); + setSearchTerm(selected.storeName); + } else { + setDisplayValue(""); + setSearchTerm(""); + } + } + }, [value, stocks]); + + // Fechar dropdown ao clicar fora + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + // Restaurar displayValue quando fechar sem selecionar + if (selectedStore) { + const selected = stocks.find((s) => s.store === selectedStore); + if (selected) { + setSearchTerm(selected.storeName); + setDisplayValue(selected.storeName); + } + } else { + setSearchTerm(""); + setDisplayValue(""); + } + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + } + }, [isOpen, selectedStore, stocks]); + + // Filtrar estoques baseado no termo de busca (busca em store e storeName) + const filteredStocks = stocks.filter((stock) => { + if (!searchTerm) return true; + const search = searchTerm.toLowerCase(); + return ( + stock.store.toLowerCase().includes(search) || + stock.storeName.toLowerCase().includes(search) + ); + }); + + const handleSelect = (store: string) => { + setSelectedStore(store); + const stock = stocks.find((s) => s.store === store); + if (stock) { + setDisplayValue(stock.storeName); + setSearchTerm(stock.storeName); + } + onValueChange?.(store); + setIsOpen(false); + }; + + const handleClear = () => { + setSearchTerm(""); + setDisplayValue(""); + setSelectedStore(""); + onValueChange?.(""); + if (inputRef.current) { + inputRef.current.focus(); + } + setIsOpen(true); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setSearchTerm(newValue); + setDisplayValue(newValue); + setIsOpen(true); + // Se limpar o input, limpar também a seleção + if (!newValue) { + setSelectedStore(""); + onValueChange?.(""); + } + }; + + const handleInputFocus = () => { + setIsOpen(true); + // Quando focar, mostrar o termo de busca atual + if (selectedStore) { + const selected = stocks.find((s) => s.store === selectedStore); + if (selected) { + setSearchTerm(selected.storeName); + } + } + }; + + const handleToggleDropdown = () => { + setIsOpen(!isOpen); + if (!isOpen && inputRef.current) { + inputRef.current.focus(); + } + }; + + const formatNumber = (num: number): string => { + return num.toLocaleString("pt-BR", { + minimumFractionDigits: 3, + maximumFractionDigits: 3, + }); + }; + + return ( +
+ {label && ( + + )} +
+ {/* Input */} +
+ + {/* Botões de ação */} +
+ {(searchTerm || displayValue) && ( + + )} + +
+
+ + {/* Dropdown com Tabela */} + {isOpen && ( +
+ {/* Tabela */} +
+ + + + + + + + + + + + + + {filteredStocks.length === 0 ? ( + + + + ) : ( + filteredStocks.map((stock) => { + const isSelected = stock.store === selectedStore; + return ( + handleSelect(stock.store)} + className={`cursor-pointer transition-colors ${ + isSelected + ? "bg-blue-50 hover:bg-blue-100" + : "hover:bg-slate-50" + }`} + > + + + + + + + + + ); + }) + )} + +
+ Loja + + Nome da Loja + + Entrega + + Estoque + + Pertence + + Bloq + + Transf +
+ Nenhuma filial encontrada +
+ {stock.store} + + {stock.storeName} + + + + {formatNumber(stock.quantity)} + + + + {stock.blocked || "0"} + + {stock.transfer || 0} +
+
+
+ )} +
+
+ ); +}; + +export default FilialSelector; + diff --git a/components/FilterSidebar.tsx b/components/FilterSidebar.tsx new file mode 100644 index 0000000..78398d8 --- /dev/null +++ b/components/FilterSidebar.tsx @@ -0,0 +1,675 @@ +import React, { useState, useEffect } from "react"; +import { + productService, + ClasseMercadologica, +} from "../src/services/product.service"; +import { authService } from "../src/services/auth.service"; +import { CustomAutocomplete } from "./ui/autocomplete"; + +interface DepartmentItem { + name: string; + url: string; + path: string; // Adicionar path para seguir padrão do Angular + subcategories: DepartmentItem[]; +} + +interface FilterSidebarProps { + selectedDepartment: string; + onDepartmentChange: (department: string) => void; + selectedBrands: string[]; + onBrandsChange: (brands: string[]) => void; + filters: { + onlyInStock: boolean; + stockBranch: string; + onPromotion: boolean; + discountRange: [number, number]; + priceDropped: boolean; + opportunities: boolean; + unmissableOffers: boolean; + }; + onFiltersChange: (filters: FilterSidebarProps["filters"]) => void; + onApplyFilters: () => void; + brands?: string[]; // Marcas podem ser passadas como prop opcional + onBrandsLoaded?: (brands: string[]) => void; // Callback quando marcas são carregadas +} + +const FilterSidebar: React.FC = ({ + selectedDepartment, + onDepartmentChange, + selectedBrands, + onBrandsChange, + filters, + onFiltersChange, + onApplyFilters, + brands: brandsProp, + onBrandsLoaded, +}) => { + const [expandedDepartments, setExpandedDepartments] = useState([]); + const [departments, setDepartments] = useState([]); + const [brands, setBrands] = useState(brandsProp || []); + const [stores, setStores] = useState< + Array<{ id: string; name: string; shortName: string }> + >([]); + const [loadingDepartments, setLoadingDepartments] = useState(true); + const [loadingBrands, setLoadingBrands] = useState(false); + const [loadingStores, setLoadingStores] = useState(true); + const [error, setError] = useState(null); + + // Sincronizar marcas quando a prop mudar + useEffect(() => { + if (brandsProp) { + setBrands(brandsProp); + if (onBrandsLoaded) { + onBrandsLoaded(brandsProp); + } + } + }, [brandsProp, onBrandsLoaded]); + + /** + * Carrega as filiais disponíveis para o usuário + */ + useEffect(() => { + const loadStores = async () => { + try { + setLoadingStores(true); + const storesData = await productService.getStores(); + setStores(storesData); + } catch (err: any) { + console.error("Erro ao carregar filiais:", err); + // Não define erro global pois as filiais são opcionais + } finally { + setLoadingStores(false); + } + }; + + loadStores(); + }, []); + + /** + * Mapeia os dados hierárquicos da API para a estrutura do componente + * Segue o mesmo padrão do Angular: mapItems + */ + /** + * Mapeia os dados hierárquicos da API para a estrutura do componente + * Segue EXATAMENTE o padrão do Angular: mapItems + * No Angular: path: item.url (cada item tem sua própria URL, usada diretamente) + * + * IMPORTANTE: No Angular, quando clica em uma seção dentro de um departamento, + * a URL da seção no banco já deve ser completa (ex: "ferragens/cadeados") + * ou precisa ser construída concatenando departamento/seção. + * + * Seguindo o exemplo do usuário "ferragens/cadeados", vamos construir o path + * completo quando há hierarquia, mas também manter a URL original do item. + */ + const mapItems = ( + items: any[], + textFields: string[], + childFields: string[], + level: number = 0, + parentPath: string = "" // Path do item pai para construir URL completa quando necessário + ): DepartmentItem[] => { + const childField = childFields[level]; + const textField = textFields[level]; + + return items + .filter((item) => item && item[textField] != null) + .map((item) => { + // No Angular: path: item.url (usa diretamente a URL do item) + const itemUrl = item.url || ""; + + // Construir path completo seguindo hierarquia + // Se há parentPath e itemUrl não começa com parentPath, concatenar + // Caso contrário, usar apenas itemUrl (já vem completo da API) + let fullPath = itemUrl; + if (parentPath && itemUrl && !itemUrl.startsWith(parentPath)) { + // Se a URL não começa com o path do pai, construir concatenando + fullPath = `${parentPath}/${itemUrl}`; + } else if (!itemUrl && parentPath) { + // Se não tem URL mas tem parentPath, usar apenas parentPath + fullPath = parentPath; + } + + const result: DepartmentItem = { + name: item[textField] || "", + url: itemUrl, // URL original do item + path: fullPath, // Path completo para usar no filtro (seguindo padrão do Angular) + subcategories: [], + }; + + if ( + childField && + item[childField] && + Array.isArray(item[childField]) && + item[childField].length > 0 + ) { + // Passar o path completo para os filhos (para construir hierarquia completa) + result.subcategories = mapItems( + item[childField], + textFields, + childFields, + level + 1, + fullPath // Passar path completo para construir hierarquia + ); + } + + return result; + }); + }; + + /** + * Carrega os departamentos da API + */ + useEffect(() => { + const loadDepartments = async () => { + try { + setLoadingDepartments(true); + setError(null); + + const data = await productService.getClasseMercadologica(); + // Seguir EXATAMENTE o padrão do Angular: mapItems com os mesmos parâmetros + const mappedItems = mapItems( + data.filter((d) => d.tituloEcommerce != null), // Filtrar como no Angular + ["tituloEcommerce", "descricaoSecao", "descricaoCategoria"], + ["secoes", "categorias"], + 0, // level inicial + "" // parentPath inicial vazio + ); + + setDepartments(mappedItems); + + // Departamentos iniciam todos fechados (não expandidos) + } catch (err: any) { + console.error("Erro ao carregar departamentos:", err); + setError("Erro ao carregar departamentos. Tente novamente."); + } finally { + setLoadingDepartments(false); + } + }; + + loadDepartments(); + }, []); + + /** + * Carrega as marcas a partir dos produtos + * Segue o mesmo padrão do Angular: extrai marcas dos produtos retornados pela API + * No Angular: quando getProductByFilter é chamado, os produtos são processados para extrair marcas + * + * NOTA: As marcas serão carregadas quando os produtos forem buscados pela primeira vez + * Por enquanto, não carregamos automaticamente para evitar chamadas desnecessárias à API + * O componente pai pode passar as marcas via props quando necessário + */ + useEffect(() => { + // As marcas serão carregadas dinamicamente quando os produtos forem buscados + // Isso evita uma chamada extra à API no carregamento inicial + // O componente pai pode passar as marcas via props quando necessário + setLoadingBrands(false); + }, []); + + return ( + + ); +}; + +export default FilterSidebar; diff --git a/components/Gauge.tsx b/components/Gauge.tsx new file mode 100644 index 0000000..9743d8d --- /dev/null +++ b/components/Gauge.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, +} from "recharts"; + +interface GaugeProps { + value: number; + color: string; + label: string; +} + +const Gauge: React.FC = ({ value, color, label }) => { + const data = [{ value: value }, { value: 100 - value }]; + return ( +
+

+ {label} +

+
+ + + + + + + + +
+ {value}% + + Meta + +
+
+
+ ); +}; + +export default Gauge; + + + diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000..0db6f0b --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,119 @@ +import React from "react"; +import { View } from "../types"; +import icon from "../assets/icone.svg"; + +interface HeaderProps { + user: { name: string; store: string }; + currentView: View; + onNavigate: (view: View) => void; + cartCount: number; + onCartClick: () => void; + onLogout?: () => void; +} + +const Header: React.FC = ({ + user, + onNavigate, + cartCount, + onCartClick, + onLogout, +}) => { + return ( +
+
+
onNavigate(View.HOME_MENU)} + > +
+ Jurunense Icon +
+
+ + VendaWeb + Platforma VendaWeb + + + Jurunense + +
+
+ +
+
+
+
+ + {user.name} + + + Filial: {user.store} + +
+
+ +
+ + + +
+
+
+ +
+ ); +}; + +export default Header; diff --git a/components/ImageZoomModal.tsx b/components/ImageZoomModal.tsx new file mode 100644 index 0000000..1f243d7 --- /dev/null +++ b/components/ImageZoomModal.tsx @@ -0,0 +1,167 @@ +import React, { useState, useEffect } from "react"; + +interface ImageZoomModalProps { + isOpen: boolean; + onClose: () => void; + imageUrl: string; + productName: string; +} + +/** + * ImageZoomModal Component + * Modal para exibir zoom de imagem de produto + * Layout baseado no ConfirmDialog + */ +const ImageZoomModal: React.FC = ({ + isOpen, + onClose, + imageUrl, + productName, +}) => { + const [isAnimating, setIsAnimating] = useState(false); + const [shouldRender, setShouldRender] = useState(false); + const [imageError, setImageError] = useState(false); + + useEffect(() => { + if (isOpen) { + setShouldRender(true); + setImageError(false); + // Pequeno delay para garantir que o DOM está pronto antes de iniciar a animação + setTimeout(() => setIsAnimating(true), 10); + } else { + // Iniciar animação de saída + setIsAnimating(false); + // Remover do DOM após a animação terminar + const timer = setTimeout(() => setShouldRender(false), 300); + return () => clearTimeout(timer); + } + }, [isOpen]); + + // Não renderizar se não deve estar visível + if (!shouldRender) return null; + + const handleClose = () => { + setIsAnimating(false); + setTimeout(() => { + onClose(); + }, 300); + }; + + return ( +
+ {/* Overlay */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+
+
+ + + +
+
+

Visualização da Imagem

+

+ {productName} +

+
+
+ +
+
+
+ + {/* Content - Imagem */} +
+ {imageUrl && !imageError ? ( +
+ {productName} { + setImageError(true); + }} + /> +
+ ) : ( +
+ + + +

Imagem não disponível

+
+ )} +
+ + {/* Actions */} +
+ +
+
+
+ ); +}; + +export default ImageZoomModal; diff --git a/components/LoadingSpinner.tsx b/components/LoadingSpinner.tsx new file mode 100644 index 0000000..71fe2d0 --- /dev/null +++ b/components/LoadingSpinner.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import webpImgLoading from "../assets/loading.webp"; + +interface LoadingSpinnerProps { + message?: string; + subMessage?: string; + size?: "sm" | "md" | "lg"; +} + +/** + * LoadingSpinner Component + * Componente reutilizável para exibir estado de carregamento + * + * @param message - Mensagem principal de carregamento + * @param subMessage - Mensagem secundária (opcional) + * @param size - Tamanho do spinner (sm, md, lg) + */ +const LoadingSpinner: React.FC = ({ + message = "Carregando...", + subMessage, + size = "md", +}) => { + const sizeClasses = { + sm: "h-8 w-8 border-2", + md: "h-16 w-16 border-4", + lg: "h-24 w-24 border-4", + }; + + const innerSizeClasses = { + sm: "h-4 w-4", + md: "h-8 w-8", + lg: "h-12 w-12", + }; + + return ( +
+
+ {/*
+
+
+
*/} + + + Loading + +
+

{message}

+ {subMessage && ( +

{subMessage}

+ )} +
+ ); +}; + +export default LoadingSpinner; diff --git a/components/NoData.tsx b/components/NoData.tsx new file mode 100644 index 0000000..f3b3d5a --- /dev/null +++ b/components/NoData.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "./ui/empty"; +import { + IconFolderCode, + IconFileX, + IconSearch, + IconInbox, + IconDatabaseOff, + IconClipboardX, +} from "@tabler/icons-react"; +import { ArrowUpRightIcon } from "lucide-react"; + +interface NoDataProps { + title?: string; + description?: string; + icon?: + | "search" + | "file" + | "inbox" + | "folder" + | "database" + | "clipboard" + | React.ReactNode; + action?: React.ReactNode; + variant?: "default" | "outline" | "muted" | "gradient"; + showLearnMore?: boolean; + learnMoreHref?: string; + learnMoreText?: string; +} + +const NoData: React.FC = ({ + title = "Nenhum resultado encontrado", + description = "Não foram encontrados registros com os filtros informados. Tente ajustar os parâmetros de pesquisa.", + icon = "search", + action, + variant = "outline", + showLearnMore = false, + learnMoreHref = "#", + learnMoreText = "Saiba mais", +}) => { + const getIcon = () => { + if (React.isValidElement(icon)) { + return icon; + } + + const iconClass = "h-10 w-10 text-slate-400"; + + switch (icon) { + case "file": + return ; + case "inbox": + return ; + case "folder": + return ; + case "database": + return ; + case "clipboard": + return ; + case "search": + default: + return ; + } + }; + + const getVariantClasses = () => { + const baseClasses = "min-h-[450px] w-full"; + + switch (variant) { + case "outline": + return `${baseClasses} border-2 border-dashed border-slate-200 bg-white`; + case "muted": + return `${baseClasses} bg-gradient-to-b from-slate-50/80 via-white to-white`; + case "gradient": + return `${baseClasses} bg-gradient-to-br from-slate-50 via-blue-50/30 to-white border border-slate-100`; + default: + return `${baseClasses} bg-white`; + } + }; + + return ( + + + +
+ {getIcon()} +
+
+ + {title} + + + {description} + +
+ {action && {action}} + {showLearnMore && ( + + {learnMoreText} + + + )} +
+ ); +}; + +export default NoData; diff --git a/components/NoImagePlaceholder.tsx b/components/NoImagePlaceholder.tsx new file mode 100644 index 0000000..85f0e00 --- /dev/null +++ b/components/NoImagePlaceholder.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import NoImageIcon from "../assets/no-image-svgrepo-com.svg"; + +interface NoImagePlaceholderProps { + size?: "sm" | "md" | "lg"; + className?: string; +} + +/** + * NoImagePlaceholder Component + * Componente reutilizável para exibir placeholder quando não há imagem disponível + * + * @param size - Tamanho do placeholder (sm, md, lg) + * @param className - Classes CSS adicionais + */ +const NoImagePlaceholder: React.FC = ({ + size = "md", + className = "", +}) => { + const sizeClasses = { + sm: "w-16 h-14 p-4", + md: "w-24 h-20 p-4", + lg: "w-32 h-28 p-4", + }; + + const textSizeClasses = { + sm: "text-xs", + md: "text-sm", + lg: "text-base", + }; + + return ( +
+
+ Sem imagem +
+ + Sem imagem + +
+ ); +}; + +export default NoImagePlaceholder; diff --git a/components/OrderItemsModal.tsx b/components/OrderItemsModal.tsx new file mode 100644 index 0000000..3ef434f --- /dev/null +++ b/components/OrderItemsModal.tsx @@ -0,0 +1,207 @@ +import React, { useState, useEffect } from "react"; +import { formatCurrency } from "../utils/formatters"; +import NoData from "./NoData"; + +interface OrderItem { + productId: number; + description: string; + package: string; + color?: string; + local: string; + quantity: number; + price: number; + subTotal: number; +} + +interface OrderItemsModalProps { + isOpen: boolean; + onClose: () => void; + orderId: number; + orderItems: OrderItem[]; +} + +/** + * OrderItemsModal Component + * Modal para exibir os itens de um pedido + * Segue o padrão de estilização do ConfirmDialog + */ +const OrderItemsModal: React.FC = ({ + isOpen, + onClose, + orderId, + orderItems, +}) => { + const [isAnimating, setIsAnimating] = useState(false); + const [shouldRender, setShouldRender] = useState(false); + + useEffect(() => { + if (isOpen) { + setShouldRender(true); + setTimeout(() => setIsAnimating(true), 10); + } else { + setIsAnimating(false); + const timer = setTimeout(() => setShouldRender(false), 300); + return () => clearTimeout(timer); + } + }, [isOpen]); + + if (!shouldRender) return null; + + const handleClose = () => { + setIsAnimating(false); + setTimeout(() => { + onClose(); + }, 300); + }; + + return ( +
+ {/* Overlay */} +
+ + {/* Dialog */} +
+ {/* Header */} +
+
+
+
+ + + +
+
+

+ Itens do pedido {orderId.toString()} +

+

+ Detalhes +

+
+ +
+
+
+
+ + {/* Content */} +
+ {orderItems.length > 0 ? ( +
+ + + + {[ + "Código", + "Descrição", + "Embalagem", + "Cor", + "Ambiente", + "P.Venda", + "Qtde", + "Valor Total", + ].map((h) => ( + + ))} + + + + {orderItems.map((item, idx) => ( + + + + + + + + + + + ))} + +
+ {h} +
+ {item.productId} + + {item.description} + + {item.package} + + {item.color || "-"} + + {item.local} + + {formatCurrency(item.price)} + + {item.quantity.toFixed(3)} + + {formatCurrency(item.subTotal)} +
+
+ ) : ( +
+ +
+ )} +
+ + {/* Actions */} +
+ +
+
+
+ ); +}; + +export default OrderItemsModal; diff --git a/components/PrintOrderDialog.tsx b/components/PrintOrderDialog.tsx new file mode 100644 index 0000000..6099646 --- /dev/null +++ b/components/PrintOrderDialog.tsx @@ -0,0 +1,214 @@ +import React, { useState, useEffect } from "react"; +import { Button } from "./ui/button"; + +interface PrintOrderDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (model: "A" | "B" | "P") => void; + orderId: number; + includeModelP?: boolean; +} + +/** + * PrintOrderDialog Component + * Diálogo para seleção do modelo de impressão do pedido + * Segue o padrão de estilização do ConfirmDialog + */ +const PrintOrderDialog: React.FC = ({ + isOpen, + onClose, + onConfirm, + orderId, + includeModelP = false, +}) => { + const [isAnimating, setIsAnimating] = useState(false); + const [shouldRender, setShouldRender] = useState(false); + const [selectedModel, setSelectedModel] = useState<"A" | "B" | "P">("A"); + + useEffect(() => { + if (isOpen) { + setShouldRender(true); + setTimeout(() => setIsAnimating(true), 10); + } else { + setIsAnimating(false); + const timer = setTimeout(() => setShouldRender(false), 300); + return () => clearTimeout(timer); + } + }, [isOpen]); + + if (!shouldRender) return null; + + const handleConfirm = () => { + setIsAnimating(false); + setTimeout(() => { + onConfirm(selectedModel); + onClose(); + }, 300); + }; + + const handleCancel = () => { + setIsAnimating(false); + setTimeout(() => { + onClose(); + }, 300); + }; + + return ( +
+ {/* Overlay */} +
+ + {/* Dialog */} +
+ {/* Header */} +
+
+
+
+ + + +
+
+

+ {includeModelP ? "Selecione o modelo de orçamento" : "Selecione o modelo de pedido"} +

+

+ Impressão +

+
+ +
+
+
+
+ + {/* Content */} +
+
+ +
+ + {includeModelP && ( + + )} + +
+
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +}; + +export default PrintOrderDialog; + diff --git a/components/ProductCard.tsx b/components/ProductCard.tsx new file mode 100644 index 0000000..65dc8c6 --- /dev/null +++ b/components/ProductCard.tsx @@ -0,0 +1,287 @@ +import React, { useState } from "react"; +import { Product, OrderItem } from "../types"; +import NoImagePlaceholder from "./NoImagePlaceholder"; +import ImageZoomModal from "./ImageZoomModal"; +import ProductStoreDeliveryModal from "./ProductStoreDeliveryModal"; +import { authService } from "../src/services/auth.service"; + +interface ProductCardProps { + product: Product; + onAddToCart: (p: Product | OrderItem) => void; + onViewDetails: (product: Product, quantity?: number) => void; +} + +/** + * ProductCard Component + * Componente reutilizável para exibir um card de produto na lista de produtos + * + * @param product - Dados do produto a ser exibido + * @param onAddToCart - Callback para adicionar produto ao carrinho + * @param onViewDetails - Callback para abrir modal de detalhes + */ +const ProductCard: React.FC = ({ + product: p, + onAddToCart, + onViewDetails, +}) => { + const [quantity, setQuantity] = useState(1); + const [imageError, setImageError] = useState(false); + const [showImageZoom, setShowImageZoom] = useState(false); + const [showStoreDeliveryModal, setShowStoreDeliveryModal] = useState(false); + + // Calcular parcelamento (10x) + const installmentValue = p.price / 10; + + return ( +
+ {/* Imagem do Produto */} +
+ {p.image && + !p.image.includes("placeholder") && + p.image !== + "https://placehold.co/200x200/f8fafc/949494/png?text=Sem+Imagem" && + !imageError ? ( + <> + {p.name} { + setImageError(true); + }} + /> + {/* Botão de Zoom */} + + + ) : ( + + )} + {/* Badge de Desconto */} + {typeof p.discount === "number" && p.discount > 0 && ( +
+ + {p.discount.toFixed(2)}% + +
+ + + +
+
+ )} + {/* Botão Ver Mais */} + +
+ + {/* Informações do Produto */} +
+ {/* Título do Produto */} +

+ {p.name} +

+ + {/* Descrição Detalhada (se disponível) */} + {p.description && ( +

+ {p.description} +

+ )} + + {/* Informações: Marca - Código - EAN - Modelo */} + {(() => { + const parts: string[] = []; + if (p.mark && p.mark !== "0" && p.mark !== "Sem marca") { + parts.push(p.mark); + } + if (p.code && p.code !== "0") { + parts.push(p.code); + } + if (p.ean && p.ean !== "0") { + parts.push(p.ean); + } + if (p.model && p.model !== "0") { + parts.push(p.model); + } + return parts.length > 0 ? ( +
+

+ {parts.join(" - ")} +

+
+ ) : null; + })()} + + {/* Preços */} +
+ {p.originalPrice && p.originalPrice > p.price && ( +

+ de R${" "} + {p.originalPrice.toLocaleString("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+ )} +
+

+ por R${" "} + {p.price.toLocaleString("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+
+ {/* Parcelamento */} +

+ ou em 10x de R${" "} + {installmentValue.toLocaleString("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+
+ + {/* Informações de Estoque */} +
+
+ + Estoque loja + + + {p.stockLocal.toFixed(2) || 0} UN + +
+
+ + Estoque disponível + + + {p.stockAvailable?.toFixed(2) || p.stockLocal.toFixed(0) || 0} UN + +
+
+ + Estoque geral + + + {p.stockGeneral.toFixed(2) || 0} UN + +
+
+ + {/* Seletor de Quantidade e Botão Adicionar */} +
+ {/* Seletor de Quantidade */} +
+ + { + const val = parseFloat(e.target.value) || 1; + setQuantity(Math.max(1, val)); + }} + className="w-20 text-center text-sm font-bold border-0 focus:outline-none focus:ring-0" + /> + +
+ + {/* Botão Adicionar */} + +
+
+ + {/* Modal de Zoom da Imagem */} + setShowImageZoom(false)} + imageUrl={p.image || ""} + productName={p.name} + /> + + {/* Modal de Seleção de Loja e Tipo de Entrega */} + setShowStoreDeliveryModal(false)} + onConfirm={(stockStore, deliveryType) => { + // Adicionar ao carrinho com as informações selecionadas + onAddToCart({ + ...p, + quantity, + stockStore, + deliveryType, + } as OrderItem); + setShowStoreDeliveryModal(false); + }} + product={p} + quantity={quantity} + /> +
+ ); +}; + +export default ProductCard; diff --git a/components/ProductDetailModal.tsx b/components/ProductDetailModal.tsx new file mode 100644 index 0000000..d8c5217 --- /dev/null +++ b/components/ProductDetailModal.tsx @@ -0,0 +1,1323 @@ +import React, { useState, useEffect, useRef } from "react"; +import { Product, OrderItem } from "../types"; +import { productService, SaleProduct } from "../src/services/product.service"; +import { authService } from "../src/services/auth.service"; +import NoImagePlaceholder from "./NoImagePlaceholder"; +import RelatedProductCard from "./RelatedProductCard"; +import FilialSelector from "./FilialSelector"; +import { + X, + ChevronRight, + ChevronLeft, + Minus, + Plus, + ShoppingCart, + FileText, + Info, + ArrowRight, + AlertCircle, +} from "lucide-react"; +import { validateMinValue, validateRequired } from "../lib/utils"; + +interface ProductDetailModalProps { + product: Product | null; + isOpen: boolean; + onClose: () => void; + onAddToCart: (p: Product | OrderItem) => void; + initialQuantity?: number; // Quantidade inicial ao abrir o modal + onProductChange?: (product: Product) => void; // Callback para mudar o produto exibido no modal +} + +interface ProductDetail extends SaleProduct { + images?: string[]; + stocks?: Array<{ + store: string; + storeName: string; + quantity: number; + work: boolean; + blocked: string; + breakdown: number; + transfer: number; + allowDelivery: number; + }>; + installments?: Array<{ + installment: number; + installmentValue: number; + }>; +} + +const ProductDetailModal: React.FC = ({ + product, + isOpen, + onClose, + onAddToCart, + initialQuantity = 1, + onProductChange, +}) => { + const [productDetail, setProductDetail] = useState( + null + ); + const [buyTogether, setBuyTogether] = useState([]); + const [similarProducts, setSimilarProducts] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingRelated, setLoadingRelated] = useState(false); + const [error, setError] = useState(null); + const [quantity, setQuantity] = useState(initialQuantity); + const [selectedStore, setSelectedStore] = useState(""); + const [deliveryType, setDeliveryType] = useState(""); + const [currentImageIndex, setCurrentImageIndex] = useState(0); + const [activeTab, setActiveTab] = useState<"details" | "specs">("details"); + const [imageError, setImageError] = useState(false); + + // Estados para validação de formulário + const [formErrors, setFormErrors] = useState<{ + quantity?: string; + deliveryType?: string; + selectedStore?: string; + }>({}); + + // Refs para scroll até as seções (scroll vertical) + const buyTogetherSectionRef = useRef(null); + const similarProductsSectionRef = useRef(null); + + // Ref para o container principal do modal (para fazer scroll) + const modalContentRef = useRef(null); + + // Ref para controlar animação em andamento + const scrollAnimationRef = useRef(null); + + // Refs para containers de rolagem horizontal (Compre Junto e Produtos Similares) + const buyTogetherScrollRef = useRef(null); + const similarProductsScrollRef = useRef(null); + + // Estados para controlar visibilidade dos botões de rolagem + const [buyTogetherScrollState, setBuyTogetherScrollState] = useState({ + canScrollLeft: false, + canScrollRight: false, + }); + const [similarProductsScrollState, setSimilarProductsScrollState] = useState({ + canScrollLeft: false, + canScrollRight: false, + }); + + // Tipos de entrega - seguindo EXATAMENTE o padrão do Angular + // Fonte: vendaweb_portal/src/app/sales/components/tintometrico-modal/tintometrico-modal.component.ts + const deliveryTypes = [ + { type: "RI", description: "Retira Imediata" }, + { type: "RP", description: "Retira Posterior" }, + { type: "EN", description: "Entrega" }, + { type: "EF", description: "Encomenda" }, + ]; + + useEffect(() => { + if (isOpen && product) { + // Inicializar quantidade com o valor inicial ou 1 + setQuantity(initialQuantity || 1); + loadProductDetail(); + loadRelatedProducts(); + } else { + // Reset states when modal closes + setProductDetail(null); + setBuyTogether([]); + setSimilarProducts([]); + setQuantity(1); + setCurrentImageIndex(0); + setActiveTab("details"); + } + }, [isOpen, product, initialQuantity]); + + // Função para scroll suave e elegante até uma seção específica + // Usa animação customizada com easing para uma experiência mais fluida + const scrollToSection = (sectionRef: React.RefObject) => { + if (!sectionRef.current || !modalContentRef.current) return; + + // Cancelar animação anterior se houver para evitar conflitos + if (scrollAnimationRef.current !== null) { + cancelAnimationFrame(scrollAnimationRef.current); + scrollAnimationRef.current = null; + } + + const sectionElement = sectionRef.current; + const containerElement = modalContentRef.current; + + // Calcular a posição relativa da seção dentro do container + const containerRect = containerElement.getBoundingClientRect(); + const sectionRect = sectionElement.getBoundingClientRect(); + + // Calcular o offset necessário (posição da seção - posição do container + scroll atual) + const targetScroll = + sectionRect.top - containerRect.top + containerElement.scrollTop - 24; // 24px de margem elegante + + const startScroll = containerElement.scrollTop; + const distance = targetScroll - startScroll; + + // Se a distância for muito pequena, não animar + if (Math.abs(distance) < 5) { + containerElement.scrollTop = targetScroll; + return; + } + + // Duração adaptativa: mais suave para distâncias maiores + // Base: 600ms, máximo: 1200ms, proporcional à distância + const baseDuration = 600; + const maxDuration = 1200; + const distanceFactor = Math.min(Math.abs(distance) / 600, 1); + const duration = + baseDuration + (maxDuration - baseDuration) * distanceFactor; + + let startTime: number | null = null; + + // Função de easing (ease-out-quart) para animação mais elegante e natural + // Esta função cria uma curva de aceleração suave que desacelera suavemente no final + // Mais suave que cubic, proporciona uma sensação mais premium e fluida + const easeOutQuart = (t: number): number => { + return 1 - Math.pow(1 - t, 4); + }; + + // Função de animação usando requestAnimationFrame para máxima fluidez (60fps) + const animateScroll = (currentTime: number) => { + if (startTime === null) startTime = currentTime; + const timeElapsed = currentTime - startTime; + const progress = Math.min(timeElapsed / duration, 1); + + // Aplicar easing para movimento suave e natural + const easedProgress = easeOutQuart(progress); + const currentScroll = startScroll + distance * easedProgress; + + containerElement.scrollTop = currentScroll; + + // Continuar animação até completar + if (progress < 1) { + scrollAnimationRef.current = requestAnimationFrame(animateScroll); + } else { + // Garantir que chegamos exatamente na posição final + containerElement.scrollTop = targetScroll; + scrollAnimationRef.current = null; + } + }; + + // Iniciar animação + scrollAnimationRef.current = requestAnimationFrame(animateScroll); + }; + + // Função para verificar estado de rolagem horizontal + const checkScrollState = ( + containerRef: React.RefObject, + setState: React.Dispatch< + React.SetStateAction<{ canScrollLeft: boolean; canScrollRight: boolean }> + > + ) => { + if (!containerRef.current) return; + const container = containerRef.current; + const { scrollLeft, scrollWidth, clientWidth } = container; + + // Verificar se há conteúdo suficiente para rolar + const hasScrollableContent = scrollWidth > clientWidth; + const isAtStart = scrollLeft <= 5; // 5px de tolerância + const isAtEnd = scrollLeft >= scrollWidth - clientWidth - 5; // 5px de tolerância + + setState({ + canScrollLeft: hasScrollableContent && !isAtStart, + canScrollRight: hasScrollableContent && !isAtEnd, + }); + }; + + // Função para rolar horizontalmente + const scrollHorizontally = ( + containerRef: React.RefObject, + direction: "left" | "right", + setState: React.Dispatch< + React.SetStateAction<{ canScrollLeft: boolean; canScrollRight: boolean }> + > + ) => { + if (!containerRef.current) return; + const container = containerRef.current; + const scrollAmount = 300; // Quantidade de pixels para rolar + const targetScroll = + direction === "left" + ? container.scrollLeft - scrollAmount + : container.scrollLeft + scrollAmount; + + container.scrollTo({ + left: targetScroll, + behavior: "smooth", + }); + + // Verificar estado após um pequeno delay para permitir a animação + setTimeout(() => { + checkScrollState(containerRef, setState); + }, 100); + }; + + const loadProductDetail = async () => { + if (!product?.code) return; + + try { + setLoading(true); + setError(null); + + const store = authService.getStore(); + if (!store) { + throw new Error("Loja não encontrada. Faça login novamente."); + } + + const productId = parseInt(product.code); + if (isNaN(productId)) { + throw new Error("ID do produto inválido"); + } + + // Buscar detalhes do produto + const detail = await productService.getProductDetail(store, productId); + + // Processar imagens + const images: string[] = []; + if (detail.urlImage) { + // Se urlImage contém múltiplas URLs separadas por vírgula, ponto e vírgula ou pipe + const imageUrls = detail.urlImage + .split(/[,;|]/) + .map((url) => url.trim()) + .filter((url) => url.length > 0); + if (imageUrls.length > 0) { + images.push(...imageUrls); + } else { + // Se não conseguiu separar, usar a URL completa + images.push(detail.urlImage); + } + } + if (images.length === 0 && product.image) { + images.push(product.image); + } + + setProductDetail({ ...detail, images }); + + // Buscar estoques + try { + const stocks = await productService.getProductStocks(store, productId); + setProductDetail((prev) => (prev ? { ...prev, stocks } : null)); + } catch (err) { + console.warn("Erro ao carregar estoques:", err); + } + + // Buscar parcelamento + try { + const installments = await productService.getProductInstallments( + store, + productId, + quantity + ); + setProductDetail((prev) => (prev ? { ...prev, installments } : null)); + } catch (err) { + console.warn("Erro ao carregar parcelamento:", err); + } + } catch (err: any) { + console.error("Erro ao carregar detalhes do produto:", err); + setError(err.message || "Erro ao carregar detalhes do produto"); + } finally { + setLoading(false); + } + }; + + const loadRelatedProducts = async () => { + if (!product?.code) return; + + try { + setLoadingRelated(true); + + const store = authService.getStore(); + if (!store) return; + + const productId = parseInt(product.code); + if (isNaN(productId)) return; + + // Buscar produtos "compre junto" e similares em paralelo + const [buyTogetherData, similarData] = await Promise.all([ + productService.getProductsBuyTogether(store, productId).catch(() => []), + productService.getProductsSimilar(store, productId).catch(() => []), + ]); + + setBuyTogether(buyTogetherData || []); + setSimilarProducts(similarData || []); + } catch (err: any) { + console.error("Erro ao carregar produtos relacionados:", err); + } finally { + setLoadingRelated(false); + } + }; + + // Verificar estado de rolagem quando os produtos relacionados são carregados + useEffect(() => { + if (buyTogether.length > 0) { + // Usar requestAnimationFrame para garantir que o DOM foi renderizado + const checkAfterRender = () => { + requestAnimationFrame(() => { + checkScrollState(buyTogetherScrollRef, setBuyTogetherScrollState); + // Verificar novamente após um pequeno delay para garantir + setTimeout(() => { + checkScrollState(buyTogetherScrollRef, setBuyTogetherScrollState); + }, 100); + }); + }; + + // Verificar imediatamente e após delays + checkAfterRender(); + const timeout1 = setTimeout(checkAfterRender, 200); + const timeout2 = setTimeout(checkAfterRender, 500); + + return () => { + clearTimeout(timeout1); + clearTimeout(timeout2); + }; + } + }, [buyTogether, isOpen, loadingRelated]); + + useEffect(() => { + if (similarProducts.length > 0) { + // Usar requestAnimationFrame para garantir que o DOM foi renderizado + const checkAfterRender = () => { + requestAnimationFrame(() => { + checkScrollState( + similarProductsScrollRef, + setSimilarProductsScrollState + ); + // Verificar novamente após um pequeno delay para garantir + setTimeout(() => { + checkScrollState( + similarProductsScrollRef, + setSimilarProductsScrollState + ); + }, 100); + }); + }; + + // Verificar imediatamente e após delays + checkAfterRender(); + const timeout1 = setTimeout(checkAfterRender, 200); + const timeout2 = setTimeout(checkAfterRender, 500); + + return () => { + clearTimeout(timeout1); + clearTimeout(timeout2); + }; + } + }, [similarProducts, isOpen, loadingRelated]); + + // Adicionar listener para scroll manual (mouse/touch) e resize + useEffect(() => { + const buyTogetherContainer = buyTogetherScrollRef.current; + const similarProductsContainer = similarProductsScrollRef.current; + + const handleBuyTogetherScroll = () => { + checkScrollState(buyTogetherScrollRef, setBuyTogetherScrollState); + }; + + const handleSimilarProductsScroll = () => { + checkScrollState(similarProductsScrollRef, setSimilarProductsScrollState); + }; + + // Função para verificar após resize da janela + const handleResize = () => { + if (buyTogetherContainer) { + checkScrollState(buyTogetherScrollRef, setBuyTogetherScrollState); + } + if (similarProductsContainer) { + checkScrollState( + similarProductsScrollRef, + setSimilarProductsScrollState + ); + } + }; + + if (buyTogetherContainer) { + buyTogetherContainer.addEventListener("scroll", handleBuyTogetherScroll); + } + if (similarProductsContainer) { + similarProductsContainer.addEventListener( + "scroll", + handleSimilarProductsScroll + ); + } + + // Adicionar listener de resize + window.addEventListener("resize", handleResize); + + return () => { + if (buyTogetherContainer) { + buyTogetherContainer.removeEventListener( + "scroll", + handleBuyTogetherScroll + ); + } + if (similarProductsContainer) { + similarProductsContainer.removeEventListener( + "scroll", + handleSimilarProductsScroll + ); + } + window.removeEventListener("resize", handleResize); + }; + }, [buyTogether.length, similarProducts.length, isOpen]); + + // Função para validar o formulário + const validateForm = (): { isValid: boolean; errors: typeof formErrors } => { + const errors: { + quantity?: string; + deliveryType?: string; + selectedStore?: string; + } = {}; + + // Validar quantidade + if (!validateRequired(quantity)) { + errors.quantity = "A quantidade é obrigatória"; + } else if (!validateMinValue(quantity, 0.01)) { + errors.quantity = "A quantidade deve ser maior que zero"; + } else if (isNaN(quantity) || quantity <= 0) { + errors.quantity = "A quantidade deve ser um número válido maior que zero"; + } + + // Validar tipo de entrega + if (!validateRequired(deliveryType)) { + errors.deliveryType = "O tipo de entrega é obrigatório"; + } + + // Validar filial de estoque (quando há estoques disponíveis) + if ( + productDetail?.stocks && + productDetail.stocks.length > 0 && + !validateRequired(selectedStore) + ) { + errors.selectedStore = + "A filial de estoque é obrigatória quando há estoques disponíveis"; + } + + setFormErrors(errors); + + // Retorna objeto com isValid e errors + return { isValid: Object.keys(errors).length === 0, errors }; + }; + + const handleAddToCart = () => { + if (!product) { + setError("Produto não encontrado"); + return; + } + + // Limpar erros anteriores + setFormErrors({}); + setError(null); + + // Validar formulário + const validation = validateForm(); + + if (!validation.isValid) { + // Aguardar um tick para que o estado seja atualizado antes de focar + setTimeout(() => { + const firstErrorField = Object.keys(validation.errors)[0]; + + if (firstErrorField === "quantity") { + // Focar no input de quantidade + const quantityInput = document.querySelector( + 'input[type="number"]' + ) as HTMLInputElement; + quantityInput?.focus(); + } else if (firstErrorField === "deliveryType") { + // Focar no select de tipo de entrega + const deliverySelect = document.querySelector( + "select" + ) as HTMLSelectElement; + deliverySelect?.focus(); + } else if (firstErrorField === "selectedStore") { + // Focar no FilialSelector + const storeInput = document.querySelector( + 'input[placeholder*="buscar"]' + ) as HTMLInputElement; + storeInput?.focus(); + } + }, 100); + return; + } + + // Criar OrderItem com todos os campos necessários + // Seguindo exatamente o padrão do Angular (product-detail.component.ts linha 173-203) + // Se temos productDetail (SaleProduct), usar seus dados diretamente + const productWithQuantity: OrderItem = { + ...product, + // Se productDetail existe, usar os dados do SaleProduct (mais completos) + ...(productDetail && { + id: productDetail.idProduct?.toString() || product.id, + code: productDetail.idProduct?.toString() || product.code, + name: productDetail.title || product.name, + description: + productDetail.description || + productDetail.smallDescription || + product.description, + price: productDetail.salePrice || product.price, + originalPrice: productDetail.listPrice || product.originalPrice, + discount: productDetail.offPercent || product.discount, + mark: productDetail.brand || product.mark, + image: + productDetail.images?.[0] || productDetail.urlImage || product.image, + ean: productDetail.ean || product.ean, + model: productDetail.idProduct?.toString() || product.model, + productType: productDetail.productType, + mutiple: productDetail.mutiple, + base: productDetail.base, + letter: productDetail.letter, + line: productDetail.line, + color: productDetail.color, + can: productDetail.can, + }), + quantity, + deliveryType, // Tipo de entrega selecionado (obrigatório) + stockStore: selectedStore || product.stockLocal?.toString() || null, // Filial selecionada ou estoque local + }; + + onAddToCart(productWithQuantity); + // Opcional: fechar modal após adicionar + // onClose(); + }; + + const mapSaleProductToProduct = (saleProduct: SaleProduct): Product => { + const productId = + saleProduct.idProduct?.toString() || saleProduct.id?.toString() || ""; + + let discount: number | undefined = saleProduct.offPercent; + const listPrice = saleProduct.listPrice || 0; + const salePrice = saleProduct.salePrice || saleProduct.salePromotion || 0; + if (!discount && listPrice > 0 && salePrice > 0 && salePrice < listPrice) { + discount = Math.round(((listPrice - salePrice) / listPrice) * 100); + } + + return { + id: productId || `product-${Math.random()}`, + code: productId || "", + name: + saleProduct.title || + saleProduct.smallDescription || + saleProduct.description || + "Produto sem nome", + description: saleProduct.description || saleProduct.smallDescription, + price: salePrice || listPrice || 0, + originalPrice: + listPrice > 0 && salePrice > 0 && salePrice < listPrice + ? listPrice + : undefined, + discount: discount, + mark: saleProduct.brand || "Sem marca", + image: saleProduct.urlImage || "", + stockLocal: saleProduct.store_stock || saleProduct.stock || 0, + stockAvailable: saleProduct.stock || saleProduct.store_stock || 0, + stockGeneral: saleProduct.full_stock || saleProduct.stock || 0, + ean: saleProduct.ean || undefined, + model: saleProduct.idProduct?.toString() || undefined, + }; + }; + + if (!isOpen || !product) return null; + + const images = + productDetail?.images || (product.image ? [product.image] : []); + const hasMultipleImages = images.length > 1; + const hasValidImage = + images.length > 0 && + currentImageIndex >= 0 && + currentImageIndex < images.length; + + return ( +
+
+ {/* Header */} +
+
+

+ Detalhes do Produto +

+ + {/* Botões de navegação rápida */} + {!loading && + (buyTogether.length > 0 || similarProducts.length > 0) && ( +
+ {buyTogether.length > 0 && ( + + )} + {similarProducts.length > 0 && ( + + )} +
+ )} +
+ + +
+ + {/* Content */} +
+ {loading ? ( +
+
+

+ Carregando detalhes... +

+
+ ) : error ? ( +
+

{error}

+
+ ) : ( +
+ {/* Left Column - Images */} +
+ {/* Main Image */} +
+ {hasValidImage && images[currentImageIndex] ? ( + <> + {product.name} { + // Se a imagem falhar ao carregar, mostrar placeholder + setCurrentImageIndex(-1); + }} + /> + {/* Navigation Arrows */} + {hasMultipleImages && ( + <> + + + + )} + + ) : ( + + )} +
+ + {/* Thumbnails */} + {hasMultipleImages && ( +
+ {images.map((img, idx) => ( + + ))} +
+ )} +
+ + {/* Right Column - Product Info */} +
+ {/* Title and Badges */} +
+

+ {productDetail?.title || product.name} +

+
+ + Código: {productDetail?.idProduct || product.code} + + {productDetail?.productType === "A" && ( + + Autosserviço + + )} + {productDetail?.productType === "S" && ( + + Showroom + + )} + {product.discount ? ( + + {product.discount.toFixed(0)}% OFF + + ) : null} +
+
+ + {/* Description */} +
+

+ Descrição +

+

+ {productDetail?.description || + product.description || + "Sem descrição disponível"} +

+
+ + {/* Price */} +
+ {product.originalPrice && + product.originalPrice > product.price && ( +

+ de R${" "} + {product.originalPrice.toLocaleString("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+ )} +
+

+ por R${" "} + {product.price.toLocaleString("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+
+ {productDetail?.installments && + productDetail.installments.length > 0 && ( +

+ ou em {productDetail.installments[0].installment}x de R${" "} + {productDetail.installments[0].installmentValue.toLocaleString( + "pt-BR", + { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + } + )} +

+ )} +
+ + {/* Add to Cart Form */} +
+

+ Adicionar ao Carrinho +

+ + {/* Quantity */} +
+ +
+ + { + const val = parseFloat(e.target.value); + if (!isNaN(val) && val > 0) { + setQuantity(val); + // Limpar erro ao alterar + if (formErrors.quantity) { + setFormErrors((prev) => ({ + ...prev, + quantity: undefined, + })); + } + } + }} + onBlur={() => { + // Validar ao sair do campo + if ( + !validateRequired(quantity) || + !validateMinValue(quantity, 0.01) + ) { + setFormErrors((prev) => ({ + ...prev, + quantity: "A quantidade deve ser maior que zero", + })); + } + }} + className={`w-20 text-center text-sm font-bold border-0 focus:outline-none focus:ring-2 focus:ring-orange-500/20 bg-white ${ + formErrors.quantity ? "text-red-600" : "" + }`} + /> + +
+ {formErrors.quantity && ( +
+ + {formErrors.quantity} +
+ )} +
+ + {/* Stock Info */} + {productDetail?.stocks && productDetail.stocks.length > 0 && ( +
+ { + setSelectedStore(value); + // Limpar erro ao alterar + if (formErrors.selectedStore) { + setFormErrors((prev) => ({ + ...prev, + selectedStore: undefined, + })); + } + }} + label="Filial Retira" + placeholder="Digite para buscar..." + /> + {formErrors.selectedStore && ( +
+ + {formErrors.selectedStore} +
+ )} +
+ )} + + {/* Delivery Type */} +
+ + + {formErrors.deliveryType && ( +
+ + {formErrors.deliveryType} +
+ )} +
+ + {/* Add Button */} + +
+ + {/* Tabs */} +
+
+ + +
+ + {activeTab === "details" && ( +
+
+
+ + EAN + +

+ {product.ean || "N/A"} +

+
+
+ + Marca + +

+ {product.mark || "N/A"} +

+
+
+ + Estoque Loja + +

+ {product.stockLocal || 0} UN +

+
+
+ + Estoque Disponível + +

+ {product.stockAvailable || 0} UN +

+
+
+
+ )} + + {activeTab === "specs" && ( +
+

+ {productDetail?.technicalData || + "Sem especificações técnicas disponíveis"} +

+
+ )} +
+
+
+ )} + + {/* Compre Junto Section */} + {!loading && buyTogether.length > 0 && ( +
+

+ Compre Junto +

+ {loadingRelated ? ( +
+
+
+ ) : ( +
+ {/* Botão de rolagem esquerda - sempre visível */} + + {/* Container com produtos */} +
+ {buyTogether.map((relatedProduct) => { + const mappedProduct = + mapSaleProductToProduct(relatedProduct); + return ( +
+ + onAddToCart({ ...p, quantity: 1 }) + } + onClick={() => { + // Atualizar o produto exibido no modal + if (onProductChange) { + onProductChange(mappedProduct); + // Scroll para o topo do modal + if (modalContentRef.current) { + modalContentRef.current.scrollTo({ + top: 0, + behavior: "smooth", + }); + } + } + }} + /> +
+ ); + })} +
+ {/* Botão de rolagem direita - sempre visível quando há scroll */} + +
+ )} +
+ )} + + {/* Produtos Similares */} + {!loading && similarProducts.length > 0 && ( +
+

+ Produtos Similares +

+ {loadingRelated ? ( +
+
+
+ ) : ( +
+ {/* Botão de rolagem esquerda - sempre visível quando há scroll */} + + {/* Container com produtos */} +
+ {similarProducts.map((similarProduct) => { + const mappedProduct = + mapSaleProductToProduct(similarProduct); + return ( +
+ + onAddToCart({ ...p, quantity: 1 }) + } + onClick={() => { + // Atualizar o produto exibido no modal + if (onProductChange) { + onProductChange(mappedProduct); + // Scroll para o topo do modal + if (modalContentRef.current) { + modalContentRef.current.scrollTo({ + top: 0, + behavior: "smooth", + }); + } + } + }} + /> +
+ ); + })} +
+ {/* Botão de rolagem direita - sempre visível quando há scroll */} + +
+ )} +
+ )} +
+
+ +
+ ); +}; + +export default ProductDetailModal; diff --git a/components/ProductListItem.tsx b/components/ProductListItem.tsx new file mode 100644 index 0000000..bfb1a10 --- /dev/null +++ b/components/ProductListItem.tsx @@ -0,0 +1,294 @@ +import React, { useState } from "react"; +import { Product } from "../types"; +import NoImagePlaceholder from "./NoImagePlaceholder"; +import ImageZoomModal from "./ImageZoomModal"; +import ProductStoreDeliveryModal from "./ProductStoreDeliveryModal"; +import { authService } from "../src/services/auth.service"; + +interface ProductListItemProps { + product: Product; + quantity: number; + onQuantityChange: (quantity: number) => void; + onAddToCart: ( + product: Product, + quantity: number, + stockStore?: string, + deliveryType?: string + ) => void; + onViewDetails: (product: Product, quantity?: number) => void; +} + +/** + * ProductListItem Component + * Componente para exibir um produto no modo lista + */ +const ProductListItem: React.FC = ({ + product, + quantity, + onQuantityChange, + onAddToCart, + onViewDetails, +}) => { + const [imageError, setImageError] = useState(false); + const [showImageZoom, setShowImageZoom] = useState(false); + const [showStoreDeliveryModal, setShowStoreDeliveryModal] = useState(false); + + return ( +
+ {/* Imagem do produto */} +
+ {product.image && + product.image !== + "https://placehold.co/200x200/f8fafc/949494/png?text=Sem+Imagem" && + !product.image.includes("placeholder") && + !imageError ? ( + <> + {product.name} { + setImageError(true); + }} + /> + {/* Badge de Desconto */} + {typeof product.discount === "number" && product.discount > 0 && ( +
+ + {product.discount.toFixed(2)}% + +
+ + + +
+
+ )} + {/* Botão de Zoom */} + + + ) : ( + + )} +
+ + {/* Informações do produto */} +
+ {/* Parte superior: Título, descrição, informações */} +
+
+

+ {product.name} +

+ +
+ + {/* Descrição */} + {product.description && ( +

+ {product.description} +

+ )} + + {/* Informações: Marca - Código - EAN - Modelo */} + {(() => { + const parts: string[] = []; + if ( + product.mark && + product.mark !== "0" && + product.mark !== "Sem marca" + ) { + parts.push(product.mark); + } + if (product.code && product.code !== "0") { + parts.push(product.code); + } + if (product.ean && product.ean !== "0") { + parts.push(product.ean); + } + if (product.model && product.model !== "0") { + parts.push(product.model); + } + return parts.length > 0 ? ( +
+

+ {parts.join(" - ")} +

+
+ ) : null; + })()} + + {/* Preços */} +
+ {product.originalPrice && product.originalPrice > product.price && ( +

+ de R${" "} + {product.originalPrice.toLocaleString("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+ )} +
+

+ por R${" "} + {product.price.toLocaleString("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+
+ {/* Parcelamento */} +

+ ou em 10x de R${" "} + {(product.price / 10).toLocaleString("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+
+ + {/* Informações de Estoque */} +
+
+ + Estoque loja + + + {product.stockLocal.toFixed(2) || 0} UN + +
+
+ + Estoque disponível + + + {product.stockAvailable?.toFixed(2) || + product.stockLocal.toFixed(2) || + 0}{" "} + UN + +
+
+ + Estoque geral + + + {product.stockGeneral.toFixed(2) || 0} UN + +
+
+
+ + {/* Parte inferior: Seletor de quantidade e botão adicionar */} +
+ {/* Seletor de Quantidade */} +
+ + { + const val = parseFloat(e.target.value) || 1; + const newQty = Math.max(1, val); + onQuantityChange(newQty); + }} + className="w-20 text-center text-sm font-bold border-0 focus:outline-none focus:ring-0" + /> + +
+ + {/* Botão Adicionar */} + +
+
+ + {/* Modal de Zoom da Imagem */} + setShowImageZoom(false)} + imageUrl={product.image || ""} + productName={product.name} + /> + + {/* Modal de Seleção de Loja e Tipo de Entrega */} + setShowStoreDeliveryModal(false)} + onConfirm={(stockStore, deliveryType) => { + // Adicionar ao carrinho com as informações selecionadas + onAddToCart(product, quantity, stockStore, deliveryType); + setShowStoreDeliveryModal(false); + }} + product={product} + quantity={quantity} + /> +
+ ); +}; + +export default ProductListItem; diff --git a/components/ProductStoreDeliveryModal.tsx b/components/ProductStoreDeliveryModal.tsx new file mode 100644 index 0000000..fa4a953 --- /dev/null +++ b/components/ProductStoreDeliveryModal.tsx @@ -0,0 +1,258 @@ +import React, { useState, useEffect } from "react"; +import { Product } from "../types"; +import { productService } from "../src/services/product.service"; +import { authService } from "../src/services/auth.service"; +import FilialSelector from "./FilialSelector"; +import { X } from "lucide-react"; + +interface Stock { + store: string; + storeName: string; + quantity: number; + work: boolean; + blocked: string; + breakdown: number; + transfer: number; + allowDelivery: number; +} + +interface ProductStoreDeliveryModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (stockStore: string, deliveryType: string) => void; + product: Product | null; + quantity?: number; +} + +const ProductStoreDeliveryModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + product, + quantity = 1, +}) => { + const [selectedStore, setSelectedStore] = useState(""); + const [deliveryType, setDeliveryType] = useState(""); + const [stocks, setStocks] = useState([]); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState<{ + stockStore?: string; + deliveryType?: string; + }>({}); + + // Tipos de entrega - seguindo EXATAMENTE o padrão do Angular + const deliveryTypes = [ + { type: "RI", description: "Retira Imediata" }, + { type: "RP", description: "Retira Posterior" }, + { type: "EN", description: "Entrega" }, + { type: "EF", description: "Encomenda" }, + ]; + + // Carregar estoques quando o modal abrir + useEffect(() => { + if (isOpen && product?.code) { + loadStocks(); + } else { + // Resetar estados quando fechar + setSelectedStore(""); + setDeliveryType(""); + setStocks([]); + setErrors({}); + } + }, [isOpen, product]); + + const loadStocks = async () => { + if (!product?.code) return; + + try { + setLoading(true); + const store = authService.getStore(); + if (!store) { + throw new Error("Loja não encontrada. Faça login novamente."); + } + + const productId = parseInt(product.code); + if (isNaN(productId)) { + throw new Error("ID do produto inválido"); + } + + const stocksData = await productService.getProductStocks(store, productId); + setStocks(stocksData); + + // Selecionar a primeira loja por padrão se houver apenas uma + if (stocksData.length === 1) { + setSelectedStore(stocksData[0].store); + } + } catch (err: any) { + console.error("Erro ao carregar estoques:", err); + setErrors({ stockStore: "Erro ao carregar lojas de estoque" }); + } finally { + setLoading(false); + } + }; + + const handleConfirm = () => { + const newErrors: { stockStore?: string; deliveryType?: string } = {}; + + // Validar loja de estoque + if (!selectedStore) { + newErrors.stockStore = "Selecione uma loja de estoque"; + } + + // Validar tipo de entrega + if (!deliveryType) { + newErrors.deliveryType = "Selecione um tipo de entrega"; + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } + + // Se tudo estiver válido, confirmar + onConfirm(selectedStore, deliveryType); + onClose(); + }; + + if (!isOpen || !product) return null; + + return ( +
+ {/* Overlay */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+

+ {product.code} - {product.name} +

+
+ +
+ + {/* Content */} +
+
+

+ Selecione a loja de estoque e forma de entrega +

+
+ + {/* LOCAL DE ESTOQUE */} +
+ + {loading ? ( +
+
+ + Carregando lojas... + +
+ ) : stocks.length > 0 ? ( + { + setSelectedStore(value); + if (errors.stockStore) { + setErrors((prev) => ({ ...prev, stockStore: undefined })); + } + }} + placeholder="Selecione a loja..." + label="" + className="w-full" + /> + ) : ( +
+

+ Nenhuma loja de estoque disponível +

+
+ )} + {errors.stockStore && ( +
+ {errors.stockStore} +
+ )} +
+ + {/* TIPO DE ENTREGA */} +
+ +
+ {deliveryTypes.map((dt) => ( + + ))} +
+ {errors.deliveryType && ( +
+ {errors.deliveryType} +
+ )} +

+ Informe a forma de entrega do produto +

+
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +}; + +export default ProductStoreDeliveryModal; + diff --git a/components/ReceivePixDialog.tsx b/components/ReceivePixDialog.tsx new file mode 100644 index 0000000..691b8e6 --- /dev/null +++ b/components/ReceivePixDialog.tsx @@ -0,0 +1,241 @@ +import React, { useState, useEffect } from "react"; +import QRCode from "react-qr-code"; +import { formatCurrency } from "../utils/formatters"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; + +interface ReceivePixDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (value: number) => void; + orderId: number; + customerName: string; + orderValue: number; + showQrCode: boolean; + qrCodeValue?: string; + pixValue?: number; + processing?: boolean; +} + +const ReceivePixDialog: React.FC = ({ + isOpen, + onClose, + onConfirm, + orderId, + customerName, + orderValue, + showQrCode, + qrCodeValue = "", + pixValue = 0, + processing = false, +}) => { + const [inputValue, setInputValue] = useState(""); + const [isModalAnimating, setIsModalAnimating] = useState(false); + const [shouldRenderModal, setShouldRenderModal] = useState(false); + + useEffect(() => { + if (isOpen) { + setShouldRenderModal(true); + setTimeout(() => setIsModalAnimating(true), 10); + if (!showQrCode) { + setInputValue(orderValue.toFixed(2)); + } + } else { + setIsModalAnimating(false); + const timer = setTimeout(() => setShouldRenderModal(false), 300); + return () => clearTimeout(timer); + } + }, [isOpen, showQrCode, orderValue]); + + const handleConfirm = () => { + const value = parseFloat(inputValue); + if (isNaN(value) || value <= 0) { + return; + } + onConfirm(value); + }; + + const handleCopyQrCode = () => { + if (qrCodeValue) { + navigator.clipboard.writeText(qrCodeValue); + } + }; + + if (!shouldRenderModal) return null; + + return ( +
+ {/* Overlay */} +
+ + {/* Dialog */} +
+ {/* Header */} +
+
+
+
+ + + +
+
+

Recebimento via PIX

+

+ {showQrCode ? "QR Code gerado" : "Informe o valor"} +

+
+ +
+
+
+
+ + {/* Content */} +
+ {!showQrCode ? ( +
+
+ + setInputValue(e.target.value)} + placeholder="0.00" + className="mt-2" + /> +

+ Valor máximo: {formatCurrency(orderValue)} +

+
+
+ ) : ( +
+ {/* Informações do pedido */} +
+
+ +

{orderId}

+
+
+ +

{customerName}

+
+
+ +

+ {formatCurrency(pixValue)} +

+
+
+ + {/* QR Code */} +
+
+ +
+
+ + {/* Código PIX */} +
+ +
+ (e.target as HTMLInputElement).select()} + className="font-mono text-xs" + /> + +
+
+
+ )} +
+ + {/* Actions */} +
+ {!showQrCode ? ( + <> + + + + ) : ( + + )} +
+
+
+ ); +}; + +export default ReceivePixDialog; + diff --git a/components/RelatedProductCard.tsx b/components/RelatedProductCard.tsx new file mode 100644 index 0000000..419bd3c --- /dev/null +++ b/components/RelatedProductCard.tsx @@ -0,0 +1,75 @@ +import React, { useState } from "react"; +import { Product } from "../types"; +import NoImagePlaceholder from "./NoImagePlaceholder"; + +interface RelatedProductCardProps { + product: Product; + onAddToCart: (product: Product) => void; + onClick?: () => void; +} + +/** + * RelatedProductCard Component + * Componente reutilizável para exibir cards de produtos relacionados (Compre Junto / Similares) + * + * @param product - Dados do produto + * @param onAddToCart - Callback para adicionar ao carrinho + * @param onClick - Callback quando o card é clicado (opcional) + */ +const RelatedProductCard: React.FC = ({ + product, + onAddToCart, + onClick, +}) => { + const [imageError, setImageError] = useState(false); + + return ( +
+
+ {product.image && !product.image.includes("placeholder") && !imageError ? ( + {product.name} setImageError(true)} + /> + ) : ( + + )} +
+

+ {product.name} +

+

+ R${" "} + {product.price.toLocaleString("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +

+ +
+ ); +}; + +export default RelatedProductCard; + + diff --git a/components/SearchInput.tsx b/components/SearchInput.tsx new file mode 100644 index 0000000..c10ae5d --- /dev/null +++ b/components/SearchInput.tsx @@ -0,0 +1,108 @@ +import React from "react"; + +interface SearchInputProps { + value: string; + onChange: (value: string) => void; + onSearch: () => void; + placeholder?: string; + loading?: boolean; + disabled?: boolean; + minLength?: number; +} + +/** + * SearchInput Component + * Componente reutilizável para campo de busca com botão de pesquisa + * + * @param value - Valor atual do input + * @param onChange - Callback quando o valor muda + * @param onSearch - Callback quando o botão de busca é clicado ou Enter é pressionado + * @param placeholder - Texto placeholder do input + * @param loading - Indica se está carregando + * @param disabled - Indica se está desabilitado + * @param minLength - Tamanho mínimo para habilitar a busca + */ +const SearchInput: React.FC = ({ + value, + onChange, + onSearch, + placeholder = "Ex: Cimento, Tijolo, Furadeira...", + loading = false, + disabled = false, + minLength = 3, +}) => { + const isValid = value.trim().length >= minLength; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !loading && isValid) { + onSearch(); + } + }; + + return ( +
+ onChange(e.target.value)} + onKeyDown={handleKeyDown} + disabled={disabled || loading} + /> + + {value.trim().length > 0 && value.trim().length < minLength && ( +

+ Digite pelo menos {minLength} caracteres +

+ )} + + + +
+ ); +}; + +export default SearchInput; diff --git a/components/StatCard.tsx b/components/StatCard.tsx new file mode 100644 index 0000000..787b255 --- /dev/null +++ b/components/StatCard.tsx @@ -0,0 +1,43 @@ +import React from "react"; + +interface StatCardProps { + label: string; + value: string; + trend?: string; + color: string; +} + +const StatCard: React.FC = ({ + label, + value, + trend, + color, +}) => ( +
+
+ + {label} + + {trend && ( + + {trend} + + )} +
+
+ {value} +
+
+
+); + +export default StatCard; + + + diff --git a/components/StimulsoftViewer.tsx b/components/StimulsoftViewer.tsx new file mode 100644 index 0000000..157cf1b --- /dev/null +++ b/components/StimulsoftViewer.tsx @@ -0,0 +1,334 @@ +import React, { useEffect, useRef, useState } from "react"; + +interface StimulsoftViewerProps { + requestUrl: string; + action?: string; + width?: string; + height?: string; + onClose?: () => void; + orderId?: number; + model?: string; +} + +/** + * StimulsoftViewer Component + * Componente para renderizar o Stimulsoft Reports Viewer + * Baseado no componente stimulsoft-viewer-angular do portal Angular + * + * O componente usa a biblioteca Stimulsoft Reports.JS para processar o JSON + * retornado pelo servidor e renderizar o viewer + */ +const StimulsoftViewer: React.FC = ({ + requestUrl, + action, + width = "100%", + height = "800px", + onClose, + orderId, + model, +}) => { + const viewerRef = useRef(null); + const iframeRef = useRef(null); + const formRef = useRef(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Gerar ID único para o viewer (deve ser declarado antes do useEffect) + const viewerIdRef = useRef( + `stimulsoft-viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + ); + const viewerId = viewerIdRef.current; + + // Construir a URL base (sem query params, pois vamos usar POST) + const baseUrl = action ? requestUrl.replace("{action}", action) : requestUrl; + const urlWithoutParams = baseUrl.split("?")[0]; + + useEffect(() => { + let isMounted = true; + + if (!viewerRef.current) return; + + // Limpar conteúdo anterior + viewerRef.current.innerHTML = ""; + + // Extrair parâmetros da URL original se não foram fornecidos via props + const urlParams = new URLSearchParams(baseUrl.split("?")[1] || ""); + const finalOrderId = orderId + ? String(orderId) + : urlParams.get("orderId") || ""; + const finalModel = model || urlParams.get("model") || "A"; + + // Criar iframe + const iframe = document.createElement("iframe"); + iframe.name = `stimulsoft-viewer-iframe-${Date.now()}`; + iframe.style.width = "100%"; + iframe.style.height = "100%"; + iframe.style.border = "none"; + iframe.style.borderRadius = "0 0 1.5rem 1.5rem"; + iframe.title = "Stimulsoft Viewer"; + iframe.allow = "fullscreen"; + + // Criar formulário que faz POST com multipart/form-data + // O componente Angular stimulsoft-viewer-angular faz POST com esses parâmetros + // O navegador vai criar o boundary automaticamente quando usamos multipart/form-data + const form = document.createElement("form"); + form.method = "POST"; + form.action = urlWithoutParams; + form.target = iframe.name; + form.setAttribute("enctype", "multipart/form-data"); + form.style.display = "none"; + + // Adicionar campos do formulário conforme a requisição funcional do Angular + const fields = { + orderId: finalOrderId, + model: finalModel, + stiweb_component: "Viewer", + stiweb_imagesScalingFactor: "1", + stiweb_action: "AngularViewerData", + }; + + // Log para debug + console.log("StimulsoftViewer - URL:", urlWithoutParams); + console.log("StimulsoftViewer - Campos:", fields); + + Object.entries(fields).forEach(([key, value]) => { + const input = document.createElement("input"); + input.type = "hidden"; + input.name = key; + input.value = String(value); + form.appendChild(input); + }); + + // Verificar se orderId está vazio (pode causar erro 500) + if (!finalOrderId) { + setError("Erro: orderId não foi fornecido."); + setLoading(false); + return; + } + + let loadTimeout: NodeJS.Timeout; + let errorDetected = false; + + const handleIframeLoad = () => { + if (isMounted && !errorDetected) { + // Verificar se houve erro no iframe (status 500, etc) + try { + const iframeDoc = + iframe.contentDocument || iframe.contentWindow?.document; + if (iframeDoc) { + const bodyText = + iframeDoc.body?.innerText || iframeDoc.body?.textContent || ""; + // Se o conteúdo contém indicadores de erro + if ( + bodyText.includes("500") || + bodyText.includes("Internal Server Error") || + bodyText.includes("Error") + ) { + errorDetected = true; + setError( + "Erro ao carregar o relatório. O servidor retornou um erro." + ); + setLoading(false); + return; + } + } + } catch (e) { + // CORS pode impedir acesso ao conteúdo do iframe, mas isso é normal + console.log( + "Não foi possível verificar conteúdo do iframe (pode ser CORS):", + e + ); + } + + clearTimeout(loadTimeout); + setLoading(false); + } + }; + + const handleIframeError = () => { + if (isMounted) { + errorDetected = true; + setError( + "Erro ao carregar o relatório. Verifique a URL e a conexão com o servidor." + ); + setLoading(false); + } + }; + + // Timeout para detectar se o iframe não carregou + loadTimeout = setTimeout(() => { + if (isMounted && loading) { + // Verificar se o iframe realmente carregou + try { + if (iframe.contentWindow && iframe.contentDocument) { + // Iframe carregou, mas pode ter demorado + handleIframeLoad(); + } else { + errorDetected = true; + setError("Timeout ao carregar o relatório. Verifique a conexão."); + setLoading(false); + } + } catch (e) { + // CORS pode impedir acesso, mas isso não significa erro + // Se passou muito tempo, pode ser um problema + errorDetected = true; + setError("Timeout ao carregar o relatório."); + setLoading(false); + } + } + }, 30000); // 30 segundos de timeout + + iframe.addEventListener("load", handleIframeLoad); + iframe.addEventListener("error", handleIframeError); + + // Adicionar iframe primeiro ao DOM e aguardar estar pronto + if (viewerRef.current) { + viewerRef.current.appendChild(iframe); + } + + formRef.current = form; + iframeRef.current = iframe; + + // Aguardar o iframe estar completamente carregado antes de submeter o formulário + const waitForIframeReady = () => { + if (!isMounted) return; + + // Verificar se o iframe está pronto + try { + // Tentar acessar o contentWindow para verificar se está pronto + if (iframe.contentWindow) { + // Iframe está pronto, adicionar formulário e submeter + // Adicionar formulário ao body do documento + document.body.appendChild(form); + + // Aguardar um pequeno delay antes de submeter + setTimeout(() => { + if (isMounted && formRef.current) { + try { + console.log("Submetendo formulário para:", urlWithoutParams); + formRef.current.submit(); + } catch (e) { + console.error("Erro ao submeter formulário:", e); + errorDetected = true; + setError("Erro ao enviar requisição ao servidor."); + setLoading(false); + } + } + }, 50); + } else { + // Iframe ainda não está pronto, tentar novamente + setTimeout(waitForIframeReady, 10); + } + } catch (e) { + // Erro ao acessar contentWindow (pode ser CORS), mas continuar mesmo assim + document.body.appendChild(form); + setTimeout(() => { + if (isMounted && formRef.current) { + try { + console.log("Submetendo formulário para:", urlWithoutParams); + formRef.current.submit(); + } catch (err) { + console.error("Erro ao submeter formulário:", err); + errorDetected = true; + setError("Erro ao enviar requisição ao servidor."); + setLoading(false); + } + } + }, 100); + } + }; + + // Aguardar um pouco antes de verificar se o iframe está pronto + setTimeout(waitForIframeReady, 50); + + return () => { + isMounted = false; + clearTimeout(loadTimeout); + if (iframeRef.current) { + iframeRef.current.removeEventListener("load", handleIframeLoad); + iframeRef.current.removeEventListener("error", handleIframeError); + } + // Remover formulário do body se ainda estiver lá + if (formRef.current && formRef.current.parentNode) { + formRef.current.parentNode.removeChild(formRef.current); + } + }; + }, [baseUrl, urlWithoutParams, orderId, model]); + + return ( +
+ {/*