Initial commit
This commit is contained in:
commit
812ef26e9f
|
|
@ -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?
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
|
@ -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`
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
|
@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
|
|
@ -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 |
|
|
@ -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="<Transparent Rectangle>" class="cls-1" width="32" height="32"/></svg>
|
||||
|
After Width: | Height: | Size: 799 B |
|
|
@ -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 |
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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ê já 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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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 já deve ser completa (ex: "ferragens/cadeados")
|
||||
* ou precisa ser construída concatenando departamento/seção.
|
||||
*
|
||||
* Seguindo o exemplo do usuário "ferragens/cadeados", vamos construir o path
|
||||
* completo quando há hierarquia, mas também manter a URL original do item.
|
||||
*/
|
||||
const mapItems = (
|
||||
items: any[],
|
||||
textFields: string[],
|
||||
childFields: string[],
|
||||
level: number = 0,
|
||||
parentPath: string = "" // Path do item pai para construir URL completa quando necessário
|
||||
): DepartmentItem[] => {
|
||||
const childField = childFields[level];
|
||||
const textField = textFields[level];
|
||||
|
||||
return items
|
||||
.filter((item) => item && item[textField] != null)
|
||||
.map((item) => {
|
||||
// No Angular: path: item.url (usa diretamente a URL do item)
|
||||
const itemUrl = item.url || "";
|
||||
|
||||
// Construir path completo seguindo hierarquia
|
||||
// Se há parentPath e itemUrl não começa com parentPath, concatenar
|
||||
// Caso contrário, usar apenas itemUrl (já vem completo da API)
|
||||
let fullPath = itemUrl;
|
||||
if (parentPath && itemUrl && !itemUrl.startsWith(parentPath)) {
|
||||
// Se a URL não começa com o path do pai, construir concatenando
|
||||
fullPath = `${parentPath}/${itemUrl}`;
|
||||
} else if (!itemUrl && parentPath) {
|
||||
// Se não tem URL mas tem parentPath, usar apenas parentPath
|
||||
fullPath = parentPath;
|
||||
}
|
||||
|
||||
const result: DepartmentItem = {
|
||||
name: item[textField] || "",
|
||||
url: itemUrl, // URL original do item
|
||||
path: fullPath, // Path completo para usar no filtro (seguindo padrão do Angular)
|
||||
subcategories: [],
|
||||
};
|
||||
|
||||
if (
|
||||
childField &&
|
||||
item[childField] &&
|
||||
Array.isArray(item[childField]) &&
|
||||
item[childField].length > 0
|
||||
) {
|
||||
// Passar o path completo para os filhos (para construir hierarquia completa)
|
||||
result.subcategories = mapItems(
|
||||
item[childField],
|
||||
textFields,
|
||||
childFields,
|
||||
level + 1,
|
||||
fullPath // Passar path completo para construir hierarquia
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Carrega os departamentos da API
|
||||
*/
|
||||
useEffect(() => {
|
||||
const loadDepartments = async () => {
|
||||
try {
|
||||
setLoadingDepartments(true);
|
||||
setError(null);
|
||||
|
||||
const data = await productService.getClasseMercadologica();
|
||||
// Seguir EXATAMENTE o padrão do Angular: mapItems com os mesmos parâmetros
|
||||
const mappedItems = mapItems(
|
||||
data.filter((d) => d.tituloEcommerce != null), // Filtrar como no Angular
|
||||
["tituloEcommerce", "descricaoSecao", "descricaoCategoria"],
|
||||
["secoes", "categorias"],
|
||||
0, // level inicial
|
||||
"" // parentPath inicial vazio
|
||||
);
|
||||
|
||||
setDepartments(mappedItems);
|
||||
|
||||
// Departamentos iniciam todos fechados (não expandidos)
|
||||
} catch (err: any) {
|
||||
console.error("Erro ao carregar departamentos:", err);
|
||||
setError("Erro ao carregar departamentos. Tente novamente.");
|
||||
} finally {
|
||||
setLoadingDepartments(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDepartments();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Carrega as marcas a partir dos produtos
|
||||
* Segue o mesmo padrão do Angular: extrai marcas dos produtos retornados pela API
|
||||
* No Angular: quando getProductByFilter é chamado, os produtos são processados para extrair marcas
|
||||
*
|
||||
* NOTA: As marcas serão carregadas quando os produtos forem buscados pela primeira vez
|
||||
* Por enquanto, não carregamos automaticamente para evitar chamadas desnecessárias à API
|
||||
* O componente pai pode passar as marcas via props quando necessário
|
||||
*/
|
||||
useEffect(() => {
|
||||
// As marcas serão carregadas dinamicamente quando os produtos forem buscados
|
||||
// Isso evita uma chamada extra à API no carregamento inicial
|
||||
// O componente pai pode passar as marcas via props quando necessário
|
||||
setLoadingBrands(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import React from "react";
|
||||
import NoImageIcon from "../assets/no-image-svgrepo-com.svg";
|
||||
|
||||
interface NoImagePlaceholderProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* NoImagePlaceholder Component
|
||||
* Componente reutilizável para exibir placeholder quando não há imagem disponível
|
||||
*
|
||||
* @param size - Tamanho do placeholder (sm, md, lg)
|
||||
* @param className - Classes CSS adicionais
|
||||
*/
|
||||
const NoImagePlaceholder: React.FC<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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
@ -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 };
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
@ -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 };
|
||||
|
||||
|
|
@ -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 };
|
||||
|
||||
|
||||
|
||||
|
|
@ -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 };
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
@ -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();
|
||||
|
||||
|
||||
|
||||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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ê já 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;
|
||||
|
|
@ -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, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Loading…
Reference in New Issue