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