From 812ef26e9fd3fec8833e8acdfc3b34fec1d6567a Mon Sep 17 00:00:00 2001 From: JuruSysadmin Date: Thu, 8 Jan 2026 09:09:16 -0300 Subject: [PATCH] Initial commit --- .gitignore | 24 + App.tsx | 214 + MIGRATION_SUMMARY.md | 156 + README.md | 20 + README_AUTH.md | 174 + assets/icone.svg | 20 + assets/loading.webp | Bin 0 -> 53282 bytes assets/logo2.svg | 1 + assets/no-image-svgrepo-com.svg | 2 + assets/pix-svgrepo-com.svg | 2 + components/ArcGauge.tsx | 153 + components/Baldinho.tsx | 325 + components/CartDrawer.tsx | 469 + components/CategoryCard.tsx | 52 + components/ConfirmDialog.tsx | 373 + components/CreateCustomerDialog.tsx | 520 + components/EditItemModal.tsx | 492 + components/FilialSelector.tsx | 324 + components/FilterSidebar.tsx | 675 + components/Gauge.tsx | 54 + components/Header.tsx | 119 + components/ImageZoomModal.tsx | 167 + components/LoadingSpinner.tsx | 59 + components/NoData.tsx | 116 + components/NoImagePlaceholder.tsx | 52 + components/OrderItemsModal.tsx | 207 + components/PrintOrderDialog.tsx | 214 + components/ProductCard.tsx | 287 + components/ProductDetailModal.tsx | 1323 ++ components/ProductListItem.tsx | 294 + components/ProductStoreDeliveryModal.tsx | 258 + components/ReceivePixDialog.tsx | 241 + components/RelatedProductCard.tsx | 75 + components/SearchInput.tsx | 108 + components/StatCard.tsx | 43 + components/StimulsoftViewer.tsx | 334 + components/checkout/AddressFormModal.tsx | 692 + components/checkout/AddressSelectionModal.tsx | 279 + components/checkout/AddressStep.tsx | 417 + components/checkout/CheckoutProductsTable.tsx | 386 + components/checkout/CheckoutSummary.tsx | 245 + components/checkout/CheckoutWizard.tsx | 141 + components/checkout/ConfirmModal.tsx | 48 + components/checkout/CustomerSearchModal.tsx | 146 + components/checkout/CustomerStep.tsx | 347 + .../checkout/DeliveryAvailabilityStatus.tsx | 255 + components/checkout/DeliveryTaxModal.tsx | 201 + components/checkout/DiscountItemModal.tsx | 284 + components/checkout/DiscountOrderModal.tsx | 226 + components/checkout/InfoModal.tsx | 45 + components/checkout/LoadingModal.tsx | 87 + components/checkout/NotesStep.tsx | 251 + components/checkout/PaymentStep.tsx | 187 + components/dashboard/DashboardDayView.tsx | 298 + components/dashboard/DashboardSellerView.tsx | 409 + components/dashboard/OrdersView.tsx | 1145 ++ components/dashboard/PreorderView.tsx | 993 ++ components/dashboard/ProductsSoldView.tsx | 473 + components/ui/autocomplete.tsx | 168 + components/ui/button.tsx | 53 + components/ui/combobox.tsx | 102 + components/ui/command.tsx | 108 + components/ui/date-input.tsx | 37 + components/ui/empty.tsx | 97 + components/ui/input.tsx | 28 + components/ui/label.tsx | 26 + components/ui/popover.tsx | 30 + docs/SHOPPING_CART_GUIDE.md | 389 + index.html | 73 + index.tsx | 18 + lib/mui-license.ts | 33 + lib/utils.ts | 434 + metadata.json | 5 + package-lock.json | 13224 ++++++++++++++++ package.json | 41 + src/config/env.ts | 35 + src/contexts/AuthContext.tsx | 197 + src/hooks/useCart.ts | 377 + src/services/auth.service.ts | 220 + src/services/customer.service.ts | 340 + src/services/lookup.service.ts | 192 + src/services/order.service.ts | 308 + src/services/product.service.ts | 617 + src/services/shipping.service.ts | 70 + src/services/shopping.service.ts | 661 + src/types/auth.ts | 52 + src/vite-env.d.ts | 34 + tsconfig.json | 29 + types.ts | 43 + utils/formatters.ts | 18 + views/CheckoutView.tsx | 2771 ++++ views/HomeMenuView.tsx | 300 + views/LoginView.tsx | 248 + views/ProductSearchView.tsx | 1271 ++ views/SalesDashboardView.tsx | 323 + vite.config.ts | 23 + 96 files changed, 38497 insertions(+) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 MIGRATION_SUMMARY.md create mode 100644 README.md create mode 100644 README_AUTH.md create mode 100644 assets/icone.svg create mode 100644 assets/loading.webp create mode 100644 assets/logo2.svg create mode 100644 assets/no-image-svgrepo-com.svg create mode 100644 assets/pix-svgrepo-com.svg create mode 100644 components/ArcGauge.tsx create mode 100644 components/Baldinho.tsx create mode 100644 components/CartDrawer.tsx create mode 100644 components/CategoryCard.tsx create mode 100644 components/ConfirmDialog.tsx create mode 100644 components/CreateCustomerDialog.tsx create mode 100644 components/EditItemModal.tsx create mode 100644 components/FilialSelector.tsx create mode 100644 components/FilterSidebar.tsx create mode 100644 components/Gauge.tsx create mode 100644 components/Header.tsx create mode 100644 components/ImageZoomModal.tsx create mode 100644 components/LoadingSpinner.tsx create mode 100644 components/NoData.tsx create mode 100644 components/NoImagePlaceholder.tsx create mode 100644 components/OrderItemsModal.tsx create mode 100644 components/PrintOrderDialog.tsx create mode 100644 components/ProductCard.tsx create mode 100644 components/ProductDetailModal.tsx create mode 100644 components/ProductListItem.tsx create mode 100644 components/ProductStoreDeliveryModal.tsx create mode 100644 components/ReceivePixDialog.tsx create mode 100644 components/RelatedProductCard.tsx create mode 100644 components/SearchInput.tsx create mode 100644 components/StatCard.tsx create mode 100644 components/StimulsoftViewer.tsx create mode 100644 components/checkout/AddressFormModal.tsx create mode 100644 components/checkout/AddressSelectionModal.tsx create mode 100644 components/checkout/AddressStep.tsx create mode 100644 components/checkout/CheckoutProductsTable.tsx create mode 100644 components/checkout/CheckoutSummary.tsx create mode 100644 components/checkout/CheckoutWizard.tsx create mode 100644 components/checkout/ConfirmModal.tsx create mode 100644 components/checkout/CustomerSearchModal.tsx create mode 100644 components/checkout/CustomerStep.tsx create mode 100644 components/checkout/DeliveryAvailabilityStatus.tsx create mode 100644 components/checkout/DeliveryTaxModal.tsx create mode 100644 components/checkout/DiscountItemModal.tsx create mode 100644 components/checkout/DiscountOrderModal.tsx create mode 100644 components/checkout/InfoModal.tsx create mode 100644 components/checkout/LoadingModal.tsx create mode 100644 components/checkout/NotesStep.tsx create mode 100644 components/checkout/PaymentStep.tsx create mode 100644 components/dashboard/DashboardDayView.tsx create mode 100644 components/dashboard/DashboardSellerView.tsx create mode 100644 components/dashboard/OrdersView.tsx create mode 100644 components/dashboard/PreorderView.tsx create mode 100644 components/dashboard/ProductsSoldView.tsx create mode 100644 components/ui/autocomplete.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/combobox.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/date-input.tsx create mode 100644 components/ui/empty.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/popover.tsx create mode 100644 docs/SHOPPING_CART_GUIDE.md create mode 100644 index.html create mode 100644 index.tsx create mode 100644 lib/mui-license.ts create mode 100644 lib/utils.ts create mode 100644 metadata.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/config/env.ts create mode 100644 src/contexts/AuthContext.tsx create mode 100644 src/hooks/useCart.ts create mode 100644 src/services/auth.service.ts create mode 100644 src/services/customer.service.ts create mode 100644 src/services/lookup.service.ts create mode 100644 src/services/order.service.ts create mode 100644 src/services/product.service.ts create mode 100644 src/services/shipping.service.ts create mode 100644 src/services/shopping.service.ts create mode 100644 src/types/auth.ts create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 utils/formatters.ts create mode 100644 views/CheckoutView.tsx create mode 100644 views/HomeMenuView.tsx create mode 100644 views/LoginView.tsx create mode 100644 views/ProductSearchView.tsx create mode 100644 views/SalesDashboardView.tsx create mode 100644 vite.config.ts 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 0000000000000000000000000000000000000000..fff5750d96beed458db2744f6cce8159eb017947 GIT binary patch literal 53282 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

2Y2mQlnSt!)8zCC4TC@4H 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 literal 0 HcmV?d00001 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 ( +
+ {/*