Initial commit

This commit is contained in:
JuruSysadmin 2026-01-08 09:09:16 -03:00
commit 812ef26e9f
96 changed files with 38497 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -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?

214
App.tsx Normal file
View File

@ -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>(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 (
<div className="flex flex-col h-screen lg:h-screen overflow-hidden fixed inset-0 lg:relative lg:inset-auto">
{isDashboardOrSearch && user && (
<Header
user={{
name: user.username || user.name || 'Usuário',
store: user.store || 'Loja'
}}
currentView={currentView}
onNavigate={setCurrentView}
cartCount={cart.reduce((acc, i) => acc + i.quantity, 0)}
onCartClick={() => setIsCartOpen(true)}
onLogout={handleLogout}
/>
)}
<div className="flex-1 overflow-auto bg-slate-50 lg:overflow-auto">
{(() => {
switch (currentView) {
case View.LOGIN:
return <LoginView onLogin={handleLogin} />;
case View.HOME_MENU:
return user ? (
<HomeMenuView
onNavigate={setCurrentView}
user={{
name: user.username || user.name || 'Usuário',
store: user.store || 'Loja'
}}
onLogout={handleLogout}
/>
) : null;
case View.SALES_DASHBOARD:
return <SalesDashboardView onNavigate={setCurrentView} onNewOrder={handleNewOrder} />;
case View.PRODUCT_SEARCH:
return <ProductSearchView onAddToCart={handleAddToCart} />;
case View.CHECKOUT:
return (
<CheckoutView
cart={cart}
onBack={() => setCurrentView(View.PRODUCT_SEARCH)}
onCartUpdate={refreshCart}
onClearCart={clearCart}
/>
);
default:
return <LoginView onLogin={handleLogin} />;
}
})()}
</div>
{/* Carrinho Global */}
<CartDrawer
isOpen={isCartOpen}
onClose={() => setIsCartOpen(false)}
items={cart}
onUpdateQuantity={updateQuantity}
onRemove={removeFromCart}
onCheckout={() => {
setIsCartOpen(false);
setCurrentView(View.CHECKOUT);
}}
onNewOrder={handleNewOrder}
/>
</div>
);
};
return (
<div className="min-h-screen lg:min-h-screen">
{/* Meta tag para mobile app-like experience */}
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
{renderView()}
{/* Confirmação de Logout */}
<ConfirmDialog
isOpen={showLogoutConfirm}
type="warning"
title="Confirmar Saída"
message="Deseja realmente sair do sistema? Todos os dados não salvos serão perdidos."
onConfirm={executeLogout}
onClose={() => setShowLogoutConfirm(false)}
confirmText="Sair"
cancelText="Cancelar"
/>
</div>
);
};
export default App;

156
MIGRATION_SUMMARY.md Normal file
View File

@ -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)

20
README.md Normal file
View File

@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# 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`

174
README_AUTH.md Normal file
View File

@ -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 <div>Carregando...</div>;
if (!isAuthenticated) return <div>Faça login</div>;
return <div>Olá, {user?.username}!</div>;
}
```
## 🔄 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

20
assets/icone.svg Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Camada_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 883.73 949.91">
<defs>
<style>
.cls-1 {
fill: #fe3b1f;
}
.cls-2 {
fill: #131d52;
}
</style>
</defs>
<g id="Layer_1">
<g>
<polygon class="cls-1" points="0 503.75 0 0 503.75 0 0 503.75"/>
<path class="cls-2" d="M503.75,0v218.84h153.32v297.82c0,119.1-96.89,215.99-215.99,215.99s-215.99-96.89-215.99-215.99v-12.9H0v6.65c0,242.35,197.17,439.51,439.52,439.51h4.7c242.35,0,439.52-197.17,439.52-439.52V0h-379.97Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 607 B

BIN
assets/loading.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

1
assets/logo2.svg Normal file
View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="Camada_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><style>.cls-1{fill:#201751;}.cls-2{fill:#fe3b1f;}</style></defs><g><path class="cls-2" d="M32.62,38.59h-.78v-1.26h-1.34v1.26h-.78v-3.18h.78v1.24h1.34v-1.24h.78v3.18Z"/><path class="cls-2" d="M35.92,35.82c.33,.32,.49,.72,.49,1.18s-.16,.85-.49,1.18c-.33,.32-.73,.48-1.2,.48s-.88-.16-1.2-.48c-.33-.32-.49-.71-.49-1.18s.16-.86,.49-1.18c.33-.32,.73-.48,1.2-.48s.88,.16,1.2,.48Zm-.55,1.86c.17-.18,.26-.4,.26-.67s-.09-.5-.26-.68c-.17-.18-.39-.27-.65-.27s-.48,.09-.65,.27c-.17,.18-.26,.41-.26,.68s.09,.49,.26,.67c.17,.18,.39,.27,.65,.27s.48-.09,.65-.27Z"/><path class="cls-2" d="M40.5,38.59h-.76v-1.59l-.83,1.59h-.48l-.84-1.6v1.6h-.76v-3.18h.77l1.07,1.97,1.07-1.97h.77v3.18Z"/><path class="cls-2" d="M43.54,38.59h-2.48v-3.18h2.47v.67h-1.68v.6h1.54v.64h-1.54v.6h1.7v.67Z"/><path class="cls-2" d="M46.31,38.2c.22-.15,.38-.34,.48-.59l.23,.06c-.11,.3-.29,.54-.56,.72-.27,.18-.58,.26-.93,.26-.46,0-.85-.16-1.16-.48-.32-.32-.48-.71-.48-1.17s.16-.85,.48-1.17c.32-.32,.7-.48,1.16-.48,.35,0,.67,.09,.93,.26,.27,.17,.45,.41,.56,.7l-.23,.07c-.09-.25-.25-.45-.48-.59s-.49-.22-.79-.22c-.39,0-.72,.14-1,.41-.27,.27-.41,.61-.41,1.01s.14,.74,.41,1.01c.27,.27,.6,.41,1,.41,.3,0,.56-.07,.78-.22Z"/><path class="cls-2" d="M47.89,36.88h1.9v.21h-1.9v1.27h2.04v.22h-2.28v-3.18h2.28v.22h-2.04v1.26Z"/><path class="cls-2" d="M53.19,38.59h-.21l-2.24-2.77v2.77h-.24v-3.18h.21l2.24,2.79v-2.79h.24v3.18Z"/><path class="cls-2" d="M55.17,35.62v2.97h-.25v-2.97h-1.24v-.22h2.73v.22h-1.24Z"/><path class="cls-2" d="M57.14,36.88h1.9v.21h-1.9v1.27h2.04v.22h-2.28v-3.18h2.28v.22h-2.04v1.26Z"/><path class="cls-2" d="M61.99,38.59l-1.31-1.46h-.7v1.46h-.24v-3.18h1.45c.3,0,.54,.08,.72,.24s.27,.37,.27,.62-.09,.47-.27,.62c-.18,.16-.42,.24-.72,.24h-.2l1.32,1.46h-.31Zm-.8-1.68c.22,0,.41-.06,.54-.18,.14-.12,.21-.27,.21-.47s-.07-.35-.21-.47c-.14-.12-.32-.18-.54-.18h-1.21v1.29h1.21Z"/></g><g><path class="cls-1" d="M16.59,33.25h-1.89v-.57c-.47,.5-1.08,.74-1.82,.74-.69,0-1.25-.23-1.67-.68-.42-.45-.63-1.05-.63-1.79v-3.64h1.88v3.24c0,.35,.09,.63,.28,.84s.43,.32,.73,.32c.39,0,.69-.14,.9-.41,.22-.27,.32-.69,.32-1.25v-2.75h1.89v5.93Z"/><path class="cls-1" d="M22.09,27.28l-.08,1.89h-.34c-1.36,0-2.04,.74-2.04,2.22v1.85h-1.89v-5.93h1.89v1.13c.49-.81,1.17-1.22,2.04-1.22,.16,0,.3,.02,.43,.05Z"/><path class="cls-1" d="M28.58,33.25h-1.89v-.57c-.47,.5-1.08,.74-1.82,.74-.69,0-1.25-.23-1.67-.68-.42-.45-.63-1.05-.63-1.79v-3.64h1.88v3.24c0,.35,.09,.63,.28,.84,.19,.22,.43,.32,.73,.32,.39,0,.69-.14,.9-.41,.22-.27,.32-.69,.32-1.25v-2.75h1.89v5.93Z"/><path class="cls-1" d="M35.1,27.82c.42,.45,.63,1.05,.63,1.79v3.64h-1.88v-3.24c0-.35-.09-.63-.28-.84s-.43-.32-.73-.32c-.39,0-.69,.14-.9,.41-.22,.27-.32,.69-.32,1.25v2.75h-1.89v-5.93h1.89v.57c.47-.5,1.08-.74,1.82-.74,.69,0,1.25,.23,1.67,.68Z"/><path class="cls-1" d="M42.66,30.77h-4.35c.08,.35,.23,.63,.46,.84,.23,.2,.5,.31,.82,.31,.59,0,1.01-.23,1.26-.69l1.68,.34c-.25,.61-.63,1.08-1.14,1.39-.51,.31-1.11,.47-1.8,.47-.87,0-1.61-.29-2.22-.88-.61-.59-.91-1.34-.91-2.26s.3-1.67,.91-2.26c.61-.59,1.35-.89,2.23-.89s1.58,.29,2.16,.86c.58,.57,.88,1.33,.9,2.28v.48Zm-3.83-1.84c-.24,.17-.4,.39-.48,.69h2.45c-.09-.31-.24-.54-.45-.7-.21-.16-.46-.24-.74-.24s-.53,.08-.77,.25Z"/><path class="cls-1" d="M48.85,27.82c.42,.45,.63,1.05,.63,1.79v3.64h-1.88v-3.24c0-.35-.09-.63-.28-.84s-.43-.32-.73-.32c-.39,0-.69,.14-.9,.41-.22,.27-.32,.69-.32,1.25v2.75h-1.89v-5.93h1.89v.57c.47-.5,1.08-.74,1.82-.74,.69,0,1.25,.23,1.67,.68Z"/><path class="cls-1" d="M52.04,31.32c.08,.46,.43,.7,1.05,.7,.24,0,.42-.05,.56-.14s.21-.2,.21-.34c0-.22-.2-.37-.59-.44l-1.2-.24c-1.15-.21-1.72-.79-1.72-1.72,0-.61,.24-1.09,.71-1.46,.47-.37,1.09-.55,1.84-.55s1.32,.15,1.8,.46c.48,.31,.77,.72,.89,1.24l-1.72,.34c-.03-.2-.14-.36-.31-.5-.18-.13-.4-.2-.68-.2-.24,0-.41,.05-.51,.14-.11,.09-.16,.2-.16,.32,0,.21,.15,.35,.45,.41l1.39,.28c.54,.12,.95,.33,1.23,.64,.28,.31,.41,.69,.41,1.14,0,.64-.25,1.13-.74,1.48-.5,.35-1.15,.52-1.95,.52-.75,0-1.38-.14-1.88-.43-.51-.29-.81-.72-.9-1.29l1.84-.38Z"/><path class="cls-1" d="M62.31,30.77h-4.35c.08,.35,.23,.63,.46,.84,.23,.2,.5,.31,.82,.31,.59,0,1.01-.23,1.26-.69l1.68,.34c-.25,.61-.63,1.08-1.14,1.39-.51,.31-1.11,.47-1.8,.47-.87,0-1.61-.29-2.22-.88-.61-.59-.91-1.34-.91-2.26s.3-1.67,.91-2.26c.61-.59,1.35-.89,2.23-.89s1.58,.29,2.16,.86c.58,.57,.88,1.33,.9,2.28v.48Zm-3.83-1.84c-.24,.17-.4,.39-.48,.69h2.45c-.09-.31-.24-.54-.45-.7-.21-.16-.46-.24-.74-.24s-.53,.08-.77,.25Z"/></g><g><polygon class="cls-2" points="1.69 29.75 1.69 25.34 6.11 25.34 1.69 29.75"/><path class="cls-1" d="M6.11,25.34v1.92h1.34v2.61c0,1.04-.85,1.89-1.89,1.89s-1.89-.85-1.89-1.89v-.11H1.69v.06c0,2.12,1.73,3.85,3.85,3.85h.04c2.12,0,3.85-1.73,3.85-3.85v-4.47h-3.33Z"/></g></svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#94a3b8" width="800px" height="800px" viewBox="0 0 32 32" id="icon" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;}</style></defs><title>no-image</title><path d="M30,3.4141,28.5859,2,2,28.5859,3.4141,30l2-2H26a2.0027,2.0027,0,0,0,2-2V5.4141ZM26,26H7.4141l7.7929-7.793,2.3788,2.3787a2,2,0,0,0,2.8284,0L22,19l4,3.9973Zm0-5.8318-2.5858-2.5859a2,2,0,0,0-2.8284,0L19,19.1682l-2.377-2.3771L26,7.4141Z"/><path d="M6,22V19l5-4.9966,1.3733,1.3733,1.4159-1.416-1.375-1.375a2,2,0,0,0-2.8284,0L6,16.1716V6H22V4H6A2.002,2.002,0,0,0,4,6V22Z"/><rect id="_Transparent_Rectangle_" data-name="&lt;Transparent Rectangle&gt;" class="cls-1" width="32" height="32"/></svg>

After

Width:  |  Height:  |  Size: 799 B

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#32a852" width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M11.917 11.71a2.046 2.046 0 0 1-1.454-.602l-2.1-2.1a.4.4 0 0 0-.551 0l-2.108 2.108a2.044 2.044 0 0 1-1.454.602h-.414l2.66 2.66c.83.83 2.177.83 3.007 0l2.667-2.668h-.253zM4.25 4.282c.55 0 1.066.214 1.454.602l2.108 2.108a.39.39 0 0 0 .552 0l2.1-2.1a2.044 2.044 0 0 1 1.453-.602h.253L9.503 1.623a2.127 2.127 0 0 0-3.007 0l-2.66 2.66h.414z"/><path d="m14.377 6.496-1.612-1.612a.307.307 0 0 1-.114.023h-.733c-.379 0-.75.154-1.017.422l-2.1 2.1a1.005 1.005 0 0 1-1.425 0L5.268 5.32a1.448 1.448 0 0 0-1.018-.422h-.9a.306.306 0 0 1-.109-.021L1.623 6.496c-.83.83-.83 2.177 0 3.008l1.618 1.618a.305.305 0 0 1 .108-.022h.901c.38 0 .75-.153 1.018-.421L7.375 8.57a1.034 1.034 0 0 1 1.426 0l2.1 2.1c.267.268.638.421 1.017.421h.733c.04 0 .079.01.114.024l1.612-1.612c.83-.83.83-2.178 0-3.008z"/></svg>

After

Width:  |  Height:  |  Size: 1016 B

153
components/ArcGauge.tsx Normal file
View File

@ -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<ArcGaugeProps> = ({
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 (
<div className="w-full flex flex-col items-center mt-[-30px]">
{title && (
<h2 className="text-base font-bold text-slate-700 mb-3">{title}</h2>
)}
<div
className="w-full max-w-[230px] relative"
style={{ height: "140px", marginTop: "-30px" }}
>
<GaugeComponent
value={gaugeValue}
type="radial"
style={{ width: "100%", height: "100%" }}
labels={{
tickLabels: {
type: "inner",
ticks: [
{ value: 0 },
{ value: (20 / 100) * max },
{ value: (40 / 100) * max },
{ value: (60 / 100) * max },
{ value: (80 / 100) * max },
{ value: max },
],
defaultTickValueConfig: {
formatTextValue: (value) => {
// 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 */}
<div
className="absolute"
style={{
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
marginTop: "88px",
pointerEvents: "none",
}}
>
<span
className="text-xl font-black leading-tight"
style={{ color: currentColor }}
>
{normalizedValue.toFixed(2).replace(".", ",")}%
</span>
</div>
</div>
{label && (
<span className="text-[9px] font-bold text-slate-400 uppercase mt-2">
{label}
</span>
)}
</div>
);
};
export default ArcGauge;

325
components/Baldinho.tsx Normal file
View File

@ -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<BaldinhoProps> = ({
selectedDeliveryDate,
onDateChange,
deliveryDays: deliveryDaysProp,
}) => {
const [deliveryDays, setDeliveryDays] = useState<DeliveryDay[]>(
deliveryDaysProp || []
);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(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 (
<div className="bg-white rounded-2xl shadow-lg shadow-slate-200/60 border border-slate-100 overflow-hidden mb-12">
<div className="grid grid-cols-1 lg:grid-cols-12">
{/* Lateral: Seleção Atual */}
<div className="lg:col-span-4 bg-[#002147] p-6 text-white relative overflow-hidden flex flex-col justify-center">
<div className="relative z-10">
<div className="w-10 h-10 bg-orange-500 rounded-xl flex items-center justify-center mb-5 shadow-lg shadow-orange-500/20">
<svg
className="w-5 h-5 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<span className="text-orange-400 font-black text-[9px] uppercase tracking-[0.3em] mb-3 block">
Fluxo de Logística
</span>
<h4 className="text-sm font-bold text-blue-100/70 mb-1.5">
Agendamento de Entrega:
</h4>
<div className="flex items-baseline space-x-2">
<span className="text-4xl font-black tracking-tighter">
{selectedDeliveryDate}
</span>
</div>
<div className="mt-6 p-4 rounded-xl bg-white/5 border border-white/10 backdrop-blur-sm">
<p className="text-xs font-medium text-blue-50 leading-relaxed">
Confira a grade ao lado. Dias em{" "}
<span className="text-red-400 font-black">VERMELHO</span> estão
com capacidade esgotada ou sem operação.
</p>
</div>
</div>
{/* Elementos decorativos */}
<div className="absolute top-[-20%] right-[-10%] w-80 h-80 bg-orange-500/10 rounded-full blur-3xl"></div>
<div className="absolute bottom-[-10%] left-[-10%] w-64 h-64 bg-blue-400/10 rounded-full blur-3xl"></div>
</div>
{/* Tabela de Disponibilidade */}
<div className="lg:col-span-8 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h4 className="text-lg font-black text-[#002147] tracking-tight mb-0.5">
Capacidade Operacional
</h4>
<p className="text-slate-400 text-xs font-medium">
Selecione uma data disponível para continuar o pedido
</p>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center text-[9px] font-black uppercase text-slate-400 tracking-widest">
<span className="w-2.5 h-2.5 bg-red-500 rounded-full mr-1.5 shadow-sm shadow-red-500/20"></span>{" "}
Bloqueado
</div>
<div className="flex items-center text-[9px] font-black uppercase text-slate-400 tracking-widest">
<span className="w-2.5 h-2.5 bg-emerald-500 rounded-full mr-1.5 shadow-sm shadow-emerald-500/20"></span>{" "}
Liberado
</div>
</div>
</div>
<div className="rounded-xl border border-slate-200 overflow-hidden shadow-inner bg-slate-50/30">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500 mb-3"></div>
<span className="text-xs text-slate-500 font-medium">
Carregando disponibilidade...
</span>
</div>
</div>
) : error ? (
<div className="p-6 bg-red-50 border border-red-200 rounded-lg m-4">
<p className="text-xs text-red-600 font-medium">{error}</p>
</div>
) : deliveryDays.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-xs text-slate-400 font-medium">
Nenhum agendamento disponível
</p>
</div>
) : (
<div className="max-h-[300px] overflow-auto custom-scrollbar">
<table className="w-full text-left text-xs border-collapse">
<thead className="bg-white sticky top-0 z-20 border-b border-slate-200">
<tr className="text-slate-500 font-black uppercase tracking-widest text-[9px]">
<th className="px-5 py-3 border-r border-slate-100">
Data
</th>
<th className="px-5 py-3 border-r border-slate-100">
Dia
</th>
<th className="px-5 py-3 border-r border-slate-100 text-center">
Capacidade
</th>
<th className="px-5 py-3 border-r border-slate-100 text-center">
Carga Atual
</th>
<th className="px-5 py-3">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{deliveryDays.map((d, i) => {
const occupancy =
d.cap > 0 ? (d.sales / d.cap) * 100 : 100;
const isFull = d.available === "Não";
return (
<tr
key={i}
onClick={() => !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"
}`}
>
<td className="px-5 py-3.5 font-black border-r border-slate-100/10 text-sm">
{d.date}
</td>
<td className="px-5 py-3.5 font-bold border-r border-slate-100/10 uppercase text-[9px] tracking-widest opacity-80">
{d.day}
</td>
<td className="px-5 py-3.5 border-r border-slate-100/10 text-center font-bold text-xs">
{d.cap} Ton
</td>
<td className="px-5 py-3.5 border-r border-slate-100/10">
<div className="flex flex-col items-center">
<span className="font-bold text-[10px] mb-1.5">
{d.sales.toFixed(3)} Ton
</span>
<div
className={`w-20 h-1 rounded-full overflow-hidden ${
isFull
? "bg-white/20"
: "bg-slate-100 shadow-inner"
}`}
>
<div
className={`h-full transition-all duration-1000 ${
isFull
? "bg-white"
: occupancy > 85
? "bg-orange-500"
: "bg-emerald-500"
}`}
style={{
width: `${Math.min(occupancy, 100)}%`,
}}
></div>
</div>
</div>
</td>
<td className="px-5 py-3.5">
<div className="flex items-center justify-between">
<span
className={`px-3 py-1 rounded-full text-[8px] font-black uppercase tracking-[0.1em] shadow-sm ${
isFull
? "bg-white text-red-600"
: "bg-emerald-100 text-emerald-700 border border-emerald-200"
}`}
>
{d.available === "Sim"
? "• Disponível"
: "• Esgotado"}
</span>
{!isFull && (
<svg
className="w-4 h-4 opacity-0 group-hover:opacity-100 group-hover:translate-x-1 transition-all text-orange-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3"
d="M9 5l7 7-7 7"
/>
</svg>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default Baldinho;

469
components/CartDrawer.tsx Normal file
View File

@ -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<CartDrawerProps> = ({
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 (
<div className="fixed inset-0 z-[100] flex justify-end">
{/* Overlay */}
<div
className={`absolute inset-0 bg-[#001f3f]/40 backdrop-blur-[2px] transition-opacity duration-400 ${
isAnimating ? "opacity-100" : "opacity-0"
}`}
onClick={handleClose}
></div>
{/* Panel - Fullscreen em mobile, sidebar em desktop */}
<div
className={`relative w-full lg:max-w-[440px] bg-white h-full shadow-2xl flex flex-col transition-transform duration-400 ease-out-quart ${
isAnimating ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="p-4 lg:p-6 bg-[#002147] text-white flex justify-between items-center relative overflow-hidden safe-area-top">
<div className="relative z-10">
<span className="text-orange-400 text-[9px] font-black uppercase tracking-[0.2em] block mb-0.5">
Seu Carrinho
</span>
<h3 className="text-lg lg:text-xl font-black">
{items.length} {items.length === 1 ? "Produto" : "Produtos"}
</h3>
</div>
<button
onClick={handleClose}
className="relative z-10 w-10 h-10 lg:w-12 lg:h-12 flex items-center justify-center rounded-2xl bg-white/10 hover:bg-white/20 transition-colors touch-manipulation"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<div className="absolute right-[-20%] top-[-20%] w-64 h-64 bg-blue-400/10 rounded-full blur-3xl"></div>
</div>
<div className="flex-1 overflow-auto p-4 lg:p-5 space-y-4 lg:space-y-5 custom-scrollbar">
{items.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center opacity-40">
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mb-3">
<svg
className="w-8 h-8"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"
/>
</svg>
</div>
<p className="font-bold text-sm">Seu carrinho está vazio</p>
<p className="text-xs">Adicione produtos para começar.</p>
</div>
) : (
items.map((item) => (
<div key={item.id} className="flex space-x-4 group">
<div className="w-20 h-20 bg-slate-50 rounded-xl p-3 mt-1.5 flex items-center justify-center shrink-0 border border-slate-100">
{item.image && item.image.trim() !== "" ? (
<img
src={item.image}
alt={item.name}
className="max-h-full mix-blend-multiply"
/>
) : (
<svg
className="w-8 h-8 text-slate-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<h5 className="text-xs font-bold text-[#002147] line-clamp-1 mb-1 group-hover:text-orange-600 transition-colors">
{item.name}
</h5>
<span className="text-lg font-black block mb-0.5">
R$ {item.price.toFixed(2)}
</span>
<div className="flex items-center justify-between">
<div className="flex bg-slate-100 rounded-lg p-0.5 items-center border border-slate-200">
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log(
"🛒 [CartDrawer] Botão - clicado:",
item.id
);
onUpdateQuantity(item.id, -1).catch((err) => {
console.error(
"🛒 [CartDrawer] Erro ao diminuir quantidade:",
err
);
});
}}
className="w-7 h-7 flex items-center justify-center font-bold text-slate-400 hover:text-[#002147] transition-colors text-sm touch-manipulation"
>
-
</button>
<span className="px-3 text-xs font-black min-w-[32px] text-center">
{item.quantity}
</span>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log(
"🛒 [CartDrawer] Botão + clicado:",
item.id
);
onUpdateQuantity(item.id, 1).catch((err) => {
console.error(
"🛒 [CartDrawer] Erro ao aumentar quantidade:",
err
);
});
}}
className="w-7 h-7 flex items-center justify-center font-bold text-slate-400 hover:text-[#002147] transition-colors text-sm touch-manipulation"
>
+
</button>
</div>
<button
onClick={() => {
setConfirmDialog({
isOpen: true,
itemId: item.id,
productName: item.name,
});
}}
className="text-slate-300 hover:text-red-500 transition-colors p-1.5"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
</div>
))
)}
</div>
{items.length > 0 && (
<div className="p-4 lg:p-6 bg-slate-50 border-t border-slate-200 space-y-4 safe-area-bottom">
<div className="flex justify-between items-end">
<div>
<span className="text-slate-400 font-bold uppercase text-[9px] tracking-widest block mb-0.5">
Total do Pedido
</span>
<span className="text-3xl font-black text-[#002147]">
R$ {total.toFixed(2)}
</span>
</div>
</div>
<div className="grid grid-cols-1 gap-2">
<button
onClick={onCheckout}
className="w-full py-3.5 bg-orange-500 text-white font-black uppercase text-xs tracking-[0.2em] rounded-xl shadow-lg shadow-orange-500/20 hover:bg-orange-600 transition-all flex items-center justify-center active:scale-95"
>
Finalizar Venda
<svg
className="w-4 h-4 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M14 5l7 7m0 0l-7 7m7-7H3"
/>
</svg>
</button>
{onNewOrder && (
<button
onClick={handleNewOrderClick}
className="w-full py-3 bg-slate-200 text-slate-700 font-black uppercase text-xs tracking-[0.1em] rounded-xl hover:bg-slate-300 transition-all flex items-center justify-center active:scale-95"
>
<svg
className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M12 4v16m8-8H4"
/>
</svg>
Novo Pedido
</button>
)}
<button
onClick={handleClose}
className="w-full py-3 text-slate-400 font-bold uppercase text-[9px] tracking-widest hover:text-[#002147] transition-colors"
>
Continuar Comprando
</button>
</div>
</div>
)}
</div>
<style>{`
/* Função de easing elegante para animações suaves */
.ease-out-quart {
transition-timing-function: cubic-bezier(0.25, 1, 0.5, 1);
}
.duration-400 {
transition-duration: 400ms;
}
`}</style>
{/* Dialog de Confirmação - Remover Item */}
<ConfirmDialog
isOpen={confirmDialog.isOpen}
onClose={() =>
setConfirmDialog({ isOpen: false, itemId: "", productName: "" })
}
onConfirm={() => {
onRemove(confirmDialog.itemId);
setConfirmDialog({ isOpen: false, itemId: "", productName: "" });
}}
type="delete"
message={
<>
Deseja remover o produto{" "}
<span className="font-black text-[#002147]">
"{confirmDialog.productName}"
</span>{" "}
do carrinho?
<br />
<span className="text-xs text-slate-400 mt-2 block">
Esta ação não pode ser desfeita.
</span>
</>
}
/>
{/* Dialog - Continuar ou Iniciar Novo Pedido */}
<ConfirmDialog
isOpen={showContinueOrNewDialog}
onClose={handleContinueOrder}
onConfirm={handleStartNewOrder}
type="info"
title="Carrinho Existente"
message={
<>
Você possui um carrinho com itens.
<br />
<br />
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 */}
<ConfirmDialog
isOpen={showNewOrderDialog}
onClose={() => setShowNewOrderDialog(false)}
onConfirm={handleConfirmNewOrder}
type="warning"
title="Novo Pedido"
message={
<>
Deseja iniciar um novo pedido?
<br />
<br />
<span className="text-sm font-bold text-slate-700">
Todos os dados do pedido atual serão perdidos:
</span>
<ul className="text-xs text-slate-600 mt-2 space-y-1 list-disc list-inside">
<li>Itens do carrinho</li>
<li>Dados do cliente</li>
<li>Endereço de entrega</li>
<li>Plano de pagamento</li>
<li>Dados financeiros</li>
<li>Informações de entrega</li>
</ul>
<br />
<span className="text-xs text-slate-400 block">
Esta ação não pode ser desfeita.
</span>
</>
}
confirmText="Sim, Iniciar Novo Pedido"
cancelText="Cancelar"
/>
</div>
);
};
export default CartDrawer;

View File

@ -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<CategoryCardProps> = ({
label,
icon,
onClick,
}) => {
return (
<div
onClick={onClick}
className="group relative bg-white p-6 rounded-2xl border-2 border-orange-100 shadow-lg shadow-orange-500/[0.03] hover:shadow-orange-500/[0.08] hover:border-orange-400 hover:-translate-y-1 transition-all duration-300 cursor-pointer flex flex-col items-center justify-center text-center overflow-hidden min-h-[180px]"
>
{/* Fundo Decorativo */}
<div className="absolute top-0 right-0 w-32 h-32 bg-orange-50/50 rounded-bl-[100px] -mr-16 -mt-16 group-hover:scale-150 transition-transform duration-500"></div>
<div className="mb-6 group-hover:scale-110 transition-transform flex items-center justify-center h-16 relative z-10">
{icon}
</div>
<div className="flex flex-col items-center w-full relative z-10">
<span className="text-[9px] font-black text-orange-400/70 uppercase tracking-[0.3em] mb-1.5 group-hover:text-orange-500 transition-colors">
Produtos
</span>
<span className="text-lg font-black text-orange-600 uppercase tracking-tighter leading-none group-hover:scale-105 transition-transform">
{label}
</span>
</div>
{/* Indicador de Hover */}
<div className="absolute bottom-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-orange-400 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
);
};
export default CategoryCard;

View File

@ -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<ConfirmDialogProps> = ({
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<DialogType, DialogConfig> = {
info: {
title: "Informação",
icon: (
<svg
className="w-6 h-6 text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
confirmButtonColor: "blue",
headerBgColor: "bg-[#002147]",
iconBgColor: "bg-blue-500/20",
iconColor: "text-blue-400",
subtitle: "Informação",
},
warning: {
title: "Atenção",
icon: (
<svg
className="w-6 h-6 text-orange-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
),
confirmButtonColor: "orange",
headerBgColor: "bg-[#002147]",
iconBgColor: "bg-orange-500/20",
iconColor: "text-orange-400",
subtitle: "Atenção",
},
error: {
title: "Erro",
icon: (
<svg
className="w-6 h-6 text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
confirmButtonColor: "red",
headerBgColor: "bg-[#002147]",
iconBgColor: "bg-red-500/20",
iconColor: "text-red-400",
subtitle: "Erro",
},
success: {
title: "Sucesso",
icon: (
<svg
className="w-6 h-6 text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
confirmButtonColor: "green",
headerBgColor: "bg-[#002147]",
iconBgColor: "bg-green-500/20",
iconColor: "text-green-400",
subtitle: "Sucesso",
},
delete: {
title: "Confirmar Exclusão",
icon: (
<svg
className="w-6 h-6 text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
),
confirmButtonColor: "red",
headerBgColor: "bg-[#002147]",
iconBgColor: "bg-red-500/20",
iconColor: "text-red-400",
subtitle: "Atenção",
},
confirm: {
title: "Confirmar Ação",
icon: (
<svg
className="w-6 h-6 text-orange-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
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 (
<div className="fixed inset-0 z-[200] flex items-center justify-center">
{/* Overlay */}
<div
className={`absolute inset-0 bg-[#001f3f]/60 backdrop-blur-sm transition-opacity duration-300 ${
isAnimating ? "opacity-100" : "opacity-0"
}`}
onClick={handleCancel}
></div>
{/* Dialog - Altura consistente em todos os dispositivos */}
<div
className={`relative bg-white rounded-3xl shadow-2xl max-w-md w-full mx-4 h-auto max-h-[90vh] flex flex-col transform transition-all duration-300 ${
isAnimating ? "scale-100 opacity-100" : "scale-95 opacity-0"
}`}
>
{/* Header */}
<div
className={`p-4 lg:p-6 ${config.headerBgColor} text-white rounded-t-3xl relative overflow-hidden`}
>
<div className="relative z-10">
<div className="flex items-center gap-3 mb-2">
{finalIcon && (
<div
className={`w-12 h-12 ${config.iconBgColor} rounded-2xl flex items-center justify-center`}
>
{finalIcon}
</div>
)}
<div className="flex-1">
<h3 className="text-xl font-black">{finalTitle}</h3>
{shouldShowWarning && config.subtitle && (
<p
className={`text-xs ${config.iconColor} font-bold uppercase tracking-wider mt-0.5`}
>
{config.subtitle}
</p>
)}
</div>
</div>
</div>
<div
className={`absolute right-[-10%] top-[-10%] w-32 h-32 ${
type === "info"
? "bg-blue-400/10"
: type === "warning"
? "bg-orange-400/10"
: type === "error"
? "bg-red-400/10"
: type === "success"
? "bg-green-400/10"
: type === "delete"
? "bg-red-400/10"
: "bg-orange-400/10"
} rounded-full blur-2xl`}
></div>
</div>
{/* Content */}
<div className="p-4 lg:p-6 overflow-y-auto custom-scrollbar flex-1">
{typeof message === "string" ? (
<p className="text-slate-600 text-sm leading-relaxed whitespace-pre-line">
{message}
</p>
) : (
<div className="text-slate-600 text-sm leading-relaxed">
{message}
</div>
)}
</div>
{/* Actions */}
<div className="p-4 lg:p-6 pt-0 flex gap-3">
{/* 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") ? (
<button
onClick={handleCancel}
className="flex-1 py-3 px-4 bg-white border-2 border-slate-300 text-slate-700 font-bold uppercase text-xs tracking-wider rounded-xl hover:bg-slate-50 transition-all"
>
{cancelText}
</button>
) : null}
<button
onClick={handleConfirm}
className={`${
(type === "success" || type === "error") &&
finalConfirmText === "OK"
? "w-full"
: "flex-1"
} py-3 px-4 bg-blue-600 text-white font-bold uppercase text-xs tracking-wider rounded-xl hover:bg-blue-700 transition-all shadow-lg active:scale-95`}
>
{finalConfirmText}
</button>
</div>
</div>
</div>
);
};
export default ConfirmDialog;

View File

@ -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<CreateCustomerDialogProps> = ({
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<Customer | null>(null);
const [formData, setFormData] = useState({
name: "",
document: "",
cellPhone: "",
cep: "",
address: "",
number: "",
city: "",
state: "",
complement: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
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 (
<div className="fixed inset-0 z-[200] flex items-center justify-center">
{/* Overlay */}
<div
className={`absolute inset-0 bg-[#001f3f]/60 backdrop-blur-sm transition-opacity duration-300 ${
isAnimating ? "opacity-100" : "opacity-0"
}`}
onClick={handleClose}
></div>
{/* Dialog */}
<div
className={`relative bg-white rounded-3xl shadow-2xl max-w-3xl w-full mx-4 max-h-[90vh] overflow-hidden transform transition-all duration-300 ${
isAnimating ? "scale-100 opacity-100" : "scale-95 opacity-0"
}`}
>
{/* Header */}
<div className="p-6 bg-[#002147] text-white rounded-t-3xl relative overflow-hidden">
<div className="relative z-10">
<div className="flex items-center gap-3 mb-2">
<div className="w-12 h-12 bg-green-500/20 rounded-2xl flex items-center justify-center">
<svg
className="w-6 h-6 text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
/>
</svg>
</div>
<div className="flex-1">
<h3 className="text-xl font-black">Novo Cliente</h3>
<p className="text-xs text-green-400 font-bold uppercase tracking-wider mt-0.5">
Cadastro
</p>
</div>
<button
onClick={handleClose}
className="w-10 h-10 bg-white/10 rounded-xl flex items-center justify-center text-white hover:bg-white/20 transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<div className="absolute right-[-10%] top-[-10%] w-32 h-32 bg-green-400/10 rounded-full blur-2xl"></div>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)] custom-scrollbar">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Nome do Cliente */}
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
Nome do Cliente *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => 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 && (
<p className="text-red-500 text-xs mt-1">{errors.name}</p>
)}
</div>
{/* CPF/CNPJ e Contato */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
CPF / CNPJ *
</label>
<div className="relative">
<input
type="text"
value={formData.document}
onChange={(e) =>
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 && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<svg
className="animate-spin h-5 w-5 text-blue-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
)}
</div>
{errors.document && (
<p className="text-red-500 text-xs mt-1">{errors.document}</p>
)}
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
Contato *
</label>
<input
type="text"
value={formData.cellPhone}
onChange={(e) =>
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 && (
<p className="text-red-500 text-xs mt-1">
{errors.cellPhone}
</p>
)}
</div>
</div>
{/* CEP e Endereço */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
CEP *
</label>
<input
type="text"
value={formData.cep}
onChange={(e) => 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 && (
<p className="text-red-500 text-xs mt-1">{errors.cep}</p>
)}
</div>
<div className="md:col-span-2">
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
Endereço *
</label>
<input
type="text"
value={formData.address}
onChange={(e) => 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 && (
<p className="text-red-500 text-xs mt-1">{errors.address}</p>
)}
</div>
</div>
{/* Número, Cidade e Estado */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
Número *
</label>
<input
type="text"
value={formData.number}
onChange={(e) => 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 && (
<p className="text-red-500 text-xs mt-1">{errors.number}</p>
)}
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
Cidade *
</label>
<input
type="text"
value={formData.city}
onChange={(e) => 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 && (
<p className="text-red-500 text-xs mt-1">{errors.city}</p>
)}
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
Estado *
</label>
<input
type="text"
value={formData.state}
onChange={(e) => 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 && (
<p className="text-red-500 text-xs mt-1">{errors.state}</p>
)}
</div>
</div>
{/* Complemento */}
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
Complemento
</label>
<input
type="text"
value={formData.complement}
onChange={(e) =>
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)"
/>
</div>
{/* Erro de submit */}
{errors.submit && (
<div className="p-3 bg-red-50 border border-red-200 rounded-xl">
<p className="text-red-600 text-sm font-medium">
{errors.submit}
</p>
</div>
)}
</form>
</div>
{/* Actions */}
<div className="p-6 pt-05 flex gap-3 border-t border-slate-200">
<button
onClick={handleClose}
className="flex-1 py-3 px-4 bg-slate-100 text-slate-600 font-bold uppercase text-xs tracking-wider rounded-xl hover:bg-slate-200 transition-all"
>
Cancelar
</button>
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="flex-1 py-3 px-4 text-white font-black uppercase text-xs tracking-wider rounded-xl transition-all shadow-lg bg-green-500 hover:bg-green-600 shadow-green-500/20 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? "Cadastrando..." : "Cadastrar Cliente"}
</button>
</div>
</div>
{/* Dialog de Cliente Encontrado */}
<ConfirmDialog
isOpen={showCustomerFoundDialog}
onClose={handleCloseCustomerFoundDialog}
onConfirm={handleUseFoundCustomer}
type="info"
title="Cliente já cadastrado"
message={`Cliente encontrado com este CPF/CNPJ!\n\nNome: ${
foundCustomer?.name || ""
}\n\nOs dados do formulário foram preenchidos automaticamente. Você pode editar os dados ou usar o cliente diretamente.`}
confirmText="Usar Cliente"
cancelText="Continuar Editando"
showWarning={false}
/>
</div>
);
};
export default CreateCustomerDialog;

View File

@ -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<void>;
}
const EditItemModal: React.FC<EditItemModalProps> = ({
item,
isOpen,
onClose,
onConfirm,
}) => {
const [productDetail, setProductDetail] = useState<SaleProduct | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-200 bg-[#002147]">
<h2 className="text-lg font-black text-white">
Editar Item do Pedido
</h2>
<button
onClick={onClose}
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-white" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-slate-200 border-t-orange-500"></div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Left: Image */}
<div className="flex items-center justify-center bg-slate-50 rounded-xl p-8 min-h-[400px]">
{item.image && item.image.trim() !== "" ? (
<img
src={item.image}
alt={item.name}
className="max-h-full max-w-full object-contain mix-blend-multiply"
/>
) : (
<div className="text-slate-300 text-center">
<svg
className="w-24 h-24 mx-auto mb-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p className="text-sm font-medium">Sem imagem</p>
</div>
)}
</div>
{/* Right: Product Info */}
<div className="space-y-4">
{/* Product Title */}
<div>
<h3 className="text-base font-black text-[#002147] mb-1">
#{productDetail?.title || item.name}
</h3>
<p className="text-xs text-slate-600 mb-2">
{productDetail?.smallDescription ||
productDetail?.description ||
item.description ||
""}
</p>
<div className="flex flex-wrap gap-1.5 text-xs text-slate-500 mb-2">
<span className="font-medium">
{productDetail?.brand || item.mark}
</span>
<span></span>
<span>{productDetail?.idProduct || item.code}</span>
{productDetail?.ean && (
<>
<span></span>
<span>{productDetail.ean}</span>
</>
)}
</div>
{productDetail?.productType === "A" && (
<span className="inline-block bg-green-500 text-white px-2 py-0.5 rounded text-[10px] font-bold uppercase">
AUTOSSERVIÇO
</span>
)}
</div>
{/* Price */}
<div className="space-y-1">
{listPrice > 0 && listPrice > salePrice && (
<p className="text-xs text-slate-500">
de R$ {listPrice.toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{" "}
por
</p>
)}
<div className="flex items-baseline gap-2">
<p className="text-2xl font-black text-orange-600">
R$ {salePrice.toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
{discount > 0 && (
<span className="bg-orange-500 text-white px-2 py-0.5 rounded text-[10px] font-black">
-{discount}%
</span>
)}
</div>
{productDetail?.installments &&
productDetail.installments.length > 0 && (
<p className="text-[10px] text-slate-600">
POR UN EM {productDetail.installments[0].installment}X DE
R${" "}
{productDetail.installments[0].installmentValue.toLocaleString(
"pt-BR",
{
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}
)}
</p>
)}
</div>
{/* Store Selector */}
{stocks.length > 0 && (
<div>
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wide mb-2">
LOCAL DE ESTOQUE
</label>
<FilialSelector
stocks={stocks}
value={selectedStore}
onValueChange={(value) => {
setSelectedStore(value);
if (formErrors.selectedStore) {
setFormErrors((prev) => ({
...prev,
selectedStore: undefined,
}));
}
}}
placeholder="Digite para buscar..."
/>
{formErrors.selectedStore && (
<p className="text-red-600 text-xs mt-1">
{formErrors.selectedStore}
</p>
)}
</div>
)}
{/* Description (Collapsible) */}
<div className="border-t border-slate-200 pt-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-700 uppercase tracking-wide">
DESCRIÇÃO DO PRODUTO
</span>
<button
onClick={() => setShowDescription(!showDescription)}
className="text-xs text-blue-600 hover:underline"
>
Ver mais
</button>
</div>
{showDescription && (
<p className="text-xs text-slate-600 mt-2">
{productDetail?.description ||
item.description ||
"Sem descrição disponível"}
</p>
)}
</div>
{/* Quantity */}
<div>
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wide mb-2">
Quantidade
</label>
<div className="flex items-center border-2 border-[#002147] rounded-lg overflow-hidden w-fit">
<button
onClick={() => {
const newQty = Math.max(1, quantity - 1);
setQuantity(newQty);
if (formErrors.quantity) {
setFormErrors((prev) => ({
...prev,
quantity: undefined,
}));
}
}}
className="bg-[#002147] text-white px-4 py-2.5 hover:bg-[#003366] transition-colors font-bold"
>
<Minus className="w-4 h-4" />
</button>
<input
type="number"
min="1"
step="1"
value={quantity}
onChange={(e) => {
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"
/>
<button
onClick={() => {
setQuantity((q) => q + 1);
if (formErrors.quantity) {
setFormErrors((prev) => ({
...prev,
quantity: undefined,
}));
}
}}
className="bg-[#002147] text-white px-4 py-2.5 hover:bg-[#003366] transition-colors font-bold"
>
<Plus className="w-4 h-4" />
</button>
</div>
{formErrors.quantity && (
<p className="text-red-600 text-xs mt-1">
{formErrors.quantity}
</p>
)}
</div>
{/* Environment */}
<div>
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wide mb-2">
Ambiente
</label>
<input
type="text"
value={environment}
onChange={(e) => 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=""
/>
</div>
{/* Delivery Type */}
<div>
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wide mb-2">
Tipo de Entrega
</label>
<select
value={deliveryType}
onChange={(e) => {
setDeliveryType(e.target.value);
if (formErrors.deliveryType) {
setFormErrors((prev) => ({
...prev,
deliveryType: undefined,
}));
}
}}
className={`w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500/20 text-sm ${
formErrors.deliveryType
? "border-red-500"
: "border-slate-300"
}`}
>
<option value="">Selecione tipo de entrega</option>
{deliveryTypes.map((dt) => (
<option key={dt.type} value={dt.type}>
{dt.description}
</option>
))}
</select>
{formErrors.deliveryType && (
<p className="text-red-600 text-xs mt-1">
{formErrors.deliveryType}
</p>
)}
</div>
{/* Total Price and Confirm Button */}
<div className="pt-4 border-t border-slate-200 space-y-3">
<div className="flex items-center gap-2">
<span className="text-base font-bold text-[#002147]">
R$ {total.toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
<button
className="p-1 hover:bg-slate-100 rounded transition-colors"
title="Editar preço"
>
<Edit2 className="w-4 h-4 text-slate-400" />
</button>
</div>
<button
onClick={handleConfirm}
className="w-full bg-orange-500 text-white py-3.5 rounded-lg font-black uppercase text-xs tracking-wider hover:bg-orange-600 transition-all shadow-lg"
>
ADICIONAR AO CARRINHO
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default EditItemModal;

View File

@ -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<FilialSelectorProps> = ({
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<string>(value || "");
const [displayValue, setDisplayValue] = useState<string>("");
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const tableRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
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 (
<div className={className}>
{label && (
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wide mb-2">
{label}
</label>
)}
<div ref={containerRef} className="relative">
{/* Input */}
<div className="relative">
<input
ref={inputRef}
type="text"
value={isOpen ? searchTerm : displayValue}
onChange={handleInputChange}
onFocus={handleInputFocus}
placeholder={placeholder}
className="w-full px-4 py-3 pr-20 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500/20 focus:border-orange-500 text-sm font-medium"
/>
{/* Botões de ação */}
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
{(searchTerm || displayValue) && (
<button
type="button"
onClick={handleClear}
className="p-1.5 hover:bg-slate-100 rounded transition-colors"
title="Limpar"
>
<svg
className="w-4 h-4 text-slate-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
<button
type="button"
onClick={handleToggleDropdown}
className="p-1.5 hover:bg-slate-100 rounded transition-colors"
title={isOpen ? "Fechar" : "Abrir"}
>
<svg
className={`w-4 h-4 text-slate-500 transition-transform ${
isOpen ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</div>
</div>
{/* Dropdown com Tabela */}
{isOpen && (
<div
ref={tableRef}
className="absolute z-50 w-full mt-1 bg-white border border-slate-300 rounded-lg shadow-xl max-h-[400px] flex flex-col overflow-hidden"
>
{/* Tabela */}
<div className="overflow-y-auto flex-1">
<table className="w-full">
<thead className="bg-slate-50 sticky top-0 z-10">
<tr>
<th className="px-3 py-2 text-left text-[9px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200" style={{ width: "50px" }}>
Loja
</th>
<th className="px-3 py-2 text-left text-[9px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200" style={{ width: "200px" }}>
Nome da Loja
</th>
<th className="px-3 py-2 text-center text-[9px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200" style={{ width: "60px" }}>
Entrega
</th>
<th className="px-3 py-2 text-right text-[9px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200" style={{ width: "80px" }}>
Estoque
</th>
<th className="px-3 py-2 text-center text-[9px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200" style={{ width: "60px" }}>
Pertence
</th>
<th className="px-3 py-2 text-right text-[9px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200" style={{ width: "70px" }}>
Bloq
</th>
<th className="px-3 py-2 text-right text-[9px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200" style={{ width: "70px" }}>
Transf
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredStocks.length === 0 ? (
<tr>
<td
colSpan={7}
className="px-4 py-8 text-center text-sm text-slate-500"
>
Nenhuma filial encontrada
</td>
</tr>
) : (
filteredStocks.map((stock) => {
const isSelected = stock.store === selectedStore;
return (
<tr
key={stock.store}
onClick={() => handleSelect(stock.store)}
className={`cursor-pointer transition-colors ${
isSelected
? "bg-blue-50 hover:bg-blue-100"
: "hover:bg-slate-50"
}`}
>
<td className="px-3 py-2 text-xs text-slate-700 font-medium">
{stock.store}
</td>
<td className="px-3 py-2 text-xs text-slate-700 font-medium">
{stock.storeName}
</td>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
checked={stock.allowDelivery === 1}
readOnly
className="w-4 h-4 text-orange-500 border-slate-300 rounded focus:ring-orange-500 focus:ring-2 cursor-default pointer-events-none"
/>
</td>
<td className="px-3 py-2 text-xs text-slate-700 text-right font-medium">
{formatNumber(stock.quantity)}
</td>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
checked={stock.work}
readOnly
className="w-4 h-4 text-orange-500 border-slate-300 rounded focus:ring-orange-500 focus:ring-2 cursor-default pointer-events-none"
/>
</td>
<td className="px-3 py-2 text-xs text-slate-700 text-right font-medium">
{stock.blocked || "0"}
</td>
<td className="px-3 py-2 text-xs text-slate-700 text-right font-medium">
{stock.transfer || 0}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
};
export default FilialSelector;

View File

@ -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<FilterSidebarProps> = ({
selectedDepartment,
onDepartmentChange,
selectedBrands,
onBrandsChange,
filters,
onFiltersChange,
onApplyFilters,
brands: brandsProp,
onBrandsLoaded,
}) => {
const [expandedDepartments, setExpandedDepartments] = useState<string[]>([]);
const [departments, setDepartments] = useState<DepartmentItem[]>([]);
const [brands, setBrands] = useState<string[]>(brandsProp || []);
const [stores, setStores] = useState<
Array<{ id: string; name: string; shortName: string }>
>([]);
const [loadingDepartments, setLoadingDepartments] = useState<boolean>(true);
const [loadingBrands, setLoadingBrands] = useState<boolean>(false);
const [loadingStores, setLoadingStores] = useState<boolean>(true);
const [error, setError] = useState<string | null>(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 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 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 (
<aside className="w-full xl:w-80 bg-white border-r border-slate-200 p-4 xl:p-6 flex flex-col overflow-y-auto custom-scrollbar">
{/* Todos Departamentos */}
<div className="mb-8">
<h3 className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-4">
Todos Departamentos
</h3>
{loadingDepartments ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-orange-500"></div>
<span className="ml-2 text-xs text-slate-500">Carregando...</span>
</div>
) : error ? (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-xs text-red-600">{error}</p>
</div>
) : (
<nav className="space-y-0.5">
{departments.map((dept) => (
<div key={dept.name}>
<div className="flex items-center">
{/* Ícone para expandir/colapsar (apenas se tiver subcategorias) */}
{dept.subcategories.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
setExpandedDepartments((prev) =>
prev.includes(dept.name)
? prev.filter((d) => d !== dept.name)
: [...prev, dept.name]
);
}}
className="p-1 hover:bg-slate-100 rounded transition-colors"
>
<svg
className={`w-3 h-3 transition-transform ${
expandedDepartments.includes(dept.name)
? "rotate-90"
: ""
}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
)}
{/* Espaçador quando não tem subcategorias */}
{dept.subcategories.length === 0 && (
<div className="w-5"></div>
)}
{/* Botão do texto para aplicar filtro */}
<button
onClick={() => {
// Sempre aplicar o filtro ao clicar no texto
// Seguir padrão do Angular: usar path (que é item.url)
// No Angular: this.filters.urlCategory = data.dataItem.path;
onDepartmentChange(dept.path || dept.url || dept.name);
// Se tiver subcategorias e não estiver expandido, expandir também
if (
dept.subcategories.length > 0 &&
!expandedDepartments.includes(dept.name)
) {
setExpandedDepartments((prev) => [...prev, dept.name]);
}
}}
className={`flex-1 text-left px-3 py-2 rounded-lg text-xs font-bold transition-all ${
selectedDepartment ===
(dept.path || dept.url || dept.name)
? "bg-blue-50 text-blue-600"
: "text-slate-600 hover:bg-slate-50"
}`}
>
{dept.name}
</button>
</div>
{dept.subcategories.length > 0 &&
expandedDepartments.includes(dept.name) && (
<div className="ml-5 mt-0.5 space-y-0.5">
{dept.subcategories.map((sub) => {
const hasSubcategories =
sub.subcategories && sub.subcategories.length > 0;
const isSubExpanded = expandedDepartments.includes(
`${dept.name}-${sub.name}`
);
return (
<div key={sub.url || sub.name}>
<div className="flex items-center">
{/* Ícone para expandir/colapsar (apenas se tiver subcategorias) */}
{hasSubcategories && (
<button
onClick={(e) => {
e.stopPropagation();
setExpandedDepartments((prev) =>
isSubExpanded
? prev.filter(
(d) =>
d !== `${dept.name}-${sub.name}`
)
: [...prev, `${dept.name}-${sub.name}`]
);
}}
className="p-1 hover:bg-slate-100 rounded transition-colors"
>
<svg
className={`w-3 h-3 transition-transform ${
isSubExpanded ? "rotate-90" : ""
}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
)}
{/* Espaçador quando não tem subcategorias */}
{!hasSubcategories && <div className="w-5"></div>}
{/* Botão do texto para aplicar filtro */}
<button
onClick={() => {
// Sempre aplicar o filtro ao clicar no texto
// Seguir EXATAMENTE o padrão do Angular: usar path (que já é a URL completa)
// No Angular: this.filters.urlCategory = data.dataItem.path;
// O path já foi construído no mapItems com a hierarquia completa
onDepartmentChange(
sub.path || sub.url || sub.name
);
// Se tiver subcategorias e não estiver expandido, expandir também
if (hasSubcategories && !isSubExpanded) {
setExpandedDepartments((prev) => [
...prev,
`${dept.name}-${sub.name}`,
]);
}
}}
className={`flex-1 text-left px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
selectedDepartment ===
(sub.path || sub.url || sub.name)
? "bg-blue-50 text-blue-600"
: "text-slate-500 hover:bg-slate-50"
}`}
>
{sub.name}
</button>
</div>
{/* Renderizar categorias (terceiro nível) se existirem */}
{hasSubcategories && isSubExpanded && (
<div className="ml-5 mt-0.5 space-y-0.5">
{sub.subcategories.map((category) => (
<button
key={category.url || category.name}
onClick={() => {
// Seguir EXATAMENTE o padrão do Angular: usar path (que já é a URL completa)
// No Angular: this.filters.urlCategory = data.dataItem.path;
onDepartmentChange(
category.path ||
category.url ||
category.name
);
}}
className={`w-full text-left px-3 py-1.5 rounded-lg text-xs font-medium transition-all flex items-center ${
selectedDepartment ===
(category.path ||
category.url ||
category.name)
? "bg-blue-50 text-blue-600"
: "text-slate-400 hover:bg-slate-50"
}`}
>
<svg
className="w-3 h-3 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
{category.name}
</button>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
))}
</nav>
)}
</div>
{/* Marcas */}
<div className="mb-8">
<h3 className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-4">
Marcas
</h3>
{loadingBrands ? (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-orange-500"></div>
<span className="ml-2 text-xs text-slate-500">Carregando...</span>
</div>
) : brands.length === 0 ? (
<p className="text-xs text-slate-400 italic">
Nenhuma marca disponível
</p>
) : (
<div className="space-y-1.5 max-h-[300px] overflow-y-auto custom-scrollbar">
{brands.map((brand) => (
<label
key={brand}
className="flex items-center px-3 py-2 rounded-lg hover:bg-slate-50 cursor-pointer group"
>
<input
type="checkbox"
checked={selectedBrands.includes(brand)}
onChange={(e) => {
if (e.target.checked) {
onBrandsChange([...selectedBrands, brand]);
} else {
onBrandsChange(selectedBrands.filter((b) => b !== brand));
}
}}
className="w-4 h-4 border-2 border-slate-300 rounded text-orange-500 focus:ring-2 focus:ring-orange-500/20 cursor-pointer"
/>
<span className="ml-3 text-xs font-medium text-slate-600 group-hover:text-slate-900">
{brand}
</span>
</label>
))}
</div>
)}
</div>
{/* Filtros */}
<div className="mb-8">
<h3 className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-4">
Filtros
</h3>
<div className="space-y-4">
{/* Somente produtos com estoque */}
<label className="flex items-center cursor-pointer group">
<input
type="checkbox"
checked={filters.onlyInStock}
onChange={(e) =>
onFiltersChange({ ...filters, onlyInStock: e.target.checked })
}
className="w-4 h-4 border-2 border-slate-300 rounded text-orange-500 focus:ring-2 focus:ring-orange-500/20 cursor-pointer"
/>
<span className="ml-3 text-xs font-medium text-slate-600 group-hover:text-slate-900">
Somente produtos com estoque
</span>
</label>
{/* Filial de estoque */}
<div>
<label className="block text-xs font-medium text-slate-600 mb-1.5">
Filial de estoque
</label>
{loadingStores ? (
<div className="flex items-center justify-center py-2">
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-orange-500"></div>
<span className="ml-2 text-xs text-slate-400">
Carregando...
</span>
</div>
) : (
<CustomAutocomplete
options={[
{ value: "", label: "Selecione a filial" },
...stores.map((store) => ({
value: store.id,
label: store.shortName || store.name || store.id,
})),
]}
value={filters.stockBranch}
onValueChange={(value) =>
onFiltersChange({ ...filters, stockBranch: value })
}
placeholder="Selecione a filial"
className="h-9 text-xs"
/>
)}
</div>
{/* Produtos em promoção */}
<label className="flex items-center cursor-pointer group">
<input
type="checkbox"
checked={filters.onPromotion}
onChange={(e) =>
onFiltersChange({ ...filters, onPromotion: e.target.checked })
}
className="w-4 h-4 border-2 border-slate-300 rounded text-orange-500 focus:ring-2 focus:ring-orange-500/20 cursor-pointer"
/>
<span className="ml-3 text-xs font-medium text-slate-600 group-hover:text-slate-900">
Produtos em promoção
</span>
</label>
{/* Faixa de desconto */}
<div>
<label className="block text-xs font-medium text-slate-600 mb-2">
Faixa de desconto
</label>
<div className="px-1">
<div className="relative h-2 bg-slate-200 rounded-full mb-3">
{/* Barra de progresso visual */}
<div
className="absolute h-2 bg-orange-500 rounded-full"
style={{
left: `${filters.discountRange[0]}%`,
width: `${
filters.discountRange[1] - filters.discountRange[0]
}%`,
}}
></div>
{/* Input mínimo */}
<input
type="range"
min="0"
max="100"
step="5"
value={filters.discountRange[0]}
onChange={(e) => {
const min = parseInt(e.target.value);
const max = Math.max(min, filters.discountRange[1]);
onFiltersChange({ ...filters, discountRange: [min, max] });
}}
className="absolute w-full h-2 bg-transparent appearance-none cursor-pointer z-10 opacity-0"
style={{
background: "transparent",
}}
/>
{/* Input máximo */}
<input
type="range"
min="0"
max="100"
step="5"
value={filters.discountRange[1]}
onChange={(e) => {
const max = parseInt(e.target.value);
const min = Math.min(max, filters.discountRange[0]);
onFiltersChange({ ...filters, discountRange: [min, max] });
}}
className="absolute w-full h-2 bg-transparent appearance-none cursor-pointer z-10 opacity-0"
/>
{/* Handles visuais */}
<div
className="absolute w-4 h-4 bg-orange-500 rounded-full border-2 border-white shadow-md cursor-grab active:cursor-grabbing z-20 top-1/2 -translate-y-1/2 hover:scale-110 transition-transform"
style={{
left: `calc(${filters.discountRange[0]}% - 8px)`,
}}
></div>
<div
className="absolute w-4 h-4 bg-orange-500 rounded-full border-2 border-white shadow-md cursor-grab active:cursor-grabbing z-20 top-1/2 -translate-y-1/2 hover:scale-110 transition-transform"
style={{
left: `calc(${filters.discountRange[1]}% - 8px)`,
}}
></div>
</div>
<div className="flex justify-between mb-2 text-[10px] text-slate-400">
<span>0</span>
<span>20</span>
<span>40</span>
<span>60</span>
<span>80</span>
<span>100</span>
</div>
<div className="text-center text-xs font-medium text-slate-700">
{filters.discountRange[0]}% - {filters.discountRange[1]}%
</div>
</div>
</div>
{/* Produtos que baixaram de preço */}
<label className="flex items-center cursor-pointer group">
<input
type="checkbox"
checked={filters.priceDropped}
onChange={(e) =>
onFiltersChange({ ...filters, priceDropped: e.target.checked })
}
className="w-4 h-4 border-2 border-slate-300 rounded text-orange-500 focus:ring-2 focus:ring-orange-500/20 cursor-pointer"
/>
<span className="ml-3 text-xs font-medium text-slate-600 group-hover:text-slate-900">
Produtos que baixaram de preço
</span>
</label>
{/* Oportunidades */}
<label className="flex items-center cursor-pointer group">
<input
type="checkbox"
checked={filters.opportunities}
onChange={(e) =>
onFiltersChange({ ...filters, opportunities: e.target.checked })
}
className="w-4 h-4 border-2 border-slate-300 rounded text-orange-500 focus:ring-2 focus:ring-orange-500/20 cursor-pointer"
/>
<span className="ml-3 text-xs font-medium text-slate-600 group-hover:text-slate-900">
Oportunidades
</span>
</label>
{/* Ofertas imperdiveis */}
<label className="flex items-center cursor-pointer group">
<input
type="checkbox"
checked={filters.unmissableOffers}
onChange={(e) =>
onFiltersChange({
...filters,
unmissableOffers: e.target.checked,
})
}
className="w-4 h-4 border-2 border-slate-300 rounded text-orange-500 focus:ring-2 focus:ring-orange-500/20 cursor-pointer"
/>
<span className="ml-3 text-xs font-medium text-slate-600 group-hover:text-slate-900">
Ofertas imperdiveis
</span>
</label>
</div>
</div>
{/* Botão Aplicar Filtros */}
<button
onClick={onApplyFilters}
className="w-full py-3 lg:py-3 bg-orange-500 text-white rounded-lg font-bold text-xs uppercase tracking-wider hover:bg-orange-600 transition-all shadow-lg shadow-orange-500/20 touch-manipulation"
>
Aplicar Filtros
</button>
{/* Informação da Loja */}
<div className="mt-auto pt-6 border-t border-slate-200">
<div className="bg-slate-50 rounded-xl p-4 border border-slate-100">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 block">
Sua Loja
</span>
<span className="text-sm font-extrabold text-[#002147]">
Jurunense BR-316
</span>
<div className="mt-3 flex items-center text-xs font-bold text-emerald-600">
<span className="w-2 h-2 bg-emerald-500 rounded-full mr-2"></span>
Loja aberta agora
</div>
</div>
</div>
</aside>
);
};
export default FilterSidebar;

54
components/Gauge.tsx Normal file
View File

@ -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<GaugeProps> = ({ value, color, label }) => {
const data = [{ value: value }, { value: 100 - value }];
return (
<div className="bg-white p-8 rounded-3xl border border-slate-100">
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-6">
{label}
</h4>
<div className="w-full h-40 relative">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
startAngle={180}
endAngle={0}
innerRadius={55}
outerRadius={75}
paddingAngle={0}
dataKey="value"
stroke="none"
>
<Cell fill={color} />
<Cell fill="#f1f5f9" />
</Pie>
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center pt-10">
<span className="text-3xl font-black text-[#002147]">{value}%</span>
<span className="text-[10px] font-bold text-slate-400 uppercase">
Meta
</span>
</div>
</div>
</div>
);
};
export default Gauge;

119
components/Header.tsx Normal file
View File

@ -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<HeaderProps> = ({
user,
onNavigate,
cartCount,
onCartClick,
onLogout,
}) => {
return (
<header className="sticky top-0 z-40 w-full bg-white/95 lg:bg-white/80 backdrop-blur-md border-b border-slate-200 safe-area-top">
<div className="max-w-[1600px] mx-auto px-3 lg:px-6 h-14 lg:h-16 flex items-center justify-between">
<div
className="flex items-center space-x-2 lg:space-x-3 cursor-pointer group"
onClick={() => onNavigate(View.HOME_MENU)}
>
<div className="bg-[#ffffff] p-1.5 lg:p-2 rounded-xl transition-transform group-hover:scale-105">
<img src={icon} alt="Jurunense Icon" className="w-5 h-5 lg:w-6 lg:h-6" />
</div>
<div className="flex flex-col">
<span className="font-extrabold text-xs lg:text-base text-[#002147] leading-none tracking-tight">
<span className="lg:hidden">VendaWeb</span>
<span className="hidden lg:inline">Platforma VendaWeb</span>
</span>
<span className="text-[8px] lg:text-[9px] font-bold text-orange-500 uppercase tracking-[0.2em]">
Jurunense
</span>
</div>
</div>
<div className="flex items-center space-x-2 lg:space-x-8">
<div className="hidden lg:flex items-center space-x-4">
<div className="h-8 w-[1px] bg-slate-200"></div>
<div className="flex flex-col items-end">
<span className="text-sm font-bold text-slate-800">
{user.name}
</span>
<span className="text-[10px] font-medium text-slate-400 uppercase tracking-wider">
Filial: {user.store}
</span>
</div>
</div>
<div className="flex items-center space-x-2 lg:space-x-4">
<button
onClick={onCartClick}
className="relative p-3 lg:p-2.5 bg-slate-50 text-slate-600 rounded-xl hover:bg-[#002147] hover:text-white transition-all group touch-manipulation"
>
<svg
className="w-6 h-6 lg:w-6 lg:h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"
/>
</svg>
{cartCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 min-w-[22px] h-[22px] bg-orange-500 text-white text-[10px] font-black flex items-center justify-center rounded-full border-2 border-white ring-2 ring-orange-500/20 animate-bounce-slow">
{cartCount}
</span>
)}
</button>
<button
onClick={() => {
if (onLogout) {
onLogout();
} else {
onNavigate(View.LOGIN);
}
}}
className="p-3 lg:p-2.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all touch-manipulation"
title="Sair"
>
<svg
className="w-6 h-6 lg:w-6 lg:h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</button>
</div>
</div>
</div>
<style>{`
@keyframes bounce-slow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
.animate-bounce-slow { animation: bounce-slow 2s infinite ease-in-out; }
`}</style>
</header>
);
};
export default Header;

View File

@ -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<ImageZoomModalProps> = ({
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 (
<div className="fixed inset-0 z-[200] flex items-center justify-center">
{/* Overlay */}
<div
className={`absolute inset-0 bg-[#001f3f]/60 backdrop-blur-sm transition-opacity duration-300 ${
isAnimating ? "opacity-100" : "opacity-0"
}`}
onClick={handleClose}
></div>
{/* Modal */}
<div
className={`relative bg-white rounded-3xl shadow-2xl max-w-4xl w-full mx-4 transform transition-all duration-300 ${
isAnimating ? "scale-100 opacity-100" : "scale-95 opacity-0"
}`}
>
{/* Header */}
<div className="p-6 bg-[#002147] text-white rounded-t-3xl relative overflow-hidden">
<div className="relative z-10 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-blue-500/20 rounded-2xl flex items-center justify-center">
<svg
className="w-6 h-6 text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7"
/>
</svg>
</div>
<div>
<h3 className="text-xl font-black">Visualização da Imagem</h3>
<p className="text-xs text-blue-400 font-bold uppercase tracking-wider mt-0.5">
{productName}
</p>
</div>
</div>
<button
onClick={handleClose}
className="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="absolute right-[-10%] top-[-10%] w-32 h-32 bg-blue-400/10 rounded-full blur-2xl"></div>
</div>
{/* Content - Imagem */}
<div className="p-8 flex items-center justify-center bg-slate-50 min-h-[400px] max-h-[70vh] overflow-hidden">
{imageUrl && !imageError ? (
<div className="w-full h-full flex items-center justify-center">
<img
src={imageUrl}
alt={productName}
className="w-auto h-auto max-w-full max-h-[60vh] object-contain rounded-lg shadow-lg"
style={{
maxWidth: "100%",
maxHeight: "60vh",
width: "auto",
height: "auto",
}}
onError={() => {
setImageError(true);
}}
/>
</div>
) : (
<div className="flex flex-col items-center justify-center text-slate-400 min-h-[400px]">
<svg
className="w-24 h-24 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p className="text-sm font-medium">Imagem não disponível</p>
</div>
)}
</div>
{/* Actions */}
<div className="p-6 pt-0">
<button
onClick={handleClose}
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white font-black uppercase text-xs tracking-wider rounded-xl transition-all shadow-lg active:scale-95"
>
Fechar
</button>
</div>
</div>
</div>
);
};
export default ImageZoomModal;

View File

@ -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<LoadingSpinnerProps> = ({
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 (
<div className="flex flex-col items-center justify-center py-20">
<div className="relative">
{/* <div
className={`animate-spin rounded-full ${sizeClasses[size]} border-slate-200 border-t-orange-500`}
></div>
<div className="absolute inset-0 flex items-center justify-center">
<div
className={`${innerSizeClasses[size]} bg-orange-500 rounded-full animate-pulse`}
></div>
</div> */}
<picture>
<source srcSet={webpImgLoading} type="image/webp" />
<img src={webpImgLoading} alt="Loading" className="w-16 h-16" />
</picture>
</div>
<p className="mt-6 text-slate-600 font-bold text-sm">{message}</p>
{subMessage && (
<p className="mt-2 text-slate-400 text-xs">{subMessage}</p>
)}
</div>
);
};
export default LoadingSpinner;

116
components/NoData.tsx Normal file
View File

@ -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<NoDataProps> = ({
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 <IconFileX className={iconClass} stroke={1.5} />;
case "inbox":
return <IconInbox className={iconClass} stroke={1.5} />;
case "folder":
return <IconFolderCode className={iconClass} stroke={1.5} />;
case "database":
return <IconDatabaseOff className={iconClass} stroke={1.5} />;
case "clipboard":
return <IconClipboardX className={iconClass} stroke={1.5} />;
case "search":
default:
return <IconSearch className={iconClass} stroke={1.5} />;
}
};
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 (
<Empty className={getVariantClasses()}>
<EmptyHeader>
<EmptyMedia variant="icon" className="mb-6">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-slate-50 to-slate-100 border border-slate-200/50 shadow-sm">
{getIcon()}
</div>
</EmptyMedia>
<EmptyTitle className="text-xl font-black text-slate-900 mb-3">
{title}
</EmptyTitle>
<EmptyDescription className="text-sm text-slate-600 max-w-md leading-relaxed">
{description}
</EmptyDescription>
</EmptyHeader>
{action && <EmptyContent className="mt-8">{action}</EmptyContent>}
{showLearnMore && (
<a
href={learnMoreHref}
className="mt-6 inline-flex items-center gap-1 text-sm font-medium text-slate-500 hover:text-[#002147] transition-colors"
>
{learnMoreText}
<ArrowUpRightIcon className="h-4 w-4" />
</a>
)}
</Empty>
);
};
export default NoData;

View File

@ -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 imagem disponível
*
* @param size - Tamanho do placeholder (sm, md, lg)
* @param className - Classes CSS adicionais
*/
const NoImagePlaceholder: React.FC<NoImagePlaceholderProps> = ({
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 (
<div
className={`flex flex-col items-center justify-center text-slate-400 ${className}`}
>
<div
className={`${sizeClasses[size]} flex items-center justify-center mb-2 p-2`}
>
<img
src={NoImageIcon}
alt="Sem imagem"
className={`${sizeClasses[size]} object-contain`}
/>
</div>
<span className={`font-medium text-slate-400 ${textSizeClasses[size]}`}>
Sem imagem
</span>
</div>
);
};
export default NoImagePlaceholder;

View File

@ -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<OrderItemsModalProps> = ({
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 (
<div className="fixed inset-0 z-[200] flex items-center justify-center">
{/* Overlay */}
<div
className={`absolute inset-0 bg-[#001f3f]/60 backdrop-blur-sm transition-opacity duration-300 ${
isAnimating ? "opacity-100" : "opacity-0"
}`}
onClick={handleClose}
></div>
{/* Dialog */}
<div
className={`relative bg-white rounded-3xl shadow-2xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col transform transition-all duration-300 ${
isAnimating ? "scale-100 opacity-100" : "scale-95 opacity-0"
}`}
>
{/* Header */}
<div className="p-6 bg-[#002147] text-white rounded-t-3xl relative overflow-hidden">
<div className="relative z-10">
<div className="flex items-center gap-3 mb-2">
<div className="w-12 h-12 bg-blue-500/20 rounded-2xl flex items-center justify-center">
<svg
className="w-6 h-6 text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<div className="flex-1">
<h3 className="text-xl font-black">
Itens do pedido {orderId.toString()}
</h3>
<p className="text-xs text-blue-400 font-bold uppercase tracking-wider mt-0.5">
Detalhes
</p>
</div>
<button
onClick={handleClose}
className="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<div className="absolute right-[-10%] top-[-10%] w-32 h-32 bg-blue-400/10 rounded-full blur-2xl"></div>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto flex-1">
{orderItems.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
{[
"Código",
"Descrição",
"Embalagem",
"Cor",
"Ambiente",
"P.Venda",
"Qtde",
"Valor Total",
].map((h) => (
<th
key={h}
className="px-4 py-3 text-[9px] font-black text-slate-400 uppercase tracking-widest text-left"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{orderItems.map((item, idx) => (
<tr key={idx} className="hover:bg-slate-50/50">
<td className="px-4 py-3 text-xs text-slate-600">
{item.productId}
</td>
<td className="px-4 py-3 text-xs text-slate-600">
{item.description}
</td>
<td className="px-4 py-3 text-xs text-slate-600">
{item.package}
</td>
<td className="px-4 py-3 text-xs text-slate-600">
{item.color || "-"}
</td>
<td className="px-4 py-3 text-xs text-slate-600">
{item.local}
</td>
<td className="px-4 py-3 text-xs text-slate-600 text-right">
{formatCurrency(item.price)}
</td>
<td className="px-4 py-3 text-xs text-slate-600 text-right">
{item.quantity.toFixed(3)}
</td>
<td className="px-4 py-3 text-xs font-bold text-slate-800 text-right">
{formatCurrency(item.subTotal)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="bg-white rounded-2xl border border-slate-100 overflow-hidden">
<NoData
title="Nenhum item encontrado"
description={`Não foram encontrados itens para o pedido #${orderId}. Verifique se o pedido possui itens cadastrados.`}
icon="file"
variant="outline"
/>
</div>
)}
</div>
{/* Actions */}
<div className="p-6 pt-0">
<button
onClick={handleClose}
className="w-full py-3 px-4 text-white font-black uppercase text-xs tracking-wider rounded-xl transition-all shadow-lg bg-blue-500 hover:bg-blue-600 shadow-blue-500/20 active:scale-95"
>
Fechar
</button>
</div>
</div>
</div>
);
};
export default OrderItemsModal;

View File

@ -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<PrintOrderDialogProps> = ({
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 (
<div className="fixed inset-0 z-[200] flex items-center justify-center">
{/* Overlay */}
<div
className={`absolute inset-0 bg-[#001f3f]/60 backdrop-blur-sm transition-opacity duration-300 ${
isAnimating ? "opacity-100" : "opacity-0"
}`}
onClick={handleCancel}
></div>
{/* Dialog */}
<div
className={`relative bg-white rounded-3xl shadow-2xl max-w-md w-full mx-4 transform transition-all duration-300 ${
isAnimating ? "scale-100 opacity-100" : "scale-95 opacity-0"
}`}
>
{/* Header */}
<div className="p-6 bg-[#002147] text-white rounded-t-3xl relative overflow-hidden">
<div className="relative z-10">
<div className="flex items-center gap-3 mb-2">
<div className="w-12 h-12 bg-blue-500/20 rounded-2xl flex items-center justify-center">
<svg
className="w-6 h-6 text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"
/>
</svg>
</div>
<div className="flex-1">
<h3 className="text-xl font-black">
{includeModelP ? "Selecione o modelo de orçamento" : "Selecione o modelo de pedido"}
</h3>
<p className="text-xs text-blue-400 font-bold uppercase tracking-wider mt-0.5">
Impressão
</p>
</div>
<button
onClick={handleCancel}
className="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<div className="absolute right-[-10%] top-[-10%] w-32 h-32 bg-blue-400/10 rounded-full blur-2xl"></div>
</div>
{/* Content */}
<div className="p-6">
<div className="space-y-4">
<label className="block text-sm font-bold text-slate-700 mb-4">
{includeModelP ? "Selecione o modelo de orçamento" : "Selecione o modelo de pedido"}
</label>
<div className="space-y-3">
<label
className={`flex items-center p-4 border-2 rounded-xl cursor-pointer transition-all ${
selectedModel === "B"
? "bg-blue-50 border-[#002147]"
: "border-slate-300 hover:bg-slate-50 hover:border-[#002147]"
}`}
>
<input
type="radio"
name="printModel"
value="B"
checked={selectedModel === "B"}
onChange={() => setSelectedModel("B")}
className="w-5 h-5 text-[#002147] border-2 border-slate-300 focus:ring-2 focus:ring-[#002147] focus:ring-offset-2 cursor-pointer"
/>
<span className="ml-3 text-sm font-medium text-slate-700">
Bobina
</span>
</label>
{includeModelP && (
<label
className={`flex items-center p-4 border-2 rounded-xl cursor-pointer transition-all ${
selectedModel === "P"
? "bg-blue-50 border-[#002147]"
: "border-slate-300 hover:bg-slate-50 hover:border-[#002147]"
}`}
>
<input
type="radio"
name="printModel"
value="P"
checked={selectedModel === "P"}
onChange={() => setSelectedModel("P")}
className="w-5 h-5 text-[#002147] border-2 border-slate-300 focus:ring-2 focus:ring-[#002147] focus:ring-offset-2 cursor-pointer"
/>
<span className="ml-3 text-sm font-medium text-slate-700">
Bobina sem Preço
</span>
</label>
)}
<label
className={`flex items-center p-4 border-2 rounded-xl cursor-pointer transition-all ${
selectedModel === "A"
? "bg-blue-50 border-[#002147]"
: "border-slate-300 hover:bg-slate-50 hover:border-[#002147]"
}`}
>
<input
type="radio"
name="printModel"
value="A"
checked={selectedModel === "A"}
onChange={() => setSelectedModel("A")}
className="w-5 h-5 text-[#002147] border-2 border-slate-300 focus:ring-2 focus:ring-[#002147] focus:ring-offset-2 cursor-pointer"
/>
<span className="ml-3 text-sm font-medium text-slate-700">
Formulário A4
</span>
</label>
</div>
</div>
</div>
{/* Actions */}
<div className="p-6 pt-0 flex gap-3">
<Button
onClick={handleCancel}
variant="outline"
className="flex-1"
>
Cancelar
</Button>
<Button
onClick={handleConfirm}
className="flex-1"
>
Imprimir
</Button>
</div>
</div>
</div>
);
};
export default PrintOrderDialog;

287
components/ProductCard.tsx Normal file
View File

@ -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<ProductCardProps> = ({
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 (
<div className="bg-white rounded-[2rem] shadow-sm border border-slate-100 overflow-hidden flex flex-col h-full group hover:shadow-2xl hover:shadow-slate-200 transition-all">
{/* Imagem do Produto */}
<div className="h-64 bg-slate-50 relative overflow-hidden flex items-center justify-center p-5 group/image">
{p.image &&
!p.image.includes("placeholder") &&
p.image !==
"https://placehold.co/200x200/f8fafc/949494/png?text=Sem+Imagem" &&
!imageError ? (
<>
<img
src={p.image}
alt={p.name}
className="max-h-full object-contain mix-blend-multiply group-hover:scale-110 transition-transform duration-500"
onError={() => {
setImageError(true);
}}
/>
{/* Botão de Zoom */}
<button
onClick={() => setShowImageZoom(true)}
className="absolute bottom-4 right-4 bg-[#002147]/80 hover:bg-[#002147] text-white p-2.5 rounded-lg transition-all opacity-0 group-hover/image:opacity-100 shadow-lg backdrop-blur-sm"
title="Ampliar imagem"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7"
/>
</svg>
</button>
</>
) : (
<NoImagePlaceholder size="md" />
)}
{/* Badge de Desconto */}
{typeof p.discount === "number" && p.discount > 0 && (
<div className="absolute top-4 left-4 flex items-center gap-2">
<span className="bg-orange-500 text-white px-3 py-1.5 rounded-lg text-xs font-black uppercase tracking-wider shadow-lg">
{p.discount.toFixed(2)}%
</span>
<div className="bg-orange-500 text-white p-2 rounded-full shadow-lg">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</div>
)}
{/* Botão Ver Mais */}
<button
onClick={() => onViewDetails(p)}
className="absolute top-4 right-4 bg-[#002147] text-white px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wide hover:bg-[#003366] transition-all shadow-lg"
>
Ver mais
</button>
</div>
{/* Informações do Produto */}
<div className="p-6 flex-1 flex flex-col">
{/* Título do Produto */}
<h4 className="text-lg font-extrabold text-[#002147] leading-tight mb-2 line-clamp-2">
{p.name}
</h4>
{/* Descrição Detalhada (se disponível) */}
{p.description && (
<p className="text-sm text-slate-600 mb-3 line-clamp-2">
{p.description}
</p>
)}
{/* 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 ? (
<div className="mb-4">
<p className="text-xs text-slate-500 font-medium leading-relaxed">
{parts.join(" - ")}
</p>
</div>
) : null;
})()}
{/* Preços */}
<div className="mb-4">
{p.originalPrice && p.originalPrice > p.price && (
<p className="text-sm text-slate-400 line-through mb-1">
de R${" "}
{p.originalPrice.toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
)}
<div className="flex items-center gap-3">
<p className="text-3xl font-black text-orange-600">
por R${" "}
{p.price.toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
</div>
{/* Parcelamento */}
<p className="text-sm text-slate-600 mt-2">
ou em 10x de R${" "}
{installmentValue.toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
</div>
{/* Informações de Estoque */}
<div className="mb-4 space-y-2 border-t border-slate-100 pt-4">
<div className="flex justify-between items-center">
<span className="text-xs text-slate-600 font-medium">
Estoque loja
</span>
<span className="text-xs font-bold text-slate-800">
{p.stockLocal.toFixed(2) || 0} UN
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-slate-600 font-medium">
Estoque disponível
</span>
<span className="text-xs font-bold text-slate-800">
{p.stockAvailable?.toFixed(2) || p.stockLocal.toFixed(0) || 0} UN
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-slate-600 font-medium">
Estoque geral
</span>
<span className="text-xs font-bold text-slate-800">
{p.stockGeneral.toFixed(2) || 0} UN
</span>
</div>
</div>
{/* Seletor de Quantidade e Botão Adicionar */}
<div className="mt-auto pt-4 border-t border-slate-100 flex items-center gap-3">
{/* Seletor de Quantidade */}
<div className="flex items-center border border-slate-300 rounded-lg overflow-hidden">
<button
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
className="bg-[#002147] text-white px-4 py-2 hover:bg-[#003366] transition-colors font-bold"
disabled={quantity <= 1}
>
-
</button>
<input
type="number"
min="1"
value={quantity.toFixed(2)}
onChange={(e) => {
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"
/>
<button
onClick={() => setQuantity((q) => q + 1)}
className="bg-[#002147] text-white px-4 py-2 hover:bg-[#003366] transition-colors font-bold"
>
+
</button>
</div>
{/* Botão Adicionar */}
<button
onClick={() => {
// Se tiver estoque na loja, adicionar direto com "Retira Imediata"
if (p.stockLocal && p.stockLocal > 0) {
const currentStore = authService.getStore() || "";
onAddToCart({
...p,
quantity,
stockStore: currentStore,
deliveryType: "RI", // Retira Imediata
} as OrderItem);
} else {
// Caso contrário, abrir modal para selecionar loja e tipo de entrega
setShowStoreDeliveryModal(true);
}
}}
className="flex-1 bg-orange-500 text-white py-2.5 rounded-lg font-bold uppercase text-xs tracking-wide hover:bg-orange-600 transition-all shadow-md"
>
Adicionar
</button>
</div>
</div>
{/* Modal de Zoom da Imagem */}
<ImageZoomModal
isOpen={showImageZoom}
onClose={() => setShowImageZoom(false)}
imageUrl={p.image || ""}
productName={p.name}
/>
{/* Modal de Seleção de Loja e Tipo de Entrega */}
<ProductStoreDeliveryModal
isOpen={showStoreDeliveryModal}
onClose={() => 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}
/>
</div>
);
};
export default ProductCard;

File diff suppressed because it is too large Load Diff

View File

@ -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<ProductListItemProps> = ({
product,
quantity,
onQuantityChange,
onAddToCart,
onViewDetails,
}) => {
const [imageError, setImageError] = useState(false);
const [showImageZoom, setShowImageZoom] = useState(false);
const [showStoreDeliveryModal, setShowStoreDeliveryModal] = useState(false);
return (
<div className="flex gap-6">
{/* Imagem do produto */}
<div className="flex-shrink-0 w-48 h-48 bg-slate-50 rounded-xl overflow-hidden relative flex items-center justify-center p-4 group/image">
{product.image &&
product.image !==
"https://placehold.co/200x200/f8fafc/949494/png?text=Sem+Imagem" &&
!product.image.includes("placeholder") &&
!imageError ? (
<>
<img
src={product.image}
alt={product.name}
className="max-w-full max-h-full object-contain mix-blend-multiply"
onError={() => {
setImageError(true);
}}
/>
{/* Badge de Desconto */}
{typeof product.discount === "number" && product.discount > 0 && (
<div className="absolute top-3 left-3 flex items-center gap-2">
<span className="bg-orange-500 text-white px-3 py-1.5 rounded-lg text-xs font-black uppercase tracking-wider shadow-lg">
{product.discount.toFixed(2)}%
</span>
<div className="bg-orange-500 text-white p-2 rounded-full shadow-lg">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</div>
)}
{/* Botão de Zoom */}
<button
onClick={() => setShowImageZoom(true)}
className="absolute bottom-3 right-3 bg-[#002147]/80 hover:bg-[#002147] text-white p-2.5 rounded-lg transition-all opacity-0 group-hover/image:opacity-100 shadow-lg backdrop-blur-sm"
title="Ampliar imagem"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7"
/>
</svg>
</button>
</>
) : (
<NoImagePlaceholder size="md" />
)}
</div>
{/* Informações do produto */}
<div className="flex-1 flex flex-col justify-between min-w-0">
{/* Parte superior: Título, descrição, informações */}
<div>
<div className="flex items-start justify-between gap-4 mb-2">
<h3 className="text-xl font-extrabold text-[#002147] leading-tight flex-1">
{product.name}
</h3>
<button
onClick={() => onViewDetails(product)}
className="flex-shrink-0 bg-[#002147] text-white px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wide hover:bg-[#003366] transition-all shadow-md"
>
Ver mais
</button>
</div>
{/* Descrição */}
{product.description && (
<p className="text-sm text-slate-600 mb-3 line-clamp-2">
{product.description}
</p>
)}
{/* 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 ? (
<div className="mb-3">
<p className="text-xs text-slate-500 font-medium leading-relaxed">
{parts.join(" - ")}
</p>
</div>
) : null;
})()}
{/* Preços */}
<div className="mb-4">
{product.originalPrice && product.originalPrice > product.price && (
<p className="text-sm text-slate-400 line-through mb-1">
de R${" "}
{product.originalPrice.toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
)}
<div className="flex items-center gap-3 mb-2">
<p className="text-3xl font-black text-orange-600">
por R${" "}
{product.price.toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
</div>
{/* Parcelamento */}
<p className="text-sm text-slate-600">
ou em 10x de R${" "}
{(product.price / 10).toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
</div>
{/* Informações de Estoque */}
<div className="mb-4 space-y-2 border-t border-slate-100 pt-4">
<div className="flex justify-between items-center">
<span className="text-xs text-slate-600 font-medium">
Estoque loja
</span>
<span className="text-xs font-bold text-slate-800">
{product.stockLocal.toFixed(2) || 0} UN
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-slate-600 font-medium">
Estoque disponível
</span>
<span className="text-xs font-bold text-slate-800">
{product.stockAvailable?.toFixed(2) ||
product.stockLocal.toFixed(2) ||
0}{" "}
UN
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-slate-600 font-medium">
Estoque geral
</span>
<span className="text-xs font-bold text-slate-800">
{product.stockGeneral.toFixed(2) || 0} UN
</span>
</div>
</div>
</div>
{/* Parte inferior: Seletor de quantidade e botão adicionar */}
<div className="pt-4 border-t border-slate-100 flex items-center gap-3">
{/* Seletor de Quantidade */}
<div className="flex items-center border border-slate-300 rounded-lg overflow-hidden">
<button
onClick={() => {
const newQty = Math.max(1, quantity - 1);
onQuantityChange(newQty);
}}
className="bg-[#002147] text-white px-4 py-2 hover:bg-[#003366] transition-colors font-bold"
disabled={quantity <= 1}
>
-
</button>
<input
type="number"
min="1"
value={quantity.toFixed(2)}
onChange={(e) => {
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"
/>
<button
onClick={() => {
onQuantityChange(quantity + 1);
}}
className="bg-[#002147] text-white px-4 py-2 hover:bg-[#003366] transition-colors font-bold"
>
+
</button>
</div>
{/* Botão Adicionar */}
<button
onClick={() => {
// Se tiver estoque na loja, adicionar direto com "Retira Imediata"
if (product.stockLocal && product.stockLocal > 0) {
const currentStore = authService.getStore() || "";
onAddToCart(product, quantity, currentStore, "RI"); // RI = Retira Imediata
} else {
// Caso contrário, abrir modal para selecionar loja e tipo de entrega
setShowStoreDeliveryModal(true);
}
}}
className="flex-1 bg-orange-500 text-white py-2.5 rounded-lg font-bold uppercase text-xs tracking-wide hover:bg-orange-600 transition-all shadow-md"
>
Adicionar
</button>
</div>
</div>
{/* Modal de Zoom da Imagem */}
<ImageZoomModal
isOpen={showImageZoom}
onClose={() => setShowImageZoom(false)}
imageUrl={product.image || ""}
productName={product.name}
/>
{/* Modal de Seleção de Loja e Tipo de Entrega */}
<ProductStoreDeliveryModal
isOpen={showStoreDeliveryModal}
onClose={() => setShowStoreDeliveryModal(false)}
onConfirm={(stockStore, deliveryType) => {
// Adicionar ao carrinho com as informações selecionadas
onAddToCart(product, quantity, stockStore, deliveryType);
setShowStoreDeliveryModal(false);
}}
product={product}
quantity={quantity}
/>
</div>
);
};
export default ProductListItem;

View File

@ -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<ProductStoreDeliveryModalProps> = ({
isOpen,
onClose,
onConfirm,
product,
quantity = 1,
}) => {
const [selectedStore, setSelectedStore] = useState<string>("");
const [deliveryType, setDeliveryType] = useState<string>("");
const [stocks, setStocks] = useState<Stock[]>([]);
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 (
<div className="fixed inset-0 z-[200] flex items-center justify-center">
{/* Overlay */}
<div
className="absolute inset-0 bg-[#001f3f]/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-3xl shadow-2xl max-w-md w-full mx-4 max-h-[90vh] flex flex-col">
{/* Header */}
<div className="p-4 lg:p-6 bg-[#002147] text-white rounded-t-3xl relative overflow-hidden flex items-center justify-between">
<div className="flex-1 pr-4">
<h3 className="text-lg font-black leading-tight">
{product.code} - {product.name}
</h3>
</div>
<button
onClick={onClose}
className="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors touch-manipulation flex-shrink-0"
title="Fechar"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-4 lg:p-6 overflow-y-auto flex-1">
<div className="mb-6">
<h4 className="text-base font-black text-orange-600 mb-4">
Selecione a loja de estoque e forma de entrega
</h4>
</div>
{/* LOCAL DE ESTOQUE */}
<div className="mb-6">
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wide mb-2">
LOCAL DE ESTOQUE
</label>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-orange-500"></div>
<span className="ml-2 text-sm text-slate-500">
Carregando lojas...
</span>
</div>
) : stocks.length > 0 ? (
<FilialSelector
stocks={stocks}
value={selectedStore}
onValueChange={(value) => {
setSelectedStore(value);
if (errors.stockStore) {
setErrors((prev) => ({ ...prev, stockStore: undefined }));
}
}}
placeholder="Selecione a loja..."
label=""
className="w-full"
/>
) : (
<div className="p-4 bg-slate-50 border border-slate-200 rounded-lg">
<p className="text-sm text-slate-600">
Nenhuma loja de estoque disponível
</p>
</div>
)}
{errors.stockStore && (
<div className="flex items-center gap-1 mt-1 text-red-600 text-xs">
<span>{errors.stockStore}</span>
</div>
)}
</div>
{/* TIPO DE ENTREGA */}
<div className="mb-6">
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wide mb-3">
TIPO DE ENTREGA
</label>
<div className="space-y-3">
{deliveryTypes.map((dt) => (
<label
key={dt.type}
className="flex items-center p-3 border border-slate-200 rounded-lg cursor-pointer hover:bg-slate-50 transition-colors group"
>
<input
type="radio"
name="deliveryType"
value={dt.type}
checked={deliveryType === dt.type}
onChange={(e) => {
setDeliveryType(e.target.value);
if (errors.deliveryType) {
setErrors((prev) => ({
...prev,
deliveryType: undefined,
}));
}
}}
className="w-4 h-4 text-orange-500 border-slate-300 focus:ring-orange-500 focus:ring-2 cursor-pointer"
/>
<span className="ml-3 text-sm font-medium text-slate-700 group-hover:text-slate-900">
{dt.description}
</span>
</label>
))}
</div>
{errors.deliveryType && (
<div className="flex items-center gap-1 mt-2 text-red-600 text-xs">
<span>{errors.deliveryType}</span>
</div>
)}
<p className="text-xs text-slate-500 mt-3">
Informe a forma de entrega do produto
</p>
</div>
</div>
{/* Actions */}
<div className="p-4 lg:p-6 pt-0 flex gap-3">
<button
onClick={onClose}
className="flex-1 py-3 px-4 bg-white border-2 border-slate-300 text-slate-700 font-bold uppercase text-xs tracking-wider rounded-xl hover:bg-slate-50 transition-all"
>
Cancelar
</button>
<button
onClick={handleConfirm}
className="flex-1 py-3 px-4 bg-blue-600 text-white font-bold uppercase text-xs tracking-wider rounded-xl hover:bg-blue-700 transition-all shadow-lg"
>
Gravar
</button>
</div>
</div>
</div>
);
};
export default ProductStoreDeliveryModal;

View File

@ -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<ReceivePixDialogProps> = ({
isOpen,
onClose,
onConfirm,
orderId,
customerName,
orderValue,
showQrCode,
qrCodeValue = "",
pixValue = 0,
processing = false,
}) => {
const [inputValue, setInputValue] = useState<string>("");
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 (
<div className="fixed inset-0 z-[200] flex items-center justify-center">
{/* Overlay */}
<div
className={`absolute inset-0 bg-[#001f3f]/60 backdrop-blur-sm transition-opacity duration-300 ${
isModalAnimating ? "opacity-100" : "opacity-0"
}`}
onClick={onClose}
></div>
{/* Dialog */}
<div
className={`relative bg-white rounded-3xl shadow-2xl max-w-md w-full mx-4 transform transition-all duration-300 ${
isModalAnimating ? "scale-100 opacity-100" : "scale-95 opacity-0"
}`}
>
{/* Header */}
<div className="p-6 bg-[#002147] text-white rounded-t-3xl relative overflow-hidden">
<div className="relative z-10">
<div className="flex items-center gap-3 mb-2">
<div className="w-12 h-12 bg-teal-500/20 rounded-2xl flex items-center justify-center">
<svg
className="w-6 h-6 text-teal-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
</div>
<div className="flex-1">
<h3 className="text-xl font-black">Recebimento via PIX</h3>
<p className="text-xs text-blue-400 font-bold uppercase tracking-wider mt-0.5">
{showQrCode ? "QR Code gerado" : "Informe o valor"}
</p>
</div>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-600 rounded-lg transition-colors"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<div className="absolute right-[-10%] top-[-10%] w-32 h-32 bg-teal-400/10 rounded-full blur-2xl"></div>
</div>
{/* Content */}
<div className="p-6">
{!showQrCode ? (
<div className="space-y-4">
<div>
<Label htmlFor="pixValue">Informe o valor a ser recebido via PIX</Label>
<Input
id="pixValue"
type="number"
step="0.01"
min="0.01"
max={orderValue}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="0.00"
className="mt-2"
/>
<p className="text-xs text-slate-500 mt-1">
Valor máximo: {formatCurrency(orderValue)}
</p>
</div>
</div>
) : (
<div className="space-y-4">
{/* Informações do pedido */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<Label className="text-xs text-slate-500">Pedido</Label>
<p className="text-sm font-bold text-slate-800">{orderId}</p>
</div>
<div>
<Label className="text-xs text-slate-500">Cliente</Label>
<p className="text-sm font-bold text-slate-800 truncate">{customerName}</p>
</div>
<div className="col-span-2">
<Label className="text-xs text-slate-500">Valor</Label>
<p className="text-lg font-black text-teal-600">
{formatCurrency(pixValue)}
</p>
</div>
</div>
{/* QR Code */}
<div className="flex justify-center p-4 bg-slate-50 rounded-xl">
<div className="bg-white p-4 rounded-lg">
<QRCode
value={qrCodeValue}
size={200}
level="M"
/>
</div>
</div>
{/* Código PIX */}
<div>
<Label className="text-xs text-slate-500 mb-2 block">
Código PIX (copiar e colar)
</Label>
<div className="flex gap-2">
<Input
readOnly
value={qrCodeValue}
onClick={(e) => (e.target as HTMLInputElement).select()}
className="font-mono text-xs"
/>
<button
onClick={handleCopyQrCode}
className="px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 transition-colors text-sm font-medium"
title="Copiar código PIX"
>
Copiar
</button>
</div>
</div>
</div>
)}
</div>
{/* Actions */}
<div className="p-6 pt-0 flex justify-end gap-3">
{!showQrCode ? (
<>
<button
onClick={onClose}
className="flex-1 py-3 px-4 bg-gray-200 text-gray-700 font-black uppercase text-xs tracking-wider rounded-xl transition-all shadow-lg shadow-gray-300/20 active:scale-95 hover:bg-gray-300"
>
Cancelar
</button>
<button
onClick={handleConfirm}
disabled={processing || !inputValue || parseFloat(inputValue) <= 0}
className="flex-1 py-3 px-4 bg-teal-500 text-white font-black uppercase text-xs tracking-wider rounded-xl transition-all shadow-lg shadow-teal-500/20 active:scale-95 hover:bg-teal-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{processing ? "Gerando..." : "Gerar"}
</button>
</>
) : (
<button
onClick={onClose}
className="flex-1 py-3 px-4 bg-teal-500 text-white font-black uppercase text-xs tracking-wider rounded-xl transition-all shadow-lg shadow-teal-500/20 active:scale-95 hover:bg-teal-600"
>
Fechar
</button>
)}
</div>
</div>
</div>
);
};
export default ReceivePixDialog;

View File

@ -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<RelatedProductCardProps> = ({
product,
onAddToCart,
onClick,
}) => {
const [imageError, setImageError] = useState(false);
return (
<div
className="bg-white border border-slate-200 rounded-2xl p-4 hover:shadow-lg transition-all cursor-pointer group"
onClick={onClick}
>
<div className="h-32 bg-slate-50 rounded-xl mb-3 flex items-center justify-center overflow-hidden">
{product.image && !product.image.includes("placeholder") && !imageError ? (
<img
src={product.image}
alt={product.name}
className="max-h-full max-w-full object-contain group-hover:scale-110 transition-transform"
onError={() => setImageError(true)}
/>
) : (
<NoImagePlaceholder size="sm" />
)}
</div>
<h4 className="text-sm font-bold text-[#002147] mb-2 line-clamp-2">
{product.name}
</h4>
<p className="text-lg font-black text-orange-600 mb-3">
R${" "}
{product.price.toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
<button
onClick={(e) => {
e.stopPropagation();
// Ao clicar em "Adicionar", abre o modal de detalhes do produto
// (mesmo comportamento do Angular portal)
if (onClick) {
onClick();
} else {
// Fallback: se não houver onClick, adiciona diretamente ao carrinho
onAddToCart({ ...product, quantity: 1 });
}
}}
className="w-full bg-[#002147] text-white py-2 rounded-lg text-xs font-bold uppercase tracking-wide hover:bg-[#003366] transition-all"
>
Adicionar
</button>
</div>
);
};
export default RelatedProductCard;

108
components/SearchInput.tsx Normal file
View File

@ -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<SearchInputProps> = ({
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<HTMLInputElement>) => {
if (e.key === "Enter" && !loading && isValid) {
onSearch();
}
};
return (
<div className="relative group mb-0 lg:mb-10 max-w-full lg:max-w-4xl mx-auto">
<input
type="text"
placeholder={placeholder}
className="w-full p-3 lg:p-5 pl-10 lg:pl-14 rounded-xl lg:rounded-2xl bg-white shadow-sm lg:shadow-lg shadow-slate-200 outline-none border-2 border-transparent focus:border-orange-500 transition-all text-sm lg:text-base font-medium disabled:opacity-50 disabled:cursor-not-allowed"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled || loading}
/>
<button
onClick={onSearch}
disabled={loading || !isValid}
className="absolute right-1.5 lg:right-2 top-1.5 lg:top-2 bottom-1.5 lg:bottom-2 bg-[#002147] text-white px-3 lg:px-6 rounded-lg lg:rounded-xl font-black uppercase text-[10px] lg:text-xs tracking-widest hover:bg-[#003366] transition-all shadow-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center touch-manipulation"
>
{loading ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Buscando...
</>
) : (
"Buscar"
)}
</button>
{value.trim().length > 0 && value.trim().length < minLength && (
<p className="absolute -bottom-6 left-0 text-xs text-slate-400 font-medium mt-1">
Digite pelo menos {minLength} caracteres
</p>
)}
<svg
className="absolute left-3 lg:left-8 top-1/2 -translate-y-1/2 w-4 h-4 lg:w-5 lg:h-5 text-slate-300 group-focus-within:text-orange-500 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
);
};
export default SearchInput;

43
components/StatCard.tsx Normal file
View File

@ -0,0 +1,43 @@
import React from "react";
interface StatCardProps {
label: string;
value: string;
trend?: string;
color: string;
}
const StatCard: React.FC<StatCardProps> = ({
label,
value,
trend,
color,
}) => (
<div className="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 group hover:shadow-md transition-shadow">
<div className="flex justify-between items-start mb-3">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-[0.2em]">
{label}
</span>
{trend && (
<span
className={`text-[10px] font-bold px-2 py-0.5 rounded-md ${
trend.startsWith("+")
? "bg-emerald-50 text-emerald-600"
: "bg-red-50 text-red-600"
}`}
>
{trend}
</span>
)}
</div>
<div className="flex items-baseline space-x-1">
<span className={`text-2xl font-black text-[#002147]`}>{value}</span>
</div>
<div className={`mt-4 h-1 w-10 rounded-full ${color}`}></div>
</div>
);
export default StatCard;

View File

@ -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<StimulsoftViewerProps> = ({
requestUrl,
action,
width = "100%",
height = "800px",
onClose,
orderId,
model,
}) => {
const viewerRef = useRef<HTMLDivElement>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const formRef = useRef<HTMLFormElement | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="relative w-full h-full" style={{ width, height }}>
{/* <iframe
// src="http://10.1.1.205:8068/Viewer/InitViewerOrder?orderId=519003838&model=A"
src="http://10.1.1.205:8068/Viewer/ViewerEvent?orderId=519003838&model=A"
width="100%"
height="800"
style={{ border: "none" }}
/> */}
{onClose && (
<button
onClick={onClose}
className="absolute top-2 right-2 z-50 p-2 bg-white rounded-lg shadow-md hover:bg-slate-100 transition-colors"
title="Fechar"
>
<svg
className="w-5 h-5 text-slate-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 z-40">
<div className="flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#002147]"></div>
<p className="text-sm text-slate-600 font-medium">
Carregando relatório...
</p>
</div>
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-white z-40">
<div className="text-center p-6">
<p className="text-red-600 font-medium mb-2">{error}</p>
<p className="text-sm text-slate-500">URL: {baseUrl}</p>
<button
onClick={() => {
setError(null);
setLoading(true);
// Recarregar o viewer
window.location.reload();
}}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
Tentar novamente
</button>
</div>
</div>
)}
<div
id={viewerId}
ref={viewerRef}
style={{
width: "100%",
height: "100%",
border: "none",
borderRadius: "0 0 1.5rem 1.5rem",
}}
/>
</div>
);
};
export default StimulsoftViewer;

View File

@ -0,0 +1,692 @@
import React, { useState, useEffect, useRef } from "react";
import { X, MapPin, Save, Search } from "lucide-react";
import { CustomerAddress, customerService } from "../../src/services/customer.service";
interface AddressFormModalProps {
isOpen: boolean;
customerId: number | null;
address?: CustomerAddress | null;
onClose: () => void;
onSave: (address: CustomerAddress) => void;
}
declare global {
interface Window {
google: any;
initMap: () => void;
}
}
const AddressFormModal: React.FC<AddressFormModalProps> = ({
isOpen,
customerId,
address,
onClose,
onSave,
}) => {
const [formData, setFormData] = useState({
zipCode: "",
address: "",
number: "",
complement: "",
neighborhood: "",
city: "",
state: "",
referencePoint: "",
note: "",
addressType: "Casa",
isPrimary: false,
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(false);
const [mapLoaded, setMapLoaded] = useState(false);
const [map, setMap] = useState<any>(null);
const [marker, setMarker] = useState<any>(null);
const [geocoder, setGeocoder] = useState<any>(null);
const mapRef = useRef<HTMLDivElement>(null);
const [coordinates, setCoordinates] = useState<{ lat: number; lng: number } | null>(null);
useEffect(() => {
if (isOpen) {
if (address) {
setFormData({
zipCode: address.zipCode || "",
address: address.address || "",
number: address.number || "",
complement: address.complement || "",
neighborhood: address.neighborhood || "",
city: address.city || "",
state: address.state || "",
referencePoint: address.referencePoint || "",
note: address.note || "",
addressType: address.addressType || "Casa",
isPrimary: address.isPrimary || false,
});
if (address.latitude && address.longitude) {
setCoordinates({ lat: address.latitude, lng: address.longitude });
}
} else {
setFormData({
zipCode: "",
address: "",
number: "",
complement: "",
neighborhood: "",
city: "",
state: "",
referencePoint: "",
note: "",
addressType: "Casa",
isPrimary: false,
});
setCoordinates(null);
}
loadGoogleMaps();
}
}, [isOpen, address]);
const loadGoogleMaps = () => {
if (window.google && window.google.maps) {
initializeMap();
return;
}
if (!document.querySelector('script[src*="maps.googleapis.com"]')) {
const script = document.createElement("script");
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || "";
if (apiKey) {
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&callback=initMap`;
script.async = true;
script.defer = true;
window.initMap = initializeMap;
document.head.appendChild(script);
} else {
console.warn("Google Maps API Key não configurada. Configure VITE_GOOGLE_MAPS_API_KEY no arquivo .env");
}
} else {
initializeMap();
}
};
const initializeMap = () => {
if (!mapRef.current || !window.google) return;
const initialCenter = coordinates || { lat: -23.5505, lng: -46.6333 }; // São Paulo default
const mapInstance = new window.google.maps.Map(mapRef.current, {
center: initialCenter,
zoom: coordinates ? 15 : 10,
mapTypeControl: false,
streetViewControl: false,
fullscreenControl: false,
});
const geocoderInstance = new window.google.maps.Geocoder();
setGeocoder(geocoderInstance);
setMap(mapInstance);
let markerInstance: any = null;
if (coordinates) {
markerInstance = new window.google.maps.Marker({
position: coordinates,
map: mapInstance,
draggable: true,
animation: window.google.maps.Animation.DROP,
});
markerInstance.addListener("dragend", (e: any) => {
const newPosition = {
lat: e.latLng.lat(),
lng: e.latLng.lng(),
};
setCoordinates(newPosition);
reverseGeocode(newPosition);
});
} else {
// Adiciona marcador no centro inicial
markerInstance = new window.google.maps.Marker({
position: initialCenter,
map: mapInstance,
draggable: true,
animation: window.google.maps.Animation.DROP,
});
markerInstance.addListener("dragend", (e: any) => {
const newPosition = {
lat: e.latLng.lat(),
lng: e.latLng.lng(),
};
setCoordinates(newPosition);
reverseGeocode(newPosition);
});
}
setMarker(markerInstance);
// Click no mapa para adicionar/mover marcador
mapInstance.addListener("click", (e: any) => {
const newPosition = {
lat: e.latLng.lat(),
lng: e.latLng.lng(),
};
setCoordinates(newPosition);
if (markerInstance) {
markerInstance.setPosition(newPosition);
} else {
markerInstance = new window.google.maps.Marker({
position: newPosition,
map: mapInstance,
draggable: true,
});
markerInstance.addListener("dragend", (e: any) => {
const newPos = {
lat: e.latLng.lat(),
lng: e.latLng.lng(),
};
setCoordinates(newPos);
reverseGeocode(newPos);
});
setMarker(markerInstance);
}
reverseGeocode(newPosition);
});
// Autocomplete para busca de endereço
const autocomplete = new window.google.maps.places.Autocomplete(
document.getElementById("address-search") as HTMLInputElement,
{ types: ["address"] }
);
autocomplete.addListener("place_changed", () => {
const place = autocomplete.getPlace();
if (place.geometry) {
const location = place.geometry.location;
const newPosition = {
lat: location.lat(),
lng: location.lng(),
};
setCoordinates(newPosition);
mapInstance.setCenter(newPosition);
mapInstance.setZoom(15);
if (markerInstance) {
markerInstance.setPosition(newPosition);
}
// Preencher campos do formulário
const addressComponents = place.address_components || [];
addressComponents.forEach((component: any) => {
const type = component.types[0];
if (type === "street_number") {
setFormData((prev) => ({ ...prev, number: component.long_name }));
} else if (type === "route") {
setFormData((prev) => ({ ...prev, address: component.long_name }));
} else if (type === "sublocality_level_1" || type === "neighborhood") {
setFormData((prev) => ({ ...prev, neighborhood: component.long_name }));
} else if (type === "locality") {
setFormData((prev) => ({ ...prev, city: component.long_name }));
} else if (type === "administrative_area_level_1") {
setFormData((prev) => ({ ...prev, state: component.short_name }));
} else if (type === "postal_code") {
setFormData((prev) => ({ ...prev, zipCode: component.long_name }));
}
});
if (place.formatted_address) {
setFormData((prev) => ({ ...prev, address: place.formatted_address.split(",")[0] }));
}
}
});
setMapLoaded(true);
};
const reverseGeocode = (position: { lat: number; lng: number }) => {
if (!geocoder) return;
geocoder.geocode({ location: position }, (results: any[], status: string) => {
if (status === "OK" && results[0]) {
const result = results[0];
const addressComponents = result.address_components || [];
addressComponents.forEach((component: any) => {
const type = component.types[0];
if (type === "street_number") {
setFormData((prev) => ({ ...prev, number: component.long_name }));
} else if (type === "route") {
setFormData((prev) => ({ ...prev, address: component.long_name }));
} else if (type === "sublocality_level_1" || type === "neighborhood") {
setFormData((prev) => ({ ...prev, neighborhood: component.long_name }));
} else if (type === "locality") {
setFormData((prev) => ({ ...prev, city: component.long_name }));
} else if (type === "administrative_area_level_1") {
setFormData((prev) => ({ ...prev, state: component.short_name }));
} else if (type === "postal_code") {
setFormData((prev) => ({ ...prev, zipCode: component.long_name }));
}
});
}
});
};
const handleSearchByCEP = async () => {
if (!formData.zipCode || formData.zipCode.length < 8) return;
const cep = formData.zipCode.replace(/\D/g, "");
if (cep.length !== 8) return;
try {
const response = await fetch(`https://viacep.com.br/ws/${cep}/json/`);
const data = await response.json();
if (!data.erro) {
setFormData((prev) => ({
...prev,
address: data.logradouro || "",
neighborhood: data.bairro || "",
city: data.localidade || "",
state: data.uf || "",
}));
// Geocodificar o endereço completo
if (geocoder && data.logradouro) {
const fullAddress = `${data.logradouro}, ${data.localidade}, ${data.uf}, Brasil`;
geocoder.geocode({ address: fullAddress }, (results: any[], status: string) => {
if (status === "OK" && results[0]) {
const location = results[0].geometry.location;
const newPosition = {
lat: location.lat(),
lng: location.lng(),
};
setCoordinates(newPosition);
if (map) {
map.setCenter(newPosition);
map.setZoom(15);
}
if (marker) {
marker.setPosition(newPosition);
} else if (map) {
const newMarker = new window.google.maps.Marker({
position: newPosition,
map: map,
draggable: true,
});
newMarker.addListener("dragend", (e: any) => {
const newPos = {
lat: e.latLng.lat(),
lng: e.latLng.lng(),
};
setCoordinates(newPos);
reverseGeocode(newPos);
});
setMarker(newMarker);
}
}
});
}
}
} catch (error) {
console.error("Erro ao buscar CEP:", error);
}
};
const handleChange = (field: string, value: string | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
const validate = () => {
const newErrors: Record<string, string> = {};
if (!formData.zipCode) newErrors.zipCode = "CEP é obrigatório";
if (!formData.address) newErrors.address = "Endereço é obrigatório";
if (!formData.number) newErrors.number = "Número é obrigatório";
if (!formData.city) newErrors.city = "Cidade é obrigatória";
if (!formData.state) newErrors.state = "Estado é obrigatório";
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = async () => {
if (!validate() || !customerId) return;
setIsLoading(true);
try {
const addressData: Partial<CustomerAddress> = {
...formData,
latitude: coordinates?.lat,
longitude: coordinates?.lng,
};
let savedAddress: CustomerAddress | null;
if (address?.id) {
// Atualizar endereço existente
// TODO: Implementar updateAddress no service
savedAddress = await customerService.createAddress(customerId, addressData);
} else {
// Criar novo endereço
savedAddress = await customerService.createAddress(customerId, addressData);
}
if (savedAddress) {
onSave(savedAddress);
onClose();
}
} catch (error: any) {
console.error("Erro ao salvar endereço:", error);
setErrors({ general: error.message || "Erro ao salvar endereço" });
} finally {
setIsLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-6xl max-h-[90vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="p-6 bg-[#002147] text-white rounded-t-3xl relative overflow-hidden flex-shrink-0">
<div className="relative z-10 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-orange-500/20 rounded-2xl flex items-center justify-center">
<MapPin className="w-6 h-6 text-orange-400" />
</div>
<div>
<h3 className="text-xl font-black">
{address ? "Editar Endereço" : "Cadastrar Novo Endereço"}
</h3>
<p className="text-xs text-orange-400 font-bold uppercase tracking-wider mt-0.5">
{address ? "Atualize os dados do endereço" : "Preencha os dados e selecione no mapa"}
</p>
</div>
</div>
<button
onClick={onClose}
className="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/10 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="absolute right-[-10%] top-[-10%] w-32 h-32 bg-orange-400/10 rounded-full blur-2xl"></div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Formulário */}
<div className="space-y-4">
<h4 className="text-sm font-black uppercase text-slate-600 mb-4">
Dados do Endereço
</h4>
{/* Busca por CEP ou Endereço */}
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Buscar por CEP ou Endereço
</label>
<div className="flex gap-2">
<input
id="address-search"
type="text"
placeholder="Digite o CEP ou endereço..."
className="flex-1 px-4 py-3 bg-white border-2 border-slate-200 rounded-xl font-bold text-slate-700 outline-none focus:border-orange-500 transition-all"
/>
<button
onClick={handleSearchByCEP}
className="px-4 py-3 bg-[#002147] text-white rounded-xl font-bold hover:bg-[#001a36] transition-colors"
>
<Search className="w-5 h-5" />
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
CEP *
</label>
<input
type="text"
value={formData.zipCode}
onChange={(e) => handleChange("zipCode", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
errors.zipCode ? "border-red-500" : "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
placeholder="00000-000"
/>
{errors.zipCode && (
<p className="text-red-500 text-xs mt-1">{errors.zipCode}</p>
)}
</div>
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Tipo
</label>
<select
value={formData.addressType}
onChange={(e) => handleChange("addressType", 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"
>
<option value="Casa">Casa</option>
<option value="Trabalho">Trabalho</option>
<option value="Outro">Outro</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Endereço *
</label>
<input
type="text"
value={formData.address}
onChange={(e) => handleChange("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`}
/>
{errors.address && (
<p className="text-red-500 text-xs mt-1">{errors.address}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Número *
</label>
<input
type="text"
value={formData.number}
onChange={(e) => handleChange("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`}
/>
{errors.number && (
<p className="text-red-500 text-xs mt-1">{errors.number}</p>
)}
</div>
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Complemento
</label>
<input
type="text"
value={formData.complement}
onChange={(e) => handleChange("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"
/>
</div>
</div>
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Bairro
</label>
<input
type="text"
value={formData.neighborhood}
onChange={(e) => handleChange("neighborhood", 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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Cidade *
</label>
<input
type="text"
value={formData.city}
onChange={(e) => handleChange("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`}
/>
{errors.city && (
<p className="text-red-500 text-xs mt-1">{errors.city}</p>
)}
</div>
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Estado *
</label>
<input
type="text"
value={formData.state}
onChange={(e) => handleChange("state", e.target.value)}
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`}
maxLength={2}
placeholder="SP"
/>
{errors.state && (
<p className="text-red-500 text-xs mt-1">{errors.state}</p>
)}
</div>
</div>
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Ponto de Referência
</label>
<input
type="text"
value={formData.referencePoint}
onChange={(e) => handleChange("referencePoint", 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"
/>
</div>
<div>
<label className="block text-xs font-black uppercase text-slate-400 mb-2">
Observações
</label>
<textarea
value={formData.note}
onChange={(e) => handleChange("note", e.target.value)}
rows={3}
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 resize-none"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isPrimary"
checked={formData.isPrimary}
onChange={(e) => handleChange("isPrimary", e.target.checked)}
className="w-5 h-5 rounded border-slate-300 text-[#002147] focus:ring-2 focus:ring-orange-500/20"
/>
<label htmlFor="isPrimary" className="text-sm font-bold text-slate-700">
Definir como endereço principal
</label>
</div>
{errors.general && (
<div className="p-3 bg-red-50 border border-red-200 rounded-xl">
<p className="text-red-600 text-sm font-bold">{errors.general}</p>
</div>
)}
</div>
{/* Mapa */}
<div>
<h4 className="text-sm font-black uppercase text-slate-600 mb-4">
Localização no Mapa
</h4>
<div className="relative">
<div
ref={mapRef}
className="w-full h-[500px] rounded-2xl border-2 border-slate-200 overflow-hidden"
/>
{!mapLoaded && (
<div className="absolute inset-0 flex items-center justify-center bg-slate-100">
<div className="text-center">
<div className="w-12 h-12 border-4 border-[#002147] border-t-transparent rounded-full animate-spin mx-auto mb-2"></div>
<p className="text-sm font-bold text-slate-600">Carregando mapa...</p>
</div>
</div>
)}
<div className="mt-3 p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-600">
<strong>Dica:</strong> Clique no mapa ou arraste o marcador para definir a localização exata do endereço.
</p>
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="p-6 border-t border-slate-200 flex-shrink-0 flex items-center justify-end gap-3">
<button
onClick={onClose}
className="px-6 py-3 rounded-xl font-bold text-slate-700 hover:bg-slate-100 transition-colors"
>
Cancelar
</button>
<button
onClick={handleSave}
disabled={isLoading}
className="flex items-center gap-2 px-6 py-3 rounded-xl font-black bg-[#002147] text-white hover:bg-[#001a36] transition-all shadow-lg shadow-[#002147]/20 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
Salvando...
</>
) : (
<>
<Save className="w-4 h-4" />
Salvar Endereço
</>
)}
</button>
</div>
</div>
</div>
);
};
export default AddressFormModal;

View File

@ -0,0 +1,279 @@
import React, { useState, useEffect } from "react";
import { X, MapPin, Plus, CheckCircle } from "lucide-react";
import {
CustomerAddress,
customerService,
} from "../../src/services/customer.service";
interface AddressSelectionModalProps {
isOpen: boolean;
customerId: number | null;
onClose: () => void;
onSelect: (address: CustomerAddress) => void;
onCreateNew: () => void;
}
const AddressSelectionModal: React.FC<AddressSelectionModalProps> = ({
isOpen,
customerId,
onClose,
onSelect,
onCreateNew,
}) => {
const [addresses, setAddresses] = useState<CustomerAddress[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectedAddressId, setSelectedAddressId] = useState<number | null>(
null
);
useEffect(() => {
if (isOpen && customerId) {
loadAddresses();
} else if (isOpen && !customerId) {
console.warn("AddressSelectionModal: customerId não fornecido");
setAddresses([]);
}
}, [isOpen, customerId]);
const loadAddresses = async () => {
if (!customerId) {
console.warn(
"AddressSelectionModal: customerId é obrigatório para carregar endereços"
);
setAddresses([]);
return;
}
setIsLoading(true);
try {
console.log(
"AddressSelectionModal: Carregando endereços para customerId:",
customerId
);
const customerAddresses = await customerService.getCustomerAddresses(
customerId
);
console.log(
"AddressSelectionModal: Endereços carregados:",
customerAddresses
);
console.log(
"AddressSelectionModal: Quantidade de endereços:",
customerAddresses.length
);
if (!Array.isArray(customerAddresses)) {
console.error(
"AddressSelectionModal: Resposta da API não é um array:",
customerAddresses
);
setAddresses([]);
return;
}
setAddresses(customerAddresses);
} catch (error) {
console.error(
"AddressSelectionModal: Erro ao carregar endereços:",
error
);
setAddresses([]);
} finally {
setIsLoading(false);
}
};
const handleSelect = () => {
if (selectedAddressId) {
const address = addresses.find(
(a) =>
a.id === selectedAddressId ||
a.idAddress === selectedAddressId ||
a.addressId === selectedAddressId
);
if (address) {
onSelect(address);
onClose();
}
}
};
const formatAddress = (address: CustomerAddress) => {
const parts = [];
const street = address.address || address.street || "";
const number = address.number || address.numberAddress || "";
const complement = address.complement || "";
if (street) parts.push(street);
if (number) parts.push(number);
if (complement) parts.push(complement);
return parts.join(", ");
};
const getAddressId = (address: CustomerAddress): number | null => {
return address.id || address.idAddress || address.addressId || null;
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="p-6 bg-[#002147] text-white rounded-t-3xl relative overflow-hidden flex-shrink-0">
<div className="relative z-10 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-orange-500/20 rounded-2xl flex items-center justify-center">
<MapPin className="w-6 h-6 text-orange-400" />
</div>
<div>
<h3 className="text-xl font-black">
Selecionar endereço de entrega
</h3>
<p className="text-xs text-orange-400 font-bold uppercase tracking-wider mt-0.5">
{addresses.length} endereço{addresses.length !== 1 ? "s" : ""}{" "}
encontrado{addresses.length !== 1 ? "s" : ""}
</p>
</div>
</div>
<button
onClick={onClose}
className="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/10 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="absolute right-[-10%] top-[-10%] w-32 h-32 bg-orange-400/10 rounded-full blur-2xl"></div>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex flex-col p-6">
{isLoading ? (
<div className="flex-1 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-[#002147] border-t-transparent rounded-full animate-spin"></div>
</div>
) : addresses.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center">
<div className="w-20 h-20 bg-slate-100 rounded-full flex items-center justify-center mb-4">
<MapPin className="w-10 h-10 text-slate-400" />
</div>
<p className="text-lg font-bold text-slate-600 mb-2">
Sem endereços cadastrados
</p>
<p className="text-sm text-slate-500 text-center mb-6">
Este cliente não possui endereços cadastrados. Clique no botão
abaixo para cadastrar um novo endereço.
</p>
<button
onClick={() => {
onClose();
onCreateNew();
}}
className="flex items-center gap-2 bg-[#002147] text-white px-6 py-3 rounded-xl font-black uppercase text-xs tracking-widest hover:bg-[#001a36] transition-all shadow-lg"
>
<Plus className="w-4 h-4" />
Cadastrar Novo Endereço
</button>
</div>
) : (
<>
<div className="mb-4 flex justify-end">
<button
onClick={() => {
onClose();
onCreateNew();
}}
className="flex items-center gap-2 bg-white border-2 border-[#002147] text-[#002147] px-4 py-2 rounded-xl font-bold text-xs hover:bg-[#002147] hover:text-white transition-all"
>
<Plus className="w-4 h-4" />
Novo Endereço
</button>
</div>
<div
className="flex-1 overflow-y-auto space-y-3 pr-2"
style={{
scrollbarWidth: "thin",
scrollbarColor: "#cbd5e1 #f1f5f9",
}}
>
{addresses.map((address) => {
const addressId = getAddressId(address);
const isSelected = selectedAddressId === addressId;
return (
<div
key={addressId || Math.random()}
onClick={() => setSelectedAddressId(addressId)}
className={`p-4 rounded-2xl border-2 transition-all cursor-pointer ${
isSelected
? "bg-orange-50 border-orange-500"
: "bg-slate-50 border-slate-200 hover:border-orange-300"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h4 className="font-black text-slate-800">
{address.addressType || "Endereço"}
</h4>
{address.isPrimary && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded-full text-[10px] font-black uppercase">
Principal
</span>
)}
</div>
<p className="text-sm font-bold text-slate-700 mb-1">
{formatAddress(address)}
</p>
<p className="text-xs text-slate-500">
{(address.neighborhood || address.neighbourhood) &&
`${
address.neighborhood || address.neighbourhood
} - `}
{address.city && `${address.city}`}
{address.state && `/${address.state}`}
</p>
{address.zipCode && (
<p className="text-xs text-slate-500 mt-1">
CEP: {address.zipCode}
</p>
)}
</div>
{isSelected && (
<div className="ml-4">
<CheckCircle className="w-6 h-6 text-orange-500" />
</div>
)}
</div>
</div>
);
})}
</div>
</>
)}
</div>
{/* Footer */}
{addresses.length > 0 && (
<div className="p-6 border-t border-slate-200 flex-shrink-0 flex items-center justify-end gap-3">
<button
onClick={onClose}
className="px-6 py-3 rounded-xl font-bold text-slate-700 hover:bg-slate-100 transition-colors"
>
Cancelar
</button>
<button
onClick={handleSelect}
disabled={selectedAddressId === null}
className="px-6 py-3 rounded-xl font-black bg-[#002147] text-white hover:bg-[#001a36] transition-all shadow-lg shadow-[#002147]/20 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
>
Selecionar
</button>
</div>
)}
</div>
</div>
);
};
export default AddressSelectionModal;

View File

@ -0,0 +1,417 @@
import React, { useState, useEffect } from "react";
import { CustomerAddress } from "../../src/services/customer.service";
import {
MapPin,
Plus,
Pencil,
Trash2,
ArrowLeft,
ArrowRight,
CheckCircle,
} from "lucide-react";
import AddressSelectionModal from "./AddressSelectionModal";
import AddressFormModal from "./AddressFormModal";
import ConfirmDialog from "../ConfirmDialog";
interface AddressStepProps {
addressForm: {
zipCode: string;
address: string;
number: string;
city: string;
state: string;
complement: string;
referencePoint: string;
note: string;
};
addressErrors: Record<string, string>;
selectedAddress: CustomerAddress | null;
customerId: number | null;
onFormChange: (field: string, value: string) => void;
onShowAddressModal: () => void;
onRemoveAddress: () => void;
onSelectAddress: (address: CustomerAddress) => void;
onPrevious: () => void;
onNext: () => void;
}
const AddressStep: React.FC<AddressStepProps> = ({
addressForm,
addressErrors,
selectedAddress,
customerId,
onFormChange,
onShowAddressModal,
onRemoveAddress,
onSelectAddress,
onPrevious,
onNext,
}) => {
const [showSelectionModal, setShowSelectionModal] = useState(false);
const [showFormModal, setShowFormModal] = useState(false);
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [editingAddress, setEditingAddress] = useState<CustomerAddress | null>(
null
);
const formatAddress = (address: CustomerAddress) => {
const parts = [];
if (address.address) parts.push(address.address);
if (address.number) parts.push(address.number);
if (address.complement) parts.push(address.complement);
return parts.join(", ");
};
const handleSelectFromList = () => {
if (!customerId) {
console.warn(
"AddressStep: customerId não fornecido. Não é possível abrir o modal de seleção de endereços."
);
return;
}
console.log(
"AddressStep: Abrindo modal de seleção de endereços para customerId:",
customerId
);
setShowSelectionModal(true);
};
const handleCreateNew = () => {
setEditingAddress(null);
setShowFormModal(true);
};
const handleEdit = () => {
if (selectedAddress) {
setEditingAddress(selectedAddress);
setShowFormModal(true);
}
};
const handleAddressSelected = (address: CustomerAddress) => {
onSelectAddress(address);
setShowSelectionModal(false);
};
const handleAddressSaved = (address: CustomerAddress) => {
onSelectAddress(address);
setShowFormModal(false);
setEditingAddress(null);
};
return (
<>
<div className="animate-fade-in">
<div className="flex justify-between items-center mb-6">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">
Endereço de entrega
</h3>
<div className="flex gap-2">
<button
onClick={handleSelectFromList}
className="flex items-center gap-2 bg-slate-100 px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest text-[#002147] hover:bg-slate-200 transition-colors"
>
<MapPin className="w-4 h-4" />
Selecionar
</button>
<button
onClick={handleCreateNew}
className="flex items-center gap-2 bg-slate-100 px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest text-[#002147] hover:bg-slate-200 transition-colors"
>
<Plus className="w-4 h-4" />
Novo
</button>
{selectedAddress && (
<>
<button
onClick={handleEdit}
className="flex items-center gap-2 bg-[#002147] text-white px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest hover:bg-[#003366] transition-colors"
>
<Pencil className="w-4 h-4" />
Editar
</button>
<button
onClick={() => setShowRemoveDialog(true)}
className="flex items-center gap-2 bg-red-100 text-red-600 px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest hover:bg-red-200 transition-colors"
>
<Trash2 className="w-4 h-4" />
Remover
</button>
</>
)}
</div>
</div>
{selectedAddress ? (
/* Endereço Selecionado */
<div className="bg-gradient-to-br from-orange-50 to-orange-100/50 rounded-2xl p-6 border-2 border-orange-200 mb-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-3">
<CheckCircle className="w-5 h-5 text-green-500" />
<h4 className="font-black text-slate-800 text-lg">
{selectedAddress.addressType || "Endereço"}
</h4>
{selectedAddress.isPrimary && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded-full text-[10px] font-black uppercase">
Principal
</span>
)}
</div>
<p className="text-sm font-bold text-slate-700 mb-2">
{formatAddress(selectedAddress)}
</p>
<p className="text-xs text-slate-600 mb-1">
{selectedAddress.neighborhood &&
`${selectedAddress.neighborhood} - `}
{selectedAddress.city && `${selectedAddress.city}`}
{selectedAddress.state && `/${selectedAddress.state}`}
</p>
{selectedAddress.zipCode && (
<p className="text-xs text-slate-600">
CEP: {selectedAddress.zipCode}
</p>
)}
{selectedAddress.referencePoint && (
<p className="text-xs text-slate-500 mt-2">
<strong>Referência:</strong>{" "}
{selectedAddress.referencePoint}
</p>
)}
</div>
</div>
</div>
) : (
/* Sem Endereço Selecionado */
<div className="bg-slate-50 rounded-2xl p-8 border-2 border-dashed border-slate-300 mb-6 text-center">
<div className="w-20 h-20 bg-slate-200 rounded-full flex items-center justify-center mx-auto mb-4">
<MapPin className="w-10 h-10 text-slate-400" />
</div>
<p className="text-lg font-bold text-slate-600 mb-2">
Nenhum endereço selecionado
</p>
<p className="text-sm text-slate-500 mb-6">
Selecione um endereço existente ou cadastre um novo endereço de
entrega
</p>
<div className="flex gap-3 justify-center">
<button
onClick={handleSelectFromList}
className="flex items-center gap-2 bg-[#002147] text-white px-6 py-3 rounded-xl font-black uppercase text-xs tracking-widest hover:bg-[#001a36] transition-all"
>
<MapPin className="w-4 h-4" />
Selecionar Endereço
</button>
<button
onClick={handleCreateNew}
className="flex items-center gap-2 bg-white border-2 border-[#002147] text-[#002147] px-6 py-3 rounded-xl font-black uppercase text-xs tracking-widest hover:bg-[#002147] hover:text-white transition-all"
>
<Plus className="w-4 h-4" />
Cadastrar Novo
</button>
</div>
</div>
)}
{/* Formulário de Endereço (apenas quando há endereço selecionado) */}
{selectedAddress && (
<form className="space-y-4 mt-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
CEP
</label>
<input
type="text"
value={addressForm.zipCode}
onChange={(e) => onFormChange("zipCode", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
addressErrors.zipCode
? "border-red-500"
: "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
/>
{addressErrors.zipCode && (
<p className="text-red-500 text-xs mt-1">
{addressErrors.zipCode}
</p>
)}
</div>
<div className="md:col-span-2">
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
endereço
</label>
<input
type="text"
value={addressForm.address}
onChange={(e) => onFormChange("address", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
addressErrors.address
? "border-red-500"
: "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
/>
{addressErrors.address && (
<p className="text-red-500 text-xs mt-1">
{addressErrors.address}
</p>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
número
</label>
<input
type="text"
value={addressForm.number}
onChange={(e) => onFormChange("number", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
addressErrors.number ? "border-red-500" : "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
/>
{addressErrors.number && (
<p className="text-red-500 text-xs mt-1">
{addressErrors.number}
</p>
)}
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
cidade
</label>
<input
type="text"
value={addressForm.city}
onChange={(e) => onFormChange("city", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
addressErrors.city ? "border-red-500" : "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
/>
{addressErrors.city && (
<p className="text-red-500 text-xs mt-1">
{addressErrors.city}
</p>
)}
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
estado
</label>
<input
type="text"
value={addressForm.state}
onChange={(e) => onFormChange("state", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
addressErrors.state ? "border-red-500" : "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
/>
{addressErrors.state && (
<p className="text-red-500 text-xs mt-1">
{addressErrors.state}
</p>
)}
</div>
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
complemento
</label>
<input
type="text"
value={addressForm.complement}
onChange={(e) => onFormChange("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"
/>
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
Ponto de referência
</label>
<input
type="text"
value={addressForm.referencePoint}
onChange={(e) => onFormChange("referencePoint", 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"
/>
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
observações
</label>
<input
type="text"
value={addressForm.note}
onChange={(e) => onFormChange("note", 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"
/>
</div>
</form>
)}
<div className="flex justify-between gap-2 mt-6">
<button
type="button"
onClick={onPrevious}
className="flex items-center gap-2 bg-slate-100 px-6 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest text-[#002147] hover:bg-slate-200 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Retornar
</button>
<button
type="button"
onClick={onNext}
className="flex items-center gap-2 bg-[#002147] text-white px-6 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest hover:bg-[#003366] transition-colors"
>
Avançar
<ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
{/* Modais */}
<AddressSelectionModal
isOpen={showSelectionModal}
customerId={customerId}
onClose={() => setShowSelectionModal(false)}
onSelect={handleAddressSelected}
onCreateNew={() => {
setShowSelectionModal(false);
handleCreateNew();
}}
/>
<AddressFormModal
isOpen={showFormModal}
customerId={customerId}
address={editingAddress}
onClose={() => {
setShowFormModal(false);
setEditingAddress(null);
}}
onSave={handleAddressSaved}
/>
<ConfirmDialog
isOpen={showRemoveDialog}
onClose={() => setShowRemoveDialog(false)}
onConfirm={() => {
onRemoveAddress();
setShowRemoveDialog(false);
}}
type="delete"
title="Remover Endereço"
message="Tem certeza que deseja remover este endereço de entrega? Esta ação não pode ser desfeita."
confirmText="Remover"
cancelText="Cancelar"
/>
</>
);
};
export default AddressStep;

View File

@ -0,0 +1,386 @@
import React from "react";
import { OrderItem } from "../../types";
interface CheckoutProductsTableProps {
cart: OrderItem[];
onEdit?: (item: OrderItem) => void;
onDiscount?: (item: OrderItem) => void;
onRemove?: (item: OrderItem) => void;
}
const CheckoutProductsTable: React.FC<CheckoutProductsTableProps> = ({
cart,
onEdit,
onDiscount,
onRemove,
}) => {
// Renderizar badge de condição
const renderConditionBadge = (item: OrderItem) => {
if (item.discount && item.discount > 0) {
return (
<span className="bg-[#cc4b5c] text-white px-2 py-1 rounded-full text-[9px] font-bold uppercase tracking-tight">
Em promoção
</span>
);
}
return null;
};
// Renderizar badge de tipo de entrega
const renderDeliveryTypeBadge = (item: OrderItem) => {
if (item.deliveryType === "RI") {
return (
<span className="bg-[#385942] text-white px-2 py-1 rounded-full text-[9px] font-bold uppercase tracking-tight">
Retira imediata
</span>
);
}
if (item.deliveryType === "EN") {
return (
<span className="bg-[#cc4b5c] text-white px-2 py-1 rounded-full text-[9px] font-bold uppercase tracking-tight">
Entrega
</span>
);
}
if (item.deliveryType === "RP") {
return (
<span className="bg-[#ef7d00] text-white px-2 py-1 rounded-full text-[9px] font-bold uppercase tracking-tight">
Retira posterior
</span>
);
}
if (item.deliveryType === "EF") {
return (
<span className="bg-[#5a3d7a] text-white px-2 py-1 rounded-full text-[9px] font-bold uppercase tracking-tight">
Encomenda
</span>
);
}
return null;
};
return (
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden mb-8">
{/* Cards View - Mobile/Tablet */}
<div className="lg:hidden">
{cart.length === 0 ? (
<div className="p-8 text-center text-slate-400 italic font-medium">
Nenhum item adicionado ao pedido.
</div>
) : (
<div className="divide-y divide-slate-100">
{cart.map((item, idx) => (
<div
key={item.id}
className="p-4 hover:bg-slate-50/50 transition-colors"
>
{/* Header do Card */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-bold text-slate-400">
#{idx + 1}
</span>
<span className="text-xs font-medium text-slate-500">
Cód: {item.code}
</span>
{renderConditionBadge(item)}
</div>
<h3 className="font-bold text-sm text-[#002147] leading-tight mb-2">
{item.name}
</h3>
</div>
</div>
{/* Informações do Produto */}
<div className="space-y-2 mb-3">
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500">Marca:</span>
<span className="font-medium text-slate-700 uppercase">
{item.mark || "-"}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500">Desconto:</span>
<span className="font-semibold text-slate-700">
{item.discount ? `${item.discount.toFixed(2)}%` : "0,00%"}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500">F.Retira:</span>
<span className="font-medium text-slate-700">
{item.stockStore ? `Loja ${item.stockStore}` : "-"}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500">Tipo de entrega:</span>
{renderDeliveryTypeBadge(item)}
</div>
</div>
{/* Valores */}
<div className="bg-slate-50 rounded-lg p-3 mb-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-slate-500">Preço unitário:</span>
<span className="font-bold text-sm text-slate-700">
R$ {item.price.toFixed(2)}
</span>
</div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-slate-500">Quantidade:</span>
<span className="font-bold text-sm text-slate-700">
{item.quantity.toFixed(2)}
</span>
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-200">
<span className="text-xs font-bold text-slate-600">
Valor total:
</span>
<span className="font-black text-base text-[#002147]">
R$ {(item.price * item.quantity).toFixed(2)}
</span>
</div>
</div>
{/* Ações */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-slate-100">
<button
onClick={() => onEdit && onEdit(item)}
className="flex items-center gap-1.5 px-3 py-2 bg-emerald-50 text-emerald-600 rounded-lg hover:bg-emerald-100 transition-colors text-xs font-bold touch-manipulation"
title="Editar"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
Editar
</button>
<button
onClick={() => onDiscount && onDiscount(item)}
className="flex items-center gap-1.5 px-3 py-2 bg-orange-50 text-orange-500 rounded-lg hover:bg-orange-100 transition-colors text-xs font-bold touch-manipulation"
title="Desconto"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Desconto
</button>
<button
onClick={() => onRemove && onRemove(item)}
className="flex items-center gap-1.5 px-3 py-2 bg-red-50 text-red-500 rounded-lg hover:bg-red-100 transition-colors text-xs font-bold touch-manipulation"
title="Remover"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Remover
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Table View - Desktop */}
<div className="hidden lg:block overflow-x-auto custom-scrollbar">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-50 text-slate-500 font-bold uppercase border-b border-slate-200">
<th className="px-4 py-4 border-r border-slate-200">Seq</th>
<th className="px-4 py-4 border-r border-slate-200">Código</th>
<th className="px-4 py-4 border-r border-slate-200 min-w-[300px]">
Produto
</th>
<th className="px-4 py-4 border-r border-slate-200 text-center">
Condição
</th>
<th className="px-4 py-4 border-r border-slate-200">Marca</th>
<th className="px-4 py-4 border-r border-slate-200">Descon</th>
<th className="px-4 py-4 border-r border-slate-200">F.Retira</th>
<th className="px-4 py-4 border-r border-slate-200">
Tipo de entre.
</th>
<th className="px-4 py-4 border-r border-slate-200">Preço</th>
<th className="px-4 py-4 border-r border-slate-200">Qtde</th>
<th className="px-4 py-4 border-r border-slate-200">
Valor total
</th>
<th className="px-4 py-4 text-center">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{cart.length === 0 ? (
<tr>
<td
colSpan={12}
className="px-4 py-10 text-center text-slate-400 italic font-medium"
>
Nenhum item adicionado ao pedido.
</td>
</tr>
) : (
cart.map((item, idx) => (
<tr
key={item.id}
className="hover:bg-slate-50/50 transition-colors"
>
<td className="px-4 py-4 border-r border-slate-100 font-medium">
{idx + 1}
</td>
<td className="px-4 py-4 border-r border-slate-100 text-slate-600">
{item.code}
</td>
<td className="px-4 py-4 border-r border-slate-100 font-bold text-[#002147]">
{item.name}
</td>
<td className="px-4 py-4 border-r border-slate-100 text-center">
{item.discount && item.discount > 0 && (
<span className="bg-[#cc4b5c] text-white px-3 py-1 rounded-full text-[9px] font-bold uppercase tracking-tight">
Em promoção
</span>
)}
</td>
<td className="px-4 py-4 border-r border-slate-100 text-slate-600 font-medium uppercase">
{item.mark || "-"}
</td>
<td className="px-4 py-4 border-r border-slate-100 font-semibold text-slate-500">
{item.discount ? `${item.discount.toFixed(2)}%` : "0,00%"}
</td>
<td className="px-4 py-4 border-r border-slate-100 font-medium text-slate-600">
{item.stockStore ? `Loja ${item.stockStore}` : "-"}
</td>
<td className="px-4 py-4 border-r border-slate-100 text-center">
{item.deliveryType === "RI" && (
<span className="bg-[#385942] text-white px-3 py-1 rounded-full text-[9px] font-bold uppercase tracking-tight">
Retira imediata
</span>
)}
{item.deliveryType === "EN" && (
<span className="bg-[#cc4b5c] text-white px-3 py-1 rounded-full text-[9px] font-bold uppercase tracking-tight">
Entrega
</span>
)}
{item.deliveryType === "RP" && (
<span className="bg-[#ef7d00] text-white px-3 py-1 rounded-full text-[9px] font-bold uppercase tracking-tight">
Retira posterior
</span>
)}
{item.deliveryType === "EF" && (
<span className="bg-[#5a3d7a] text-white px-3 py-1 rounded-full text-[9px] font-bold uppercase tracking-tight">
Encomenda
</span>
)}
</td>
<td className="px-4 py-4 border-r border-slate-100 font-bold text-slate-700">
R$ {item.price.toFixed(2)}
</td>
<td className="px-4 py-4 border-r border-slate-100 font-bold text-slate-700">
{item.quantity.toFixed(2)}
</td>
<td className="px-4 py-4 border-r border-slate-100 font-black text-[#002147]">
R$ {(item.price * item.quantity).toFixed(2)}
</td>
<td className="px-4 py-4">
<div className="flex items-center justify-center space-x-1.5">
<button
onClick={() => onEdit && onEdit(item)}
className="p-1.5 bg-slate-100 text-emerald-600 rounded hover:bg-emerald-50 transition-colors"
title="Editar"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
<button
onClick={() => onDiscount && onDiscount(item)}
className="p-1.5 bg-slate-100 text-orange-500 rounded hover:bg-orange-50 transition-colors"
title="Desconto"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<button
onClick={() => onRemove && onRemove(item)}
className="p-1.5 bg-slate-100 text-red-500 rounded hover:bg-red-50 transition-colors"
title="Remover"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
};
export default CheckoutProductsTable;

View File

@ -0,0 +1,245 @@
import React from "react";
import {
Edit,
Percent,
FileText,
CheckCircle,
ShoppingCart,
Package,
Truck,
DollarSign,
} from "lucide-react";
import DeliveryAvailabilityStatus from "./DeliveryAvailabilityStatus";
interface CheckoutSummaryProps {
subtotal: number;
totalWeight: number;
taxValue: string;
discountValue: string;
total: number;
isLoadingOrder: boolean;
isLoadingPreOrder: boolean;
shippingDate: Date | null;
onTaxChange: (value: string) => void;
onDiscountChange: (value: string) => void;
onChangeTax: () => void;
onApplyDiscount: () => void;
onCreateOrder: () => void;
onCreatePreOrder: () => void;
}
const CheckoutSummary: React.FC<CheckoutSummaryProps> = ({
subtotal,
totalWeight,
taxValue,
discountValue,
total,
isLoadingOrder,
isLoadingPreOrder,
shippingDate,
onTaxChange,
onDiscountChange,
onChangeTax,
onApplyDiscount,
onCreateOrder,
onCreatePreOrder,
}) => {
return (
<div className="bg-white rounded-3xl shadow-2xl border border-slate-200 overflow-hidden h-full flex flex-col">
{/* Header */}
<div className="p-6 bg-[#002147] text-white rounded-t-3xl relative overflow-hidden flex-shrink-0">
<div className="relative z-10">
<div className="flex items-center gap-3 mb-2">
<div className="w-12 h-12 bg-orange-500/20 rounded-2xl flex items-center justify-center">
<ShoppingCart className="w-6 h-6 text-orange-400" />
</div>
<div className="flex-1">
<h3 className="text-xl font-black">Revisar Detalhes do Pedido</h3>
<p className="text-xs text-orange-400 font-bold uppercase tracking-wider mt-0.5">
Resumo
</p>
</div>
</div>
</div>
<div className="absolute right-[-10%] top-[-10%] w-32 h-32 bg-orange-400/10 rounded-full blur-2xl"></div>
</div>
{/* Content */}
<div className="p-6 flex-1 flex flex-col overflow-hidden">
<div
className="space-y-3 flex-1 overflow-y-auto pr-2"
style={{ scrollbarWidth: "thin", scrollbarColor: "#cbd5e1 #f1f5f9" }}
>
{/* Valor do Pedido e Peso do Pedido - Mesma Linha */}
<div className="grid grid-cols-2 gap-3">
{/* Valor do Pedido */}
<div className="bg-slate-50 rounded-2xl p-3 border border-slate-200 hover:border-orange-300 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 bg-blue-500/10 rounded-xl flex items-center justify-center flex-shrink-0">
<DollarSign className="w-4 h-4 text-blue-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-[10px] font-black uppercase text-slate-400 tracking-wider">
Valor do Pedido
</p>
<p className="text-base font-black text-[#002147] mt-0.5">
R$ {subtotal.toFixed(2)}
</p>
</div>
</div>
</div>
</div>
{/* Peso do Pedido */}
<div className="bg-slate-50 rounded-2xl p-3 border border-slate-200 hover:border-orange-300 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 bg-purple-500/10 rounded-xl flex items-center justify-center flex-shrink-0">
<Package className="w-4 h-4 text-purple-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-[10px] font-black uppercase text-slate-400 tracking-wider">
Peso do Pedido
</p>
<p className="text-base font-black text-[#002147] mt-0.5">
{totalWeight.toFixed(3)} Kg
</p>
</div>
</div>
</div>
</div>
</div>
{/* Taxa de Entrega e Desconto */}
<div className="bg-slate-50 rounded-2xl p-3 border border-slate-200 hover:border-orange-300 transition-colors">
{/* Taxa de Entrega */}
<div className="mb-3">
<div className="flex items-center gap-2 mb-2">
<div className="w-9 h-9 bg-green-500/10 rounded-xl flex items-center justify-center flex-shrink-0">
<Truck className="w-4 h-4 text-green-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-[10px] font-black uppercase text-slate-400 tracking-wider">
Taxa de Entrega
</p>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={taxValue}
onChange={(e) => onTaxChange(e.target.value)}
className="flex-1 bg-white border-2 border-slate-200 rounded-xl px-3 py-2 text-right font-black text-sm text-slate-700 outline-none focus:border-orange-500 transition-all"
disabled
/>
<button
onClick={onChangeTax}
className="flex items-center gap-1.5 bg-white border-2 border-[#002147] text-[#002147] font-bold py-2 px-3 rounded-xl text-[10px] hover:bg-[#002147] hover:text-white transition-all flex-shrink-0"
>
<Edit className="w-3.5 h-3.5" />
Alterar
</button>
</div>
</div>
{/* Divisor */}
<div className="my-3 border-t border-slate-200"></div>
{/* Desconto sobre o Total */}
<div>
<div className="flex items-center gap-2 mb-2">
<div className="w-9 h-9 bg-red-500/10 rounded-xl flex items-center justify-center flex-shrink-0">
<Percent className="w-4 h-4 text-red-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-[10px] font-black uppercase text-slate-400 tracking-wider">
Desconto sobre o Total
</p>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={discountValue}
onChange={(e) => onDiscountChange(e.target.value)}
className="flex-1 bg-white border-2 border-slate-200 rounded-xl px-3 py-2 text-right font-black text-sm text-slate-700 outline-none focus:border-orange-500 transition-all"
/>
<button
onClick={onApplyDiscount}
className="flex items-center gap-1.5 bg-white border-2 border-[#002147] text-[#002147] font-bold py-2 px-3 rounded-xl text-[10px] hover:bg-[#002147] hover:text-white transition-all flex-shrink-0"
>
<Percent className="w-3.5 h-3.5" />
Aplicar
</button>
</div>
</div>
</div>
{/* Status de Disponibilidade de Entrega */}
{shippingDate && (
<DeliveryAvailabilityStatus
selectedDate={shippingDate}
orderWeight={totalWeight / 1000} // Converter de kg para toneladas
/>
)}
</div>
{/* Valor Total e Botões - Fixo no final */}
<div className="flex-shrink-0 mt-4 space-y-3">
{/* Valor Total */}
<div className="bg-gradient-to-br from-orange-50 to-orange-100/50 rounded-2xl p-4 border-2 border-orange-200">
<div className="flex flex-col items-center text-center">
<p className="text-[10px] font-black uppercase text-orange-600 tracking-widest mb-1">
Valor Total
</p>
<p className="text-4xl font-black text-[#002147] leading-none">
R$ {total.toFixed(2)}
</p>
</div>
</div>
{/* Botões Finais */}
<div className="space-y-2">
<button
onClick={onCreatePreOrder}
disabled={isLoadingPreOrder || isLoadingOrder}
className="w-full flex items-center justify-center gap-2 bg-[#2d327d] text-white px-6 py-3 rounded-xl font-black uppercase text-xs tracking-widest hover:bg-[#1e2255] transition-all shadow-lg shadow-[#2d327d]/20 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoadingPreOrder ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
Processando...
</>
) : (
<>
<FileText className="w-4 h-4" />
FECHAR ORÇAMENTO
</>
)}
</button>
<button
onClick={onCreateOrder}
disabled={isLoadingOrder || isLoadingPreOrder}
className="w-full flex items-center justify-center gap-2 bg-[#f97316] text-white px-6 py-3 rounded-xl font-black uppercase text-xs tracking-widest hover:bg-[#e86a14] transition-all shadow-lg shadow-orange-500/20 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoadingOrder ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
Processando...
</>
) : (
<>
<CheckCircle className="w-4 h-4" />
FECHAR PEDIDO
</>
)}
</button>
</div>
</div>
</div>
</div>
);
};
export default CheckoutSummary;

View File

@ -0,0 +1,141 @@
import React from "react";
import { User, CreditCard, MapPin, FileText } from "lucide-react";
type Step = "customer" | "payment" | "address" | "notes";
interface CheckoutWizardProps {
currentStep: Step;
onStepChange: (step: Step) => void;
children: React.ReactNode;
}
const CheckoutWizard: React.FC<CheckoutWizardProps> = ({
currentStep,
onStepChange,
children,
}) => {
const steps = [
{ id: "customer" as Step, label: "Cliente", icon: User, shortLabel: "Cliente" },
{ id: "payment" as Step, label: "Financeiro", icon: CreditCard, shortLabel: "Financeiro" },
{ id: "address" as Step, label: "Endereço de Entrega", icon: MapPin, shortLabel: "Endereço" },
{ id: "notes" as Step, label: "Observações", icon: FileText, shortLabel: "Observações" },
];
const currentStepIndex = steps.findIndex((s) => s.id === currentStep);
return (
<div className="space-y-4 lg:space-y-6 flex flex-col h-full">
{/* Navegação dos Passos - Mobile/Tablet: Cards verticais */}
<div className="lg:hidden space-y-2">
{steps.map((step, index) => {
const Icon = step.icon;
const isActive = currentStep === step.id;
const isCompleted = index < currentStepIndex;
const isUpcoming = index > currentStepIndex;
return (
<button
key={step.id}
onClick={() => onStepChange(step.id)}
className={`w-full flex items-center gap-3 p-4 rounded-xl border-2 transition-all touch-manipulation ${
isActive
? "bg-[#002147] border-[#002147] text-white shadow-lg shadow-blue-900/20"
: isCompleted
? "bg-emerald-50 border-emerald-200 text-emerald-700 hover:bg-emerald-100"
: "bg-white border-slate-200 text-slate-600 hover:border-slate-300 hover:bg-slate-50"
}`}
>
{/* Ícone com indicador */}
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${
isActive
? "bg-white/20 text-white"
: isCompleted
? "bg-emerald-500 text-white"
: "bg-slate-100 text-slate-400"
}`}
>
{isCompleted ? (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M5 13l4 4L19 7"
/>
</svg>
) : (
<Icon className="w-5 h-5" />
)}
</div>
{/* Label */}
<div className="flex-1 text-left">
<div className="text-xs font-bold uppercase tracking-wider mb-0.5">
Passo {index + 1}
</div>
<div className="text-sm font-black">{step.label}</div>
</div>
{/* Indicador de seta */}
{isActive && (
<div className="text-white">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
)}
</button>
);
})}
</div>
{/* Navegação dos Passos - Desktop: Tabs horizontais */}
<div className="hidden lg:flex border-b border-slate-200 overflow-x-auto flex-shrink-0">
{steps.map((step) => {
const Icon = step.icon;
return (
<button
key={step.id}
onClick={() => onStepChange(step.id)}
className={`flex items-center gap-2 px-8 py-4 text-xs font-black uppercase tracking-widest whitespace-nowrap transition-all relative ${
currentStep === step.id
? "text-[#002147]"
: "text-slate-400 hover:text-slate-600"
}`}
>
<Icon className="w-4 h-4" />
{step.label}
{currentStep === step.id && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-orange-500 rounded-t-full"></div>
)}
</button>
);
})}
</div>
{/* Conteúdo dos Passos */}
<div className="bg-white p-4 lg:p-8 rounded-2xl shadow-sm border border-slate-100 min-h-[350px] flex-1">
{children}
</div>
</div>
);
};
export default CheckoutWizard;

View File

@ -0,0 +1,48 @@
import React from "react";
interface ConfirmModalProps {
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
}
const ConfirmModal: React.FC<ConfirmModalProps> = ({
isOpen,
title,
message,
onConfirm,
onCancel,
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-[#001f3f]/90 backdrop-blur-sm flex items-center justify-center z-[100] p-6">
<div className="bg-white w-full max-w-md rounded-2xl shadow-2xl overflow-hidden">
<div className="p-8">
<h4 className="text-xl font-black text-[#002147] mb-4">{title}</h4>
<p className="text-slate-700 font-medium mb-6">{message}</p>
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
className="bg-slate-100 text-slate-700 px-6 py-2 rounded-lg font-bold uppercase text-xs tracking-widest hover:bg-slate-200 transition-colors"
>
Cancelar
</button>
<button
onClick={onConfirm}
className="bg-[#002147] text-white px-6 py-2 rounded-lg font-bold uppercase text-xs tracking-widest hover:bg-[#003366] transition-colors"
>
Confirmar
</button>
</div>
</div>
</div>
</div>
);
};
export default ConfirmModal;

View File

@ -0,0 +1,146 @@
import React from "react";
import { Customer } from "../../src/services/customer.service";
import { maskDocument } from "../../lib/utils";
interface CustomerSearchModalProps {
isOpen: boolean;
searchTerm: string;
searchResults: Customer[];
isSearching: boolean;
onClose: () => void;
onSearchChange: (term: string) => void;
onSelectCustomer: (customer: Customer) => void;
}
const CustomerSearchModal: React.FC<CustomerSearchModalProps> = ({
isOpen,
searchTerm,
searchResults,
isSearching,
onClose,
onSearchChange,
onSelectCustomer,
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center">
{/* Overlay */}
<div
className="absolute inset-0 bg-[#001f3f]/60 backdrop-blur-sm transition-opacity duration-300 opacity-100"
onClick={onClose}
></div>
{/* Dialog */}
<div className="relative bg-white rounded-3xl shadow-2xl max-w-2xl w-full mx-4 transform transition-all duration-300 scale-100 opacity-100">
{/* Header */}
<div className="p-6 bg-[#002147] text-white rounded-t-3xl relative overflow-hidden">
<div className="relative z-10">
<div className="flex items-center gap-3 mb-2">
<div className="w-12 h-12 bg-orange-500/20 rounded-2xl flex items-center justify-center">
<svg
className="w-6 h-6 text-orange-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<div className="flex-1">
<h3 className="text-xl font-black">Vincular Cliente</h3>
<p className="text-xs text-orange-400 font-bold uppercase tracking-wider mt-0.5">
Busca
</p>
</div>
<button
onClick={onClose}
className="w-10 h-10 bg-white/10 rounded-xl flex items-center justify-center text-white hover:bg-white/20 transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<div className="absolute right-[-10%] top-[-10%] w-32 h-32 bg-orange-400/10 rounded-full blur-2xl"></div>
</div>
{/* Content */}
<div className="p-6">
<input
type="text"
placeholder="Nome ou CPF/CNPJ..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl outline-none focus:border-orange-500 text-base font-medium mb-4 transition-colors"
autoFocus
/>
<div className="space-y-3 max-h-[400px] overflow-auto custom-scrollbar">
{isSearching ? (
<div className="text-center py-12 text-slate-400">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500 mb-3"></div>
<p className="text-sm font-medium">Buscando clientes...</p>
</div>
) : searchResults.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<svg
className="w-12 h-12 mx-auto mb-3 text-slate-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p className="text-sm font-medium">
{searchTerm.length >= 3
? "Nenhum cliente encontrado"
: "Digite pelo menos 3 caracteres para buscar"}
</p>
</div>
) : (
searchResults.map((customer) => (
<div
key={customer.customerId}
onClick={() => onSelectCustomer(customer)}
className="p-4 border border-slate-200 rounded-xl hover:bg-slate-50 hover:border-orange-300 cursor-pointer transition-all group"
>
<h5 className="font-black text-[#002147] text-base mb-1 group-hover:text-orange-600 transition-colors">
{customer.name}
</h5>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
{maskDocument(customer.cpfCnpj || customer.document || "")}
</p>
</div>
))
)}
</div>
</div>
</div>
</div>
);
};
export default CustomerSearchModal;

View File

@ -0,0 +1,347 @@
import React from "react";
import { Customer } from "../../src/services/customer.service";
import {
UserPlus,
Search,
FileText,
ArrowRight,
Link2,
User,
CheckCircle,
} from "lucide-react";
interface CustomerStepProps {
customerForm: {
name: string;
document: string;
cellPhone: string;
cep: string;
address: string;
number: string;
city: string;
state: string;
complement: string;
};
customerErrors: Record<string, string>;
selectedCustomer: Customer | null;
onFormChange: (field: string, value: string) => void;
onShowCreateModal: () => void;
onShowSearchModal: () => void;
onNext: () => void;
}
const CustomerStep: React.FC<CustomerStepProps> = ({
customerForm,
customerErrors,
selectedCustomer,
onFormChange,
onShowCreateModal,
onShowSearchModal,
onNext,
}) => {
return (
<div className="animate-fade-in">
<div className="flex justify-between items-center mb-6">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">
Informações do cliente
</h3>
<div className="flex gap-2">
<button
onClick={onShowSearchModal}
className="flex items-center gap-2 bg-slate-100 px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest text-[#002147] hover:bg-slate-200 transition-colors"
>
<Search className="w-4 h-4" />
Selecionar
</button>
<button
onClick={onShowCreateModal}
className="flex items-center gap-2 bg-slate-100 px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest text-[#002147] hover:bg-slate-200 transition-colors"
>
<UserPlus className="w-4 h-4" />
Novo
</button>
</div>
</div>
{selectedCustomer ? (
<>
{/* Cliente Selecionado */}
<div className="bg-gradient-to-br from-orange-50 to-orange-100/50 rounded-2xl p-6 border-2 border-orange-200 mb-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-3">
<CheckCircle className="w-5 h-5 text-green-500" />
<h4 className="font-black text-slate-800 text-lg">
{selectedCustomer.name || "Cliente"}
</h4>
</div>
<div className="space-y-1">
{selectedCustomer.cpfCnpj && (
<p className="text-sm font-bold text-slate-700">
CPF/CNPJ: {selectedCustomer.cpfCnpj}
</p>
)}
{selectedCustomer.cellPhone && (
<p className="text-sm font-bold text-slate-700">
Contato: {selectedCustomer.cellPhone}
</p>
)}
{selectedCustomer.address && (
<p className="text-xs text-slate-600">
{selectedCustomer.address}
{selectedCustomer.number &&
`, ${selectedCustomer.number}`}
{selectedCustomer.complement &&
` - ${selectedCustomer.complement}`}
</p>
)}
{(selectedCustomer.city || selectedCustomer.state) && (
<p className="text-xs text-slate-600">
{selectedCustomer.city && `${selectedCustomer.city}`}
{selectedCustomer.state && `/${selectedCustomer.state}`}
{selectedCustomer.cep &&
` - CEP: ${selectedCustomer.cep}`}
</p>
)}
</div>
</div>
</div>
</div>
<form className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="md:col-span-2">
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
Nome do Cliente
</label>
<input
type="text"
value={customerForm.name}
onChange={(e) => onFormChange("name", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
customerErrors.name ? "border-red-500" : "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
disabled
/>
{customerErrors.name && (
<p className="text-red-500 text-xs mt-1">
{customerErrors.name}
</p>
)}
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
CPF / CNPJ
</label>
<input
type="text"
value={customerForm.document}
onChange={(e) => onFormChange("document", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
customerErrors.document
? "border-red-500"
: "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
disabled
/>
{customerErrors.document && (
<p className="text-red-500 text-xs mt-1">
{customerErrors.document}
</p>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
CONTATO
</label>
<input
type="text"
value={customerForm.cellPhone}
onChange={(e) => onFormChange("cellPhone", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
customerErrors.cellPhone
? "border-red-500"
: "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
disabled
/>
{customerErrors.cellPhone && (
<p className="text-red-500 text-xs mt-1">
{customerErrors.cellPhone}
</p>
)}
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
CEP
</label>
<input
type="text"
value={customerForm.cep}
onChange={(e) => onFormChange("cep", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
customerErrors.cep ? "border-red-500" : "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
disabled
/>
{customerErrors.cep && (
<p className="text-red-500 text-xs mt-1">
{customerErrors.cep}
</p>
)}
</div>
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
endereço
</label>
<input
type="text"
value={customerForm.address}
onChange={(e) => onFormChange("address", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
customerErrors.address ? "border-red-500" : "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
disabled
/>
{customerErrors.address && (
<p className="text-red-500 text-xs mt-1">
{customerErrors.address}
</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
número
</label>
<input
type="text"
value={customerForm.number}
onChange={(e) => onFormChange("number", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
customerErrors.number
? "border-red-500"
: "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
disabled
/>
{customerErrors.number && (
<p className="text-red-500 text-xs mt-1">
{customerErrors.number}
</p>
)}
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
cidade
</label>
<input
type="text"
value={customerForm.city}
onChange={(e) => onFormChange("city", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
customerErrors.city ? "border-red-500" : "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
disabled
/>
{customerErrors.city && (
<p className="text-red-500 text-xs mt-1">
{customerErrors.city}
</p>
)}
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
estado
</label>
<input
type="text"
value={customerForm.state}
onChange={(e) => onFormChange("state", e.target.value)}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
customerErrors.state ? "border-red-500" : "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
disabled
/>
{customerErrors.state && (
<p className="text-red-500 text-xs mt-1">
{customerErrors.state}
</p>
)}
</div>
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
complemento
</label>
<input
type="text"
value={customerForm.complement}
onChange={(e) => onFormChange("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"
disabled
/>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
type="button"
onClick={onShowSearchModal}
className="flex items-center gap-2 bg-slate-100 px-6 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest text-[#002147] hover:bg-slate-200 transition-colors"
>
<FileText className="w-4 h-4" />
Pré-Cadastro
</button>
<button
type="button"
onClick={onNext}
className="flex items-center gap-2 bg-[#002147] text-white px-6 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest hover:bg-[#003366] transition-colors"
>
Avançar
<ArrowRight className="w-4 h-4" />
</button>
</div>
</form>
</>
) : (
/* Sem Cliente Selecionado */
<div className="bg-slate-50 rounded-2xl p-8 border-2 border-dashed border-slate-300 mb-6 text-center">
<div className="w-20 h-20 bg-slate-200 rounded-full flex items-center justify-center mx-auto mb-4">
<User className="w-10 h-10 text-slate-400" />
</div>
<p className="text-lg font-bold text-slate-600 mb-2">
Nenhum cliente selecionado
</p>
<p className="text-sm text-slate-500 mb-6">
Selecione um cliente existente ou cadastre um novo cliente
</p>
<div className="flex gap-3 justify-center">
<button
onClick={onShowSearchModal}
className="flex items-center gap-2 bg-[#002147] text-white px-6 py-3 rounded-xl font-black uppercase text-xs tracking-widest hover:bg-[#001a36] transition-all"
>
<Search className="w-4 h-4" />
Selecionar Cliente
</button>
<button
onClick={onShowCreateModal}
className="flex items-center gap-2 bg-white border-2 border-[#002147] text-[#002147] px-6 py-3 rounded-xl font-black uppercase text-xs tracking-widest hover:bg-[#002147] hover:text-white transition-all"
>
<UserPlus className="w-4 h-4" />
Cadastrar Novo
</button>
</div>
</div>
)}
</div>
);
};
export default CustomerStep;

View File

@ -0,0 +1,255 @@
import React from "react";
import { CheckCircle, XCircle, AlertCircle, Loader2 } from "lucide-react";
import { shippingService, DeliveryScheduleItem } from "../../src/services/shipping.service";
export interface DeliveryAvailabilityStatusProps {
selectedDate: Date | null;
orderWeight: number; // Peso do pedido em toneladas
isLoading?: boolean;
}
export interface DeliveryAvailabilityResult {
available: boolean;
capacity: number;
currentLoad: number;
availableCapacity: number;
occupancy: number;
message: string;
}
const DeliveryAvailabilityStatus: React.FC<DeliveryAvailabilityStatusProps> = ({
selectedDate,
orderWeight,
isLoading = false,
}) => {
const [availability, setAvailability] = React.useState<DeliveryAvailabilityResult | null>(null);
const [checking, setChecking] = React.useState(false);
React.useEffect(() => {
const checkAvailability = async () => {
if (!selectedDate || orderWeight <= 0) {
setAvailability(null);
return;
}
try {
setChecking(true);
// Formatar data para DD/MM/YYYY (formato usado pelo Baldinho)
const formattedDate = selectedDate.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
console.log("📦 [DELIVERY_AVAILABILITY] Verificando disponibilidade:");
console.log("📦 [DELIVERY_AVAILABILITY] Data selecionada:", formattedDate);
console.log("📦 [DELIVERY_AVAILABILITY] Peso do pedido:", orderWeight, "Ton");
// Buscar dados do agendamento
const response = await shippingService.getScheduleDelivery();
if (!response || !response.deliveries || !Array.isArray(response.deliveries)) {
console.warn("📦 [DELIVERY_AVAILABILITY] Resposta inválida da API");
setAvailability({
available: false,
capacity: 0,
currentLoad: 0,
availableCapacity: 0,
occupancy: 100,
message: "Não foi possível verificar a disponibilidade",
});
return;
}
console.log("📦 [DELIVERY_AVAILABILITY] Itens recebidos:", response.deliveries.length);
// Encontrar o item correspondente à data selecionada
const deliveryItem = response.deliveries.find((item: DeliveryScheduleItem) => {
const itemDate = new Date(item.dateDelivery);
const itemFormatted = itemDate.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "UTC",
});
console.log("📦 [DELIVERY_AVAILABILITY] Comparando:", itemFormatted, "com", formattedDate);
return itemFormatted === formattedDate;
});
if (!deliveryItem) {
setAvailability({
available: false,
capacity: 0,
currentLoad: 0,
availableCapacity: 0,
occupancy: 100,
message: "Data não encontrada no agendamento",
});
return;
}
// Verificar disponibilidade
const capacity = deliveryItem.deliverySize || 0;
const currentLoad = deliveryItem.saleWeigth || 0;
const availableCapacity = deliveryItem.avaliableDelivery || 0;
const isDeliveryEnabled = deliveryItem.delivery === "S";
const canFit = availableCapacity >= orderWeight;
const occupancy = capacity > 0 ? (currentLoad / capacity) * 100 : 100;
const available = isDeliveryEnabled && canFit;
let message = "";
if (!isDeliveryEnabled) {
message = "Entrega não disponível para esta data";
} else if (!canFit) {
message = `Capacidade insuficiente. Disponível: ${availableCapacity.toFixed(3)} Ton, Necessário: ${orderWeight.toFixed(3)} Ton`;
} else {
message = `Entrega disponível. Capacidade restante: ${availableCapacity.toFixed(3)} Ton`;
}
setAvailability({
available,
capacity,
currentLoad,
availableCapacity,
occupancy,
message,
});
} catch (error) {
console.error("Erro ao verificar disponibilidade:", error);
setAvailability({
available: false,
capacity: 0,
currentLoad: 0,
availableCapacity: 0,
occupancy: 100,
message: "Erro ao verificar disponibilidade de entrega",
});
} finally {
setChecking(false);
}
};
checkAvailability();
}, [selectedDate, orderWeight]);
if (!selectedDate || orderWeight <= 0) {
return null;
}
if (checking || isLoading) {
return (
<div className="mt-4 p-4 bg-slate-50 rounded-xl border border-slate-200">
<div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 text-orange-500 animate-spin" />
<span className="text-sm font-medium text-slate-600">
Verificando disponibilidade de entrega...
</span>
</div>
</div>
);
}
if (!availability) {
return null;
}
const isAvailable = availability.available;
const occupancyColor =
availability.occupancy >= 100
? "bg-red-500"
: availability.occupancy >= 85
? "bg-orange-500"
: "bg-emerald-500";
return (
<div
className={`mt-4 p-5 rounded-xl border-2 transition-all duration-300 ${
isAvailable
? "bg-emerald-50 border-emerald-300 shadow-emerald-200/20"
: "bg-red-50 border-red-300 shadow-red-200/20"
}`}
>
<div className="flex items-start gap-4">
{/* Ícone */}
<div
className={`flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center ${
isAvailable
? "bg-emerald-500/20"
: "bg-red-500/20"
}`}
>
{isAvailable ? (
<CheckCircle className="w-6 h-6 text-emerald-600" />
) : (
<XCircle className="w-6 h-6 text-red-600" />
)}
</div>
{/* Conteúdo */}
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h4
className={`text-sm font-black ${
isAvailable ? "text-emerald-900" : "text-red-900"
}`}
>
{isAvailable ? "Entrega Disponível" : "Entrega Indisponível"}
</h4>
{availability.occupancy >= 85 && availability.occupancy < 100 && (
<AlertCircle className="w-4 h-4 text-orange-500" />
)}
</div>
<p
className={`text-xs font-medium mb-3 ${
isAvailable ? "text-emerald-700" : "text-red-700"
}`}
>
{availability.message}
</p>
{/* Barra de ocupação */}
<div className="space-y-2">
<div className="flex items-center justify-between text-[10px] font-bold text-slate-600">
<span>Ocupação da Capacidade</span>
<span>{availability.occupancy.toFixed(1)}%</span>
</div>
<div className="w-full h-2 bg-slate-200 rounded-full overflow-hidden shadow-inner">
<div
className={`h-full transition-all duration-1000 ${occupancyColor}`}
style={{
width: `${Math.min(availability.occupancy, 100)}%`,
}}
></div>
</div>
<div className="grid grid-cols-3 gap-2 text-[10px] font-medium text-slate-600 mt-2">
<div>
<span className="block text-slate-400 mb-0.5">Capacidade</span>
<span className="font-black">{availability.capacity.toFixed(3)} Ton</span>
</div>
<div>
<span className="block text-slate-400 mb-0.5">Carga Atual</span>
<span className="font-black">{availability.currentLoad.toFixed(3)} Ton</span>
</div>
<div>
<span className="block text-slate-400 mb-0.5">Disponível</span>
<span className={`font-black ${
availability.availableCapacity >= orderWeight
? "text-emerald-600"
: "text-red-600"
}`}>
{availability.availableCapacity.toFixed(3)} Ton
</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default DeliveryAvailabilityStatus;

View File

@ -0,0 +1,201 @@
import React, { useState, useEffect } from "react";
import { X, Truck } from "lucide-react";
import { DeliveryTaxTable } from "../../src/services/order.service";
interface DeliveryTaxModalProps {
isOpen: boolean;
onClose: () => void;
onSelect: (deliveryTax: DeliveryTaxTable) => void;
deliveryTaxOptions: DeliveryTaxTable[];
isLoading: boolean;
}
const DeliveryTaxModal: React.FC<DeliveryTaxModalProps> = ({
isOpen,
onClose,
onSelect,
deliveryTaxOptions,
isLoading,
}) => {
const [selectedRow, setSelectedRow] = useState<number | null>(null);
useEffect(() => {
if (!isOpen) {
setSelectedRow(null);
}
}, [isOpen]);
if (!isOpen) return null;
const handleSelect = () => {
if (selectedRow !== null) {
const selected = deliveryTaxOptions[selectedRow];
onSelect(selected);
onClose();
}
};
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="p-6 bg-[#002147] text-white rounded-t-3xl relative overflow-hidden flex-shrink-0">
<div className="relative z-10 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-orange-500/20 rounded-2xl flex items-center justify-center">
<Truck className="w-6 h-6 text-orange-400" />
</div>
<div>
<h3 className="text-xl font-black">Opções de entrega para o endereço</h3>
<p className="text-xs text-orange-400 font-bold uppercase tracking-wider mt-0.5">
Selecione uma opção
</p>
</div>
</div>
<button
onClick={onClose}
className="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/10 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="absolute right-[-10%] top-[-10%] w-32 h-32 bg-orange-400/10 rounded-full blur-2xl"></div>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex flex-col p-6">
{isLoading ? (
<div className="flex-1 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-[#002147] border-t-transparent rounded-full animate-spin"></div>
</div>
) : deliveryTaxOptions.length === 0 ? (
<div className="flex-1 flex items-center justify-center">
<p className="text-slate-500 font-bold">Sem registros para serem exibidos.</p>
</div>
) : (
<>
{/* Desktop Table */}
<div className="hidden md:block flex-1 overflow-auto">
<table className="w-full border-collapse">
<thead className="bg-slate-50 sticky top-0">
<tr>
<th className="px-4 py-3 text-left text-xs font-black uppercase text-slate-600 tracking-wider border-b border-slate-200">
Filial
</th>
<th className="px-4 py-3 text-left text-xs font-black uppercase text-slate-600 tracking-wider border-b border-slate-200">
Transportadora
</th>
<th className="px-4 py-3 text-left text-xs font-black uppercase text-slate-600 tracking-wider border-b border-slate-200">
Cidade de Entrega
</th>
<th className="px-4 py-3 text-left text-xs font-black uppercase text-slate-600 tracking-wider border-b border-slate-200">
Prazo de Entrega
</th>
<th className="px-4 py-3 text-right text-xs font-black uppercase text-slate-600 tracking-wider border-b border-slate-200">
Valor Frete
</th>
</tr>
</thead>
<tbody>
{deliveryTaxOptions.map((option, index) => (
<tr
key={option.id}
onClick={() => setSelectedRow(index)}
className={`cursor-pointer transition-colors ${
selectedRow === index
? "bg-orange-50 border-l-4 border-orange-500"
: "hover:bg-slate-50"
}`}
>
<td className="px-4 py-3 text-sm font-bold text-slate-700 border-b border-slate-100">
{option.store}
</td>
<td className="px-4 py-3 text-sm font-bold text-slate-700 border-b border-slate-100">
{option.carrierName}
</td>
<td className="px-4 py-3 text-sm font-bold text-slate-700 border-b border-slate-100">
{option.cityName}
</td>
<td className="px-4 py-3 text-sm font-bold text-slate-700 border-b border-slate-100">
{option.deliveryTime}
</td>
<td className="px-4 py-3 text-right text-sm font-black text-[#002147] border-b border-slate-100">
{formatCurrency(option.deliveryValue)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile Cards */}
<div className="md:hidden flex-1 overflow-auto space-y-3">
{deliveryTaxOptions.map((option, index) => (
<div
key={option.id}
onClick={() => setSelectedRow(index)}
className={`p-4 rounded-2xl border-2 transition-all cursor-pointer ${
selectedRow === index
? "bg-orange-50 border-orange-500"
: "bg-slate-50 border-slate-200 hover:border-orange-300"
}`}
>
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-black text-slate-800">{option.carrierName}</h4>
<p className="text-xs text-slate-500">{option.cityName}</p>
</div>
<div className="text-right">
<p className="text-lg font-black text-[#002147]">
{formatCurrency(option.deliveryValue)}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-slate-500">Filial: </span>
<span className="font-bold text-slate-700">{option.store}</span>
</div>
<div>
<span className="text-slate-500">Prazo: </span>
<span className="font-bold text-slate-700">{option.deliveryTime}</span>
</div>
</div>
</div>
))}
</div>
</>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-slate-200 flex-shrink-0 flex items-center justify-end gap-3">
<button
onClick={onClose}
className="px-6 py-3 rounded-xl font-bold text-slate-700 hover:bg-slate-100 transition-colors"
>
Cancelar
</button>
<button
onClick={handleSelect}
disabled={selectedRow === null || isLoading}
className="px-6 py-3 rounded-xl font-black bg-[#002147] text-white hover:bg-[#001a36] transition-all shadow-lg shadow-[#002147]/20 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
>
Selecionar
</button>
</div>
</div>
</div>
);
};
export default DeliveryTaxModal;

View File

@ -0,0 +1,284 @@
import React, { useState, useEffect } from "react";
import { OrderItem } from "../../types";
import { shoppingService, ShoppingItem } from "../../src/services/shopping.service";
import { authService } from "../../src/services/auth.service";
interface DiscountItemModalProps {
isOpen: boolean;
item: OrderItem | null;
onClose: () => void;
onConfirm: () => void;
}
const DiscountItemModal: React.FC<DiscountItemModalProps> = ({
isOpen,
item,
onClose,
onConfirm,
}) => {
const [discount, setDiscount] = useState<number>(0);
const [discountValue, setDiscountValue] = useState<number>(0);
const [salePrice, setSalePrice] = useState<number>(0);
const [listPrice, setListPrice] = useState<number>(0);
const [isUpdating, setIsUpdating] = useState(false);
const [error, setError] = useState<string>("");
const [isAnimating, setIsAnimating] = useState(false);
const [shouldRender, setShouldRender] = useState(false);
useEffect(() => {
if (item && isOpen) {
const initialDiscount = item.discount || 0;
const initialListPrice = item.originalPrice || item.price || 0;
const initialDiscountValue = item.discountValue || 0;
const initialSalePrice = item.price || 0;
setDiscount(initialDiscount);
setDiscountValue(initialDiscountValue);
setSalePrice(initialSalePrice);
setListPrice(initialListPrice);
setError("");
}
}, [item, isOpen]);
// Animação de entrada/saída
useEffect(() => {
if (isOpen) {
setShouldRender(true);
setTimeout(() => setIsAnimating(true), 10);
} else {
setIsAnimating(false);
const timer = setTimeout(() => setShouldRender(false), 300);
return () => clearTimeout(timer);
}
}, [isOpen]);
const calcDiscountValue = () => {
if (!item) return;
const percent = discount;
const newDiscountValue = Number.parseFloat(
((listPrice * percent) / 100).toFixed(2)
);
const newSalePrice = Number.parseFloat(
(listPrice - newDiscountValue).toFixed(2)
);
setDiscountValue(newDiscountValue);
setSalePrice(newSalePrice);
};
const calcPercentDiscount = () => {
if (!item) return;
const newPercent =
Number.parseFloat((discountValue / listPrice).toFixed(2)) * 100;
const newSalePrice = Number.parseFloat(
(listPrice - discountValue).toFixed(2)
);
setDiscount(newPercent);
setSalePrice(newSalePrice);
};
const handleConfirm = async () => {
if (!item) return;
// Validações (paymentPlan já foi validado antes de abrir o modal)
const paymentPlan = localStorage.getItem("paymentPlan");
if (!paymentPlan) {
setError(
"Venda sem plano de pagamento informado, desconto não permitido!"
);
return;
}
try {
setIsUpdating(true);
setError("");
// Converter OrderItem para ShoppingItem
const shoppingItem = shoppingService.productToShoppingItem(item);
shoppingItem.id = item.id;
shoppingItem.discount = discount;
shoppingItem.discountValue = discountValue;
shoppingItem.price = salePrice;
shoppingItem.userDiscount = authService.getUser();
// Atualizar no backend
await shoppingService.updatePriceItemShopping(shoppingItem);
setIsAnimating(false);
setTimeout(() => {
onConfirm();
onClose();
}, 300);
} catch (err: any) {
setError(err.message || "Erro ao aplicar desconto");
} finally {
setIsUpdating(false);
}
};
const handleCancel = () => {
setIsAnimating(false);
setTimeout(() => {
onClose();
}, 300);
};
if (!shouldRender || !item) return null;
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center">
{/* Overlay */}
<div
className={`absolute inset-0 bg-[#001f3f]/60 backdrop-blur-sm transition-opacity duration-300 ${
isAnimating ? "opacity-100" : "opacity-0"
}`}
onClick={handleCancel}
></div>
{/* Dialog */}
<div
className={`relative bg-white rounded-3xl shadow-2xl max-w-md w-full mx-4 transform transition-all duration-300 ${
isAnimating ? "scale-100 opacity-100" : "scale-95 opacity-0"
}`}
>
{/* Header */}
<div className="p-6 bg-[#002147] text-white rounded-t-3xl relative overflow-hidden">
<div className="relative z-10">
<div className="flex items-center gap-3 mb-2">
<div className="w-12 h-12 bg-orange-500/20 rounded-2xl flex items-center justify-center">
<svg
className="w-6 h-6 text-orange-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="flex-1">
<h3 className="text-xl font-black">Desconto por produto</h3>
<p className="text-xs text-orange-400 font-bold uppercase tracking-wider mt-0.5">
Desconto sobre item de venda
</p>
</div>
</div>
</div>
<div className="absolute right-[-10%] top-[-10%] w-32 h-32 bg-orange-400/10 rounded-full blur-2xl"></div>
</div>
{/* Content */}
<div className="p-6">
<form className="space-y-4">
{/* Produto */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Produto
</label>
<input
type="text"
value={item.name}
disabled
className="w-full px-3 py-2 border border-slate-300 rounded-md bg-slate-50 text-slate-600"
/>
</div>
{/* Preço de tabela */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Preço de tabela
</label>
<input
type="number"
value={listPrice.toFixed(2)}
disabled
className="w-full px-3 py-2 border border-slate-300 rounded-md bg-slate-50 text-slate-600"
/>
</div>
{/* % Desconto e Valor de desconto */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
% Desconto
</label>
<input
type="number"
value={discount}
onChange={(e) => {
setDiscount(Number.parseFloat(e.target.value) || 0);
}}
onBlur={calcDiscountValue}
step="0.01"
min="0"
max="100"
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#002147]"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Valor de desconto
</label>
<input
type="number"
value={discountValue.toFixed(2)}
onChange={(e) => {
setDiscountValue(Number.parseFloat(e.target.value) || 0);
}}
onBlur={calcPercentDiscount}
step="0.01"
min="0"
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#002147]"
/>
</div>
</div>
{/* Preço de venda */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Preço de venda
</label>
<input
type="number"
value={salePrice.toFixed(2)}
disabled
className="w-full px-3 py-2 border border-slate-300 rounded-md bg-slate-50 text-slate-600"
/>
</div>
{/* Erro */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md text-sm">
{error}
</div>
)}
</form>
</div>
{/* Actions */}
<div className="p-6 pt-0 flex gap-3">
<button
onClick={handleCancel}
disabled={isUpdating}
className="flex-1 py-3 px-4 bg-slate-100 text-slate-600 font-bold uppercase text-xs tracking-wider rounded-xl hover:bg-slate-200 transition-all disabled:opacity-50"
>
Cancelar
</button>
<button
onClick={handleConfirm}
disabled={isUpdating}
className="flex-1 py-3 px-4 text-white font-black uppercase text-xs tracking-wider rounded-xl transition-all shadow-lg bg-orange-500 hover:bg-orange-600 shadow-orange-500/20 active:scale-95 disabled:opacity-50"
>
{isUpdating ? "Aplicando..." : "Confirmar"}
</button>
</div>
</div>
</div>
);
};
export default DiscountItemModal;

View File

@ -0,0 +1,226 @@
import React, { useState, useEffect } from "react";
import { X, Percent } from "lucide-react";
interface DiscountOrderModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (discountValue: number, discountPercent: number) => void;
orderValue: number;
profit?: number;
netProfit?: number;
isManager?: boolean;
}
const DiscountOrderModal: React.FC<DiscountOrderModalProps> = ({
isOpen,
onClose,
onConfirm,
orderValue,
profit = 0,
netProfit = 0,
isManager = false,
}) => {
const [discountPercent, setDiscountPercent] = useState<string>("0");
const [discountValue, setDiscountValue] = useState<string>("0");
useEffect(() => {
if (!isOpen) {
setDiscountPercent("0");
setDiscountValue("0");
}
}, [isOpen]);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
};
const formatPercent = (value: number) => {
return new Intl.NumberFormat("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
const handlePercentChange = (value: string) => {
const numValue = parseFloat(value.replace(/[^\d,.-]/g, "").replace(",", ".")) || 0;
setDiscountPercent(formatPercent(numValue));
if (orderValue > 0) {
const discount = (orderValue * numValue) / 100;
setDiscountValue(formatCurrency(discount));
}
};
const handleValueChange = (value: string) => {
const numValue = parseFloat(value.replace(/[^\d,.-]/g, "").replace(",", ".")) || 0;
setDiscountValue(formatCurrency(numValue));
if (orderValue > 0) {
const percent = (numValue / orderValue) * 100;
setDiscountPercent(formatPercent(percent));
}
};
const handleConfirm = () => {
const discountValueNum = parseFloat(
discountValue.replace(/[^\d,.-]/g, "").replace(",", ".")
) || 0;
const discountPercentNum = parseFloat(
discountPercent.replace(/[^\d,.-]/g, "").replace(",", ".")
) || 0;
onConfirm(discountValueNum, discountPercentNum);
onClose();
};
const netValue = orderValue - (parseFloat(discountValue.replace(/[^\d,.-]/g, "").replace(",", ".")) || 0);
const calculatedNetProfit = orderValue > 0 && netValue > 0
? (((netValue - profit) / netValue) * 100).toFixed(2)
: "0.00";
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="p-6 bg-[#002147] text-white rounded-t-3xl relative overflow-hidden flex-shrink-0">
<div className="relative z-10 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-orange-500/20 rounded-2xl flex items-center justify-center">
<Percent className="w-6 h-6 text-orange-400" />
</div>
<div>
<h3 className="text-xl font-black">Pedido de venda</h3>
<p className="text-xs text-orange-400 font-bold uppercase tracking-wider mt-0.5">
Conceder desconto sobre o pedido
</p>
</div>
</div>
<button
onClick={onClose}
className="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/10 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="absolute right-[-10%] top-[-10%] w-32 h-32 bg-orange-400/10 rounded-full blur-2xl"></div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
<form className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Valor do pedido */}
<div>
<label className="block text-xs font-black uppercase text-slate-600 tracking-wider mb-2">
Valor do pedido
</label>
<input
type="text"
value={formatCurrency(orderValue)}
disabled
className="w-full bg-slate-50 border-2 border-slate-200 rounded-xl px-4 py-3 font-black text-slate-700 outline-none"
/>
</div>
{/* % Margem (apenas para gerente) */}
{isManager && (
<div>
<label className="block text-xs font-black uppercase text-slate-600 tracking-wider mb-2">
% Margem
</label>
<input
type="text"
value={formatPercent(profit)}
disabled
className="w-full bg-slate-50 border-2 border-slate-200 rounded-xl px-4 py-3 font-black text-slate-700 outline-none"
/>
</div>
)}
{/* Percentual de desconto */}
<div>
<label className="block text-xs font-black uppercase text-slate-600 tracking-wider mb-2">
Percentual de desconto
</label>
<input
type="text"
value={discountPercent}
onChange={(e) => handlePercentChange(e.target.value)}
className="w-full bg-white border-2 border-slate-200 rounded-xl px-4 py-3 font-black text-slate-700 outline-none focus:border-orange-500 transition-all"
placeholder="0,00"
/>
</div>
{/* Valor de desconto */}
<div>
<label className="block text-xs font-black uppercase text-slate-600 tracking-wider mb-2">
Valor de desconto
</label>
<input
type="text"
value={discountValue}
onChange={(e) => handleValueChange(e.target.value)}
className="w-full bg-white border-2 border-slate-200 rounded-xl px-4 py-3 font-black text-slate-700 outline-none focus:border-orange-500 transition-all"
placeholder="R$ 0,00"
/>
</div>
{/* Valor líquido */}
<div>
<label className="block text-xs font-black uppercase text-slate-600 tracking-wider mb-2">
Valor líquido
</label>
<input
type="text"
value={formatCurrency(netValue)}
disabled
className="w-full bg-slate-50 border-2 border-slate-200 rounded-xl px-4 py-3 font-black text-slate-700 outline-none"
/>
</div>
{/* % Margem Líquida (apenas para gerente) */}
{isManager && (
<div>
<label className="block text-xs font-black uppercase text-slate-600 tracking-wider mb-2">
% Margem Líquida
</label>
<input
type="text"
value={`${calculatedNetProfit}%`}
disabled
className="w-full bg-slate-50 border-2 border-slate-200 rounded-xl px-4 py-3 font-black text-slate-700 outline-none"
/>
</div>
)}
</div>
</form>
</div>
{/* Footer */}
<div className="p-6 border-t border-slate-200 flex-shrink-0 flex items-center justify-end gap-3">
<button
onClick={onClose}
className="px-6 py-3 rounded-xl font-bold text-slate-700 hover:bg-slate-100 transition-colors"
>
Cancelar
</button>
<button
onClick={handleConfirm}
className="px-6 py-3 rounded-xl font-black bg-[#002147] text-white hover:bg-[#001a36] transition-all shadow-lg shadow-[#002147]/20 active:scale-95"
>
Aplicar
</button>
</div>
</div>
</div>
);
};
export default DiscountOrderModal;

View File

@ -0,0 +1,45 @@
import React from "react";
interface InfoModalProps {
isOpen: boolean;
title: string;
message: string;
description?: string;
onClose: () => void;
}
const InfoModal: React.FC<InfoModalProps> = ({
isOpen,
title,
message,
description,
onClose,
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-[#001f3f]/90 backdrop-blur-sm flex items-center justify-center z-[100] p-6">
<div className="bg-white w-full max-w-md rounded-2xl shadow-2xl overflow-hidden">
<div className="p-8">
<h4 className="text-xl font-black text-[#002147] mb-4">{title}</h4>
<p className="text-slate-700 font-medium mb-2">{message}</p>
{description && (
<p className="text-slate-500 text-sm">{description}</p>
)}
<div className="flex justify-end mt-6">
<button
onClick={onClose}
className="bg-[#002147] text-white px-6 py-2 rounded-lg font-bold uppercase text-xs tracking-widest hover:bg-[#003366] transition-colors"
>
OK
</button>
</div>
</div>
</div>
</div>
);
};
export default InfoModal;

View File

@ -0,0 +1,87 @@
import React, { useState, useEffect } from "react";
import { ShoppingCart } from "lucide-react";
interface LoadingModalProps {
isOpen: boolean;
title: string;
message: string;
}
const LoadingModal: React.FC<LoadingModalProps> = ({
isOpen,
title,
message,
}) => {
const [isAnimating, setIsAnimating] = useState(false);
const [shouldRender, setShouldRender] = useState(false);
console.log("🔄 [LOADING_MODAL] Renderizando - isOpen:", isOpen);
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) {
console.log("🔄 [LOADING_MODAL] Modal não está aberto, retornando null");
return null;
}
console.log("🔄 [LOADING_MODAL] Renderizando modal com título:", title);
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center">
{/* Overlay */}
<div
className={`absolute inset-0 bg-[#001f3f]/60 backdrop-blur-sm transition-opacity duration-300 ${
isAnimating ? "opacity-100" : "opacity-0"
}`}
></div>
{/* Dialog */}
<div
className={`relative bg-white rounded-3xl shadow-2xl max-w-md w-full mx-4 transform transition-all duration-300 ${
isAnimating ? "scale-100 opacity-100" : "scale-95 opacity-0"
}`}
>
{/* Header */}
<div className="p-6 bg-[#002147] text-white rounded-t-3xl relative overflow-hidden">
<div className="relative z-10">
<div className="flex items-center gap-3 mb-2">
<div className="w-12 h-12 bg-blue-500/20 rounded-2xl flex items-center justify-center">
<ShoppingCart className="w-6 h-6 text-orange-400" />
</div>
<div className="flex-1">
<h3 className="text-xl font-black">{title}</h3>
<p className="text-xs text-blue-400 font-bold uppercase tracking-wider mt-0.5">
Carregando
</p>
</div>
</div>
</div>
<div className="absolute right-[-10%] top-[-10%] w-32 h-32 bg-blue-400/10 rounded-full blur-2xl"></div>
</div>
{/* Content */}
<div className="p-6">
<div className="flex items-center gap-4">
<div className="w-8 h-8 border-4 border-[#002147] border-t-transparent rounded-full animate-spin flex-shrink-0"></div>
<p className="text-slate-600 text-sm leading-relaxed">{message}</p>
</div>
</div>
</div>
</div>
);
};
export default LoadingModal;

View File

@ -0,0 +1,251 @@
import React from "react";
import { ArrowLeft } from "lucide-react";
interface NotesStepProps {
notesForm: {
shippingDate: Date | null;
scheduleDelivery: boolean;
shippingPriority: "B" | "M" | "A";
notesText1: string;
notesText2: string;
notesDeliveryText1: string;
notesDeliveryText2: string;
notesDeliveryText3: string;
};
onFormChange: (field: string, value: any) => void;
onPrevious: () => void;
}
const NotesStep: React.FC<NotesStepProps> = ({
notesForm,
onFormChange,
onPrevious,
}) => {
// Função para formatar a data para o input type="date" (YYYY-MM-DD)
const formatDateForInput = (date: Date | null): string => {
if (!date) return "";
try {
// Verificar se é uma data válida
if (isNaN(date.getTime())) {
console.warn("📅 [NOTES_STEP] Data inválida:", date);
return "";
}
// Formatar para YYYY-MM-DD
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
} catch (error) {
console.error("📅 [NOTES_STEP] Erro ao formatar data:", error, date);
return "";
}
};
// Log para debug
React.useEffect(() => {
console.log(
"📅 [NOTES_STEP] shippingDate recebido:",
notesForm.shippingDate
);
console.log("📅 [NOTES_STEP] Tipo:", typeof notesForm.shippingDate);
if (notesForm.shippingDate) {
console.log(
"📅 [NOTES_STEP] Data formatada:",
formatDateForInput(notesForm.shippingDate)
);
}
}, [notesForm.shippingDate]);
return (
<div className="animate-fade-in">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-6">
Observações
</h3>
<form className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
Previsão de entrega
</label>
<input
type="date"
min={(() => {
// Calcular data mínima (D+3)
const today = new Date();
today.setHours(0, 0, 0, 0);
const minDate = new Date(today);
minDate.setDate(today.getDate() + 3);
// Formatar para YYYY-MM-DD
const year = minDate.getFullYear();
const month = String(minDate.getMonth() + 1).padStart(2, "0");
const day = String(minDate.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
})()}
value={formatDateForInput(notesForm.shippingDate)}
onChange={(e) => {
const dateValue = e.target.value;
console.log(
"📅 [NOTES_STEP] Valor do input alterado:",
dateValue
);
if (dateValue) {
// Criar data a partir do valor do input (YYYY-MM-DD)
// Usar data local para corresponder exatamente ao que o usuário selecionou
const [year, month, day] = dateValue.split("-").map(Number);
const date = new Date(year, month - 1, day);
// Zerar horas para garantir que seja apenas a data, sem hora
date.setHours(0, 0, 0, 0);
console.log(
"📅 [NOTES_STEP] Nova data criada (local):",
date
);
console.log(
"📅 [NOTES_STEP] Data formatada de volta:",
formatDateForInput(date)
);
onFormChange("shippingDate", date);
} else {
console.log("📅 [NOTES_STEP] Data removida (null)");
onFormChange("shippingDate", null);
}
}}
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"
/>
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
Prioridade de Entrega
</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="shippingPriority"
value="B"
checked={notesForm.shippingPriority === "B"}
onChange={(e) =>
onFormChange(
"shippingPriority",
e.target.value as "B" | "M" | "A"
)
}
className="w-4 h-4 text-[#002147] focus:ring-2 focus:ring-[#002147]"
/>
<span className="text-sm font-medium text-slate-700">
Entrega
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="shippingPriority"
value="M"
checked={notesForm.shippingPriority === "M"}
onChange={(e) =>
onFormChange(
"shippingPriority",
e.target.value as "B" | "M" | "A"
)
}
className="w-4 h-4 text-[#002147] focus:ring-2 focus:ring-[#002147]"
/>
<span className="text-sm font-medium text-slate-700">
Retira
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="shippingPriority"
value="A"
checked={notesForm.shippingPriority === "A"}
onChange={(e) =>
onFormChange(
"shippingPriority",
e.target.value as "B" | "M" | "A"
)
}
className="w-4 h-4 text-[#002147] focus:ring-2 focus:ring-[#002147]"
/>
<span className="text-sm font-medium text-slate-700">
Delivery
</span>
</label>
</div>
</div>
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
Observações do Pedido
</label>
<input
type="text"
maxLength={50}
value={notesForm.notesText1}
onChange={(e) => onFormChange("notesText1", 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 mb-2"
placeholder="Observação 1"
/>
<input
type="text"
maxLength={50}
value={notesForm.notesText2}
onChange={(e) => onFormChange("notesText2", 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="Observação 2"
/>
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
Observações de Entrega
</label>
<input
type="text"
maxLength={75}
value={notesForm.notesDeliveryText1}
onChange={(e) => onFormChange("notesDeliveryText1", 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 mb-2"
placeholder="Observação de entrega 1"
/>
<input
type="text"
maxLength={75}
value={notesForm.notesDeliveryText2}
onChange={(e) => onFormChange("notesDeliveryText2", 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 mb-2"
placeholder="Observação de entrega 2"
/>
<input
type="text"
maxLength={75}
value={notesForm.notesDeliveryText3}
onChange={(e) => onFormChange("notesDeliveryText3", 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="Observação de entrega 3"
/>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
type="button"
onClick={onPrevious}
className="flex items-center gap-2 bg-slate-100 px-6 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest text-[#002147] hover:bg-slate-200 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Retornar
</button>
</div>
</form>
</div>
);
};
export default NotesStep;

View File

@ -0,0 +1,187 @@
import React from "react";
import { StoreERP, Billing, PaymentPlan, PartnerSales } from "../../src/services/lookup.service";
import { ArrowLeft, ArrowRight } from "lucide-react";
interface PaymentStepProps {
paymentForm: {
invoiceStore: StoreERP | null;
billing: Billing | null;
paymentPlan: PaymentPlan | null;
partner: PartnerSales | null;
};
paymentErrors: Record<string, string>;
stores: StoreERP[];
billings: Billing[];
paymentPlans: PaymentPlan[];
partners: PartnerSales[];
isLoadingPaymentData: boolean;
onInvoiceStoreChange: (store: StoreERP | null) => void;
onBillingChange: (billing: Billing | null) => void;
onPaymentPlanChange: (plan: PaymentPlan | null) => void;
onPartnerChange: (partner: PartnerSales | null) => void;
onPrevious: () => void;
onNext: () => void;
}
const PaymentStep: React.FC<PaymentStepProps> = ({
paymentForm,
paymentErrors,
stores,
billings,
paymentPlans,
partners,
isLoadingPaymentData,
onInvoiceStoreChange,
onBillingChange,
onPaymentPlanChange,
onPartnerChange,
onPrevious,
onNext,
}) => {
return (
<div className="animate-fade-in">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-6">
Pagamento
</h3>
<form className="space-y-4">
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
Filial de venda
</label>
<select
value={paymentForm.invoiceStore?.id || ""}
onChange={(e) => {
const store = stores.find((s) => s.id === e.target.value);
onInvoiceStoreChange(store || null);
}}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
paymentErrors.invoiceStore ? "border-red-500" : "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
disabled={isLoadingPaymentData}
>
<option value="">Selecione a filial</option>
{stores.map((store) => (
<option key={store.id} value={store.id}>
{store.shortName} - {store.name}
</option>
))}
</select>
{paymentErrors.invoiceStore && (
<p className="text-red-500 text-xs mt-1">
{paymentErrors.invoiceStore}
</p>
)}
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
Cobrança
</label>
<select
value={paymentForm.billing?.codcob || ""}
onChange={(e) => {
const billing = billings.find((b) => b.codcob === e.target.value);
onBillingChange(billing || null);
}}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
paymentErrors.billing ? "border-red-500" : "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
disabled={isLoadingPaymentData || billings.length === 0}
>
<option value="">Selecione a cobrança</option>
{billings.map((billing) => (
<option key={billing.codcob} value={billing.codcob}>
{billing.cobranca}
</option>
))}
</select>
{paymentErrors.billing && (
<p className="text-red-500 text-xs mt-1">{paymentErrors.billing}</p>
)}
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
Plano de pagamento
</label>
<select
value={paymentForm.paymentPlan?.codplpag || ""}
onChange={(e) => {
const plan = paymentPlans.find(
(p) => p.codplpag.toString() === e.target.value
);
onPaymentPlanChange(plan || null);
}}
className={`w-full px-4 py-3 bg-slate-50 rounded-xl font-bold text-[#002147] border ${
paymentErrors.paymentPlan ? "border-red-500" : "border-slate-200"
} focus:outline-none focus:ring-2 focus:ring-orange-500/20`}
disabled={
isLoadingPaymentData ||
!paymentForm.billing ||
paymentPlans.length === 0
}
>
<option value="">Selecione o plano de pagamento</option>
{paymentPlans.map((plan) => (
<option key={plan.codplpag} value={plan.codplpag}>
{plan.descricao}
</option>
))}
</select>
{paymentErrors.paymentPlan && (
<p className="text-red-500 text-xs mt-1">
{paymentErrors.paymentPlan}
</p>
)}
</div>
<div>
<label className="block text-[10px] font-black uppercase text-slate-400 mb-2">
Parceiro Jurunense
</label>
<select
value={paymentForm.partner?.id || ""}
onChange={(e) => {
const partner = partners.find(
(p) => p.id.toString() === e.target.value
);
onPartnerChange(partner || null);
}}
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"
disabled={isLoadingPaymentData}
>
<option value="">Selecione o parceiro (opcional)</option>
{partners.map((partner) => (
<option key={partner.id} value={partner.id}>
{partner.name}
</option>
))}
</select>
</div>
<div className="flex justify-between gap-2 mt-6">
<button
type="button"
onClick={onPrevious}
className="flex items-center gap-2 bg-slate-100 px-6 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest text-[#002147] hover:bg-slate-200 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Retornar
</button>
<button
type="button"
onClick={onNext}
className="flex items-center gap-2 bg-[#002147] text-white px-6 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest hover:bg-[#003366] transition-colors"
>
Avançar
<ArrowRight className="w-4 h-4" />
</button>
</div>
</form>
</div>
);
};
export default PaymentStep;

View File

@ -0,0 +1,298 @@
import React, { useState, useEffect } from "react";
import ArcGauge from "../ArcGauge";
import LoadingSpinner from "../LoadingSpinner";
import { env } from "../../src/config/env";
import { authService } from "../../src/services/auth.service";
interface SaleSupervisor {
supervisorId: number;
name: string;
sale: number;
cost: number;
devolution: number;
objetivo: number;
profit: number;
percentual: number;
nfs: number;
}
interface DashboardSale {
sale: number;
cost: number;
devolution: number;
objetivo: number;
profit: number;
percentual: number;
nfs: number;
saleSupervisor: SaleSupervisor[];
}
const DashboardDayView: React.FC = () => {
const [data, setData] = useState<DashboardSale | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchDashboardData = async () => {
try {
setLoading(true);
setError(null);
const token = authService.getToken();
const apiUrl = env.API_URL.replace(/\/$/, ""); // Remove trailing slash
const response = await fetch(`${apiUrl}/dashboard/sale`, {
method: "GET",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Basic ${token}` }),
},
});
if (!response.ok) {
throw new Error(`Erro ao carregar dados: ${response.statusText}`);
}
const result = await response.json();
setData(result);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Erro ao carregar dados do dashboard"
);
} finally {
setLoading(false);
}
};
fetchDashboardData();
}, []);
const formatCurrency = (value: number): string => {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
};
const formatNumber = (value: number, decimals: number = 2): string => {
return new Intl.NumberFormat("pt-BR", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
};
const colors = [
{ to: 25, color: "#f31700" },
{ from: 25, to: 50, color: "#f31700" },
{ from: 50, to: 70, color: "#ffc000" },
{ from: 70, color: "#0aac25" }, // Verde mais vibrante como na imagem
];
const colorsProfit = [
{ to: 0, color: "#f31700" },
{ from: 0, to: 20, color: "#f31700" },
{ from: 20, to: 30, color: "#ffc000" },
{ from: 30, color: "#0aac25" }, // Verde mais vibrante como na imagem
];
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<LoadingSpinner />
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-2xl p-6">
<p className="text-red-600 text-sm font-medium">{error}</p>
</div>
);
}
if (!data) {
return (
<div className="bg-white p-6 rounded-2xl border border-slate-100">
<p className="text-slate-400 text-sm italic">Nenhum dado disponível</p>
</div>
);
}
const ticketMedio = data.nfs > 0 ? data.sale / data.nfs : 0;
return (
<div className="space-y-6">
{/* Header */}
<div className="bg-slate-50 border-b border-slate-200 p-6 -mx-6 -mt-6 mb-6">
<h1 className="text-2xl font-black text-slate-600">Dashboard</h1>
<small className="text-slate-500 text-sm font-medium">
Faturamento
</small>
</div>
{/* Cards Analytics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Card 1: Faturamento Líquido */}
<div className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm">
<div className="mb-4">
<h2 className="text-3xl font-bold text-[#002147]">
{formatCurrency(data.sale)}
</h2>
</div>
<div className="mb-4">
<small className="text-slate-600 text-xs font-semibold block mb-2">
Faturamento Líquido
</small>
<div className="h-2.5 bg-slate-100 rounded-md overflow-hidden">
<div
className="h-full bg-[#22baa0] rounded-md transition-all"
style={{ width: `${Math.min(data.percentual, 100)}%` }}
></div>
</div>
<small className="text-slate-500 text-xs mt-1 block">
{formatCurrency(data.objetivo)} ({formatNumber(data.percentual)}%)
</small>
</div>
<div className="mt-4">
<small className="text-slate-600 text-xs font-semibold block mb-2">
Devolução
</small>
<small className="text-slate-500 text-xs">
{formatCurrency(data.devolution)}
</small>
</div>
</div>
{/* Card 2: Realizado */}
<div className="bg-white p-4 rounded-2xl border border-slate-100 shadow-sm flex items-center justify-center">
<ArcGauge value={data.percentual} colors={colors} title="Realizado" />
</div>
{/* Card 3: Margem Líquida */}
<div className="bg-white p-4 rounded-2xl border border-slate-100 shadow-sm flex items-center justify-center">
<ArcGauge
value={data.profit}
max={40}
colors={colorsProfit}
title="Margem Líquida"
/>
</div>
{/* Card 4: Cupons e Ticket Médio */}
<div className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm">
<div className="mb-4">
<h2 className="text-3xl font-bold text-[#002147]">
{formatNumber(data.nfs, 0)}
</h2>
</div>
<div className="mb-4">
<small className="text-slate-600 text-xs font-semibold block mb-2">
Quantidade de cupons emitidos
</small>
<div className="h-2.5 bg-slate-100 rounded-md overflow-hidden">
<div
className="h-full bg-[#f6d433] rounded-md"
style={{ width: "65%" }}
></div>
</div>
</div>
<div className="mt-4">
<small className="text-slate-600 text-xs font-semibold block mb-2">
Ticket Médio
</small>
<small className="text-slate-500 text-xs">
{formatCurrency(ticketMedio)}
</small>
</div>
</div>
</div>
{/* Tabela de Supervisores */}
<div className="bg-white rounded-2xl border border-slate-100 overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="px-6 py-4 text-left text-xs font-black text-slate-600 uppercase tracking-wider">
#
</th>
<th className="px-6 py-4 text-left text-xs font-black text-slate-600 uppercase tracking-wider">
Loja
</th>
<th className="px-6 py-4 text-right text-xs font-black text-slate-600 uppercase tracking-wider">
Meta
</th>
<th className="px-6 py-4 text-right text-xs font-black text-slate-600 uppercase tracking-wider">
Realizado
</th>
<th className="px-6 py-4 text-right text-xs font-black text-slate-600 uppercase tracking-wider">
%
</th>
<th className="px-6 py-4 text-right text-xs font-black text-slate-600 uppercase tracking-wider">
Margem
</th>
<th className="px-6 py-4 text-right text-xs font-black text-slate-600 uppercase tracking-wider">
Devolução
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{data.saleSupervisor.map((supervisor, idx) => (
<tr
key={supervisor.supervisorId}
className="hover:bg-slate-50/50 transition-colors"
>
<td className="px-6 py-4 text-sm font-bold text-[#22baa0]">
{supervisor.supervisorId}
</td>
<td className="px-6 py-4 text-sm font-medium text-slate-800">
{supervisor.name}
</td>
<td className="px-6 py-4 text-sm text-right font-medium text-slate-600">
{formatCurrency(supervisor.objetivo)}
</td>
<td className="px-6 py-4 text-sm text-right font-medium text-slate-800">
{formatCurrency(supervisor.sale)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
<span className="text-sm font-medium text-slate-800">
{formatNumber(supervisor.percentual)}%
</span>
<div className="w-20 h-2.5 bg-slate-100 rounded-md overflow-hidden">
<div
className={`h-full rounded-md ${
supervisor.percentual >= 100
? "bg-emerald-500"
: supervisor.percentual >= 70
? "bg-[#22baa0]"
: supervisor.percentual >= 50
? "bg-[#ffc000]"
: "bg-[#f31700]"
}`}
style={{
width: `${Math.min(supervisor.percentual, 100)}%`,
}}
></div>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-right font-medium text-slate-800">
{formatNumber(supervisor.profit)}%
</td>
<td className="px-6 py-4 text-sm text-right font-medium text-slate-800">
{formatCurrency(supervisor.devolution)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default DashboardDayView;

View File

@ -0,0 +1,409 @@
import React, { useState, useEffect } from "react";
import ArcGauge from "../ArcGauge";
import LoadingSpinner from "../LoadingSpinner";
import { env } from "../../src/config/env";
import { authService } from "../../src/services/auth.service";
import { formatCurrency, formatNumber } from "../../utils/formatters";
interface SaleSeller {
supervisorId: number;
store: string;
sellerId: number;
sellerName: string;
qtdeDaysMonth: number;
qtdeDays: number;
objetivo: number;
saleValue: number;
dif: number;
ObjetivoSale: number;
percentualObjective: number;
qtdeInvoice: number;
ticket: number;
listPrice: number;
discountValue: number;
percentOff: number;
mix: number;
saleToday: number;
devolution: number;
preSaleValue: number;
preSaleQtde: number;
objetiveHour?: number;
percentualObjectiveHour?: number;
}
interface DashboardSeller {
objetive: number;
sale: number;
percentualSale: number;
discount: number;
mix: number;
objetiveToday: number;
saleToday: number;
nfs: number;
devolution: number;
nfsToday: number;
objetiveHour: number;
percentualObjetiveHour: number;
saleSupervisor: SaleSeller[];
}
const DashboardSellerView: React.FC = () => {
const [data, setData] = useState<DashboardSeller | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchDashboardData = async () => {
try {
setLoading(true);
setError(null);
const token = authService.getToken();
const supervisorId = authService.getSupervisor();
const apiUrl = env.API_URL.replace(/\/$/, "");
if (!supervisorId) {
throw new Error("Supervisor ID não encontrado.");
}
const response = await fetch(
`${apiUrl}/dashboard/sale/${supervisorId}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Basic ${token}` }),
},
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message ||
"Erro ao buscar dados do dashboard do vendedor."
);
}
const result = await response.json();
setData(result);
} catch (err) {
console.error("Erro ao buscar dados do dashboard:", err);
setError(
err instanceof Error
? err.message
: "Não foi possível carregar os dados do dashboard."
);
} finally {
setLoading(false);
}
};
fetchDashboardData();
}, []);
const colors = [
{ to: 25, color: "#f31700" },
{ from: 25, to: 50, color: "#f31700" },
{ from: 50, to: 70, color: "#ffc000" },
{ from: 70, color: "#0aac25" },
];
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<LoadingSpinner />
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-2xl p-6">
<p className="text-red-600 text-sm font-medium">{error}</p>
</div>
);
}
if (!data || !data.saleSupervisor || data.saleSupervisor.length === 0) {
return (
<div className="bg-blue-50 border border-blue-200 rounded-2xl p-6">
<p className="text-blue-600 text-sm font-medium">
Nenhum dado de vendas disponível.
</p>
</div>
);
}
const firstSeller = data.saleSupervisor[0];
const faturamentoDiaPercentual =
data.objetiveToday > 0 ? (data.saleToday / data.objetiveToday) * 100 : 0;
const ticketMedioDia = data.nfsToday > 0 ? data.saleToday / data.nfsToday : 0;
return (
<div className="space-y-6">
{/* Header */}
<header className="flex justify-between items-end">
<div>
<h2 className="text-2xl font-black text-[#002147]">
Dashboard - Venda mês
</h2>
<p className="text-slate-500 text-sm font-medium mt-1">
{firstSeller.supervisorId} - {firstSeller.store}
</p>
</div>
</header>
{/* Cards de Analytics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Card 1: Faturamento Dia */}
<div className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm">
<div className="mb-4">
<h2 className="text-3xl font-bold text-[#002147]">
{formatCurrency(data.saleToday)}
</h2>
</div>
<div className="mb-4">
<small className="text-slate-600 text-xs font-semibold block mb-2">
Faturamento Dia
</small>
<div className="h-2.5 bg-slate-100 rounded-md overflow-hidden">
<div
className="h-full bg-[#22baa0] rounded-md transition-all"
style={{ width: `${Math.min(faturamentoDiaPercentual, 100)}%` }}
></div>
</div>
<small className="text-slate-500 text-xs mt-1 block">
{formatCurrency(data.objetiveToday)} (
{formatNumber(faturamentoDiaPercentual)}%)
</small>
</div>
<div className="mt-4">
<small className="text-slate-600 text-xs font-semibold block mb-2">
Ticket Médio Dia
</small>
<h3 className="text-xl font-bold text-[#002147]">
{formatCurrency(ticketMedioDia)}
</h3>
</div>
</div>
{/* Card 2: Realizado Hora */}
<div className="bg-white p-4 rounded-2xl border border-slate-100 shadow-sm flex items-center justify-center">
<ArcGauge
value={data.percentualObjetiveHour}
colors={colors}
title="Realizado hora"
/>
</div>
{/* Card 3: Faturamento Líquido */}
<div className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm">
<div className="mb-4">
<h2 className="text-3xl font-bold text-[#002147]">
{formatCurrency(data.sale)}
</h2>
</div>
<div className="mb-4">
<small className="text-slate-600 text-xs font-semibold block mb-2">
Faturamento Líquido
</small>
<div className="h-2.5 bg-slate-100 rounded-md overflow-hidden">
<div
className="h-full bg-[#22baa0] rounded-md transition-all"
style={{ width: `${Math.min(data.percentualSale, 100)}%` }}
></div>
</div>
<small className="text-slate-500 text-xs mt-1 block">
{formatCurrency(data.objetive)} (
{formatNumber(data.percentualSale)}%)
</small>
</div>
<div className="mt-4">
<small className="text-slate-600 text-xs font-semibold block mb-2">
Devolução
</small>
<h3 className="text-xl font-bold text-red-500">
{formatCurrency(data.devolution)}
</h3>
</div>
</div>
{/* Card 4: Realizado */}
<div className="bg-white p-4 rounded-2xl border border-slate-100 shadow-sm flex items-center justify-center">
<ArcGauge
value={data.percentualSale}
colors={colors}
title="Realizado"
/>
</div>
</div>
{/* Tabela de Vendedores */}
<div className="bg-white rounded-2xl border border-slate-100 overflow-hidden">
<div className="p-5 border-b border-slate-50">
<h3 className="text-[9px] font-black text-slate-400 uppercase tracking-[0.2em]">
Vendedores
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50 text-left">
<tr>
{[
"Vendedor",
"Meta dia",
"Meta Hora",
"Venda dia",
"% Hora",
"% Dia",
"Meta",
"Realizado",
"Dif",
"%",
"Qtde NFs",
"Ticket Médio",
"Desconto",
"Mix",
"Qtde Orçamento",
"VL Orçamento",
].map((h) => (
<th
key={h}
className="px-4 py-3 text-[9px] font-black text-slate-400 uppercase tracking-widest whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{data.saleSupervisor.map((seller, idx) => {
const percentualDia =
seller.ObjetivoSale > 0
? (seller.saleToday / seller.ObjetivoSale) * 100
: 0;
return (
<tr
key={idx}
className="hover:bg-slate-50/50 transition-colors"
>
<td className="px-4 py-4 text-slate-500 font-medium text-xs">
{seller.sellerName}
</td>
<td className="px-4 py-4 text-slate-500 font-medium text-xs text-right">
{formatCurrency(seller.ObjetivoSale)}
</td>
<td className="px-4 py-4 text-slate-500 font-medium text-xs text-right">
{formatCurrency(seller.objetiveHour || 0)}
</td>
<td className="px-4 py-4 font-bold text-slate-800 text-xs text-right">
{formatCurrency(seller.saleToday)}
</td>
<td className="px-4 py-4">
<div className="flex items-center space-x-2">
<div className="w-20 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full ${
(seller.percentualObjectiveHour || 0) >= 100
? "bg-emerald-500"
: (seller.percentualObjectiveHour || 0) >= 70
? "bg-orange-500"
: "bg-red-500"
}`}
style={{
width: `${Math.min(
seller.percentualObjectiveHour || 0,
100
)}%`,
}}
></div>
</div>
<span className="text-[10px] font-black text-slate-700 whitespace-nowrap">
{formatNumber(seller.percentualObjectiveHour || 0)}%
</span>
</div>
</td>
<td className="px-4 py-4">
<div className="flex items-center space-x-2">
<div className="w-20 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full ${
percentualDia >= 100
? "bg-emerald-500"
: percentualDia >= 70
? "bg-orange-500"
: "bg-red-500"
}`}
style={{
width: `${Math.min(percentualDia, 100)}%`,
}}
></div>
</div>
<span className="text-[10px] font-black text-slate-700 whitespace-nowrap">
{formatNumber(percentualDia)}%
</span>
</div>
</td>
<td className="px-4 py-4 font-bold text-slate-800 text-xs text-right">
{formatCurrency(seller.objetivo)}
</td>
<td className="px-4 py-4 font-bold text-slate-800 text-xs text-right">
{formatCurrency(seller.saleValue)}
</td>
<td className="px-4 py-4 font-bold text-slate-800 text-xs text-right">
{formatCurrency(seller.dif)}
</td>
<td className="px-4 py-4">
<div className="flex items-center space-x-2">
<div className="w-20 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full ${
seller.percentualObjective >= 100
? "bg-emerald-500"
: seller.percentualObjective >= 70
? "bg-orange-500"
: "bg-red-500"
}`}
style={{
width: `${Math.min(
seller.percentualObjective,
100
)}%`,
}}
></div>
</div>
<span className="text-[10px] font-black text-slate-700 whitespace-nowrap">
{formatNumber(seller.percentualObjective)}%
</span>
</div>
</td>
<td className="px-4 py-4 font-bold text-slate-800 text-xs text-right">
{formatNumber(seller.qtdeInvoice, 0)}
</td>
<td className="px-4 py-4 font-bold text-slate-800 text-xs text-right">
{formatCurrency(seller.ticket)}
</td>
<td className="px-4 py-4 font-bold text-slate-800 text-xs text-right">
{formatCurrency(seller.discountValue)}
</td>
<td className="px-4 py-4 font-bold text-slate-800 text-xs text-right">
{formatNumber(seller.mix, 0)}
</td>
<td className="px-4 py-4 font-bold text-slate-800 text-xs text-right">
{formatNumber(seller.preSaleQtde, 0)}
</td>
<td className="px-4 py-4 font-bold text-slate-800 text-xs text-right">
{formatCurrency(seller.preSaleValue)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default DashboardSellerView;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,993 @@
import React, { useState, useEffect } from "react";
import LoadingSpinner from "../LoadingSpinner";
import { env } from "../../src/config/env";
import { authService } from "../../src/services/auth.service";
import { formatCurrency } from "../../utils/formatters";
import ConfirmDialog from "../ConfirmDialog";
import OrderItemsModal from "../OrderItemsModal";
import PrintOrderDialog from "../PrintOrderDialog";
import StimulsoftViewer from "../StimulsoftViewer";
import NoData from "../NoData";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Button } from "../ui/button";
import { CustomAutocomplete } from "../ui/autocomplete";
import { DateInput } from "../ui/date-input";
import { DataGridPremium, GridColDef } from "@mui/x-data-grid-premium";
import "../../lib/mui-license";
import { Box, IconButton } from "@mui/material";
import { Edit, Visibility, Print } from "@mui/icons-material";
interface PreOrder {
data: string;
idPreOrder: number;
value: number;
listValue: number;
idCustomer: number;
customer: string | number;
idSeller: number;
seller: string | number;
status?: string;
cpfPreCustomer?: string;
namePreCustomer?: string;
}
interface PreOrderItem {
productId: number;
description: string;
package: string;
color?: string;
local: string;
quantity: number;
price: number;
subTotal: number;
}
interface Store {
id: string;
shortName: string;
name: string;
}
const PreorderView: React.FC = () => {
const [preOrders, setPreOrders] = useState<PreOrder[]>([]);
const [stores, setStores] = useState<Store[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Filtros
const [selectedStore, setSelectedStore] = useState<string>("");
const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>("");
const [preOrderId, setPreOrderId] = useState<string>("");
const [document, setDocument] = useState<string>("");
const [customerName, setCustomerName] = useState<string>("");
// Modais
const [showOrderItems, setShowOrderItems] = useState(false);
const [orderItems, setOrderItems] = useState<PreOrderItem[]>([]);
const [selectedPreOrder, setSelectedPreOrder] = useState<PreOrder | null>(
null
);
const [showInfoDialog, setShowInfoDialog] = useState(false);
const [infoMessage, setInfoMessage] = useState("");
const [infoDescription, setInfoDescription] = useState("");
const [showCartLoadedDialog, setShowCartLoadedDialog] = useState(false);
const [showPrintDialog, setShowPrintDialog] = useState(false);
const [preOrderToPrint, setPreOrderToPrint] = useState<PreOrder | null>(null);
const [showPrintViewer, setShowPrintViewer] = useState(false);
const [printUrl, setPrintUrl] = useState<string>("");
const [printPreOrderId, setPrintPreOrderId] = useState<number | undefined>(
undefined
);
const [printModel, setPrintModel] = useState<string | undefined>(undefined);
useEffect(() => {
fetchStores();
}, []);
const fetchStores = async () => {
try {
const token = authService.getToken();
const apiUrl = env.API_URL.replace(/\/$/, "");
const response = await fetch(`${apiUrl}/lists/store`, {
method: "GET",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Basic ${token}` }),
},
});
if (response.ok) {
const data = await response.json();
setStores(data);
}
} catch (err) {
console.error("Erro ao buscar filiais:", err);
}
};
const handleSearch = async () => {
setLoading(true);
setError(null);
try {
const token = authService.getToken();
const apiUrl = env.API_URL.replace(/\/$/, "");
// Seguindo exatamente o padrão do Angular: primeiro obtém o seller, depois verifica se é gerente
// O Angular getSeller() retorna user.seller diretamente (pode ser number ou string)
let sellerId: string | number = authService.getSeller() || 0;
// Se for gerente, sellerId = 0 (como no Angular)
if (authService.isManager()) {
sellerId = 0;
}
// Converter para número se for string, mas manter como está se já for número
// O HttpParams do Angular aceita qualquer tipo e converte para string automaticamente
if (typeof sellerId === "string") {
const parsed = parseInt(sellerId, 10);
sellerId = isNaN(parsed) ? 0 : parsed;
}
// Converter datas do formato YYYY-MM-DD para strings completas de data
// O Angular envia objetos Date que são convertidos para strings completas pelo HttpParams
// Exemplo: "Wed Jan 01 2025 00:00:00 GMT-0300 (Horário Padrão de Brasília)"
let startDateValue: string = "";
let endDateValue: string = "";
if (startDate) {
// Criar Date a partir da string YYYY-MM-DD no timezone local
// Usar meia-noite local para garantir o formato correto
const [year, month, day] = startDate.split("-").map(Number);
const startDateObj = new Date(year, month - 1, day, 0, 0, 0, 0);
startDateValue = startDateObj.toString();
}
if (endDate) {
// Criar Date a partir da string YYYY-MM-DD no timezone local
// Usar meia-noite local para garantir o formato correto
const [year, month, day] = endDate.split("-").map(Number);
const endDateObj = new Date(year, month - 1, day, 0, 0, 0, 0);
endDateValue = endDateObj.toString();
}
// Seguindo exatamente o padrão do Angular: HttpParams.append() adiciona TODOS os parâmetros,
// mesmo quando são null ou strings vazias. Isso é importante para o backend processar corretamente.
// O Angular HttpParams usa uma codificação similar ao encodeURIComponent, mas preserva alguns caracteres
// como ':' (dois pontos) que são seguros em query strings
const encodeParam = (value: string): string => {
// Primeiro codifica tudo
let encoded = encodeURIComponent(value);
// Depois decodifica os caracteres que o Angular preserva (dois pontos são seguros em query strings)
encoded = encoded.replace(/%3A/g, ":");
return encoded;
};
// O Angular HttpParams.append() converte automaticamente qualquer tipo para string
// e sempre adiciona o parâmetro, mesmo quando o valor é null, undefined ou string vazia
const buildQueryParam = (
key: string,
value: string | number | null | undefined
): string => {
// Se o valor for null ou undefined, enviar como string vazia (não como "null" ou "undefined")
if (value === null || value === undefined) {
return `${key}=`;
}
const strValue = value.toString();
// Se a string estiver vazia, ainda enviar o parâmetro (como o Angular faz)
return strValue ? `${key}=${encodeParam(strValue)}` : `${key}=`;
};
// Seguindo exatamente a ordem do Angular HttpParams.append()
const queryParams: string[] = [];
queryParams.push(buildQueryParam("seller", sellerId));
queryParams.push(buildQueryParam("store", selectedStore || null));
queryParams.push(buildQueryParam("start", startDateValue || null));
queryParams.push(buildQueryParam("end", endDateValue || null));
queryParams.push(
buildQueryParam("idPreOrder", preOrderId ? parseInt(preOrderId, 10) : 0)
);
queryParams.push(buildQueryParam("document", document || null));
queryParams.push(buildQueryParam("nameCustomer", customerName || null));
const url = `${apiUrl}/preorder/list?${queryParams.join("&")}`;
const response = await fetch(url, {
method: "GET",
headers: {
Accept: "application/json, text/plain, */*",
"Content-Type": "application/json",
...(token && { Authorization: `Basic ${token}` }),
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message ||
`Erro ao buscar orçamentos: ${response.statusText}`
);
}
const result = await response.json();
// Seguindo exatamente o padrão do Angular: verificar result.success antes de usar result.data
// O Angular faz: if (result.success) { this.preOrders = result.data; }
if (result.success) {
setPreOrders(result.data || []);
} else {
// Se result.success for false, não há dados para exibir
setPreOrders([]);
if (result.message) {
setError(result.message);
}
}
} catch (err) {
console.error("Erro ao buscar orçamentos:", err);
setError(
err instanceof Error
? err.message
: "Erro ao buscar orçamentos. Tente novamente."
);
} finally {
setLoading(false);
}
};
const handleClear = () => {
setSelectedStore("");
setStartDate("");
setEndDate("");
setPreOrderId("");
setDocument("");
setCustomerName("");
setPreOrders([]);
setError(null);
};
const handleEditPreOrder = async (preOrder: PreOrder) => {
// Validação: não pode editar se já foi utilizado (exatamente como no Angular)
if (preOrder.status && preOrder.status === "ORÇAMENTO UTILIZADO") {
setInfoMessage("Alterar Orçamento");
setInfoDescription(
"Orçamento não pode ser editado.\nOrçamento já foi convertido em pedido de venda, alteração não permitida."
);
setShowInfoDialog(true);
return;
}
try {
const token = authService.getToken();
const apiUrl = env.API_URL.replace(/\/$/, "");
// Seguindo exatamente o padrão do Angular: usar HttpParams com preOrderId
const response = await fetch(
`${apiUrl}/preorder/cart?preOrderId=${preOrder.idPreOrder}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Basic ${token}` }),
},
}
);
if (response.ok) {
const result = await response.json();
console.log("📦 [PREORDER] Dados recebidos do backend:", {
cartId: result.cartId,
hasCustomer: !!result.customer,
hasPaymentPlan: !!result.paymentPlan,
hasBilling: !!result.billing,
hasPartner: !!result.partner,
hasAddress: !!result.address,
hasPreCustomer: !!result.preCustomer,
hasInvoiceStore: !!result.invoiceStore,
});
// Salvar dados no localStorage exatamente como no Angular
console.log(
"📦 [PREORDER] Salvando cartId no localStorage:",
result.cartId
);
localStorage.setItem("cart", result.cartId);
localStorage.setItem("customer", JSON.stringify(result.customer));
localStorage.setItem("paymentPlan", JSON.stringify(result.paymentPlan));
localStorage.setItem("billing", JSON.stringify(result.billing));
if (result.partner) {
console.log("📦 [PREORDER] Salvando partner");
localStorage.setItem("partner", JSON.stringify(result.partner));
}
if (result.address) {
console.log("📦 [PREORDER] Salvando address");
localStorage.setItem("address", JSON.stringify(result.address));
}
if (result.preCustomer) {
console.log("📦 [PREORDER] Salvando preCustomer");
localStorage.setItem(
"preCustomer",
JSON.stringify(result.preCustomer)
);
}
console.log("📦 [PREORDER] Salvando invoiceStore");
localStorage.setItem(
"invoiceStore",
JSON.stringify(result.invoiceStore)
);
// Criar OrderDelivery exatamente como no Angular
const orderDelivery = {
notification: result.notification1 ?? "",
notification1: result.notification2 ?? "",
notification2: "",
notificationDelivery1: result.notificationDelivery1 ?? "",
notificationDelivery2: result.notificationDelivery2 ?? "",
notificationDelivery3: result.notificationDelivery3 ?? "",
dateDelivery: result.deliveryDate,
scheduleDelivery: result.squeduleDelivery,
priorityDelivery: result.priorityDelivery,
};
console.log("📦 [PREORDER] Salvando dataDelivery");
localStorage.setItem("dataDelivery", JSON.stringify(orderDelivery));
// Verificar se o cartId foi salvo corretamente
const savedCartId = localStorage.getItem("cart");
console.log("📦 [PREORDER] CartId salvo no localStorage:", savedCartId);
console.log("📦 [PREORDER] CartId recebido do backend:", result.cartId);
console.log(
"📦 [PREORDER] CartIds são iguais?",
savedCartId === result.cartId
);
// IMPORTANTE: Carregar os itens do carrinho ANTES de navegar
// No Angular, o componente home-sales dispara LoadShoppingAction no ngOnInit
// No React, precisamos garantir que os itens sejam carregados antes da navegação
console.log(
"📦 [PREORDER] Carregando itens do carrinho antes de navegar..."
);
try {
const { shoppingService } = await import(
"../../src/services/shopping.service"
);
const items = await shoppingService.getShoppingItems(result.cartId);
console.log(
"📦 [PREORDER] Itens do carrinho carregados:",
items.length
);
console.log("📦 [PREORDER] Itens:", items);
// Salvar os itens no sessionStorage para garantir que sejam carregados após navegação
sessionStorage.setItem("pendingCartItems", JSON.stringify(items));
sessionStorage.setItem("pendingCartId", result.cartId);
console.log(
"📦 [PREORDER] Itens salvos no sessionStorage para carregamento após navegação"
);
} catch (loadError) {
console.error(
"📦 [PREORDER] Erro ao carregar itens antes de navegar:",
loadError
);
// Continuar mesmo se houver erro, o useCart tentará carregar depois
}
// Disparar evento customizado para notificar mudança no cartId
console.log("📦 [PREORDER] Disparando evento cartUpdated");
const storageEvent = new Event("cartUpdated") as any;
storageEvent.key = "cart";
storageEvent.newValue = result.cartId;
window.dispatchEvent(storageEvent);
// Mostrar mensagem de sucesso informando que os dados do carrinho foram carregados
console.log("📦 [PREORDER] Dados do carrinho carregados com sucesso");
setShowCartLoadedDialog(true);
} else {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || "Erro ao carregar dados do orçamento"
);
}
} catch (err) {
console.error("Erro ao editar orçamento:", err);
// Tratamento de erro exatamente como no Angular
setInfoMessage("Consulta de orçamentos");
setInfoDescription(
err instanceof Error
? `Ops! Houve um erro ao consultar os orçamentos.\n${err.message}`
: "Ops! Houve um erro ao consultar os orçamentos."
);
setShowInfoDialog(true);
}
};
const handleViewItems = async (preOrder: PreOrder) => {
try {
const token = authService.getToken();
const apiUrl = env.API_URL.replace(/\/$/, "");
const response = await fetch(
`${apiUrl}/preorder/itens/${preOrder.idPreOrder}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Basic ${token}` }),
},
}
);
if (response.ok) {
const items = await response.json();
// Converter para o formato esperado pelo OrderItemsModal
const convertedItems = items.map((item: PreOrderItem) => ({
productId: item.productId,
description: item.description,
package: item.package,
color: item.color,
local: item.local,
quantity: item.quantity,
price: item.price,
subTotal: item.subTotal,
}));
setOrderItems(convertedItems);
setSelectedPreOrder(preOrder);
setShowOrderItems(true);
}
} catch (err) {
console.error("Erro ao buscar itens do orçamento:", err);
setInfoMessage("Erro");
setInfoDescription("Não foi possível carregar os itens do orçamento.");
setShowInfoDialog(true);
}
};
const handleCloseOrderItemsModal = () => {
setShowOrderItems(false);
setOrderItems([]);
setSelectedPreOrder(null);
};
const handlePrintPreOrder = (preOrder: PreOrder) => {
setPreOrderToPrint(preOrder);
setShowPrintDialog(true);
};
const handleConfirmPrint = (model: "A" | "B" | "P") => {
if (!preOrderToPrint) return;
// Construir URL do viewer seguindo o padrão do Angular
const viewerUrl = env.PRINT_VIEWER_URL.replace("{action}", "InitViewer");
const url = `${viewerUrl}?order=${preOrderToPrint.idPreOrder}&model=${model}`;
// Configurar e mostrar o viewer
setPrintUrl(url);
setPrintPreOrderId(preOrderToPrint.idPreOrder);
setPrintModel(model);
setShowPrintViewer(true);
setShowPrintDialog(false);
};
const formatDate = (dateString: string | null | undefined): string => {
if (!dateString || dateString === "null" || dateString === "undefined") {
return "";
}
try {
// Tentar criar a data
let date: Date;
// Se já for uma string de data válida, usar diretamente
if (typeof dateString === "string") {
// Tentar parsear diferentes formatos
date = new Date(dateString);
// Se falhar, tentar formatos alternativos
if (isNaN(date.getTime())) {
// Tentar formato brasileiro DD/MM/YYYY
const parts = dateString.split("/");
if (parts.length === 3) {
date = new Date(
parseInt(parts[2]),
parseInt(parts[1]) - 1,
parseInt(parts[0])
);
} else {
// Tentar formato ISO
date = new Date(
dateString.replace(/(\d{2})\/(\d{2})\/(\d{4})/, "$3-$2-$1")
);
}
}
} else {
date = new Date(dateString);
}
// Verificar se a data é válida
if (isNaN(date.getTime())) {
console.warn("Data inválida:", dateString);
return "";
}
// Formatar no padrão DD/MM/YYYY
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const year = date.getFullYear();
return `${day}/${month}/${year}`;
} catch (error) {
console.error("Erro ao formatar data:", dateString, error);
return "";
}
};
const getStatusColor = (status: string): string => {
switch (status) {
case "ORÇAMENTO UTILIZADO":
return "bg-green-100 text-green-800";
case "PENDENTE":
return "bg-yellow-100 text-yellow-800";
default:
return "bg-slate-100 text-slate-800";
}
};
const getCustomerDisplay = (preOrder: PreOrder): string => {
if (preOrder.cpfPreCustomer && preOrder.idCustomer === 1) {
return `${preOrder.namePreCustomer} (PRE)`;
}
return typeof preOrder.customer === "string"
? preOrder.customer
: String(preOrder.customer);
};
// Definir colunas do DataGrid
const columns: GridColDef[] = [
{
field: "actions",
headerName: "Ações",
width: 200,
sortable: false,
filterable: false,
disableColumnMenu: true,
renderCell: (params) => {
const preOrder = params.row as PreOrder;
return (
<Box sx={{ display: "flex", gap: 0.5 }}>
<IconButton
size="small"
onClick={() => handleEditPreOrder(preOrder)}
sx={{
color: "#64748b",
"&:hover": { backgroundColor: "#f1f5f9", color: "#475569" },
}}
title="Editar orçamento"
>
<Edit fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => handleViewItems(preOrder)}
sx={{
color: "#64748b",
"&:hover": { backgroundColor: "#f1f5f9", color: "#475569" },
}}
title="Ver itens do orçamento"
>
<Visibility fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => handlePrintPreOrder(preOrder)}
sx={{
color: "#64748b",
"&:hover": { backgroundColor: "#f1f5f9", color: "#475569" },
}}
title="Imprimir orçamento"
>
<Print fontSize="small" />
</IconButton>
</Box>
);
},
},
{
field: "data",
headerName: "Data",
width: 120,
valueFormatter: (value) => {
if (!value) return "";
return formatDate(String(value));
},
},
{
field: "idPreOrder",
headerName: "N.Orçamento",
width: 130,
headerAlign: "left",
},
{
field: "status",
headerName: "Situação",
width: 180,
renderCell: (params) => {
const status = (params.value as string) || "PENDENTE";
return (
<span
className={`px-2 py-1 rounded-md text-xs font-bold uppercase ${getStatusColor(
status
)}`}
>
{status}
</span>
);
},
},
{
field: "idCustomer",
headerName: "Cód.Cliente",
width: 120,
headerAlign: "left",
},
{
field: "customer",
headerName: "Cliente",
width: 300,
flex: 1,
renderCell: (params) => {
const preOrder = params.row as PreOrder;
return (
<div className="flex items-center gap-2">
<span>{getCustomerDisplay(preOrder)}</span>
{preOrder.cpfPreCustomer && preOrder.idCustomer === 1 && (
<span className="px-2 py-0.5 bg-red-100 text-red-800 text-xs font-bold rounded">
PRE
</span>
)}
</div>
);
},
},
{
field: "value",
headerName: "Valor",
width: 130,
headerAlign: "right",
align: "right",
valueFormatter: (value) => formatCurrency(value as number),
},
{
field: "seller",
headerName: "Vendedor",
width: 200,
valueFormatter: (value) => {
return typeof value === "string" ? value : String(value);
},
},
];
return (
<div className="space-y-0">
{/* Header */}
<header>
<h2 className="text-2xl font-black text-[#002147] mb-2">
Orçamentos Pendentes
</h2>
</header>
{/* Filtros */}
<div className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Filial de venda */}
<div>
<Label htmlFor="store">Filial de venda</Label>
<CustomAutocomplete
id="store"
options={stores.map((store) => ({
value: store.id,
label: store.shortName,
}))}
value={selectedStore}
onValueChange={setSelectedStore}
placeholder="Selecione a filial de venda..."
/>
</div>
{/* Número do orçamento */}
<div>
<Label htmlFor="preOrderId">Número do orçamento</Label>
<Input
id="preOrderId"
type="text"
value={preOrderId}
onChange={(e) => setPreOrderId(e.target.value)}
placeholder="Informe o número do orçamento"
/>
</div>
{/* Data inicial */}
<div>
<Label htmlFor="startDate">Data inicial</Label>
<DateInput
id="startDate"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
{/* Data final */}
<div>
<Label htmlFor="endDate">Data final</Label>
<DateInput
id="endDate"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
{/* CPF/CNPJ */}
<div>
<Label htmlFor="document">CPF / CNPJ</Label>
<Input
id="document"
type="text"
value={document}
onChange={(e) => setDocument(e.target.value)}
placeholder="Informe o CPF ou CNPJ do cliente"
/>
</div>
{/* Nome do cliente */}
<div>
<Label htmlFor="customerName">Nome do cliente</Label>
<Input
id="customerName"
type="text"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
placeholder="Informe o nome ou razão social do cliente"
/>
</div>
</div>
{/* Botões de Ação */}
<div className="mt-6 flex gap-3">
<Button onClick={handleSearch} disabled={loading} className="flex-1">
{loading ? "Pesquisando..." : "Pesquisar"}
</Button>
<Button
onClick={handleClear}
disabled={loading}
variant="outline"
className="flex-1"
>
Limpar
</Button>
</div>
</div>
{/* Tabela de Orçamentos */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-2xl p-4">
<p className="text-red-600 text-sm font-medium">{error}</p>
</div>
)}
{loading && (
<div className="flex items-center justify-center py-20">
<LoadingSpinner />
</div>
)}
{!loading && preOrders.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 overflow-hidden">
<div className="p-5 border-b border-slate-50">
<h3 className="text-[9px] font-black text-slate-400 uppercase tracking-[0.2em]">
Orçamentos encontrados: {preOrders.length}
</h3>
</div>
<Box sx={{ height: 600, width: "100%" }}>
<DataGridPremium
rows={preOrders}
columns={columns}
getRowId={(row) => row.idPreOrder}
disableRowSelectionOnClick
hideFooter
sx={{
border: "none",
"& .MuiDataGrid-columnHeaders": {
backgroundColor: "#f8fafc",
borderBottom: "1px solid #e2e8f0",
"& .MuiDataGrid-columnHeader": {
fontSize: "9px",
fontWeight: 900,
color: "#94a3b8",
textTransform: "uppercase",
letterSpacing: "0.2em",
padding: "12px 16px",
"& .MuiDataGrid-columnHeaderTitle": {
fontWeight: 900,
},
},
},
"& .MuiDataGrid-row": {
"&:hover": {
backgroundColor: "#f8fafc",
},
},
"& .MuiDataGrid-cell": {
fontSize: "12px",
color: "#475569",
padding: "16px",
borderBottom: "1px solid #f1f5f9",
"&:focus": {
outline: "none",
},
"&:focus-within": {
outline: "none",
},
},
"& .MuiDataGrid-cell[data-field='idPreOrder']": {
fontWeight: 700,
color: "#0f172a",
},
"& .MuiDataGrid-cell[data-field='value']": {
fontWeight: 700,
color: "#0f172a",
},
"& .MuiDataGrid-cell[data-field='data']": {
color: "#64748b",
},
"& .MuiDataGrid-cell[data-field='idCustomer']": {
color: "#64748b",
},
"& .MuiDataGrid-cell[data-field='customer']": {
color: "#64748b",
},
"& .MuiDataGrid-virtualScroller": {
overflowY: "auto",
},
"& .MuiDataGrid-virtualScrollerContent": {
height: "auto !important",
},
}}
/>
</Box>
</div>
)}
{!loading && preOrders.length === 0 && !error && (
<div className="bg-white rounded-2xl border border-slate-100 overflow-hidden">
<NoData
title="Nenhum orçamento encontrado"
description="Não foram encontrados orçamentos com os filtros informados. Tente ajustar os parâmetros de pesquisa ou verifique se há orçamentos no período selecionado."
icon="clipboard"
variant="outline"
/>
</div>
)}
{/* Modal de Itens do Orçamento */}
<OrderItemsModal
isOpen={showOrderItems}
onClose={handleCloseOrderItemsModal}
orderId={selectedPreOrder?.idPreOrder || 0}
orderItems={orderItems}
/>
{/* Dialog de Informação */}
<ConfirmDialog
isOpen={showInfoDialog}
onClose={() => setShowInfoDialog(false)}
onConfirm={() => setShowInfoDialog(false)}
type="info"
title={infoMessage}
message={infoDescription}
confirmText="OK"
showWarning={false}
/>
{/* Dialog de Carrinho Carregado */}
<ConfirmDialog
isOpen={showCartLoadedDialog}
onClose={() => {
setShowCartLoadedDialog(false);
// Navegar para página de produtos após fechar o dialog
setTimeout(() => {
window.location.href = "/#/sales/home";
}, 100);
}}
onConfirm={() => {
setShowCartLoadedDialog(false);
// Navegar para página de produtos após confirmar
setTimeout(() => {
window.location.href = "/#/sales/home";
}, 100);
}}
type="success"
title="Carrinho carregado"
message="Os dados do carrinho foram carregados com sucesso!\n\nVocê será redirecionado para a página de produtos."
confirmText="OK"
showWarning={false}
/>
{/* Dialog de Seleção de Modelo de Impressão */}
{preOrderToPrint && (
<PrintOrderDialog
isOpen={showPrintDialog}
onClose={() => {
setShowPrintDialog(false);
setPreOrderToPrint(null);
}}
onConfirm={handleConfirmPrint}
orderId={preOrderToPrint.idPreOrder}
includeModelP={true}
/>
)}
{/* Modal do Viewer de Impressão */}
{showPrintViewer && printUrl && (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/80">
<div className="relative bg-white rounded-3xl shadow-2xl w-[95%] h-[90vh] max-w-7xl overflow-hidden flex flex-col">
{/* Header */}
<div className="p-6 bg-[#002147] text-white rounded-t-3xl relative overflow-hidden flex items-center justify-between">
<div>
<h3 className="text-xl font-black">Orçamento de venda</h3>
<p className="text-xs text-blue-400 font-bold uppercase tracking-wider mt-0.5">
Visualização e Impressão
</p>
</div>
<button
onClick={() => {
setShowPrintViewer(false);
setPrintUrl("");
setPrintPreOrderId(undefined);
setPrintModel(undefined);
}}
className="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Viewer Content */}
<div className="flex-1 overflow-hidden">
<StimulsoftViewer
requestUrl={printUrl}
action="InitViewer"
width="100%"
height="100%"
onClose={() => {
setShowPrintViewer(false);
setPrintUrl("");
setPrintPreOrderId(undefined);
setPrintModel(undefined);
}}
/>
</div>
</div>
</div>
)}
</div>
);
};
export default PreorderView;

View File

@ -0,0 +1,473 @@
import React, { useState, useEffect } from "react";
import LoadingSpinner from "../LoadingSpinner";
import { env } from "../../src/config/env";
import { authService } from "../../src/services/auth.service";
import { formatCurrency } from "../../utils/formatters";
import NoData from "../NoData";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Button } from "../ui/button";
import { CustomAutocomplete } from "../ui/autocomplete";
import { DateInput } from "../ui/date-input";
import { DataGridPremium, GridColDef } from "@mui/x-data-grid-premium";
import "../../lib/mui-license";
import { Box } from "@mui/material";
interface ProductOrder {
date: string;
orderId: number;
invoice?: any;
customerId: number;
customer: string;
seller: string;
productId: number;
product: string;
package: string;
quantity: number;
color?: string;
local?: string;
deliveryType: string;
itemId: string;
}
interface Store {
id: string;
shortName: string;
name: string;
}
interface ProductFilterOption {
id: string;
option: string;
}
const ProductsSoldView: React.FC = () => {
const [products, setProducts] = useState<ProductOrder[]>([]);
const [stores, setStores] = useState<Store[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Filtros
const [selectedStore, setSelectedStore] = useState<string>("");
const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>("");
const [orderId, setOrderId] = useState<string>("");
const [document, setDocument] = useState<string>("");
const [customerName, setCustomerName] = useState<string>("");
const [productFilterType, setProductFilterType] = useState<string>("");
const [productText, setProductText] = useState<string>("");
// Opções de filtro de produto
const productFilterOptions: ProductFilterOption[] = [
{ id: "ID", option: "Código" },
{ id: "EAN", option: "Ean" },
{ id: "TEXT", option: "Descrição" },
{ id: "PARTNER", option: "Código Fábrica" },
];
useEffect(() => {
fetchStores();
}, []);
const fetchStores = async () => {
try {
const token = authService.getToken();
const apiUrl = env.API_URL.replace(/\/$/, "");
const response = await fetch(`${apiUrl}/lists/store`, {
method: "GET",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Basic ${token}` }),
},
});
if (response.ok) {
const data = await response.json();
setStores(data);
}
} catch (err) {
console.error("Erro ao buscar filiais:", err);
}
};
const handleSearch = async () => {
setLoading(true);
setError(null);
try {
const token = authService.getToken();
const apiUrl = env.API_URL.replace(/\/$/, "");
// Seguindo o padrão do Angular: sellerId sempre 0
let sellerId = 0;
if (authService.isManager()) {
sellerId = 0;
}
// Se não selecionou filial, usar '99' como padrão (seguindo o Angular)
const store = selectedStore || "99";
const params = new URLSearchParams({
"x-store": store,
initialDate: startDate || "",
finalDate: endDate || "",
document: document || "",
name: customerName.toUpperCase() || "",
sellerId: sellerId.toString(),
idOrder: orderId || "",
typeFilterProduct: productFilterType || "",
productText: productText || "",
});
const response = await fetch(
`${apiUrl}/order/products-order?${params.toString()}`,
{
method: "GET",
headers: {
Accept: "application/json, text/plain, */*",
"Content-Type": "application/json",
...(token && { Authorization: `Basic ${token}` }),
},
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message ||
`Erro ao buscar produtos vendidos: ${response.statusText}`
);
}
const data = await response.json();
setProducts(data || []);
} catch (err) {
console.error("Erro ao buscar produtos vendidos:", err);
setError(
err instanceof Error
? err.message
: "Erro ao buscar produtos vendidos. Tente novamente."
);
} finally {
setLoading(false);
}
};
const handleClear = () => {
setSelectedStore("");
setStartDate("");
setEndDate("");
setOrderId("");
setDocument("");
setCustomerName("");
setProductFilterType("");
setProductText("");
setProducts([]);
setError(null);
};
const formatDate = (dateString: string): string => {
if (!dateString) return "";
const date = new Date(dateString);
return date.toLocaleDateString("pt-BR");
};
// Definir colunas do DataGrid
const columns: GridColDef[] = [
{
field: "date",
headerName: "Data",
width: 120,
valueFormatter: (value) => formatDate(value as string),
},
{
field: "orderId",
headerName: "N.Pedido",
width: 130,
headerAlign: "left",
},
{
field: "customerId",
headerName: "Cód.Cliente",
width: 120,
headerAlign: "left",
},
{
field: "customer",
headerName: "Cliente",
width: 250,
flex: 1,
},
{
field: "seller",
headerName: "Vendedor",
width: 150,
},
{
field: "productId",
headerName: "Cód.Produto",
width: 130,
headerAlign: "left",
},
{
field: "product",
headerName: "Produto",
width: 300,
flex: 1,
},
{
field: "package",
headerName: "Embalagem",
width: 120,
},
{
field: "quantity",
headerName: "Quantidade",
width: 120,
headerAlign: "right",
align: "right",
},
{
field: "deliveryType",
headerName: "Tipo Entrega",
width: 150,
},
];
return (
<div className="space-y-6">
{/* Header */}
<header>
<h2 className="text-2xl font-black text-[#002147] mb-2">
Consulta vendas por produto
</h2>
</header>
{/* Filtros */}
<div className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Filial de venda */}
<div>
<Label htmlFor="store">Filial de venda</Label>
<CustomAutocomplete
id="store"
options={stores.map((store) => ({
value: store.id,
label: store.shortName,
}))}
value={selectedStore}
onValueChange={setSelectedStore}
placeholder="Selecione a filial de venda..."
/>
</div>
{/* Número do pedido */}
<div>
<Label htmlFor="orderId">Número do pedido</Label>
<Input
id="orderId"
type="text"
value={orderId}
onChange={(e) => setOrderId(e.target.value)}
placeholder="Informe o número do pedido"
/>
</div>
{/* Data do pedido - Início */}
<div>
<Label htmlFor="startDate">Data inicial</Label>
<DateInput
id="startDate"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
{/* Data do pedido - Fim */}
<div>
<Label htmlFor="endDate">Data final</Label>
<DateInput
id="endDate"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
{/* CPF/CNPJ */}
<div>
<Label htmlFor="document">CPF / CNPJ</Label>
<Input
id="document"
type="text"
value={document}
onChange={(e) => setDocument(e.target.value)}
placeholder="Informe o CPF ou CNPJ do cliente"
/>
</div>
{/* Nome do cliente */}
<div>
<Label htmlFor="customerName">Nome do cliente</Label>
<Input
id="customerName"
type="text"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
placeholder="Informe o nome ou razão social do cliente"
/>
</div>
{/* Tipo de pesquisa do produto */}
<div>
<Label htmlFor="productFilterType">Tipo Pesquisa Produto</Label>
<CustomAutocomplete
id="productFilterType"
options={productFilterOptions.map((option) => ({
value: option.id,
label: option.option,
}))}
value={productFilterType}
onValueChange={setProductFilterType}
placeholder="Selecione o tipo de pesquisa..."
/>
</div>
{/* Produto */}
<div>
<Label htmlFor="productText">Produto</Label>
<Input
id="productText"
type="text"
value={productText}
onChange={(e) => setProductText(e.target.value)}
placeholder="Informe o texto para pesquisa do produto"
/>
</div>
</div>
{/* Botões de Ação */}
<div className="mt-6 flex gap-3">
<Button onClick={handleSearch} disabled={loading} className="flex-1">
{loading ? "Pesquisando..." : "Pesquisar"}
</Button>
<Button
onClick={handleClear}
disabled={loading}
variant="outline"
className="flex-1"
>
Limpar
</Button>
</div>
</div>
{/* Tabela de Produtos Vendidos */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-2xl p-4">
<p className="text-red-600 text-sm font-medium">{error}</p>
</div>
)}
{loading && (
<div className="flex items-center justify-center py-20">
<LoadingSpinner />
</div>
)}
{!loading && products.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 overflow-hidden">
<div className="p-5 border-b border-slate-50">
<h3 className="text-[9px] font-black text-slate-400 uppercase tracking-[0.2em]">
Produtos encontrados: {products.length}
</h3>
</div>
<Box sx={{ height: 600, width: "100%" }}>
<DataGridPremium
rows={products}
columns={columns}
getRowId={(row) => `${row.orderId}-${row.itemId}`}
disableRowSelectionOnClick
hideFooter
sx={{
border: "none",
"& .MuiDataGrid-columnHeaders": {
backgroundColor: "#f8fafc",
borderBottom: "1px solid #e2e8f0",
"& .MuiDataGrid-columnHeader": {
fontSize: "9px",
fontWeight: 900,
color: "#94a3b8",
textTransform: "uppercase",
letterSpacing: "0.2em",
padding: "12px 16px",
"& .MuiDataGrid-columnHeaderTitle": {
fontWeight: 900,
},
},
},
"& .MuiDataGrid-row": {
"&:hover": {
backgroundColor: "#f8fafc",
},
},
"& .MuiDataGrid-cell": {
fontSize: "12px",
color: "#475569",
padding: "16px",
borderBottom: "1px solid #f1f5f9",
"&:focus": {
outline: "none",
},
"&:focus-within": {
outline: "none",
},
},
"& .MuiDataGrid-cell[data-field='orderId']": {
fontWeight: 700,
color: "#0f172a",
},
"& .MuiDataGrid-cell[data-field='productId']": {
fontWeight: 700,
color: "#0f172a",
},
"& .MuiDataGrid-cell[data-field='quantity']": {
fontWeight: 700,
color: "#0f172a",
},
"& .MuiDataGrid-cell[data-field='date']": {
color: "#64748b",
},
"& .MuiDataGrid-cell[data-field='customerId']": {
color: "#64748b",
},
"& .MuiDataGrid-cell[data-field='customer']": {
color: "#64748b",
},
"& .MuiDataGrid-virtualScroller": {
overflowY: "auto",
},
"& .MuiDataGrid-virtualScrollerContent": {
height: "auto !important",
},
}}
/>
</Box>
</div>
)}
{!loading && products.length === 0 && !error && (
<div className="bg-white rounded-2xl border border-slate-100 overflow-hidden">
<NoData
title="Nenhum produto encontrado"
description="Não foram encontrados produtos vendidos com os filtros informados. Tente ajustar os parâmetros de pesquisa ou verifique se há produtos no período selecionado."
icon="search"
variant="outline"
/>
</div>
)}
</div>
);
};
export default ProductsSoldView;

View File

@ -0,0 +1,168 @@
import * as React from "react";
import Autocomplete from "@mui/material/Autocomplete";
import TextField from "@mui/material/TextField";
import { cn } from "../../lib/utils";
export interface AutocompleteOption {
value: string;
label: string;
}
export interface CustomAutocompleteProps {
options: AutocompleteOption[];
value?: string;
onValueChange?: (value: string) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
id?: string;
}
const CustomAutocomplete = React.forwardRef<HTMLDivElement, CustomAutocompleteProps>(
(
{
options,
value,
onValueChange,
placeholder = "Selecione uma opção...",
className,
disabled,
id,
},
ref
) => {
const selectedOption = options.find((option) => option.value === value) || null;
const handleChange = (
_event: React.SyntheticEvent,
newValue: AutocompleteOption | null
) => {
onValueChange?.(newValue?.value || "");
};
return (
<Autocomplete<AutocompleteOption, false, false, false>
ref={ref}
id={id}
options={options}
value={selectedOption}
onChange={handleChange}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, value) => option.value === value.value}
disabled={disabled}
className={cn("w-full", className)}
disablePortal
renderInput={(params) => (
<TextField
{...params}
placeholder={placeholder}
variant="outlined"
sx={{
"& .MuiOutlinedInput-root": {
height: "40px",
borderRadius: "12px",
borderColor: "#cbd5e1",
backgroundColor: "#ffffff",
fontSize: "14px",
color: "#0f172a",
padding: "0",
"&:hover": {
"& fieldset": {
borderColor: "#cbd5e1",
},
},
"&.Mui-focused": {
borderColor: "#002147",
boxShadow: "0 0 0 2px rgba(0, 33, 71, 0.1)",
"& fieldset": {
borderColor: "#002147",
borderWidth: "1px",
},
},
"&.Mui-disabled": {
backgroundColor: "#ffffff",
opacity: 0.5,
cursor: "not-allowed",
},
"& fieldset": {
borderColor: "#cbd5e1",
borderWidth: "1px",
},
},
"& .MuiInputBase-input": {
padding: "8px 16px",
fontSize: "14px",
color: "#0f172a",
"&::placeholder": {
color: "#94a3b8",
opacity: 1,
},
},
}}
/>
)}
sx={{
"& .MuiAutocomplete-endAdornment": {
right: "12px",
},
"& .MuiAutocomplete-clearIndicator": {
color: "#64748b",
"&:hover": {
color: "#0f172a",
},
},
"& .MuiAutocomplete-popupIndicator": {
color: "#64748b",
"&:hover": {
color: "#0f172a",
},
},
}}
componentsProps={{
popper: {
sx: {
"& .MuiAutocomplete-paper": {
borderRadius: "12px",
border: "1px solid #e2e8f0",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
marginTop: "4px",
},
"& .MuiAutocomplete-listbox": {
padding: "4px",
"& .MuiAutocomplete-option": {
borderRadius: "8px",
margin: "2px 0",
fontSize: "14px",
padding: "8px 12px",
"&:hover": {
backgroundColor: "#f1f5f9",
},
"&[aria-selected='true']": {
backgroundColor: "#f1f5f9",
color: "#002147",
fontWeight: 600,
},
"&.Mui-focused": {
backgroundColor: "#f1f5f9",
},
},
"& .MuiAutocomplete-noOptions": {
fontSize: "14px",
color: "#64748b",
padding: "16px",
textAlign: "center",
},
},
},
},
}}
noOptionsText="Nenhuma opção encontrada"
/>
);
}
);
CustomAutocomplete.displayName = "CustomAutocomplete";
export { CustomAutocomplete };

53
components/ui/button.tsx Normal file
View File

@ -0,0 +1,53 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-xl font-bold transition-colors",
{
variants: {
variant: {
default: "bg-[#002147] text-white hover:bg-[#003366]",
outline:
"border border-slate-300 bg-white text-[#002147] hover:bg-slate-50",
ghost: "hover:bg-slate-100 text-slate-700",
link: "text-[#002147] underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-8 py-2",
sm: "h-9 px-4 text-xs",
lg: "h-11 px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
compoundVariants: [],
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(
buttonVariants({ variant, size }),
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#002147] focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

102
components/ui/combobox.tsx Normal file
View File

@ -0,0 +1,102 @@
import * as React from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "../../lib/utils";
import { Button } from "./button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "./popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "./command";
export interface ComboboxOption {
value: string;
label: string;
}
export interface ComboboxProps {
options: ComboboxOption[];
value?: string;
onValueChange?: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
className?: string;
disabled?: boolean;
}
const Combobox = React.forwardRef<HTMLButtonElement, ComboboxProps>(
(
{
options,
value,
onValueChange,
placeholder = "Selecione uma opção...",
searchPlaceholder = "Pesquisar...",
emptyText = "Nenhum resultado encontrado.",
className,
disabled,
},
ref
) => {
const [open, setOpen] = React.useState(false);
const selectedOption = options.find((option) => option.value === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
ref={ref}
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"w-full justify-between font-normal h-10 bg-white border border-slate-300 text-slate-900 hover:bg-slate-50",
!selectedOption && "text-slate-400",
className
)}
disabled={disabled}
>
<span className="truncate">
{selectedOption ? selectedOption.label : placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] max-w-[400px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
onValueChange?.(option.value);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
);
Combobox.displayName = "Combobox";
export { Combobox };

108
components/ui/command.tsx Normal file
View File

@ -0,0 +1,108 @@
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "../../lib/utils";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-xl bg-white text-slate-950",
className
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b border-slate-200 px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm text-slate-500"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-slate-950 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-slate-500",
className
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-lg px-2 py-1.5 text-sm outline-none aria-selected:bg-slate-100 aria-selected:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
export {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
};

View File

@ -0,0 +1,37 @@
import * as React from "react";
import { Calendar } from "lucide-react";
import { cn } from "../../lib/utils";
export interface DateInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {}
const DateInput = React.forwardRef<HTMLInputElement, DateInputProps>(
({ className, ...props }, ref) => {
return (
<div className="relative">
<input
type="date"
ref={ref}
className={cn(
"flex h-10 w-full rounded-xl border border-slate-300 bg-white px-4 py-2 pr-10 text-sm text-slate-900",
"placeholder:text-slate-400",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#002147] focus-visible:border-transparent",
"disabled:cursor-not-allowed disabled:opacity-50",
// Esconder o ícone nativo do calendário e tornar toda a área clicável
"[&::-webkit-calendar-picker-indicator]:opacity-0 [&::-webkit-calendar-picker-indicator]:absolute [&::-webkit-calendar-picker-indicator]:right-0 [&::-webkit-calendar-picker-indicator]:w-full [&::-webkit-calendar-picker-indicator]:h-full [&::-webkit-calendar-picker-indicator]:cursor-pointer",
"[&::-webkit-inner-spin-button]:hidden [&::-webkit-outer-spin-button]:hidden",
// Firefox
"[&::-moz-calendar-picker-indicator]:opacity-0",
className
)}
{...props}
/>
<Calendar className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 pointer-events-none" />
</div>
);
}
);
DateInput.displayName = "DateInput";
export { DateInput };

97
components/ui/empty.tsx Normal file
View File

@ -0,0 +1,97 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Empty = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"flex min-h-[400px] min-w-[600px] flex-col items-center justify-center rounded-2xl p-12 text-center w-full",
className
)}
{...props}
/>
));
Empty.displayName = "Empty";
const EmptyHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col items-center space-y-4", className)}
{...props}
/>
));
EmptyHeader.displayName = "EmptyHeader";
const EmptyMedia = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
variant?: "default" | "icon";
}
>(({ className, variant = "default", ...props }, ref) => (
<div
ref={ref}
className={cn(
variant === "icon"
? "flex items-center justify-center"
: "mb-4",
className
)}
{...props}
/>
));
EmptyMedia.displayName = "EmptyMedia";
const EmptyTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-lg font-semibold text-slate-900", className)}
{...props}
/>
));
EmptyTitle.displayName = "EmptyTitle";
const EmptyDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn(
"max-w-sm text-sm text-slate-500",
className
)}
{...props}
/>
));
EmptyDescription.displayName = "EmptyDescription";
const EmptyContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("mt-6", className)}
{...props}
/>
));
EmptyContent.displayName = "EmptyContent";
export {
Empty,
EmptyHeader,
EmptyMedia,
EmptyTitle,
EmptyDescription,
EmptyContent,
};

28
components/ui/input.tsx Normal file
View File

@ -0,0 +1,28 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm text-slate-900",
"placeholder:text-slate-400",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#002147] focus-visible:border-transparent",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

26
components/ui/label.tsx Normal file
View File

@ -0,0 +1,26 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface LabelProps
extends React.LabelHTMLAttributes<HTMLLabelElement> {}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
({ className, ...props }, ref) => {
return (
<label
ref={ref}
className={cn(
"block text-sm font-bold text-slate-700 mb-2",
className
)}
{...props}
/>
);
}
);
Label.displayName = "Label";
export { Label };

30
components/ui/popover.tsx Normal file
View File

@ -0,0 +1,30 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "../../lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 rounded-xl border border-slate-200 bg-white p-4 text-slate-950 shadow-md outline-none",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

389
docs/SHOPPING_CART_GUIDE.md Normal file
View File

@ -0,0 +1,389 @@
# Guia de Uso do Carrinho de Compras - VendaWeb React
## 📋 Visão Geral
Este documento descreve como funciona o sistema de carrinho de compras no frontend React, baseado na implementação funcional do portal Angular. O guia cobre desde a adição de itens até a gestão completa do carrinho.
## 🏗️ Arquitetura
```
vendaweb_react/
├── src/
│ ├── hooks/
│ │ └── useCart.ts # Hook customizado para gerenciar estado do carrinho
│ ├── services/
│ │ └── shopping.service.ts # Serviço para interações com a API do carrinho
│ └── contexts/
│ └── AuthContext.tsx # Contexto de autenticação (necessário para token)
├── components/
│ └── CartDrawer.tsx # Componente visual do carrinho
└── views/
└── CheckoutView.tsx # View de checkout
```
## 🔑 Conceitos Fundamentais
### 1. ID do Carrinho (`idCart`)
- **Tipo**: `string | null`
- **Armazenamento**: `localStorage.getItem("cart")`
- **Comportamento**:
- Quando `null`: Backend cria um novo carrinho e retorna o `idCart` gerado
- Quando existe: Backend adiciona o item ao carrinho existente
- **IMPORTANTE**: Sempre enviar `idCart: null` (não string vazia) quando não há carrinho
### 2. ID do Item (`id`)
- **Tipo**: `string | null`
- **Comportamento**:
- Quando `null`: Backend cria um novo item e retorna o `id` (UUID) gerado
- Quando existe: Backend atualiza o item existente
- **IMPORTANTE**: Sempre enviar `id: null` para novos itens
### 3. Fluxo de Adição de Item
```
1. Usuário seleciona produto
2. productToShoppingItem() converte Product → ShoppingItem
3. createItemShopping() envia POST /shopping/item
4. Backend cria/atualiza carrinho e retorna idCart
5. Frontend salva idCart no localStorage
6. useCart hook recarrega itens do carrinho
```
## 📝 Estrutura do Payload
### Payload para Adicionar Item (POST /shopping/item)
```typescript
{
"id": null, // Sempre null para novos itens
"idCart": null, // null se não há carrinho, UUID se existe
"invoiceStore": "4", // Código da loja
"idProduct": 25960, // ID do produto (número)
"description": "Nome do Produto",
"image": "http://...", // URL da imagem ou "" se não houver
"productType": "S", // Tipo do produto
"percentUpQuantity": 0, // Sempre 0
"upQuantity": 0, // Sempre 0
"quantity": 1, // Quantidade
"price": 20.99, // Preço de venda
"deliveryType": "EN", // Tipo de entrega
"stockStore": "4", // Código da loja de estoque
"seller": 1, // ID do vendedor
"discount": 0, // Desconto percentual (sempre 0 inicialmente)
"discountValue": 0, // Valor do desconto (sempre 0 inicialmente)
"ean": 7895024019601, // Código EAN (ou idProduct se não houver)
"promotion": 20.99, // Preço promocional (0 se não houver promoção)
"listPrice": 33.9, // Preço de tabela
"userDiscount": null, // Sempre null
"mutiple": 1, // Múltiplo de venda
"auxDescription": null, // Descrição auxiliar (cor para tintométrico)
"smallDescription": "#ARG...", // Descrição curta (NÃO usar description como fallback)
"brand": "PORTOKOLL", // Marca
"base": "N", // Base tintométrica (S/N)
"line": null, // Linha tintométrica
"can": null, // Lata
"letter": null // Letra tintométrica
}
```
### Regras Importantes
1. **`id` e `idCart`**: Sempre presentes no payload, mesmo que sejam `null`
2. **`smallDescription`**: Usar apenas o campo `smallDescription` do produto (não usar `description` como fallback)
3. **`promotion`**:
- Se produto tem `promotion > 0`: usar esse valor
- Se `price < listPrice`: usar `price` como `promotion`
- Caso contrário: usar `0`
4. **`image`**: String vazia `""` quando não há imagem (não `null`)
5. **`color`**: Remover do payload se for `null` (não incluir o campo)
## 🔧 Uso do Hook `useCart`
### Importação
```typescript
import { useCart } from './src/hooks/useCart';
```
### Uso Básico
```typescript
const {
cart, // OrderItem[] - Itens do carrinho
cartId, // string | null - ID do carrinho
isLoading, // boolean - Estado de carregamento
error, // string | null - Mensagem de erro
addToCart, // (product: Product | OrderItem) => Promise<void>
updateQuantity, // (id: string, delta: number) => Promise<void>
removeFromCart, // (id: string) => Promise<void>
refreshCart, // () => Promise<void>
clearCart, // () => void
} = useCart();
```
### Exemplo Completo
```typescript
import { useCart } from './src/hooks/useCart';
import { Product } from './types';
function ProductCard({ product }: { product: Product }) {
const { addToCart, isLoading } = useCart();
const handleAddToCart = async () => {
try {
await addToCart(product);
// Item adicionado com sucesso
// O hook automaticamente:
// 1. Cria o item no backend
// 2. Salva o idCart retornado no localStorage
// 3. Recarrega os itens do carrinho
} catch (error) {
console.error('Erro ao adicionar item:', error);
}
};
return (
<button onClick={handleAddToCart} disabled={isLoading}>
{isLoading ? 'Adicionando...' : 'Adicionar ao Carrinho'}
</button>
);
}
```
## 🛠️ Uso do Serviço `shoppingService`
### Métodos Principais
#### 1. `createItemShopping(item: ShoppingItem): Promise<ShoppingItem>`
Adiciona um item ao carrinho.
```typescript
import { shoppingService } from './src/services/shopping.service';
import { Product } from './types';
// Converter Product para ShoppingItem
const shoppingItem = shoppingService.productToShoppingItem(product);
// Criar item no carrinho
const result = await shoppingService.createItemShopping(shoppingItem);
// O idCart será salvo automaticamente no localStorage
// Se result.idCart existir, será salvo
```
**Comportamento**:
- Se `item.idCart` é `null`: Backend cria novo carrinho
- Se `item.idCart` existe: Backend adiciona ao carrinho existente
- Sempre retorna o `idCart` (novo ou existente)
- Remove `paymentPlan` e `billing` do localStorage após sucesso
#### 2. `productToShoppingItem(product: Product | OrderItem): ShoppingItem`
Converte um `Product` ou `OrderItem` para `ShoppingItem`.
```typescript
const product: Product = {
id: "123",
code: "25960",
name: "Produto Exemplo",
price: 20.99,
// ... outros campos
};
const shoppingItem = shoppingService.productToShoppingItem(product);
// Retorna ShoppingItem pronto para ser enviado ao backend
```
**Regras de Conversão**:
- `idProduct`: Extraído de `product.idProduct`, `product.id` ou `product.code`
- `description`: `product.name` ou `product.description`
- `smallDescription`: Apenas `product.smallDescription` (sem fallback)
- `promotion`: Calculado conforme regras acima
- `ean`: `product.ean` ou `idProduct` como fallback
- `idCart`: Obtido do `localStorage.getItem("cart")` (pode ser `null`)
#### 3. `updateQuantityItemShopping(item: ShoppingItem): Promise<void>`
Atualiza a quantidade de um item no carrinho.
```typescript
const item = cart.find(i => i.id === itemId);
const shoppingItem = shoppingService.productToShoppingItem({
...item,
quantity: newQuantity
});
shoppingItem.id = item.id; // IMPORTANTE: Usar o UUID do item
shoppingItem.idCart = cartId;
await shoppingService.updateQuantityItemShopping(shoppingItem);
```
#### 4. `deleteItemShopping(id: string): Promise<void>`
Remove um item do carrinho.
```typescript
// IMPORTANTE: id deve ser o UUID do item (não idProduct)
await shoppingService.deleteItemShopping(itemId);
```
#### 5. `getShoppingItems(idCart: string): Promise<ShoppingItem[]>`
Obtém todos os itens de um carrinho.
```typescript
const cartId = shoppingService.getCart();
if (cartId) {
const items = await shoppingService.getShoppingItems(cartId);
}
```
## ⚠️ Validações e Regras de Negócio
### 1. Produto Tintométrico
Produtos com `base === "S"` requerem `auxDescription` (cor selecionada).
```typescript
// Validação automática em createItemShopping()
if (base === "S" && auxDescription === "") {
throw new Error("Esse produto só pode ser adicionado com coloração selecionada");
}
```
### 2. IDs dos Itens
- **Novos itens**: Sempre enviar `id: null`
- **Itens existentes**: Usar o UUID retornado pelo backend
- **IMPORTANTE**: Não usar `idProduct` como `id` do item do carrinho
### 3. ID do Carrinho
- **Primeiro item**: `idCart: null` → Backend cria novo carrinho
- **Itens subsequentes**: `idCart: <UUID>` → Backend adiciona ao carrinho existente
- **Armazenamento**: Sempre salvar o `idCart` retornado no `localStorage`
## 🔍 Debugging
### Logs do Serviço
O serviço gera logs detalhados com prefixo `🛒 [SHOPPING]`:
```typescript
console.log("🛒 [SHOPPING] createItemShopping: " + JSON.stringify(cleanItem));
console.log("🛒 [SHOPPING] Item criado com sucesso:", result);
console.log("🛒 [SHOPPING] idCart retornado:", result.idCart);
```
### Verificar Estado do Carrinho
```typescript
// No console do navegador
localStorage.getItem("cart"); // ID do carrinho
localStorage.getItem("token"); // Token de autenticação
```
### Testar Requisição Manualmente
Use o script `test_add_item.ps1` para testar a adição de itens via curl:
```powershell
# 1. Obter token do localStorage
# 2. Editar test_add_item.ps1 com o token
# 3. Executar: .\test_add_item.ps1
```
## 🐛 Problemas Comuns e Soluções
### Erro 400: "Erro ao criar item no carrinho de compras"
**Causas possíveis**:
1. `idCart` sendo enviado como string vazia `""` em vez de `null`
2. `smallDescription` usando `description` como fallback (muito longo)
3. `promotion` calculado incorretamente
4. Campos obrigatórios faltando ou com valores inválidos
**Solução**: Verificar logs do console e comparar payload com o exemplo funcional.
### Item não aparece no carrinho após adicionar
**Causas possíveis**:
1. `idCart` não foi salvo no `localStorage`
2. `refreshCart()` não foi chamado após adicionar item
3. Hook `useCart` não está recarregando itens
**Solução**: Verificar se `result.idCart` foi salvo e se `loadCartItems()` foi chamado.
### Erro ao remover item
**Causas possíveis**:
1. `id` do item não é um UUID válido
2. `id` está usando `idProduct` em vez do UUID do backend
**Solução**: Garantir que `item.id` seja o UUID retornado pelo backend, não `idProduct`.
## 📚 Referências
- **Backend API**: `POST /api/v1/shopping/item`
- **Angular Reference**: `vendaweb_portal/src/app/sales/product-detail/product-detail.component.ts`
- **Backend Service**: `vendaweb_api/src/sales/shopping/shopping.service.ts`
## ✅ Checklist de Implementação
Ao implementar funcionalidades de carrinho, verificar:
- [ ] `id` e `idCart` sempre presentes no payload (mesmo que `null`)
- [ ] `smallDescription` usa apenas campo do produto (sem fallback)
- [ ] `promotion` calculado corretamente
- [ ] `idCart` salvo no `localStorage` após criação
- [ ] `refreshCart()` chamado após modificações
- [ ] Validação de produto tintométrico implementada
- [ ] IDs dos itens são UUIDs (não `idProduct`)
- [ ] Tratamento de erros implementado
- [ ] Logs de debug adicionados
## 🔄 Fluxo Completo de Exemplo
```typescript
// 1. Usuário seleciona produto
const product: Product = await productService.getProductDetail(storeId, productId);
// 2. Adicionar ao carrinho usando hook
const { addToCart } = useCart();
await addToCart(product);
// 3. Hook internamente:
// - Converte Product → ShoppingItem
// - Envia POST /shopping/item com idCart: null (se primeiro item)
// - Backend cria carrinho e retorna idCart
// - Salva idCart no localStorage
// - Recarrega itens do carrinho
// 4. Atualizar quantidade
const { updateQuantity } = useCart();
await updateQuantity(itemId, 1); // +1
// 5. Remover item
const { removeFromCart } = useCart();
await removeFromCart(itemId);
// 6. Limpar carrinho
const { clearCart } = useCart();
clearCart(); // Limpa estado e localStorage
```
---
**Última atualização**: 2026-01-06
**Versão**: 1.0
**Autor**: Sistema de Documentação Automática

73
index.html Normal file
View File

@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Plataforma SMART | Jurunense</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Plus Jakarta Sans', sans-serif;
background-color: #f8fafc;
color: #1e293b;
}
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 10px;
}
/* Mobile App-like Styles */
@media (max-width: 1023px) {
body {
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
}
/* Safe area support for notched devices */
.safe-area-top {
padding-top: env(safe-area-inset-top);
}
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
/* Touch optimization */
.touch-manipulation {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
/* Prevent text selection on buttons */
button {
-webkit-tap-highlight-color: transparent;
user-select: none;
}
}
</style>
<script type="importmap">
{
"imports": {
"react/": "https://esm.sh/react@^19.2.3/",
"react": "https://esm.sh/react@^19.2.3",
"recharts": "https://esm.sh/recharts@^3.6.0",
"react-dom/": "https://esm.sh/react-dom@^19.2.3/"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="antialiased">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

18
index.tsx Normal file
View File

@ -0,0 +1,18 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { AuthProvider } from './src/contexts/AuthContext';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</React.StrictMode>
);

33
lib/mui-license.ts Normal file
View File

@ -0,0 +1,33 @@
// MUI X License Configuration
// Configuração da licença usando múltiplas abordagens para garantir funcionamento
// Chaves de licença disponíveis
const PERPETUAL_LICENSE_KEY =
"e0d9bb8070ce0054c9d9ecb6e82cb58fTz0wLEU9MzI0NzIxNDQwMDAwMDAsUz1wcmVtaXVtLExNPXBlcnBldHVhbCxLVj0y";
const ALTERNATIVE_LICENSE_KEY =
"61628ce74db2c1b62783a6d438593bc5Tz1NVUktRG9jLEU9MTY4MzQ0NzgyMTI4NCxTPXByZW1pdW0sTE09c3Vic2NyaXB0aW9uLEtWPTI=";
// Importar LicenseInfo do pacote de licença
import { LicenseInfo } from "@mui/x-license-pro";
// Aplicar a licença
try {
// Aplicar a licença perpétua primeiro
LicenseInfo.setLicenseKey(PERPETUAL_LICENSE_KEY);
console.log("✅ Licença MUI X Premium aplicada (Perpétua)");
} catch (error) {
console.warn(
"⚠️ Erro ao aplicar licença perpétua, tentando alternativa:",
error
);
try {
// Fallback para licença alternativa
LicenseInfo.setLicenseKey(ALTERNATIVE_LICENSE_KEY);
console.log("✅ Licença MUI X Premium aplicada (Alternativa)");
} catch (fallbackError) {
console.error("❌ Erro ao aplicar qualquer licença:", fallbackError);
}
}
export default LicenseInfo;

434
lib/utils.ts Normal file
View File

@ -0,0 +1,434 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Validação de CPF
* @param cpf - CPF a ser validado (com ou sem formatação)
* @returns true se válido, false caso contrário
*/
export function validateCPF(cpf: string): boolean {
if (!cpf) return false;
// Remove caracteres não numéricos
const cleanCPF = cpf.replace(/[^\d]/g, "");
// Verifica se tem 11 dígitos
if (cleanCPF.length !== 11) return false;
// Verifica se todos os dígitos são iguais
if (/^(\d)\1{10}$/.test(cleanCPF)) return false;
// Validação dos dígitos verificadores
let sum = 0;
let remainder;
for (let i = 1; i <= 9; i++) {
sum += parseInt(cleanCPF.substring(i - 1, i)) * (11 - i);
}
remainder = (sum * 10) % 11;
if (remainder === 10 || remainder === 11) remainder = 0;
if (remainder !== parseInt(cleanCPF.substring(9, 10))) return false;
sum = 0;
for (let i = 1; i <= 10; i++) {
sum += parseInt(cleanCPF.substring(i - 1, i)) * (12 - i);
}
remainder = (sum * 10) % 11;
if (remainder === 10 || remainder === 11) remainder = 0;
if (remainder !== parseInt(cleanCPF.substring(10, 11))) return false;
return true;
}
/**
* Validação de CNPJ
* @param cnpj - CNPJ a ser validado (com ou sem formatação)
* @returns true se válido, false caso contrário
*/
export function validateCNPJ(cnpj: string): boolean {
if (!cnpj) return false;
// Remove caracteres não numéricos
const cleanCNPJ = cnpj.replace(/[^\d]/g, "");
// Verifica se tem 14 dígitos
if (cleanCNPJ.length !== 14) return false;
// Verifica se todos os dígitos são iguais
if (/^(\d)\1{13}$/.test(cleanCNPJ)) return false;
// Validação dos dígitos verificadores
let length = cleanCNPJ.length - 2;
let numbers = cleanCNPJ.substring(0, length);
const digits = cleanCNPJ.substring(length);
let sum = 0;
let pos = length - 7;
for (let i = length; i >= 1; i--) {
sum += parseInt(numbers.charAt(length - i)) * pos--;
if (pos < 2) pos = 9;
}
let result = sum % 11 < 2 ? 0 : 11 - (sum % 11);
if (result !== parseInt(digits.charAt(0))) return false;
length = length + 1;
numbers = cleanCNPJ.substring(0, length);
sum = 0;
pos = length - 7;
for (let i = length; i >= 1; i--) {
sum += parseInt(numbers.charAt(length - i)) * pos--;
if (pos < 2) pos = 9;
}
result = sum % 11 < 2 ? 0 : 11 - (sum % 11);
if (result !== parseInt(digits.charAt(1))) return false;
return true;
}
/**
* Validação de CPF ou CNPJ
* @param document - CPF ou CNPJ a ser validado
* @returns true se válido, false caso contrário
*/
export function validateCPForCNPJ(document: string): boolean {
if (!document) return false;
const cleanDoc = document.replace(/[^\d]/g, "");
if (cleanDoc.length === 11) {
return validateCPF(document);
} else if (cleanDoc.length === 14) {
return validateCNPJ(document);
}
return false;
}
/**
* Validação de CEP
* @param cep - CEP a ser validado (com ou sem formatação)
* @returns true se válido, false caso contrário
*/
export function validateCEP(cep: string): boolean {
if (!cep) return false;
const cleanCEP = cep.replace(/[^\d]/g, "");
return cleanCEP.length === 8;
}
/**
* Validação de telefone/celular
* @param phone - Telefone a ser validado
* @returns true se válido, false caso contrário
*/
export function validatePhone(phone: string): boolean {
if (!phone) return false;
const cleanPhone = phone.replace(/[^\d]/g, "");
// Aceita telefone com 10 ou 11 dígitos (com ou sem DDD)
return cleanPhone.length >= 10 && cleanPhone.length <= 11;
}
/**
* Validação de campo obrigatório
* @param value - Valor a ser validado
* @returns true se preenchido, false caso contrário
*/
export function validateRequired(value: any): boolean {
if (value === null || value === undefined) return false;
if (typeof value === "string") {
return value.trim().length > 0;
}
return true;
}
/**
* Validação de email
* @param email - Email a ser validado
* @returns true se válido, false caso contrário
*/
export function validateEmail(email: string): boolean {
if (!email) return false;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* Validação de número mínimo de caracteres
* @param value - Valor a ser validado
* @param minLength - Tamanho mínimo
* @returns true se válido, false caso contrário
*/
export function validateMinLength(value: string, minLength: number): boolean {
if (!value) return false;
return value.trim().length >= minLength;
}
/**
* Validação de número máximo de caracteres
* @param value - Valor a ser validado
* @param maxLength - Tamanho máximo
* @returns true se válido, false caso contrário
*/
export function validateMaxLength(value: string, maxLength: number): boolean {
if (!value) return true; // Campo vazio é válido (usar validateRequired separadamente)
return value.length <= maxLength;
}
/**
* Validação de data mínima (data não pode ser anterior à data mínima)
* @param date - Data a ser validada
* @param minDate - Data mínima permitida
* @returns true se válido, false caso contrário
*/
export function validateMinDate(
date: Date | string | null,
minDate: Date | string
): boolean {
if (!date) return false;
const dateObj = typeof date === "string" ? new Date(date) : date;
const minDateObj = typeof minDate === "string" ? new Date(minDate) : minDate;
return dateObj >= minDateObj;
}
/**
* Validação de valor numérico mínimo
* @param value - Valor a ser validado
* @param min - Valor mínimo
* @returns true se válido, false caso contrário
*/
export function validateMinValue(value: number | string, min: number): boolean {
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (isNaN(numValue)) return false;
return numValue >= min;
}
/**
* Validação de valor numérico máximo
* @param value - Valor a ser validado
* @param max - Valor máximo
* @returns true se válido, false caso contrário
*/
export function validateMaxValue(value: number | string, max: number): boolean {
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (isNaN(numValue)) return false;
return numValue <= max;
}
/**
* Validação de formulário de cliente
* @param formData - Dados do formulário
* @returns objeto com isValid e errors
*/
export function validateCustomerForm(formData: {
name?: string;
document?: string;
cellPhone?: string;
cep?: string;
address?: string;
number?: string;
city?: string;
state?: string;
complement?: string;
}): { isValid: boolean; errors: Record<string, string> } {
const errors: Record<string, string> = {};
if (!validateRequired(formData.name)) {
errors.name = "Nome do cliente é obrigatório";
}
if (!validateRequired(formData.document)) {
errors.document = "CPF/CNPJ é obrigatório";
} else if (!validateCPForCNPJ(formData.document || "")) {
errors.document = "CPF/CNPJ inválido";
}
if (!validateRequired(formData.cellPhone)) {
errors.cellPhone = "Contato é obrigatório";
} else if (!validatePhone(formData.cellPhone || "")) {
errors.cellPhone = "Telefone inválido";
}
if (!validateRequired(formData.cep)) {
errors.cep = "CEP é obrigatório";
} else if (!validateCEP(formData.cep || "")) {
errors.cep = "CEP inválido";
}
if (!validateRequired(formData.address)) {
errors.address = "Endereço é obrigatório";
}
if (!validateRequired(formData.number)) {
errors.number = "Número é obrigatório";
}
if (!validateRequired(formData.city)) {
errors.city = "Cidade é obrigatória";
}
if (!validateRequired(formData.state)) {
errors.state = "Estado é obrigatório";
}
return {
isValid: Object.keys(errors).length === 0,
errors,
};
}
/**
* Validação de formulário de endereço de entrega
* @param formData - Dados do formulário
* @returns objeto com isValid e errors
*/
export function validateAddressForm(formData: {
zipCode?: string;
address?: string;
number?: string;
city?: string;
state?: string;
complement?: string;
referencePoint?: string;
note?: string;
}): { isValid: boolean; errors: Record<string, string> } {
const errors: Record<string, string> = {};
if (!validateRequired(formData.zipCode)) {
errors.zipCode = "CEP é obrigatório";
} else if (!validateCEP(formData.zipCode || "")) {
errors.zipCode = "CEP inválido";
}
if (!validateRequired(formData.address)) {
errors.address = "Endereço é obrigatório";
}
if (!validateRequired(formData.number)) {
errors.number = "Número é obrigatório";
}
if (!validateRequired(formData.city)) {
errors.city = "Cidade é obrigatória";
}
if (!validateRequired(formData.state)) {
errors.state = "Estado é obrigatório";
}
return {
isValid: Object.keys(errors).length === 0,
errors,
};
}
/**
* Validação de formulário de pagamento
* @param formData - Dados do formulário
* @returns objeto com isValid e errors
*/
export function validatePaymentForm(formData: {
invoiceStore?: any;
billing?: any;
paymentPlan?: any;
partner?: any;
}): { isValid: boolean; errors: Record<string, string> } {
const errors: Record<string, string> = {};
if (!formData.billing) {
errors.billing = "Cobrança é obrigatória";
}
if (!formData.paymentPlan) {
errors.paymentPlan = "Plano de pagamento é obrigatório";
}
return {
isValid: Object.keys(errors).length === 0,
errors,
};
}
/**
* Validação completa do pedido antes de criar
* Verifica se cliente, endereço, plano de pagamento e cobrança estão preenchidos
* @returns objeto com isValid, message e description
*/
export function validateOrder(): {
isValid: boolean;
message: string;
description: string;
} {
const customer = localStorage.getItem("customer");
const address = localStorage.getItem("address");
const paymentPlan = localStorage.getItem("paymentPlan");
const billing = localStorage.getItem("billing");
if (!customer) {
return {
isValid: false,
message: "Atenção! Não foi informado um cliente para a venda.",
description:
"Para gerar um pedido de venda é necessário selecionar um cliente.",
};
}
if (!paymentPlan) {
return {
isValid: false,
message: "Atenção! Não foi informado um plano de pagamento para a venda.",
description:
"Para gerar um pedido de venda é necessário selecionar um plano de pagamento para o pedido.",
};
}
if (!billing) {
return {
isValid: false,
message: "Atenção! Não foi informado uma cobrança para a venda.",
description:
"Para gerar um pedido de venda é necessário selecionar uma cobrança.",
};
}
return {
isValid: true,
message: "",
description: "",
};
}
/**
* Mascara CPF/CNPJ mostrando apenas os 4 primeiros dígitos
* Exemplo: 033.379.292-00 -> 033.3xx.xxx-xx
* Exemplo: 12.345.678/0001-90 -> 12.34x.xxx/xxxx-xx
* @param document - CPF ou CNPJ a ser mascarado
* @returns Documento mascarado
*/
export function maskDocument(document: string): string {
if (!document) return "";
// Remove formatação
const cleanDoc = document.replace(/[^\d]/g, "");
if (cleanDoc.length === 11) {
// CPF: 000.000.000-00
// Mostra os 4 primeiros dígitos: 000.0xx.xxx-xx
const first4 = cleanDoc.substring(0, 4);
return `${first4.substring(0, 3)}.${first4.substring(3, 4)}xx.xxx-xx`;
} else if (cleanDoc.length === 14) {
// CNPJ: 00.000.000/0000-00
// Mostra os 4 primeiros dígitos: 00.00x.xxx/xxxx-xx
const first4 = cleanDoc.substring(0, 4);
return `${first4.substring(0, 2)}.${first4.substring(2, 4)}x.xxx/xxxx-xx`;
}
// Se não for CPF nem CNPJ, retorna o documento original
return document;
}

5
metadata.json Normal file
View File

@ -0,0 +1,5 @@
{
"name": "Plataforma SMART - Jurunense",
"description": "A high-fidelity replica of the SMART Platform frontend for retail and order management, featuring dashboards, product search, and order processing flows.",
"requestFramePermissions": []
}

13224
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "plataforma-smart---jurunense",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.6",
"@mui/material": "^7.3.6",
"@mui/x-data-grid-premium": "^8.23.0",
"@mui/x-license-pro": "^6.10.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@tabler/icons-react": "^3.36.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-gauge-component": "^1.2.64",
"react-qr-code": "^2.0.18",
"recharts": "^3.6.0",
"stimulsoft-reports-js": "^2026.1.1",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

35
src/config/env.ts Normal file
View File

@ -0,0 +1,35 @@
/**
* Environment Configuration
* Centraliza todas as variáveis de ambiente da aplicação
*/
export const env = {
// API URLs
API_URL: import.meta.env.VITE_API_URL || 'http://vendaweb.jurunense.com.br/api/v1/',
API_URL_PIX: import.meta.env.VITE_API_URL_PIX || 'http://10.1.1.205:8078/api/v1/',
PRINT_VIEWER_URL: import.meta.env.VITE_PRINT_VIEWER_URL || 'http://10.1.1.205:8068/Viewer/{action}',
GOOGLE_MAPS_API_KEY: import.meta.env.VITE_GOOGLE_MAPS_API_KEY || '',
// Default Domain
DEFAULT_DOMAIN: import.meta.env.VITE_DEFAULT_DOMAIN || '@jurunense.com.br',
// Firebase Configuration
FIREBASE: {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY || '',
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN || '',
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID || '',
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET || '',
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID || '',
appId: import.meta.env.VITE_FIREBASE_APP_ID || '',
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID || '',
},
// App Configuration
APP_NAME: import.meta.env.VITE_APP_NAME || 'SMART PLATFORM',
APP_VERSION: import.meta.env.VITE_APP_VERSION || '1.0.0',
// Environment
IS_PRODUCTION: import.meta.env.PROD,
IS_DEVELOPMENT: import.meta.env.DEV,
} as const;

View File

@ -0,0 +1,197 @@
/**
* Authentication Context
* Contexto React para gerenciar estado de autenticação global
*/
import React, { createContext, useContext, useEffect, useState, useMemo, useCallback, ReactNode } from 'react';
import { authService } from '../services/auth.service';
import { AuthContextType, User } from '../types/auth';
const AuthContext = createContext<AuthContextType | undefined>(undefined);
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Carregar dados do localStorage ao inicializar
useEffect(() => {
const loadAuthData = () => {
const savedToken = authService.getToken();
const savedUser = authService.getUser();
if (savedToken && savedUser) {
setToken(savedToken);
setUser(savedUser);
}
setIsLoading(false);
};
loadAuthData();
}, []);
/**
* Realiza login do usuário
*/
const login = useCallback(async (email: string, password: string): Promise<void> => {
try {
setIsLoading(true);
const response = await authService.login(email, password);
if (response.success && response.data) {
// A resposta vem com token e dados do usuário no mesmo objeto data
const { token: newToken, ...userData } = response.data;
const newUser = userData as User;
console.log('Login response:', { newToken, newUser, fullData: response.data });
// Salvar no localStorage PRIMEIRO
authService.saveToken(newToken);
authService.saveUser(newUser);
// Atualizar estado DEPOIS (garantir que ambos sejam atualizados)
setToken(newToken);
setUser(newUser);
console.log('Estado atualizado:', { token: newToken, user: newUser });
// Limpar carrinho antigo
localStorage.removeItem('cart');
} else {
throw new Error(response.message || 'Erro ao realizar login');
}
} catch (error) {
setIsLoading(false);
throw error;
} finally {
setIsLoading(false);
}
}, []);
/**
* Realiza logout do usuário
* Limpa todos os dados de autenticação do contexto e localStorage
*/
const logout = useCallback((): void => {
console.log("🔐 [AUTH] Iniciando logout...");
// Limpar dados de autenticação do localStorage
authService.clearAuth();
// Limpar estado do contexto (forçar re-render)
setToken(null);
setUser(null);
console.log("🔐 [AUTH] Logout concluído - token e user removidos");
}, []);
/**
* Autentica usuário (para autorizações especiais)
*/
const authenticate = useCallback(async (email: string, password: string) => {
return authService.authenticate(email, password);
}, []);
/**
* Obtém token atual
*/
const getToken = useCallback((): string | null => {
return token || authService.getToken();
}, [token]);
/**
* Obtém usuário atual
*/
const getUser = useCallback((): User | null => {
return user || authService.getUser();
}, [user]);
/**
* Obtém store do usuário
*/
const getStore = useCallback((): string | null => {
return user?.store || authService.getStore();
}, [user]);
/**
* Obtém seller do usuário
*/
const getSeller = useCallback((): string | null => {
return user?.seller || authService.getSeller();
}, [user]);
/**
* Obtém supervisor do usuário
*/
const getSupervisor = useCallback((): number | null => {
return user?.supervisorId || authService.getSupervisor();
}, [user]);
/**
* Obtém delivery time do usuário
*/
const getDeliveryTime = useCallback((): string | null => {
return user?.deliveryTime || authService.getDeliveryTime();
}, [user]);
/**
* Verifica se usuário é gerente
*/
const isManager = useCallback((): boolean => {
return authService.isManager();
}, []);
// Calcular isAuthenticated baseado nos estados atuais
const isAuthenticated = useMemo(() => !!token && !!user, [token, user]);
// Memoizar o objeto value para garantir que as atualizações sejam detectadas
const value: AuthContextType = useMemo(() => ({
user,
token,
isAuthenticated,
isLoading,
login,
logout,
authenticate,
getToken,
getUser,
getStore,
getSeller,
getSupervisor,
getDeliveryTime,
isManager,
}), [
user,
token,
isAuthenticated,
isLoading,
login,
logout,
authenticate,
getToken,
getUser,
getStore,
getSeller,
getSupervisor,
getDeliveryTime,
isManager,
]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
/**
* Hook para usar o contexto de autenticação
*/
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth deve ser usado dentro de um AuthProvider');
}
return context;
};

377
src/hooks/useCart.ts Normal file
View File

@ -0,0 +1,377 @@
import { useState, useEffect, useCallback } from "react";
import { OrderItem, Product } from "../../types";
import { shoppingService, ShoppingItem } from "../services/shopping.service";
import { useAuth } from "../contexts/AuthContext";
interface UseCartReturn {
cart: OrderItem[];
isLoading: boolean;
error: string | null;
cartId: string | null;
addToCart: (product: Product | OrderItem) => Promise<void>;
updateQuantity: (id: string, delta: number) => Promise<void>;
removeFromCart: (id: string) => Promise<void>;
refreshCart: () => Promise<void>;
clearCart: () => void;
}
/**
* Hook customizado para gerenciar o carrinho de compras
* Centraliza toda a lógica de gerenciamento do carrinho
* Garante que os IDs sejam UUIDs (hashes) como no backend
*/
export const useCart = (): UseCartReturn => {
const { isAuthenticated, user } = useAuth();
const [cart, setCart] = useState<OrderItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cartId, setCartId] = useState<string | null>(null);
// Converter ShoppingItem[] para OrderItem[]
const convertShoppingItemsToOrderItems = useCallback(
(items: ShoppingItem[]): OrderItem[] => {
return items.map((item) => ({
// IMPORTANTE: Usar item.id (UUID do backend) como id do OrderItem
// O item.id é o hash/UUID gerado pelo backend (uuid.v4())
id: item.id || item.idProduct.toString(), // Fallback para idProduct se id não existir
code: item.idProduct.toString(),
name: item.description,
description: item.description,
price: item.price,
originalPrice: item.listPrice,
discount: item.discount || item.promotion,
mark: item.brand || "",
image: item.image || "",
stockLocal: item.stockStore ? parseInt(String(item.stockStore)) : 0,
stockGeneral: 0,
quantity: item.quantity,
deliveryType: item.deliveryType,
cost: item.cost,
promotion: item.promotion,
listPrice: item.listPrice,
stockStore: item.stockStore,
smallDescription: item.smallDescription,
auxDescription: item.auxDescription,
brand: item.brand,
environment: item.environment,
}));
},
[]
);
// Carregar itens do carrinho do backend
const loadCartItems = useCallback(
async (idCart: string) => {
try {
setIsLoading(true);
setError(null);
console.log("🛒 [useCart] loadCartItems chamado com cartId:", idCart);
const items = await shoppingService.getShoppingItems(idCart);
console.log("🛒 [useCart] Itens recebidos do backend:", items.length, items);
const orderItems = convertShoppingItemsToOrderItems(items);
setCart(orderItems);
console.log("🛒 [useCart] Itens convertidos e salvos no estado:", orderItems.length);
console.log("🛒 [useCart] Itens no carrinho:", orderItems);
} catch (err: any) {
console.error("🛒 [useCart] Erro ao carregar itens:", err);
setError(err.message || "Erro ao carregar itens do carrinho");
setCart([]);
} finally {
setIsLoading(false);
}
},
[convertShoppingItemsToOrderItems]
);
// Carregar cartId do localStorage ao montar e detectar mudanças
useEffect(() => {
const loadCartId = () => {
const storedCartId = shoppingService.getCart();
if (storedCartId && storedCartId !== cartId) {
console.log(
"🛒 [useCart] CartId detectado no localStorage:",
storedCartId
);
setCartId(storedCartId);
}
};
// Carregar imediatamente
loadCartId();
// Verificar se há itens pendentes no sessionStorage (após editar orçamento)
// Isso garante que os itens sejam carregados imediatamente após a navegação
const pendingCartId = sessionStorage.getItem("pendingCartId");
const pendingCartItems = sessionStorage.getItem("pendingCartItems");
if (pendingCartId && pendingCartItems) {
console.log("🛒 [useCart] Itens pendentes encontrados no sessionStorage:", {
cartId: pendingCartId,
itemsCount: JSON.parse(pendingCartItems).length,
});
try {
const items: ShoppingItem[] = JSON.parse(pendingCartItems);
const orderItems = convertShoppingItemsToOrderItems(items);
setCart(orderItems);
setCartId(pendingCartId);
shoppingService.setCart(pendingCartId);
console.log("🛒 [useCart] Itens pendentes carregados no carrinho:", orderItems.length);
console.log("🛒 [useCart] Itens carregados:", orderItems);
// Limpar sessionStorage após carregar
sessionStorage.removeItem("pendingCartId");
sessionStorage.removeItem("pendingCartItems");
} catch (err) {
console.error("🛒 [useCart] Erro ao carregar itens pendentes:", err);
sessionStorage.removeItem("pendingCartId");
sessionStorage.removeItem("pendingCartItems");
}
}
// Listener para detectar mudanças no localStorage (quando outra aba/componente atualiza)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === "cart" && e.newValue) {
console.log(
"🛒 [useCart] CartId mudou no localStorage (storage event):",
e.newValue
);
setCartId(e.newValue);
}
};
// Listener para evento customizado (quando mudança na mesma aba)
const handleCartUpdated = (e: any) => {
if (e.key === "cart" && e.newValue) {
console.log("🛒 [useCart] CartId mudou (custom event):", e.newValue);
setCartId(e.newValue);
}
};
window.addEventListener("storage", handleStorageChange);
window.addEventListener("cartUpdated", handleCartUpdated);
// Também verificar periodicamente (para mudanças na mesma aba)
const interval = setInterval(() => {
loadCartId();
}, 1000); // Verificar a cada 1 segundo
return () => {
window.removeEventListener("storage", handleStorageChange);
window.removeEventListener("cartUpdated", handleCartUpdated);
clearInterval(interval);
};
}, [cartId, convertShoppingItemsToOrderItems]);
// Carregar carrinho quando autenticado e cartId disponível
useEffect(() => {
console.log("🛒 [useCart] useEffect triggered:", {
isAuthenticated,
hasUser: !!user,
cartId,
cartLength: cart.length,
});
if (isAuthenticated && user && cartId) {
console.log("🛒 [useCart] Carregando carrinho com cartId:", cartId);
loadCartItems(cartId);
} else if (!isAuthenticated) {
// Limpar carrinho quando desautenticado
console.log("🛒 [useCart] Usuário não autenticado, limpando carrinho");
setCart([]);
setCartId(null);
} else if (isAuthenticated && user && !cartId) {
// Tentar carregar do localStorage se não tiver cartId no estado
const storedCartId = shoppingService.getCart();
if (storedCartId) {
console.log("🛒 [useCart] CartId encontrado no localStorage, atualizando estado:", storedCartId);
setCartId(storedCartId);
} else {
console.log("🛒 [useCart] Nenhum cartId encontrado no localStorage");
}
}
}, [isAuthenticated, user, cartId, loadCartItems, cart.length]);
// Adicionar item ao carrinho
const addToCart = useCallback(
async (product: Product | OrderItem) => {
try {
setIsLoading(true);
setError(null);
// Converter Product/OrderItem para ShoppingItem
const shoppingItem = shoppingService.productToShoppingItem(product);
// IMPORTANTE: O backend gera o ID do item (UUID) automaticamente
// O Angular envia id: null quando cria novo item (não remove o campo)
// Garantir que id seja null (não undefined) para corresponder ao Angular
shoppingItem.id = null;
// Criar item no backend
const result = await shoppingService.createItemShopping(shoppingItem);
// Atualizar cartId se retornado pelo backend
if (result.idCart) {
setCartId(result.idCart);
shoppingService.setCart(result.idCart);
}
// Recarregar itens do carrinho para garantir sincronização
if (result.idCart) {
await loadCartItems(result.idCart);
} else {
// Se não retornou idCart, tentar usar o do localStorage
const storedCartId = shoppingService.getCart();
if (storedCartId) {
await loadCartItems(storedCartId);
}
}
} catch (err: any) {
console.error("🛒 [useCart] Erro ao adicionar item:", err);
setError(err.message || "Erro ao adicionar item ao carrinho");
throw err;
} finally {
setIsLoading(false);
}
},
[loadCartItems]
);
// Atualizar quantidade de um item
const updateQuantity = useCallback(
async (id: string, delta: number) => {
try {
setIsLoading(true);
setError(null);
console.log("🛒 [useCart] updateQuantity chamado:", { id, delta, cartLength: cart.length });
// Encontrar o item no carrinho
const item = cart.find((i) => i.id === id);
if (!item) {
console.error("🛒 [useCart] Item não encontrado no carrinho:", { id, availableIds: cart.map(i => i.id) });
throw new Error("Item não encontrado no carrinho");
}
console.log("🛒 [useCart] Item encontrado:", { id: item.id, currentQty: item.quantity, delta });
const newQty = Math.max(1, item.quantity + delta);
console.log("🛒 [useCart] Nova quantidade:", newQty);
// IMPORTANTE: O id deve ser o UUID do item no carrinho (retornado pelo backend)
// Tentar atualizar se o id existir (mesmo que não seja um UUID perfeito)
if (!item.id) {
console.error("🛒 [useCart] Item sem ID para atualização:", item);
throw new Error("Item não possui ID válido para atualização");
}
// IMPORTANTE: Quando atualizando um item do carrinho, precisamos garantir que
// o idProduct seja numérico. O OrderItem tem 'code' que é o idProduct como string.
// Precisamos converter para número ou usar diretamente se já for número.
const itemWithIdProduct = {
...item,
quantity: newQty,
// Garantir que idProduct seja numérico (usar code que é o idProduct como string)
idProduct: typeof item.code === 'string' ? parseInt(item.code, 10) : (item.code as any),
} as OrderItem & { idProduct: number };
console.log("🛒 [useCart] Item com idProduct:", {
id: item.id,
code: item.code,
idProduct: itemWithIdProduct.idProduct
});
const shoppingItem = shoppingService.productToShoppingItem(itemWithIdProduct);
shoppingItem.id = item.id; // Usar o UUID do item do carrinho
console.log("🛒 [useCart] Atualizando item no backend:", shoppingItem);
await shoppingService.updateQuantityItemShopping(shoppingItem);
console.log("🛒 [useCart] Item atualizado com sucesso, recarregando carrinho...");
// Atualizar o estado local imediatamente para feedback visual
setCart((prevCart) =>
prevCart.map((i) =>
i.id === id ? { ...i, quantity: newQty } : i
)
);
// Recarregar carrinho do backend para garantir sincronização
const currentCartId = cartId || shoppingService.getCart();
if (currentCartId) {
// Usar setTimeout para garantir que o estado local seja atualizado primeiro
setTimeout(async () => {
await loadCartItems(currentCartId);
}, 100);
}
} catch (err: any) {
console.error("🛒 [useCart] Erro ao atualizar quantidade:", err);
setError(err.message || "Erro ao atualizar quantidade");
throw err;
} finally {
setIsLoading(false);
}
},
[cart, cartId, loadCartItems]
);
// Remover item do carrinho
const removeFromCart = useCallback(
async (id: string) => {
try {
setIsLoading(true);
setError(null);
// IMPORTANTE: O id deve ser o UUID do item no carrinho
// Verificar se o id é um UUID (tem mais de 10 caracteres e contém hífens)
if (id && id.length > 10 && id.includes("-")) {
await shoppingService.deleteItemShopping(id);
} else {
console.warn(
"🛒 [useCart] Item sem ID válido (UUID) para remoção:",
id
);
throw new Error("Item não possui ID válido para remoção");
}
// Recarregar carrinho após remoção
if (cartId) {
await loadCartItems(cartId);
}
} catch (err: any) {
console.error("🛒 [useCart] Erro ao remover item:", err);
setError(err.message || "Erro ao remover item do carrinho");
throw err;
} finally {
setIsLoading(false);
}
},
[cartId, loadCartItems]
);
// Recarregar carrinho
const refreshCart = useCallback(async () => {
const currentCartId = shoppingService.getCart();
if (currentCartId) {
setCartId(currentCartId);
await loadCartItems(currentCartId);
}
}, [loadCartItems]);
// Limpar carrinho
const clearCart = useCallback(() => {
setCart([]);
setCartId(null);
shoppingService.clearShoppingData();
}, []);
return {
cart,
isLoading,
error,
cartId,
addToCart,
updateQuantity,
removeFromCart,
refreshCart,
clearCart,
};
};

View File

@ -0,0 +1,220 @@
/**
* Authentication Service
* Serviço responsável por todas as operações de autenticação
*/
import { env } from "../config/env";
import { AuthUser, LoginResponse, ResultApi, User } from "../types/auth";
class AuthService {
private readonly API_URL = env.API_URL;
private readonly DEFAULT_DOMAIN = env.DEFAULT_DOMAIN;
/**
* Realiza login do usuário
*/
async login(email: string, password: string): Promise<LoginResponse> {
// Processar email: remover domínio se existir e adicionar em UPPERCASE
let processedEmail = email.trim();
if (
processedEmail.toLowerCase().endsWith(this.DEFAULT_DOMAIN.toLowerCase())
) {
processedEmail = processedEmail.substring(
0,
processedEmail.length - this.DEFAULT_DOMAIN.length
);
}
const emailUpperCase = (processedEmail + this.DEFAULT_DOMAIN).toUpperCase();
const passwordUpperCase = password.toUpperCase();
const response = await fetch(`${this.API_URL}auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: emailUpperCase,
password: passwordUpperCase,
} as AuthUser),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Erro ao realizar login");
}
const data: LoginResponse = await response.json();
return data;
}
/**
* Autentica usuário (para autorizações especiais)
*/
async authenticate(email: string, password: string): Promise<ResultApi> {
let processedEmail = email.trim();
if (
processedEmail.toLowerCase().endsWith(this.DEFAULT_DOMAIN.toLowerCase())
) {
processedEmail = processedEmail.substring(
0,
processedEmail.length - this.DEFAULT_DOMAIN.length
);
}
const emailUpperCase = (processedEmail + this.DEFAULT_DOMAIN).toUpperCase();
const passwordUpperCase = password.toUpperCase();
const response = await fetch(`${this.API_URL}auth/authenticate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: emailUpperCase,
password: passwordUpperCase,
} as AuthUser),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Erro ao autenticar");
}
return await response.json();
}
/**
* Salva token no localStorage
*/
saveToken(token: string): void {
localStorage.setItem("token", token);
}
/**
* Salva usuário no localStorage
*/
saveUser(user: User): void {
localStorage.setItem("user", JSON.stringify(user));
}
/**
* Obtém token do localStorage
*/
getToken(): string | null {
return localStorage.getItem("token");
}
/**
* Obtém usuário do localStorage
*/
getUser(): User | null {
const userStr = localStorage.getItem("user");
if (!userStr) return null;
try {
return JSON.parse(userStr) as User;
} catch {
return null;
}
}
/**
* Remove token e usuário do localStorage
* Limpa apenas dados de autenticação, mantendo outros dados se necessário
*/
clearAuth(): void {
localStorage.removeItem("token");
localStorage.removeItem("user");
console.log("🔐 [AUTH] Dados de autenticação removidos do localStorage");
}
/**
* Verifica se usuário está autenticado
*/
isAuthenticated(): boolean {
return this.getToken() !== null;
}
/**
* Obtém store do usuário
*/
getStore(): string | null {
const user = this.getUser();
return user?.store || null;
}
/**
* Obtém seller do usuário
*/
getSeller(): string | null {
const user = this.getUser();
if (!user?.seller) return null;
return typeof user.seller === "number"
? user.seller.toString()
: user.seller;
}
/**
* Obtém supervisor do usuário
*/
getSupervisor(): number | null {
const user = this.getUser();
return user?.supervisorId || null;
}
/**
* Obtém delivery time do usuário
*/
getDeliveryTime(): string | null {
const user = this.getUser();
return user?.deliveryTime || null;
}
/**
* Verifica se usuário é gerente
* Baseado no payload do JWT (sectorId === sectorManagerId)
*/
isManager(): boolean {
const token = this.getToken();
if (!token) return false;
try {
const payload = this.decodeToken(token);
return (
payload?.sectorId?.toString() === payload?.sectorManagerId?.toString()
);
} catch {
return false;
}
}
/**
* Decodifica token JWT
*/
private decodeToken(token: string): any {
try {
const base64Url = token.split(".")[1];
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const jsonPayload = decodeURIComponent(
atob(base64)
.split("")
.map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
.join("")
);
return JSON.parse(jsonPayload);
} catch {
return null;
}
}
/**
* Obtém headers de autorização para requisições
*/
getAuthHeaders(): HeadersInit {
const token = this.getToken();
return {
"Content-Type": "application/json",
...(token && { Authorization: `Basic ${token}` }),
};
}
}
export const authService = new AuthService();

View File

@ -0,0 +1,340 @@
import { env } from '../config/env';
export interface Customer {
customerId: number;
name: string;
cpfCnpj: string; // Campo da API (não 'document')
document?: string; // Alias para compatibilidade
cellPhone?: string;
phone?: string;
zipCode?: string; // Campo da API (não 'cep')
cep?: string; // Alias para compatibilidade
address?: string;
addressNumber?: string; // Campo da API (não 'number')
number?: string; // Alias para compatibilidade
city?: string;
state?: string;
complement?: string;
neigborhood?: string; // Campo da API (pode ter typo)
neighborhood?: string; // Versão alternativa
placeId?: number;
place?: {
placeId?: number;
id?: number;
name: string;
};
email?: string;
gender?: string;
numberState?: string;
categoryId?: number;
subCategoryId?: number;
ibgeCode?: number;
sellerId?: number;
birthdate?: string;
ramoId?: number;
communicate?: string;
latitude?: number;
longitude?: number;
cityId?: number;
addressType?: string;
}
export interface CustomerAddress {
id?: number;
addressId?: number;
idAddress?: number;
zipCode: string;
address: string;
street?: string; // Alias para address
number: string;
numberAddress?: string; // Alias para number
complement?: string;
neighborhood?: string;
neighbourhood?: string; // Alias alternativo
city: string;
state: string;
referencePoint?: string;
note?: string;
placeId?: number;
cityCode?: number;
ibgeCode?: string;
latitude?: number;
longitude?: number;
addressType?: string;
isPrimary?: boolean;
phone?: number;
cellPhone?: string;
}
class CustomerService {
private baseUrl = env.API_URL;
/**
* Busca clientes por termo de pesquisa (nome)
* @param searchTerm - Termo de busca (nome do cliente)
* @returns Array de clientes encontrados
*/
async searchCustomers(searchTerm: string): Promise<Customer[]> {
try {
// Remove espaços e converte para maiúsculo, como no Angular
const cleanTerm = searchTerm.trim().toUpperCase();
if (!cleanTerm) {
return [];
}
const url = `${this.baseUrl}customer/${encodeURIComponent(cleanTerm)}`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Erro ao buscar clientes');
}
const result = await response.json();
// A API retorna ResultApi com { success, data, message }
// O data pode ser um array ou um objeto
if (result.data) {
return Array.isArray(result.data) ? result.data : [];
}
// Se não tiver estrutura ResultApi, retorna o array diretamente
return Array.isArray(result) ? result : [];
} catch (error) {
console.error('Erro ao buscar clientes:', error);
return [];
}
}
/**
* Busca clientes por query específica (campo e valor)
* @param field - Campo para buscar (name, document, etc)
* @param textSearch - Texto de busca
* @returns Array de clientes encontrados
*/
async searchCustomersByQuery(field: string, textSearch: string): Promise<Customer[]> {
try {
const cleanText = textSearch.trim().toUpperCase();
if (!cleanText) {
return [];
}
const url = `${this.baseUrl}customer?&field=${encodeURIComponent(field)}&textsearch=${encodeURIComponent(cleanText)}`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Erro ao buscar clientes');
}
const result = await response.json();
// A API retorna ResultApi com { success, data, message }
if (result.data) {
return Array.isArray(result.data) ? result.data : [];
}
return Array.isArray(result) ? result : [];
} catch (error) {
console.error('Erro ao buscar clientes:', error);
return [];
}
}
/**
* Busca cliente por CPF/CNPJ
* @param cpfCnpj - CPF ou CNPJ do cliente
* @returns Cliente encontrado ou null
*/
async getCustomerByCpf(cpfCnpj: string): Promise<Customer | null> {
try {
const cleanCpf = cpfCnpj.replace(/[^\d]/g, '');
if (!cleanCpf) {
return null;
}
const url = `${this.baseUrl}customer/cpf/${encodeURIComponent(cleanCpf)}`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Erro ao buscar cliente por CPF/CNPJ');
}
const result = await response.json();
// A API retorna ResultApi com { success, data, message }
if (result.data) {
return result.data;
}
return null;
} catch (error) {
console.error('Erro ao buscar cliente por CPF/CNPJ:', error);
return null;
}
}
/**
* Busca endereços de um cliente
* @param customerId - ID do cliente
*/
async getCustomerAddresses(customerId: number): Promise<CustomerAddress[]> {
try {
if (!customerId || customerId <= 0) {
console.warn("customerService.getCustomerAddresses: customerId inválido:", customerId);
return [];
}
const url = `${this.baseUrl}address/${customerId}`;
console.log("customerService.getCustomerAddresses: Buscando endereços em:", url);
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
const errorText = await response.text();
console.error("customerService.getCustomerAddresses: Erro na resposta:", response.status, errorText);
throw new Error(`Erro ao buscar endereços do cliente: ${response.status}`);
}
const result = await response.json();
console.log("customerService.getCustomerAddresses: Resposta da API (raw):", result);
// A API retorna ResultApi com { success, data, message } - igual ao Angular
// O Angular faz: map((response) => response.data)
let addresses: any[] = [];
if (result && result.data) {
// ResultApi wrapper: { success: true, data: [...], message: ... }
addresses = Array.isArray(result.data) ? result.data : [];
console.log("customerService.getCustomerAddresses: Endereços extraídos de result.data:", addresses);
} else if (Array.isArray(result)) {
// Fallback: resposta direta como array
addresses = result;
console.log("customerService.getCustomerAddresses: Endereços extraídos de array direto:", addresses);
} else {
console.warn("customerService.getCustomerAddresses: Formato de resposta inesperado:", result);
}
console.log("customerService.getCustomerAddresses: Endereços extraídos:", addresses);
// Mapear campos do Angular para o formato React
const mappedAddresses = addresses.map((addr: any) => ({
id: addr.idAddress || addr.id || addr.addressId,
idAddress: addr.idAddress || addr.id || addr.addressId,
addressId: addr.idAddress || addr.id || addr.addressId,
zipCode: addr.zipCode || addr.cepent || '',
address: addr.address || addr.street || addr.enderent || '',
street: addr.street || addr.address || addr.enderent || '',
number: addr.number || addr.numberAddress || addr.numeroent || '',
numberAddress: addr.numberAddress || addr.number || addr.numeroent || '',
complement: addr.complement || addr.complementoent || '',
neighborhood: addr.neighborhood || addr.neighbourhood || addr.bairroent || '',
neighbourhood: addr.neighbourhood || addr.neighborhood || addr.bairroent || '',
city: addr.city || addr.municent || '',
state: addr.state || addr.estent || '',
referencePoint: addr.referencePoint || addr.pontorefer || '',
note: addr.note || addr.observacao || '',
placeId: addr.placeId || 0,
cityCode: addr.cityCode || addr.codmunicipio,
ibgeCode: addr.ibgeCode || addr.codmunicipio || addr.ibge || '',
latitude: addr.latitude || 0,
longitude: addr.longitude || 0,
addressType: addr.addressType || '',
isPrimary: addr.isPrimary || false,
phone: addr.phone || addr.telent,
cellPhone: addr.cellPhone || addr.fonerecebedor,
}));
console.log("customerService.getCustomerAddresses: Endereços mapeados:", mappedAddresses);
return mappedAddresses;
} catch (error) {
console.error('customerService.getCustomerAddresses: Erro ao buscar endereços do cliente:', error);
return [];
}
}
/**
* Cria um novo cliente
* @param customerData - Dados do cliente
*/
async createCustomer(customerData: Partial<Customer>): Promise<Customer | null> {
try {
const url = `${this.baseUrl}customer/create`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify(customerData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Erro ao criar cliente');
}
const result = await response.json();
// A API retorna ResultApi com { success, data, message }
if (result.success && result.data) {
return result.data;
}
return null;
} catch (error: any) {
console.error('Erro ao criar cliente:', error);
throw error; // Re-throw para que o componente possa tratar
}
}
/**
* Cria um novo endereço para um cliente
* @param customerId - ID do cliente
* @param addressData - Dados do endereço
*/
async createAddress(
customerId: number,
addressData: Partial<CustomerAddress>
): Promise<CustomerAddress | null> {
try {
const url = `${this.baseUrl}customers/${customerId}/addresses`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify(addressData),
});
if (!response.ok) {
throw new Error('Erro ao criar endereço');
}
const data = await response.json();
return data;
} catch (error) {
console.error('Erro ao criar endereço:', error);
return null;
}
}
}
export const customerService = new CustomerService();

View File

@ -0,0 +1,192 @@
import { env } from '../config/env';
export interface StoreERP {
id: string;
shortName: string;
name: string;
}
export interface Billing {
codcob: string;
cobranca: string;
description?: string;
}
export interface PaymentPlan {
codplpag: number;
descricao: string;
description?: string;
numdias?: number;
}
export interface PartnerSales {
id: number;
name: string;
}
export interface Place {
id: number;
name: string;
placeId?: number;
}
class LookupService {
private baseUrl = env.API_URL;
/**
* Busca filiais de venda disponíveis para o usuário
*/
async getStores(userId?: string): Promise<StoreERP[]> {
try {
const url = userId
? `${this.baseUrl}lists/store/user/${userId}`
: `${this.baseUrl}lists/store`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Erro ao buscar filiais');
}
const data = await response.json();
return Array.isArray(data) ? data : [];
} catch (error) {
console.error('Erro ao buscar filiais:', error);
return [];
}
}
/**
* Busca planos de pagamento
* @param billingId - ID da cobrança (padrão: '9999')
*/
async getPaymentPlans(billingId: string = '9999'): Promise<PaymentPlan[]> {
try {
const url = `${this.baseUrl}lists/paymentplan/${billingId}`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Erro ao buscar planos de pagamento');
}
const data = await response.json();
return Array.isArray(data) ? data : [];
} catch (error) {
console.error('Erro ao buscar planos de pagamento:', error);
return [];
}
}
/**
* Busca formas de cobrança para um cliente
* @param customerId - ID do cliente
*/
async getBillings(customerId: number): Promise<Billing[]> {
try {
const url = `${this.baseUrl}lists/billing/${customerId}`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Erro ao buscar formas de cobrança');
}
const data = await response.json();
return Array.isArray(data) ? data : [];
} catch (error) {
console.error('Erro ao buscar formas de cobrança:', error);
return [];
}
}
/**
* Busca parceiros Jurunense
*/
async getPartners(): Promise<PartnerSales[]> {
try {
const url = `${this.baseUrl}lists/partners`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Erro ao buscar parceiros');
}
const data = await response.json();
return Array.isArray(data) ? data : [];
} catch (error) {
console.error('Erro ao buscar parceiros:', error);
return [];
}
}
/**
* Busca praças (places)
*/
async getPlaces(): Promise<Place[]> {
try {
const url = `${this.baseUrl}lists/places`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Erro ao buscar praças');
}
const data = await response.json();
return Array.isArray(data) ? data : [];
} catch (error) {
console.error('Erro ao buscar praças:', error);
return [];
}
}
/**
* Busca praças da loja (store places)
*/
async getStorePlaces(): Promise<Place[]> {
try {
const url = `${this.baseUrl}lists/store-places`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Erro ao buscar praças da loja');
}
const data = await response.json();
return Array.isArray(data) ? data : [];
} catch (error) {
console.error('Erro ao buscar praças da loja:', error);
return [];
}
}
}
export const lookupService = new LookupService();

View File

@ -0,0 +1,308 @@
import { env } from "../config/env";
import { OrderItem } from "../../types";
export interface CartItensModel {
idProduct: number;
ean: number;
idStock: string;
deliveryMethod: string;
quantity: number;
listPrice: number;
salePrice: number;
descriptionAux: string | null;
environment?: string | null;
}
export interface CartModel {
id?: string;
userId?: number | string;
saleStore: string;
idCustomer: number;
idPaymentPlan: number;
idBilling: string;
idSeller?: number | string;
idProfessional?: number;
shippingDate?: string | Date | null;
scheduleDelivery: boolean;
shippingPriority: string;
shippingValue: number;
carrierId: number;
idAddress?: number;
idStorePlace?: number | null;
notation1?: string;
notation2?: string;
notation3?: string;
deliveryNote1?: string;
deliveryNote2?: string;
deliveryNote3?: string;
itens: CartItensModel[];
preCustomerDocument?: string | null;
preCustomerName?: string | null;
preCustomerPhone?: string | null;
}
export interface OrderResponse {
orderId?: number;
preOrderId?: number;
success: boolean;
message?: string;
}
export interface DeliveryTaxTable {
id: number;
store: string;
cityId: number;
cityName: string;
carrierId: number;
carrierName: string;
carrier: string;
minSale: number;
deliveryValue: number;
deliveryTime: string;
}
export interface CalculateDeliveryTaxRequest {
cartId: string;
cityId: number;
ibgeCode: number | string; // Pode ser número ou string (será convertido no backend)
priorityDelivery: string;
}
class OrderService {
private baseUrl = env.API_URL;
/**
* Cria um pedido de venda
* @param cart - Dados do carrinho/pedido
*/
async createOrder(cart: CartModel): Promise<OrderResponse> {
try {
const url = `${this.baseUrl}order/create`;
console.log("📦 [ORDER] Criando pedido:", JSON.stringify(cart, null, 2));
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify(cart),
});
const data = await response.json();
// Verificar se o backend retornou success: false mesmo com status 200
if (!response.ok || data.success === false) {
const errorData = data;
console.error("📦 [ORDER] Erro detalhado do backend:", errorData);
// Construir mensagem de erro mais detalhada
let errorMessage =
errorData.message || errorData.error || "Erro ao criar pedido";
// Se houver erros específicos, adicionar aos detalhes
if (errorData.errors) {
const errorDetails = Object.entries(errorData.errors)
.map(([key, value]) => `${key}: ${value}`)
.join(", ");
if (errorDetails) {
errorMessage += ` - ${errorDetails}`;
}
}
// Se houver data com mensagem de erro
if (errorData.data && typeof errorData.data === "string") {
errorMessage = errorData.data;
}
const error = new Error(errorMessage);
(error as any).errorData = errorData; // Adicionar dados completos do erro
throw error;
}
console.log("📦 [ORDER] Pedido criado com sucesso:", data);
// O backend retorna { success: boolean, data: { idOrder: number, status: string }, message: string }
if (data.success && data.data) {
return {
success: true,
orderId: data.data.idOrder || data.data.id,
message: data.message || "Pedido criado com sucesso",
};
}
return {
success: true,
orderId: data.orderId || data.id,
message: data.message || "Pedido criado com sucesso",
};
} catch (error: any) {
console.error("📦 [ORDER] Erro ao criar pedido:", error);
throw error;
}
}
/**
* Cria um orçamento (pre-order)
* @param cart - Dados do carrinho/orçamento
*/
async createPreOrder(cart: CartModel): Promise<OrderResponse> {
try {
const url = `${this.baseUrl}preorder/create`;
console.log(
"📋 [PREORDER] Criando orçamento:",
JSON.stringify(cart, null, 2)
);
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify(cart),
});
const data = await response.json();
// Verificar se o backend retornou success: false mesmo com status 200
if (!response.ok || data.success === false) {
const errorData = data;
console.error("📋 [PREORDER] Erro detalhado do backend:", errorData);
// Construir mensagem de erro mais detalhada
let errorMessage =
errorData.message || errorData.error || "Erro ao criar orçamento";
// Se houver erros específicos, adicionar aos detalhes
if (errorData.errors) {
const errorDetails = Object.entries(errorData.errors)
.map(([key, value]) => `${key}: ${value}`)
.join(", ");
if (errorDetails) {
errorMessage += ` - ${errorDetails}`;
}
}
// Se houver data com mensagem de erro
if (errorData.data && typeof errorData.data === "string") {
errorMessage = errorData.data;
}
const error = new Error(errorMessage);
(error as any).errorData = errorData; // Adicionar dados completos do erro
throw error;
}
console.log("📋 [PREORDER] Orçamento criado com sucesso:", data);
// O backend retorna { success: boolean, data: { numorca: number }, message: string }
if (data.success && data.data) {
return {
success: true,
preOrderId: data.data.numorca || data.data.id,
message: data.message || "Orçamento criado com sucesso",
};
}
return {
success: true,
preOrderId: data.preOrderId || data.numorca || data.id,
message: data.message || "Orçamento criado com sucesso",
};
} catch (error: any) {
console.error("📋 [PREORDER] Erro ao criar orçamento:", error);
throw error;
}
}
/**
* Atualiza preços do carrinho baseado no plano de pagamento
* @param paymentPlanId - ID do plano de pagamento
* @param billingId - ID da cobrança
* @param cartId - ID do carrinho
*/
async updatePricePaymentPlan(
paymentPlanId: number,
billingId: string,
cartId: string
): Promise<boolean> {
try {
const url = `${this.baseUrl}cart/${cartId}/update-payment-plan`;
const response = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify({
paymentPlanId,
billingId,
}),
});
if (!response.ok) {
throw new Error("Erro ao atualizar preços");
}
return true;
} catch (error) {
console.error("Erro ao atualizar preços:", error);
return false;
}
}
/**
* Calcula a taxa de entrega para o pedido
* @param data - Dados para cálculo da taxa de entrega
*/
async calculateDeliveryTax(
data: CalculateDeliveryTaxRequest
): Promise<DeliveryTaxTable[]> {
try {
const url = `${this.baseUrl}sales/calculatedeliverytaxorder`;
// Garantir que ibgeCode seja um número válido
const requestData = {
...data,
ibgeCode:
typeof data.ibgeCode === "string"
? data.ibgeCode.trim() !== ""
? parseInt(data.ibgeCode.trim(), 10)
: data.cityId
: isNaN(Number(data.ibgeCode))
? data.cityId
: Number(data.ibgeCode),
};
console.log(
"🛒 [ORDER] Calculando taxa de entrega com dados:",
requestData
);
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify(requestData),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || "Erro ao calcular taxa de entrega"
);
}
const data_result = await response.json();
return Array.isArray(data_result) ? data_result : data_result.data || [];
} catch (error: any) {
console.error("Erro ao calcular taxa de entrega:", error);
throw error;
}
}
}
export const orderService = new OrderService();

View File

@ -0,0 +1,617 @@
/**
* Product Service
* Serviço para gerenciar operações relacionadas a produtos, departamentos e filtros
*/
import { env } from '../config/env';
import { authService } from './auth.service';
const API_URL = env.API_URL;
export interface Categoria {
codigoSecao: number;
codigoCategoria: number;
descricaoCategoria: string;
tituloECommerce: string;
url: string;
}
export interface Secao {
codigoSecao: number;
codigoDepartamento: number;
descricaoSecao: string;
tituloEcommerce: string;
url: string;
categorias: Categoria[];
}
export interface ClasseMercadologica {
codigoDepartamento: number;
descricaoDepartamento: string;
tituloEcommerce: string;
url: string;
secoes: Secao[];
}
export interface FilterProduct {
brands?: string[];
text?: string;
urlCategory?: string;
outLine?: boolean;
campaign?: boolean;
onlyWithStock?: boolean;
promotion?: boolean;
oportunity?: boolean;
markdown?: boolean;
productPromotion?: boolean;
offers?: boolean;
storeStock?: string;
orderBy?: string;
percentOffMin?: number;
percentOffMax?: number;
}
export interface SaleProduct {
id: number;
brand: string;
category: string;
[key: string]: any;
}
export interface StoreERP {
id: string;
name: string;
shortName: string;
}
class ProductService {
/**
* Obtém o token de autenticação
*/
private getAuthHeaders(): HeadersInit {
const token = authService.getToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
/**
* Obtém os headers com informações da loja
*/
private getStoreHeaders(store?: string): HeadersInit {
const headers = this.getAuthHeaders();
const storeName = store || authService.getStore() || '';
if (!storeName || storeName.trim() === '') {
throw new Error('Loja não informada. É necessário informar a loja para buscar produtos.');
}
headers['x-store'] = storeName;
return headers;
}
/**
* Carrega as classes mercadológicas (departamentos, seções e categorias)
*/
async getClasseMercadologica(): Promise<ClasseMercadologica[]> {
try {
const response = await fetch(`${API_URL}sales/departments`, {
method: 'GET',
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`Erro ao carregar departamentos: ${response.statusText}`);
}
const data: ClasseMercadologica[] = await response.json();
return data;
} catch (error) {
console.error('Erro ao carregar departamentos:', error);
throw error;
}
}
/**
* Busca produtos com filtros
*/
async getProductByFilter(
store: string,
page: number,
size: number,
filterProduct?: FilterProduct
): Promise<SaleProduct[]> {
try {
// Validar se a loja foi fornecida
if (!store || store.trim() === '') {
throw new Error('Loja não informada. É necessário informar a loja para buscar produtos.');
}
const headers = this.getStoreHeaders(store);
headers['x-page'] = page.toString();
headers['x-size-count'] = size.toString();
let response: Response;
const url = `${API_URL}sales/products`;
if (filterProduct) {
response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(filterProduct),
});
} else {
// GET sem body, apenas headers
response = await fetch(url, {
method: 'GET',
headers,
});
}
if (!response.ok) {
let errorMessage = response.statusText;
let errorDetails: any = null;
try {
errorDetails = await response.json();
errorMessage = errorDetails?.message || errorDetails?.error || errorMessage;
} catch {
// Se não conseguir parsear o JSON, usa a mensagem padrão
}
console.error('Erro na API:', {
status: response.status,
statusText: response.statusText,
errorDetails,
});
throw new Error(`Erro ao buscar produtos: ${errorMessage}`);
}
let result: any;
try {
result = await response.json();
} catch (error) {
console.error('Erro ao parsear resposta JSON:', error);
throw new Error('Resposta da API não é um JSON válido');
}
// A API pode retornar diretamente o array ou dentro de um wrapper { data: [...] }
let data: SaleProduct[] = [];
// Verificar se result existe e é válido
if (!result) {
console.warn('Resposta da API é nula ou indefinida');
return [];
}
if (Array.isArray(result)) {
data = result;
} else if (result && typeof result === 'object' && result.data !== undefined) {
// Verificar se result.data é um array antes de atribuir
if (Array.isArray(result.data)) {
data = result.data;
} else {
console.warn('result.data não é um array:', result.data);
return [];
}
} else if (result && typeof result === 'object') {
// Se for um objeto mas não tiver data, pode ser um erro ou formato inesperado
console.warn('Resposta da API em formato inesperado:', result);
return [];
} else {
console.warn('Resposta da API não é um array ou objeto válido:', result);
return [];
}
return data;
} catch (error) {
console.error('Erro ao buscar produtos:', error);
throw error;
}
}
/**
* Busca produtos por departamento/categoria
* Segue o padrão do Angular: getProductByDepartment(store, page, size, urlDepartment)
* Endpoint: GET /sales/product/category/{urlDepartment}
*/
async getProductByDepartment(
store: string,
page: number,
size: number,
urlDepartment: string
): Promise<SaleProduct[]> {
try {
// Validar se a loja foi fornecida
if (!store || store.trim() === '') {
throw new Error('Loja não informada. É necessário informar a loja para buscar produtos.');
}
const headers = this.getStoreHeaders(store);
headers['x-page'] = page.toString();
headers['x-size-count'] = size.toString();
// No Angular, sempre usa 'category/' mesmo que seja department
// Se urlDepartment contém '/', já está no formato correto
// Caso contrário, usa 'category/' + urlDepartment
let url = '';
if (urlDepartment.includes('/')) {
url = `category/${urlDepartment}`;
} else {
url = `category/${urlDepartment}`;
}
const response = await fetch(`${API_URL}sales/product/${url}`, {
method: 'GET',
headers,
});
if (!response.ok) {
let errorMessage = response.statusText;
let errorDetails: any = null;
try {
errorDetails = await response.json();
errorMessage = errorDetails?.message || errorDetails?.error || errorMessage;
} catch {
// Se não conseguir parsear o JSON, usa a mensagem padrão
}
console.error('Erro na API:', {
status: response.status,
statusText: response.statusText,
errorDetails,
});
throw new Error(`Erro ao buscar produtos por departamento: ${errorMessage}`);
}
let result: any;
try {
result = await response.json();
} catch (error) {
console.error('Erro ao parsear resposta JSON:', error);
throw new Error('Resposta da API não é um JSON válido');
}
// A API pode retornar diretamente o array ou dentro de um wrapper
let data: SaleProduct[] = [];
if (!result) {
console.warn('Resposta da API é nula ou indefinida');
return [];
}
if (Array.isArray(result)) {
data = result;
} else if (result && typeof result === 'object' && result.data !== undefined) {
if (Array.isArray(result.data)) {
data = result.data;
} else {
console.warn('result.data não é um array:', result.data);
return [];
}
} else if (result && typeof result === 'object') {
console.warn('Resposta da API em formato inesperado:', result);
return [];
} else {
console.warn('Resposta da API não é um array ou objeto válido:', result);
return [];
}
return data;
} catch (error) {
console.error('Erro ao buscar produtos por departamento:', error);
throw error;
}
}
/**
* Busca produtos por termo de pesquisa
*/
async searchProduct(
store: string,
page: number,
size: number,
search: string
): Promise<SaleProduct[]> {
try {
const headers = this.getStoreHeaders(store);
headers['x-page'] = page.toString();
headers['x-size-count'] = size.toString();
const response = await fetch(`${API_URL}sales/products/${encodeURIComponent(search)}`, {
method: 'GET',
headers,
});
if (!response.ok) {
let errorMessage = response.statusText;
let errorDetails: any = null;
try {
errorDetails = await response.json();
errorMessage = errorDetails?.message || errorDetails?.error || errorMessage;
} catch {
// Se não conseguir parsear o JSON, usa a mensagem padrão
}
console.error('Erro na API:', {
status: response.status,
statusText: response.statusText,
errorDetails,
});
throw new Error(`Erro ao buscar produtos: ${errorMessage}`);
}
let result: any;
try {
result = await response.json();
} catch (error) {
console.error('Erro ao parsear resposta JSON:', error);
throw new Error('Resposta da API não é um JSON válido');
}
// A API pode retornar diretamente o array ou dentro de um wrapper
let data: SaleProduct[] = [];
if (!result) {
console.warn('Resposta da API é nula ou indefinida');
return [];
}
if (Array.isArray(result)) {
data = result;
} else if (result && typeof result === 'object' && result.data !== undefined) {
if (Array.isArray(result.data)) {
data = result.data;
} else {
console.warn('result.data não é um array:', result.data);
return [];
}
} else if (result && typeof result === 'object') {
console.warn('Resposta da API em formato inesperado:', result);
return [];
} else {
console.warn('Resposta da API não é um array ou objeto válido:', result);
return [];
}
return data;
} catch (error) {
console.error('Erro ao buscar produtos:', error);
throw error;
}
}
/**
* Extrai marcas únicas de uma lista de produtos
*/
extractBrandsFromProducts(products: SaleProduct[]): string[] {
const brandsSet = new Set<string>();
products.forEach((product) => {
if (product.brand) {
const brand = product.brand.replace('#', '').trim();
if (brand) {
brandsSet.add(brand.toUpperCase());
}
}
});
return Array.from(brandsSet).sort();
}
/**
* Carrega as filiais disponíveis para o usuário
* Segue o mesmo padrão do Angular: lookupService.getStore()
*/
async getStores(): Promise<StoreERP[]> {
try {
const user = authService.getUser();
if (!user || !user.id) {
throw new Error('Usuário não encontrado. É necessário estar autenticado.');
}
// No Angular: lookupService.getStore() chama lists/store/user/${this.authService.getUser()}
// onde getUser() retorna apenas o ID do usuário
const userId = user.id;
const response = await fetch(`${API_URL}lists/store/user/${userId}`, {
method: 'GET',
headers: this.getAuthHeaders(),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`Erro ao carregar filiais: ${errorData.message || response.statusText}`);
}
// A API pode retornar diretamente o array ou dentro de um wrapper
const result = await response.json();
// Se retornar dentro de um wrapper { data: [...] }, extrair o data
// Caso contrário, retornar diretamente
const data: StoreERP[] = Array.isArray(result) ? result : (result.data || result);
if (!Array.isArray(data)) {
throw new Error('Formato de resposta inválido da API');
}
return data;
} catch (error) {
console.error('Erro ao carregar filiais:', error);
throw error;
}
}
/**
* Obtém detalhes completos de um produto de venda
* Endpoint: GET /sales/product/:id
*/
async getProductDetail(store: string, id: number): Promise<SaleProduct> {
try {
if (!store || store.trim() === "") {
throw new Error("Loja não informada. É necessário informar a loja.");
}
const headers = this.getStoreHeaders(store);
const response = await fetch(`${API_URL}sales/product/${id}`, {
method: "GET",
headers,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`Erro ao buscar detalhes do produto: ${errorData.message || response.statusText}`);
}
const data: SaleProduct = await response.json();
return data;
} catch (error) {
console.error("Erro ao buscar detalhes do produto:", error);
throw error;
}
}
/**
* Obtém produtos que compram junto
* Endpoint: GET /sales/product/bytogether/:id
*/
async getProductsBuyTogether(store: string, id: number): Promise<SaleProduct[]> {
try {
if (!store || store.trim() === "") {
throw new Error("Loja não informada. É necessário informar a loja.");
}
const headers = this.getStoreHeaders(store);
const response = await fetch(`${API_URL}sales/product/bytogether/${id}`, {
method: "GET",
headers,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`Erro ao buscar produtos compre junto: ${errorData.message || response.statusText}`);
}
const result = await response.json();
const data: SaleProduct[] = Array.isArray(result) ? result : (result.data || []);
return data;
} catch (error) {
console.error("Erro ao buscar produtos compre junto:", error);
throw error;
}
}
/**
* Obtém produtos similares
* Endpoint: GET /sales/product/simil/:id
*/
async getProductsSimilar(store: string, id: number): Promise<SaleProduct[]> {
try {
if (!store || store.trim() === "") {
throw new Error("Loja não informada. É necessário informar a loja.");
}
const headers = this.getStoreHeaders(store);
const response = await fetch(`${API_URL}sales/product/simil/${id}`, {
method: "GET",
headers,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`Erro ao buscar produtos similares: ${errorData.message || response.statusText}`);
}
const result = await response.json();
const data: SaleProduct[] = Array.isArray(result) ? result : (result.data || []);
return data;
} catch (error) {
console.error("Erro ao buscar produtos similares:", error);
throw error;
}
}
/**
* Obtém estoques de um produto em todas as filiais
* Endpoint: GET /sales/stock/:storeId/:id
*/
async getProductStocks(store: string, id: number): Promise<Array<{
store: string;
storeName: string;
quantity: number;
work: boolean;
blocked: string;
breakdown: number;
transfer: number;
allowDelivery: number;
}>> {
try {
if (!store || store.trim() === "") {
throw new Error("Loja não informada. É necessário informar a loja.");
}
const headers = this.getAuthHeaders();
const response = await fetch(`${API_URL}sales/stock/${store}/${id}`, {
method: "GET",
headers,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`Erro ao buscar estoques: ${errorData.message || response.statusText}`);
}
const result = await response.json();
const data = Array.isArray(result) ? result : (result.data || []);
return data;
} catch (error) {
console.error("Erro ao buscar estoques:", error);
throw error;
}
}
/**
* Obtém opções de parcelamento de um produto
* Endpoint: GET /sales/installment/:id?quantity=:quantity
*/
async getProductInstallments(
store: string,
id: number,
quantity: number = 1
): Promise<Array<{
installment: number;
installmentValue: number;
}>> {
try {
if (!store || store.trim() === "") {
throw new Error("Loja não informada. É necessário informar a loja.");
}
const headers = this.getStoreHeaders(store);
const url = new URL(`${API_URL}sales/installment/${id}`);
url.searchParams.append("quantity", quantity.toString());
const response = await fetch(url.toString(), {
method: "GET",
headers,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`Erro ao buscar parcelamento: ${errorData.message || response.statusText}`);
}
const result = await response.json();
const data = Array.isArray(result) ? result : (result.data || []);
return data;
} catch (error) {
console.error("Erro ao buscar parcelamento:", error);
throw error;
}
}
}
export const productService = new ProductService();

View File

@ -0,0 +1,70 @@
/**
* Shipping Service
* Gerencia a comunicação com a API de logística e entrega
*/
import { env } from '../config/env';
const API_URL = env.API_URL;
export interface DeliveryScheduleItem {
dateDelivery: string; // ISO date string
delivery: 'S' | 'N';
deliverySize: number; // Capacidade em toneladas
saleWeigth: number; // Vendas em toneladas
avaliableDelivery: number; // Capacidade disponível em toneladas
}
export interface DeliveryScheduleResponse {
deliveries: DeliveryScheduleItem[];
}
class ShippingService {
/**
* Obtém o agendamento de entrega (baldinho)
* Retorna informações sobre capacidade operacional por dia
* @returns Promise com os dados de agendamento de entrega
*/
async getScheduleDelivery(): Promise<DeliveryScheduleResponse> {
try {
const response = await fetch(`${API_URL}shipping/schedule`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
let errorMessage = response.statusText;
try {
const errorData = await response.json();
errorMessage = errorData?.message || errorData?.error || errorMessage;
} catch {
// Se não conseguir parsear o JSON, usa a mensagem padrão
}
throw new Error(`Erro ao buscar agendamento de entrega: ${errorMessage}`);
}
const result = await response.json();
// A API pode retornar diretamente o objeto ou dentro de um wrapper
if (result && typeof result === 'object' && Array.isArray(result.deliveries)) {
return result;
} else if (Array.isArray(result)) {
// Se retornar diretamente um array, envolver em objeto
return { deliveries: result };
} else {
console.warn('Resposta da API em formato inesperado:', result);
return { deliveries: [] };
}
} catch (error) {
console.error('Erro ao buscar agendamento de entrega:', error);
throw error;
}
}
}
export const shippingService = new ShippingService();

View File

@ -0,0 +1,661 @@
import { env } from "../config/env";
import { OrderItem, Product } from "../../types";
import { authService } from "./auth.service";
export interface ShoppingItem {
id?: string | null;
invoiceStore?: string | null;
idCart?: string | null; // Pode ser null quando não há carrinho
idProduct: number;
description: string;
image?: string;
productType?: string;
quantity: number;
cost?: number;
price: number;
deliveryType?: string;
stockStore?: string | number;
seller?: number;
listPrice?: number;
discount?: number;
discountValue?: number;
userDiscount?: number;
promotion?: number;
mutiple?: number;
auxDescription?: string;
smallDescription?: string;
brand?: string;
ean?: number;
percentUpQuantity?: number;
upQuantity?: number;
base?: string;
letter?: string;
line?: string;
color?: string;
can?: number;
numSeq?: number;
environment?: string;
productTogether?: string;
}
class ShoppingService {
private baseUrl = env.API_URL;
/**
* Obtém o ID do carrinho do localStorage
* EXATAMENTE como no Angular: retorna null se não existir
*/
getCart(): string | null {
return localStorage.getItem("cart");
}
/**
* Salva o ID do carrinho no localStorage
* EXATAMENTE como no Angular
*/
setCart(id: string): void {
console.log("🛒 [SHOPPING] Atualizando id cart:", id);
localStorage.setItem("cart", id);
}
/**
* Cria um item no carrinho (POST /shopping/item)
* EXATAMENTE como no Angular:
* 1. Envia o item com idCart (pode ser null)
* 2. Backend cria o carrinho se idCart for null
* 3. Backend retorna o idCart na resposta
* 4. Salva o idCart retornado no localStorage
*/
async createItemShopping(item: ShoppingItem): Promise<ShoppingItem> {
try {
// Validação de produto tintométrico (igual ao Angular)
const baseValue = (item?.base ?? "").toString().trim().toUpperCase();
const colorValue = (item?.auxDescription ?? "")
.toString()
.trim()
.toUpperCase();
if (baseValue === "S" && colorValue === "") {
throw new Error(
"Esse produto só pode ser adicionado com coloração selecionada"
);
}
// EXATAMENTE como no Angular: obter idCart do localStorage
// IMPORTANTE: Sempre recuperar do localStorage antes de enviar
// Se o item não tiver idCart OU se for null/undefined, usar o do localStorage
const cartIdFromStorage = this.getCart();
console.log("🛒 [SHOPPING] cartIdFromStorage:", cartIdFromStorage);
console.log("🛒 [SHOPPING] item.idCart antes:", item.idCart);
// IMPORTANTE: O Angular envia null quando não há cart no localStorage
// O backend na linha 136 verifica: if (itemShopping.idCart == null || itemShopping.idCart == '')
// Quando idCart é null, o backend cria um novo UUID na linha 137
// Mas nas linhas 151, 156 e 160 usa itemShopping.idCart diretamente nas queries SQL
// O problema é que quando enviamos string vazia "", a query SQL falha
// A solução é enviar null (como o Angular faz) quando não há cart
// Se o item não tem idCart definido OU se é null/undefined/string vazia, usar o do localStorage
if (
!item.idCart ||
item.idCart === null ||
item.idCart === undefined ||
item.idCart === ""
) {
item.idCart = cartIdFromStorage; // Pode ser null se não houver cart no localStorage
}
// EXATAMENTE como no Angular: o campo idCart SEMPRE deve estar presente
// O Angular na linha 176 sempre envia: idCart: this.shoppingService.getCart()
// Mesmo que getCart() retorne null, o campo idCart é enviado com valor null
//
// IMPORTANTE: NÃO remover o campo idCart, mesmo que seja null
// O backend na linha 136 verifica: if (itemShopping.idCart == null || itemShopping.idCart == '')
// Se o campo não existir (undefined), pode causar problemas
const cleanItem: any = { ...item };
// IMPORTANTE: Garantir que id e idCart sempre estejam presentes no objeto
// O Angular sempre envia id: null e idCart: null quando cria novo item
// A requisição que funcionou (201 Created) tinha "idCart": null, não string vazia
// Portanto, devemos enviar null (não string vazia) para corresponder ao comportamento funcional
if (!("id" in cleanItem) || cleanItem.id === undefined) {
cleanItem.id = null;
}
if (
!("idCart" in cleanItem) ||
cleanItem.idCart === undefined ||
cleanItem.idCart === ""
) {
// IMPORTANTE: Enviar null (não string vazia) para corresponder ao exemplo funcional
// A requisição que retornou 201 Created tinha "idCart": null
cleanItem.idCart = null;
}
console.log("🛒 [SHOPPING] cleanItem.id após tratamento:", cleanItem.id);
console.log(
"🛒 [SHOPPING] cleanItem.idCart após tratamento:",
cleanItem.idCart
);
console.log("🛒 [SHOPPING] cleanItem tem id?", "id" in cleanItem);
console.log("🛒 [SHOPPING] cleanItem tem idCart?", "idCart" in cleanItem);
console.log(
"🛒 [SHOPPING] cleanItem completo:",
JSON.stringify(cleanItem, null, 2)
);
// Log igual ao Angular (exatamente como no código Angular linha 151)
console.log(
"🛒 [SHOPPING] createItemShopping: " + JSON.stringify(cleanItem)
);
const url = `${this.baseUrl}shopping/item`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify(cleanItem),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage =
errorData.message ||
errorData.error ||
"Erro ao criar item no carrinho de compras";
console.error("🛒 [SHOPPING] Erro detalhado da API:", errorData);
console.error("🛒 [SHOPPING] Status HTTP:", response.status);
console.error("🛒 [SHOPPING] Status Text:", response.statusText);
console.error(
"🛒 [SHOPPING] Item enviado:",
JSON.stringify(cleanItem, null, 2)
);
console.error("🛒 [SHOPPING] URL da requisição:", url);
console.error("🛒 [SHOPPING] Headers:", {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage
.getItem("token")
?.substring(0, 20)}...`,
});
throw new Error(errorMessage);
}
const result: any = await response.json();
console.log("🛒 [SHOPPING] Item criado com sucesso:", result);
console.log("🛒 [SHOPPING] idCart retornado:", result.idCart);
// EXATAMENTE como no Angular: salvar o idCart retornado
// IMPORTANTE: O backend sempre retorna o idCart na resposta (criado ou existente)
// Deve salvar mesmo que seja null (mas não deveria ser null após criação)
if (result.idCart && result.idCart !== null && result.idCart !== "") {
this.setCart(result.idCart);
console.log(
"🛒 [SHOPPING] CartId salvo no localStorage:",
result.idCart
);
} else {
console.warn(
"🛒 [SHOPPING] ATENÇÃO: idCart não retornado na resposta:",
result
);
}
// Remover paymentPlan e billing do localStorage (igual ao Angular)
localStorage.removeItem("paymentPlan");
localStorage.removeItem("billing");
return result as ShoppingItem;
} catch (error: any) {
console.error("🛒 [SHOPPING] Erro ao criar item no carrinho:", error);
throw error;
}
}
/**
* Atualiza a quantidade de um item no carrinho (PUT /shopping/item)
*/
async updateQuantityItemShopping(item: ShoppingItem): Promise<void> {
try {
const url = `${this.baseUrl}shopping/item`;
console.log(
"🛒 [SHOPPING] Atualizando quantidade do item:",
JSON.stringify(item)
);
const response = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify(item),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || "Erro ao atualizar quantidade do item"
);
}
// Verificar se a resposta tem conteúdo antes de tentar fazer JSON.parse
const contentType = response.headers.get("content-type");
const hasJsonContent =
contentType && contentType.includes("application/json");
// Verificar se há conteúdo no body
const text = await response.text();
let result: ShoppingItem | null = null;
if (text && text.trim().length > 0 && hasJsonContent) {
try {
result = JSON.parse(text);
console.log("🛒 [SHOPPING] Quantidade atualizada:", result);
// Atualizar idCart se retornado
if (result && result.idCart) {
this.setCart(result.idCart);
}
} catch (parseError) {
console.warn(
"🛒 [SHOPPING] Resposta não é JSON válido, mas atualização foi bem-sucedida"
);
}
} else {
console.log(
"🛒 [SHOPPING] Quantidade atualizada (resposta vazia - sucesso)"
);
}
} catch (error: any) {
console.error("🛒 [SHOPPING] Erro ao atualizar quantidade:", error);
throw error;
}
}
/**
* Atualiza o preço/desconto de um item no carrinho (PUT /shopping/item/discount)
*/
async updatePriceItemShopping(item: ShoppingItem): Promise<ShoppingItem> {
try {
const url = `${this.baseUrl}shopping/item/discount`;
console.log(
"🛒 [SHOPPING] Atualizando preço/desconto do item:",
JSON.stringify(item)
);
const response = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify(item),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || "Erro ao atualizar preço/desconto do item"
);
}
const result: ShoppingItem = await response.json();
console.log("🛒 [SHOPPING] Preço/desconto atualizado:", result);
// Atualizar idCart se retornado
if (result.idCart) {
this.setCart(result.idCart);
}
return result;
} catch (error: any) {
console.error("🛒 [SHOPPING] Erro ao atualizar preço/desconto:", error);
throw error;
}
}
/**
* Remove um item do carrinho (DELETE /shopping/item/delete/{id})
*/
async deleteItemShopping(id: string): Promise<void> {
try {
const url = `${this.baseUrl}shopping/item/delete/${id}`;
console.log("🛒 [SHOPPING] Removendo item do carrinho:", id);
const response = await fetch(url, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || "Erro ao remover item do carrinho"
);
}
console.log("🛒 [SHOPPING] Item removido com sucesso");
} catch (error: any) {
console.error("🛒 [SHOPPING] Erro ao remover item:", error);
throw error;
}
}
/**
* Obtém todos os itens do carrinho (GET /shopping/cart/{idCart})
*/
async getShoppingItems(idCart: string): Promise<ShoppingItem[]> {
if (!idCart) {
return [];
}
try {
const url = `${this.baseUrl}shopping/cart/${idCart}`;
console.log("🛒 [SHOPPING] Buscando itens do carrinho:", idCart);
const response = await fetch(url, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || "Erro ao buscar itens do carrinho"
);
}
const items: ShoppingItem[] = await response.json();
console.log("🛒 [SHOPPING] Itens do carrinho:", items);
return items;
} catch (error: any) {
console.error("🛒 [SHOPPING] Erro ao buscar itens:", error);
return [];
}
}
/**
* Converte um Product/OrderItem para ShoppingItem
* EXATAMENTE como no Angular (product-detail.component.ts linha 173-203)
*/
productToShoppingItem(
product: Product | OrderItem,
invoiceStore?: string,
seller?: number
): ShoppingItem {
const user = authService.getUser();
const cartId = this.getCart(); // Pode ser null
const store = invoiceStore || authService.getStore() || "";
// Obter seller corretamente (deve ser número, como no Angular)
let sellerId: number | null = null;
if (seller) {
sellerId = seller;
} else {
const sellerValue = authService.getSeller();
if (sellerValue) {
sellerId =
typeof sellerValue === "string"
? parseInt(sellerValue, 10)
: sellerValue;
} else if (user?.seller) {
sellerId =
typeof user.seller === "string"
? parseInt(user.seller, 10)
: user.seller;
}
}
// Criar objeto EXATAMENTE como no Angular (product-detail.component.ts linha 173-203)
// NÃO incluir campo 'cost' - Angular não envia
// Obter idProduct corretamente (pode vir de product.id, product.code, ou product.idProduct)
// No Angular usa: this.saleProduct.idProduct
let idProductValue: number;
if ("idProduct" in product && product.idProduct) {
idProductValue =
typeof product.idProduct === "number"
? product.idProduct
: parseInt(String(product.idProduct), 10);
} else {
// IMPORTANTE: Quando recebemos um OrderItem do carrinho, o 'id' é o UUID do item,
// não o idProduct. Precisamos usar 'code' que contém o idProduct como string.
// Verificar se 'id' parece ser um UUID (contém hífens) - nesse caso, usar 'code' em vez de 'id'
const productId = String(product.id || "");
const isUuid = productId.includes("-") && productId.length > 10;
// Se o id é um UUID, priorizar 'code' que contém o idProduct
const idStr = isUuid
? String(product.code || (product as any).idProduct || "")
: String(
product.code || product.id || (product as any).idProduct || ""
);
idProductValue = parseInt(idStr, 10);
// Se ainda não conseguir, verificar se há um campo 'idProduct' direto
if (isNaN(idProductValue) && (product as any).idProduct) {
idProductValue =
typeof (product as any).idProduct === "number"
? (product as any).idProduct
: parseInt(String((product as any).idProduct), 10);
}
}
// Validar se idProduct é um número válido
if (isNaN(idProductValue) || idProductValue <= 0) {
console.error("🛒 [SHOPPING] Erro ao extrair idProduct:", {
product,
id: product.id,
code: product.code,
hasIdProduct: "idProduct" in product,
});
throw new Error(
`ID do produto inválido: ${
product.code || product.id || "desconhecido"
}`
);
}
// Obter description (no Angular usa this.saleProduct.title)
const description = product.name || product.description || null;
// Obter image (no Angular usa this.images[0], que é a primeira imagem do array)
// Se product.image é uma string com múltiplas URLs separadas, pegar a primeira
// IMPORTANTE: Angular envia string vazia "" quando não há imagem, não null
// No exemplo do Angular funcionando, image é "" (string vazia)
let imageValue: string = "";
if (product.image && product.image.trim() !== "") {
// Se contém separadores (vírgula, ponto e vírgula, pipe), pegar a primeira
const imageUrls = product.image
.split(/[,;|]/)
.map((url) => url.trim())
.filter((url) => url.length > 0);
imageValue = imageUrls.length > 0 ? imageUrls[0] : product.image;
}
// Se não há imagem, manter como string vazia "" (como no Angular)
// Obter price (no Angular usa this.saleProduct.salePrice)
const priceValue = product.price || 0;
// Obter listPrice (no Angular usa this.saleProduct.listPrice)
const listPriceValue = product.originalPrice || product.price || 0;
// Obter discount (no Angular linha 184 sempre envia 0 inicialmente)
// IMPORTANTE: O Angular sempre envia discount: 0 ao criar novo item
// O desconto é aplicado depois via updatePriceItemShopping
const discountValue = 0;
// Obter promotion (no Angular linha 186: this.saleProduct.offPercent > 0 ? this.saleProduct.promotion : 0)
// IMPORTANTE: No Angular, promotion é o valor promocional do produto quando há desconto (offPercent > 0), senão é 0
// Se o produto tem promotion (preço promocional), usar esse valor
// Se não tem promotion mas tem discount > 0, usar o price (que é o preço promocional)
// Caso contrário, usar 0
let promotionValue = 0;
if ("promotion" in product && product.promotion && product.promotion > 0) {
promotionValue = product.promotion as number;
} else if (
product.discount &&
product.discount > 0 &&
priceValue > 0 &&
listPriceValue > 0 &&
priceValue < listPriceValue
) {
// Se há desconto (price < listPrice), usar o price como promotion (preço promocional)
// Isso corresponde ao exemplo funcional onde promotion é o preço promocional
promotionValue = priceValue;
}
// Obter smallDescription (no Angular usa this.saleProduct.smallDescription)
// IMPORTANTE: No payload funcional, smallDescription é curto (ex: "#ARG PORC INT 20KG CZ 3EM1 PORTOKOLL")
// NÃO usar product.description como fallback, pois pode ser a descrição longa
const smallDescriptionValue =
"smallDescription" in product && product.smallDescription
? product.smallDescription
: null;
// Obter auxDescription (no Angular usa this.form.value[auxDescription])
// No exemplo do Angular, quando não há auxDescription, envia null
// NÃO usar model como auxDescription - deve ser null se não houver valor específico
const auxDescriptionValue =
"auxDescription" in product && product.auxDescription
? String(product.auxDescription)
: null;
// Obter brand (no Angular usa this.saleProduct.brand)
const brandValue = (product.mark as string) || null;
// Obter ean (no Angular usa this.saleProduct.ean)
// IMPORTANTE: No exemplo do Angular, quando não há EAN, usa o idProduct como EAN
// Se product.ean existe e é válido, usar; caso contrário, usar idProduct
let eanValue: number;
if (product.ean) {
const parsedEan = parseInt(String(product.ean), 10);
eanValue = isNaN(parsedEan) ? idProductValue : parsedEan;
} else {
// Quando não há EAN, usar idProduct (como no exemplo do Angular)
eanValue = idProductValue;
}
// Criar objeto EXATAMENTE na mesma ordem do exemplo funcional do Angular
// Ordem baseada no payload funcional fornecido pelo usuário
const shoppingItem: any = {
id: null,
idCart: cartId, // Pode ser null - será recuperado novamente em createItemShopping
invoiceStore: store || null,
idProduct: idProductValue,
description: description,
image: imageValue, // String vazia "" quando não há imagem (não null)
productType:
"productType" in product ? (product.productType as string) : null,
percentUpQuantity: 0,
upQuantity: 0,
quantity: "quantity" in product ? (product.quantity as number) : 1,
price: priceValue,
deliveryType:
"deliveryType" in product && product.deliveryType
? (product.deliveryType as string)
: "EN",
stockStore:
"stockStore" in product && product.stockStore
? String(product.stockStore)
: product.stockLocal?.toString() || null,
seller: sellerId || null,
discount: discountValue,
// discountValue: No Angular linha 185 sempre envia 0 inicialmente
// O backend calcula o discountValue depois
discountValue: 0,
ean: eanValue, // Sempre um número (idProduct se não houver EAN)
promotion: promotionValue,
// listPrice: No Angular linha 692 usa 'price' (preço editado), não listPrice original
// Mas isso parece estar errado. Vamos usar listPriceValue (preço de tabela)
listPrice: listPriceValue,
userDiscount: null, // Sempre null no Angular
mutiple: "mutiple" in product ? (product.mutiple as number) : null,
auxDescription: auxDescriptionValue, // null quando não há valor específico
smallDescription: smallDescriptionValue,
brand: brandValue,
base: "base" in product ? (product.base as string) : null,
line: "line" in product ? (product.line as string) : null,
can: "can" in product ? (product.can as number) : null,
letter: "letter" in product ? (product.letter as string) : null,
// color: O Angular envia color, mas no exemplo funcional não aparece quando é null
// Vamos incluir apenas se tiver valor, caso contrário não incluir o campo
// Isso corresponde ao exemplo funcional que não tem color quando é null
...("color" in product && product.color
? { color: product.color as string }
: {}),
};
// Garantir que campos opcionais sejam null (não undefined) para corresponder ao Angular
// O Angular sempre envia esses campos, mesmo quando são null
if (shoppingItem.letter === undefined) shoppingItem.letter = null;
if (shoppingItem.line === undefined) shoppingItem.line = null;
if (shoppingItem.can === undefined) shoppingItem.can = null;
// IMPORTANTE: Garantir que id e idCart sempre estejam presentes, mesmo que sejam null
// O Angular sempre envia id: null e idCart: null quando cria novo item
if (!("id" in shoppingItem) || shoppingItem.id === undefined) {
shoppingItem.id = null;
}
if (!("idCart" in shoppingItem) || shoppingItem.idCart === undefined) {
shoppingItem.idCart = null;
}
// Remover apenas campos undefined (Angular não envia undefined, mas envia null)
// IMPORTANTE: NÃO remover id e idCart, mesmo que sejam null
Object.keys(shoppingItem).forEach((key) => {
if (key !== "id" && key !== "idCart" && shoppingItem[key] === undefined) {
delete shoppingItem[key];
}
});
// IMPORTANTE: Remover o campo 'color' se for null para corresponder ao exemplo funcional
// No payload funcional testado via curl, o campo 'color' não aparece quando é null
if (shoppingItem.color === null || shoppingItem.color === undefined) {
delete shoppingItem.color;
}
return shoppingItem as ShoppingItem;
}
/**
* Limpa todos os dados do carrinho e checkout do localStorage
* EXATAMENTE como no Angular cancelShopping() (linha 336-350)
*/
clearShoppingData(): void {
const keysToRemove = [
"cart",
"customer",
"preCustomer",
"paymentPlan",
"billing",
"address",
"invoiceStore",
"partner",
"shoppingItem",
"dataDelivery",
"taxDelivery",
"authorizationTax",
"authorizationPartner",
];
keysToRemove.forEach((key) => {
localStorage.removeItem(key);
});
console.log(
"🛒 [SHOPPING] Dados do carrinho e checkout limpos do localStorage:",
keysToRemove
);
}
}
export const shoppingService = new ShoppingService();

52
src/types/auth.ts Normal file
View File

@ -0,0 +1,52 @@
/**
* Authentication Types
* Tipos relacionados à autenticação do sistema
*/
export interface AuthUser {
email: string;
password: string;
}
export interface User {
id: number;
username: string;
name: string;
store: string;
seller: number | string;
supervisorId: number | null;
deliveryTime: string;
token?: string;
}
export interface LoginResponse {
success: boolean;
message: string | null;
data: User & { token: string };
errors: null;
}
export interface ResultApi<T = any> {
success: boolean;
message: string | null;
data: T;
error: null;
}
export interface AuthContextType {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
authenticate: (email: string, password: string) => Promise<ResultApi>;
getToken: () => string | null;
getUser: () => User | null;
getStore: () => string | null;
getSeller: () => string | null;
getSupervisor: () => number | null;
getDeliveryTime: () => string | null;
isManager: () => boolean;
}

34
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,34 @@
/// <reference types="vite/client" />
declare module '*.svg' {
const content: string;
export default content;
}
declare module '*.png' {
const content: string;
export default content;
}
declare module '*.jpg' {
const content: string;
export default content;
}
declare module '*.jpeg' {
const content: string;
export default content;
}
declare module '*.gif' {
const content: string;
export default content;
}
declare module '*.webp' {
const content: string;
export default content;
}

29
tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

43
types.ts Normal file
View File

@ -0,0 +1,43 @@
export enum View {
LOGIN = 'LOGIN',
HOME_MENU = 'HOME_MENU',
SALES_DASHBOARD = 'SALES_DASHBOARD',
PRODUCT_SEARCH = 'PRODUCT_SEARCH',
CHECKOUT = 'CHECKOUT'
}
export interface Product {
id: string;
code: string;
name: string;
description?: string; // Descrição detalhada do produto
price: number;
originalPrice?: number;
discount?: number;
mark: string;
image: string;
stockLocal: number;
stockAvailable?: number; // Estoque disponível
stockGeneral: number;
ean?: string; // Código EAN
model?: string; // Modelo do produto
installment?: { // Parcelamento
installments: number;
value: number;
};
}
export interface OrderItem extends Product {
quantity: number;
deliveryType?: string; // Tipo de entrega: EN (Entrega Normal), EF (Encomenda), RI (Retira Imediata), RP (Retira Posterior), RA (Retira Anterior)
cost?: number; // Custo do produto
promotion?: number; // Valor da promoção
listPrice?: number; // Preço de lista
price?: number; // Preço de venda
stockStore?: string | number; // Filial de estoque
smallDescription?: string; // Descrição curta
auxDescription?: string; // Descrição auxiliar
brand?: string; // Marca
environment?: string; // Ambiente
}

18
utils/formatters.ts Normal file
View File

@ -0,0 +1,18 @@
export const formatCurrency = (value: number): string => {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
export const formatNumber = (value: number, decimals: number = 2): string => {
return new Intl.NumberFormat("pt-BR", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
};

2771
views/CheckoutView.tsx Normal file

File diff suppressed because it is too large Load Diff

300
views/HomeMenuView.tsx Normal file
View File

@ -0,0 +1,300 @@
import React from "react";
import { View } from "../types";
interface HomeMenuViewProps {
onNavigate: (view: View) => void;
user: { name: string; store: string };
onLogout: () => void;
}
const HomeMenuView: React.FC<HomeMenuViewProps> = ({
onNavigate,
user,
onLogout,
}) => {
const menus = [
{
id: View.SALES_DASHBOARD,
title: "Vendas e Relatórios",
desc: "Acompanhe metas, pedidos e performance em tempo real.",
color: "bg-blue-600",
icon: (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
),
},
{
id: View.PRODUCT_SEARCH,
title: "Catálogo de Produtos",
desc: "Pesquise, consulte estoque e inicie novos pedidos.",
color: "bg-orange-500",
icon: (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
),
},
{
id: View.HOME_MENU,
title: "Mestre Jurunense",
desc: "Gestão administrativa e parametrização do sistema.",
color: "bg-emerald-600",
icon: (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
),
},
];
const userInitials = user?.name
? user.name
.split(".")
.map((n) => n[0])
.join("")
.substring(0, 2)
.toUpperCase()
: "U";
return (
<div className="min-h-screen bg-[#f8fafc] p-4 lg:p-6">
<div className="max-w-7xl mx-auto">
{/* Top Header Section - Perfil e Logout */}
<div className="flex flex-col md:flex-row justify-between items-center mb-8 bg-white p-4 rounded-2xl shadow-sm border border-slate-100">
<div className="flex items-center space-x-3 mb-3 md:mb-0">
<div className="w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center text-orange-600 font-black text-sm shadow-inner border-2 border-orange-200/50">
{userInitials}
</div>
<div>
<span className="text-[9px] font-black text-slate-400 uppercase tracking-[0.2em] block mb-0.5">
Usuário Conectado
</span>
<h2 className="text-base font-black text-[#002147] leading-none">
{user?.name || "Usuário"}
</h2>
<span className="text-xs font-bold text-slate-500 mt-0.5 block uppercase tracking-wide">
Filial: {user?.store || "Loja"}
</span>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="h-10 w-[1px] bg-slate-100 hidden md:block mx-4"></div>
<button
onClick={onLogout}
className="flex items-center space-x-2 px-5 py-2.5 bg-slate-50 text-[#002147] rounded-xl font-black text-[10px] uppercase tracking-widest hover:bg-red-50 hover:text-red-500 transition-all group"
>
<span>Encerrar Sessão</span>
<svg
className="w-5 h-5 group-hover:translate-x-1 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</button>
</div>
</div>
<div className="mb-8">
<h1 className="text-2xl font-black text-[#002147] mb-1 tracking-tight">
Painel SMART
</h1>
<p className="text-slate-500 text-sm font-medium">
Selecione uma das ferramentas abaixo para operar no sistema.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{menus.map((menu) => (
<button
key={menu.title}
onClick={() => onNavigate(menu.id)}
className="group relative bg-white p-6 rounded-2xl shadow-sm hover:shadow-lg hover:shadow-slate-200 transition-all text-left overflow-hidden transform hover:-translate-y-1 border border-slate-50"
>
<div className="absolute top-0 right-0 p-6 opacity-5 transition-transform group-hover:scale-110 group-hover:opacity-10">
{menu.icon}
</div>
<div
className={`${menu.color} w-12 h-12 rounded-2xl flex items-center justify-center text-white mb-5 shadow-lg transition-transform group-hover:rotate-6`}
>
{menu.icon}
</div>
<h3 className="text-lg font-black text-[#002147] mb-2">
{menu.title}
</h3>
<p className="text-slate-500 text-sm leading-relaxed font-medium">
{menu.desc}
</p>
<div className="mt-8 flex items-center text-orange-600 font-black text-xs uppercase tracking-widest opacity-0 transition-opacity group-hover:opacity-100">
Acessar agora
<svg
className="w-4 h-4 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3"
d="M14 5l7 7m0 0l-7 7m7-7H3"
/>
</svg>
</div>
</button>
))}
</div>
{/* Informações Auxiliares */}
<div className="mt-12 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-5">
<div className="bg-[#002147] rounded-2xl p-6 text-white flex items-center justify-between overflow-hidden relative group cursor-pointer hover:shadow-lg transition-all border border-[#003366]">
<div className="relative z-10">
<span className="text-orange-400 font-black text-[9px] uppercase tracking-[0.3em] mb-2 block">
Novidade
</span>
<h4 className="text-lg font-black mb-1 leading-tight">
Treinamento Mestre SMART
</h4>
<p className="text-blue-200/80 font-medium text-xs">
Confira os novos módulos de treinamento disponíveis na
plataforma.
</p>
</div>
<div className="bg-white/10 p-3 rounded-full relative z-10 group-hover:scale-110 transition-transform backdrop-blur-md">
<svg
className="w-7 h-7"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="absolute right-[-20px] bottom-[-20px] w-64 h-64 bg-orange-500/10 rounded-full blur-3xl"></div>
</div>
<div className="bg-white rounded-2xl p-6 border border-slate-100 flex items-center justify-between group cursor-pointer hover:shadow-lg transition-all">
<div>
<span className="text-emerald-500 font-black text-[9px] uppercase tracking-[0.3em] mb-2 block flex items-center">
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full mr-1.5 animate-pulse"></span>
Obrigatório
</span>
<h4 className="text-lg font-black text-[#002147] mb-1 leading-tight">
Atualização Cadastral
</h4>
<p className="text-slate-500 font-medium text-xs">
Mantenha seus dados e documentos sempre em dia para evitar
bloqueios.
</p>
</div>
<div className="bg-emerald-50 p-4 rounded-2xl group-hover:bg-emerald-100 transition-colors">
<svg
className="w-6 h-6 text-emerald-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M12 11V9m0 2v2m0-2h-2m2 0h2"
/>
</svg>
</div>
</div>
</div>
<div className="mt-12 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-1 gap-5">
<div className="bg-white rounded-2xl p-6 border border-slate-100 flex items-center justify-between md:col-span-2 xl:col-span-1 group cursor-pointer hover:shadow-lg transition-all">
<div>
<span className="text-slate-400 font-black text-[9px] uppercase tracking-[0.3em] mb-2 block">
Suporte
</span>
<h4 className="text-lg font-black text-[#002147] mb-1 leading-tight">
Central de Ajuda
</h4>
<p className="text-slate-500 font-medium text-xs">
Dúvidas técnicas ou problemas com o sistema?
</p>
</div>
<button className="bg-slate-100 group-hover:bg-slate-200 p-4 rounded-2xl transition-colors">
<svg
className="w-6 h-6 text-slate-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
);
};
export default HomeMenuView;

248
views/LoginView.tsx Normal file
View File

@ -0,0 +1,248 @@
import React, { useState } from "react";
import { useAuth } from "../src/contexts/AuthContext";
import logo from "/assets/logo2.svg?url";
import icon from "../assets/icone.svg";
import { Input } from "../components/ui/input";
import { Button } from "../components/ui/button";
import { Label } from "../components/ui/label";
interface LoginViewProps {
onLogin: () => void;
}
const LoginView: React.FC<LoginViewProps> = ({ onLogin }) => {
const { login, isLoading } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!email.trim() || !password.trim()) {
setError("Por favor, preencha todos os campos");
return;
}
if (password.length < 3) {
setError("A senha deve ter no mínimo 3 caracteres");
return;
}
try {
await login(email, password);
// O redirecionamento será feito automaticamente pelo useEffect no App.tsx
// quando isAuthenticated mudar para true
} catch (err: any) {
setError(
err.message || "Erro ao realizar login. Verifique suas credenciais."
);
}
};
return (
<div className="min-h-screen flex">
{/* Lado Esquerdo - Info & Imagem */}
<div className="hidden lg:flex w-1/2 bg-[#002147] relative p-10 flex-col justify-between overflow-hidden">
<div className="absolute top-[-10%] right-[-10%] w-96 h-96 bg-orange-500/20 rounded-full blur-[120px]"></div>
<div className="absolute bottom-[-10%] left-[-10%] w-96 h-96 bg-blue-400/10 rounded-full blur-[120px]"></div>
<div className="relative z-10">
<div className="flex items-center space-x-2 mb-8">
<div className="bg-white p-1.5 rounded-lg">
<img src={icon} alt="Jurunense Icon" className="w-6 h-6" />
</div>
<span className="text-lg font-black text-white tracking-tight">
PLATAFORMA <span className="text-orange-500">VENDAS WEB</span>
</span>
</div>
<h1 className="text-5xl font-extrabold text-white leading-tight mb-4">
Gestão inteligente para sua operação de vendas
<span className="text-orange-500"> Jurunense Home Center</span>
</h1>
<p className="text-slate-300 text-sm max-w-lg leading-relaxed">
Acesse as ferramentas de venda e dashboard da Jurunense Home Center
em um único lugar.
</p>
</div>
<div className="relative z-10 flex items-center space-x-4 opacity-50">
<span className="text-sm font-bold text-white uppercase tracking-widest">
© 2025 Jurunense Tecnologia
</span>
</div>
</div>
{/* Lado Direito - Formulário */}
<div className="w-full lg:w-1/2 flex items-center justify-center p-4 lg:p-6 bg-white">
<div className="w-full max-w-md">
<div className="mb-6 lg:hidden">
<span className="text-lg font-black text-[#002147] tracking-tight">
SMART <span className="text-orange-500">PLATFORM</span>
</span>
</div>
<div className="mb-1 flex justify-center">
<img src={logo} alt="Jurunense Logo" className="h-40 w-auto" />
</div>
<h2 className="text-2xl font-extrabold text-slate-900 mb-1">
Bem-vindo de volta
</h2>
<p className="text-slate-500 mb-6 text-sm font-medium">
Por favor, insira suas credenciais de acesso.
</p>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-2xl">
<p className="text-red-600 text-sm font-medium">{error}</p>
</div>
)}
<form className="space-y-6" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label className="text-sm font-bold text-slate-700 uppercase tracking-wide">
Usuário/e-mail de Acesso
</Label>
<Input
type="text"
placeholder="nome@jurunense.com.br"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
className="bg-slate-50 border-slate-200 focus:border-orange-500 focus:ring-orange-500/10"
autoComplete="username"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label className="text-sm font-bold text-slate-700 uppercase tracking-wide">
Senha de Acesso
</Label>
<a
href="#"
className="text-xs font-bold text-orange-600 hover:text-orange-700"
>
Esqueceu a senha?
</a>
</div>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
className="bg-slate-50 border-slate-200 focus:border-orange-500 focus:ring-orange-500/10 pr-12"
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
disabled={isLoading}
>
{showPassword ? (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
</svg>
) : (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
)}
</button>
</div>
</div>
<Button
type="submit"
disabled={isLoading}
className="w-full py-3.5 font-extrabold uppercase text-sm tracking-widest transform hover:-translate-y-0.5 active:translate-y-0 shadow-lg shadow-blue-900/20 disabled:transform-none"
>
{isLoading ? (
<>
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Autenticando...
</>
) : (
"Acessar Plataforma"
)}
</Button>
</form>
<div className="mt-8 pt-6 border-t border-slate-100 flex items-center justify-between">
<span className="text-xs text-slate-400">Problemas no acesso?</span>
<button className="text-sm font-bold text-[#002147] flex items-center hover:underline">
Suporte TI
<svg
className="w-4 h-4 ml-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M14 5l7 7m0 0l-7 7m7-7H3"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
);
};
export default LoginView;

1271
views/ProductSearchView.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,323 @@
import React, { useState } from "react";
import { View } from "../types";
import DashboardDayView from "../components/dashboard/DashboardDayView";
import DashboardSellerView from "../components/dashboard/DashboardSellerView";
import OrdersView from "../components/dashboard/OrdersView";
import ProductsSoldView from "../components/dashboard/ProductsSoldView";
import PreorderView from "../components/dashboard/PreorderView";
import ConfirmDialog from "../components/ConfirmDialog";
import { shoppingService } from "../src/services/shopping.service";
interface SalesDashboardViewProps {
onNavigate: (view: View) => void;
onNewOrder?: () => void;
}
const SalesDashboardView: React.FC<SalesDashboardViewProps> = ({
onNavigate,
onNewOrder,
}) => {
const [activeTab, setActiveTab] = useState("dashboard");
const [showNewOrderDialog, setShowNewOrderDialog] = useState(false);
const [showContinueOrNewDialog, setShowContinueOrNewDialog] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
// Verificar se há carrinho (itens no carrinho)
const hasCartItems = () => {
return localStorage.getItem("cart");
};
// Verificar se há dados do pedido atual
const hasOrderData = () => {
const hasCart = localStorage.getItem("cart");
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 (
hasCart ||
hasCustomer ||
hasAddress ||
hasPaymentPlan ||
hasBilling ||
hasDataDelivery ||
hasInvoiceStore ||
hasPartner
);
};
const handleNewOrderClick = () => {
// Se houver carrinho, perguntar se quer continuar ou iniciar novo
if (hasCartItems()) {
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();
} else {
// Se não tiver onNewOrder, fazer a limpeza localmente
shoppingService.clearShoppingData();
onNavigate(View.PRODUCT_SEARCH);
}
};
const handleContinueOrder = () => {
// Fechar o dialog e navegar para /sales/home (já estamos na dashboard, apenas fechar)
setShowContinueOrNewDialog(false);
// Se já estamos na dashboard, não precisa navegar, apenas fechar o dialog
};
const handleStartNewOrder = () => {
// Fechar o dialog de continuar/iniciar e abrir o de confirmação
setShowContinueOrNewDialog(false);
setShowNewOrderDialog(true);
};
const handleConfirmNewOrder = () => {
if (onNewOrder) {
onNewOrder();
} else {
// Se não tiver onNewOrder, fazer a limpeza localmente
shoppingService.clearShoppingData();
onNavigate(View.PRODUCT_SEARCH);
}
setShowNewOrderDialog(false);
};
const sidebarItems = [
{
id: "new",
label: "Novo pedido",
primary: true,
action: () => onNavigate(View.PRODUCT_SEARCH),
},
{
id: "dashboard",
label: "Dashboard Venda dia",
icon: "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z",
},
{
id: "dashboardseller",
label: "Dashboard Vendedor",
icon: "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z",
},
{
id: "orders",
label: "Pedidos de venda",
icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2",
},
{
id: "product-order",
label: "Produtos Vendidos",
icon: "M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z",
},
{
id: "preorder",
label: "Orçamentos pendentes",
icon: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z",
primary: false,
},
];
return (
<div className="flex h-full bg-[#f8fafc] relative">
{/* Overlay para mobile quando sidebar está aberto */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setIsSidebarOpen(false)}
/>
)}
{/* Sidebar Modernizada - Drawer em mobile, sidebar em desktop */}
<aside
className={`fixed lg:relative inset-y-0 left-0 z-50 lg:z-auto w-72 bg-white border-r border-slate-200 flex flex-col p-4 lg:p-6 overflow-y-auto transform transition-transform duration-300 ease-out ${
isSidebarOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"
}`}
>
{/* Botão fechar em mobile */}
<div className="flex justify-end mb-4 lg:hidden">
<button
onClick={() => setIsSidebarOpen(false)}
className="p-2 text-slate-400 hover:text-slate-600 rounded-lg"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="mb-6 lg:mb-8">
<button
onClick={() => {
handleNewOrderClick();
setIsSidebarOpen(false);
}}
className="w-full bg-orange-500 text-white p-3 lg:p-4 rounded-2xl font-extrabold uppercase text-xs tracking-widest shadow-lg shadow-orange-500/20 hover:bg-orange-600 transition-all flex items-center justify-center group touch-manipulation"
>
<svg
className="w-5 h-5 mr-2 group-hover:rotate-90 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
d="M12 4v16m8-8H4"
/>
</svg>
Novo Pedido
</button>
</div>
<nav className="flex-1 space-y-2">
{sidebarItems.slice(1).map((item) => (
<button
key={item.id}
onClick={() => {
if (item.action) {
item.action();
} else {
setActiveTab(item.id);
}
setIsSidebarOpen(false); // Fechar sidebar em mobile após seleção
}}
className={`w-full flex items-center px-4 py-3 lg:py-4 rounded-2xl text-sm font-bold transition-all touch-manipulation ${
activeTab === item.id
? "bg-[#002147] text-white shadow-xl shadow-blue-900/10"
: item.id === "preorder"
? "text-blue-600 hover:bg-blue-50 border border-blue-200"
: "text-slate-500 hover:bg-slate-50"
}`}
>
<svg
className="w-5 h-5 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d={item.icon}
/>
</svg>
{item.label}
</button>
))}
</nav>
</aside>
{/* Botão para abrir sidebar em mobile */}
<button
onClick={() => setIsSidebarOpen(true)}
className="fixed top-20 left-4 z-30 lg:hidden bg-white p-3 rounded-xl shadow-lg border border-slate-200 text-slate-600 hover:bg-slate-50 transition-all touch-manipulation"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
{/* Área de Conteúdo */}
<main className="flex-1 p-3 lg:p-6 overflow-auto custom-scrollbar">
<div className="max-w-[1400px] mx-auto">
{activeTab === "dashboard" && <DashboardDayView />}
{activeTab === "orders" && <OrdersView />}
{activeTab === "dashboardseller" && <DashboardSellerView />}
{activeTab === "product-order" && <ProductsSoldView />}
{activeTab === "preorder" && <PreorderView />}
</div>
</main>
{/* Dialog - Continuar ou Iniciar Novo Pedido */}
<ConfirmDialog
isOpen={showContinueOrNewDialog}
onClose={handleContinueOrder}
onConfirm={handleStartNewOrder}
type="info"
title="Carrinho Existente"
message={
<>
Você possui um carrinho com itens.
<br />
<br />
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 */}
<ConfirmDialog
isOpen={showNewOrderDialog}
onClose={() => setShowNewOrderDialog(false)}
onConfirm={handleConfirmNewOrder}
type="warning"
title="Novo Pedido"
message={
<>
Deseja iniciar um novo pedido?
<br />
<br />
<span className="text-sm font-bold text-slate-700">
Todos os dados do pedido atual serão perdidos:
</span>
<ul className="text-xs text-slate-600 mt-2 space-y-1 list-disc list-inside">
<li>Itens do carrinho</li>
<li>Dados do cliente</li>
<li>Endereço de entrega</li>
<li>Plano de pagamento</li>
<li>Dados financeiros</li>
<li>Informações de entrega</li>
</ul>
<br />
<span className="text-xs text-slate-400 block">
Esta ação não pode ser desfeita.
</span>
</>
}
confirmText="Sim, Iniciar Novo Pedido"
cancelText="Cancelar"
/>
</div>
);
};
export default SalesDashboardView;

23
vite.config.ts Normal file
View File

@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});