Primeiro commit
This commit is contained in:
79
.env.example
Normal file
79
.env.example
Normal file
@@ -0,0 +1,79 @@
|
||||
# =====================================================
|
||||
# CONFIGURAÇÕES DO SUPABASE
|
||||
# =====================================================
|
||||
|
||||
# URL do seu projeto Supabase
|
||||
# Encontre em: https://supabase.com/dashboard/project/SEU_PROJETO/settings/api
|
||||
SUPABASE_URL=https://seu-projeto-id.supabase.co
|
||||
|
||||
# Chave anônima do Supabase (public anon key)
|
||||
# Encontre em: https://supabase.com/dashboard/project/SEU_PROJETO/settings/api
|
||||
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
|
||||
# =====================================================
|
||||
# CONFIGURAÇÕES DO SERVIDOR
|
||||
# =====================================================
|
||||
|
||||
# Ambiente de execução (development, production)
|
||||
NODE_ENV=production
|
||||
|
||||
# Porta do servidor (padrão: 5000)
|
||||
PORT=5000
|
||||
|
||||
# =====================================================
|
||||
# CONFIGURAÇÕES OPCIONAIS
|
||||
# =====================================================
|
||||
|
||||
# Chave secreta para JWT (se implementado futuramente)
|
||||
# JWT_SECRET=sua_chave_secreta_muito_forte_aqui
|
||||
|
||||
# Configurações de upload (se necessário)
|
||||
# MAX_FILE_SIZE=5242880
|
||||
# UPLOAD_PATH=./uploads
|
||||
|
||||
# =====================================================
|
||||
# INSTRUÇÕES
|
||||
# =====================================================
|
||||
|
||||
# 1. Copie este arquivo para .env
|
||||
# 2. Substitua os valores pelos seus dados reais do Supabase
|
||||
# 3. Nunca commite o arquivo .env no Git
|
||||
# 4. Para produção, use variáveis de ambiente do servidor/plataforma
|
||||
|
||||
# =====================================================
|
||||
# CONFIGURAÇÕES GOOGLE SHEETS (OPCIONAL)
|
||||
# =====================================================
|
||||
|
||||
# Para usar a integração com Google Sheets:
|
||||
# 1. Acesse https://console.cloud.google.com/
|
||||
# 2. Crie um novo projeto ou selecione um existente
|
||||
# 3. Ative as APIs: Google Sheets API e Google Drive API
|
||||
# 4. Crie credenciais OAuth 2.0 para aplicação web
|
||||
# 5. Baixe o arquivo JSON das credenciais
|
||||
# 6. Renomeie para 'google-credentials.json' e coloque na pasta /config/
|
||||
# 7. Configure o URI de redirecionamento: http://localhost:5000/auth/google/callback
|
||||
|
||||
# =====================================================
|
||||
# CONFIGURAÇÕES PIX - MERCADO PAGO
|
||||
# =====================================================
|
||||
|
||||
# Access Token do Mercado Pago (TEST para desenvolvimento, PROD para produção)
|
||||
MERCADOPAGO_ACCESS_TOKEN=TEST-sua_access_token_aqui
|
||||
|
||||
# Public Key do Mercado Pago
|
||||
MERCADOPAGO_PUBLIC_KEY=pk_test_sua_public_key_aqui
|
||||
|
||||
# URL base para webhooks (importante para produção)
|
||||
BASE_URL=http://localhost:5000
|
||||
|
||||
# =====================================================
|
||||
# INSTRUÇÕES PIX
|
||||
# =====================================================
|
||||
|
||||
# 1. Acesse: https://www.mercadopago.com.br/developers
|
||||
# 2. Crie uma conta de desenvolvedor
|
||||
# 3. Vá em "Suas integrações" > "Criar aplicação"
|
||||
# 4. Escolha "Pagamentos online" e "Checkout Pro"
|
||||
# 5. Copie as credenciais de TEST para desenvolvimento
|
||||
# 6. Para produção, use as credenciais PROD
|
||||
# 7. Configure o webhook URL no painel do Mercado Pago
|
||||
105
.gitignore
vendored
Normal file
105
.gitignore
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
client/node_modules/
|
||||
|
||||
# Production builds
|
||||
client/build/
|
||||
dist/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
public
|
||||
|
||||
# Storybook build outputs
|
||||
.out
|
||||
.storybook-out
|
||||
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Uploads directory (keep structure but ignore files)
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
98
ADICIONAR-CAMPO-DESCRICAO.md
Normal file
98
ADICIONAR-CAMPO-DESCRICAO.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# 📝 Como Adicionar o Campo Descrição na Tabela Produtos
|
||||
|
||||
## ⚠️ **IMPORTANTE: Execute este passo para ativar a funcionalidade de descrição**
|
||||
|
||||
Para que a funcionalidade de descrição de produtos funcione completamente, você precisa adicionar o campo `descricao` na tabela `produtos` do Supabase.
|
||||
|
||||
## 🔧 **Passos para Adicionar o Campo:**
|
||||
|
||||
### **Opção 1: Via Painel do Supabase (Recomendado)**
|
||||
|
||||
1. **Acesse o Painel do Supabase:**
|
||||
- Vá para [https://supabase.com/dashboard](https://supabase.com/dashboard)
|
||||
- Faça login na sua conta
|
||||
- Selecione seu projeto
|
||||
|
||||
2. **Navegue até o Editor SQL:**
|
||||
- No menu lateral, clique em **"SQL Editor"**
|
||||
- Ou vá para **"Table Editor"** > **"produtos"** > **"Add Column"**
|
||||
|
||||
3. **Execute o Comando SQL:**
|
||||
```sql
|
||||
ALTER TABLE produtos ADD COLUMN descricao TEXT;
|
||||
```
|
||||
|
||||
4. **Ou Adicione via Interface:**
|
||||
- Na **Table Editor**, selecione a tabela **"produtos"**
|
||||
- Clique em **"Add Column"**
|
||||
- Nome: `descricao`
|
||||
- Tipo: `text`
|
||||
- Nullable: ✅ (permitir valores nulos)
|
||||
- Clique em **"Save"**
|
||||
|
||||
### **Opção 2: Via psql (Se disponível)**
|
||||
|
||||
```bash
|
||||
# Conecte ao seu banco Supabase
|
||||
psql "postgresql://postgres:[SUA_SENHA]@db.[SEU_PROJETO_ID].supabase.co:5432/postgres"
|
||||
|
||||
# Execute o comando
|
||||
ALTER TABLE produtos ADD COLUMN descricao TEXT;
|
||||
|
||||
# Verifique se foi adicionado
|
||||
\d produtos
|
||||
```
|
||||
|
||||
## ✅ **Verificação**
|
||||
|
||||
Após adicionar o campo, você pode verificar se funcionou:
|
||||
|
||||
1. **Teste a API:**
|
||||
```bash
|
||||
curl -X GET "http://localhost:5000/api/produtos"
|
||||
```
|
||||
|
||||
2. **Teste no Frontend:**
|
||||
- Acesse a página de produtos
|
||||
- Tente cadastrar um novo produto
|
||||
- O campo descrição deve aparecer
|
||||
- O botão "Gerar com IA" deve funcionar
|
||||
|
||||
## 🎯 **Funcionalidades Ativadas Após Adicionar o Campo:**
|
||||
|
||||
- ✅ **Campo Descrição:** Textarea para descrição detalhada
|
||||
- ✅ **Geração com IA:** Botão para gerar descrição automaticamente
|
||||
- ✅ **Salvamento:** Descrição salva junto com o produto
|
||||
- ✅ **Edição:** Possibilidade de editar descrições existentes
|
||||
|
||||
## 🔄 **Status Atual do Sistema:**
|
||||
|
||||
- ✅ **Cálculo de Margem:** Funcionando perfeitamente
|
||||
- ✅ **Configuração ChatGPT:** Implementada nas configurações
|
||||
- ✅ **APIs Backend:** Todas criadas e funcionando
|
||||
- ⚠️ **Campo Descrição:** Requer adição manual no banco
|
||||
- ✅ **Interface:** Pronta para usar assim que o campo for adicionado
|
||||
|
||||
## 🆘 **Se Tiver Problemas:**
|
||||
|
||||
1. **Erro de Permissão:**
|
||||
- Verifique se você tem permissões de administrador no projeto Supabase
|
||||
|
||||
2. **Campo Não Aparece:**
|
||||
- Aguarde alguns minutos para o cache atualizar
|
||||
- Reinicie o servidor Node.js
|
||||
|
||||
3. **Ainda com Erro:**
|
||||
- Verifique os logs do servidor
|
||||
- Teste a API diretamente com curl/Postman
|
||||
|
||||
## 📞 **Suporte:**
|
||||
|
||||
Se precisar de ajuda, verifique:
|
||||
- Console do navegador para erros JavaScript
|
||||
- Logs do servidor Node.js
|
||||
- Painel de logs do Supabase
|
||||
|
||||
---
|
||||
|
||||
**Após executar estes passos, todas as funcionalidades de descrição e IA estarão 100% operacionais!** 🚀
|
||||
233
CHAT-WHATSAPP-GUIDE.md
Normal file
233
CHAT-WHATSAPP-GUIDE.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# 💬 Sistema de Chat WhatsApp - Liberi Kids
|
||||
|
||||
## 🎯 **Funcionalidades Implementadas**
|
||||
|
||||
### **1. Chat Integrado nas Vendas**
|
||||
- **Botão Chat (💬):** Abre conversa completa com histórico
|
||||
- **Botão Mensagem Rápida (📱):** Envia mensagem pré-formatada via WhatsApp Web
|
||||
- **Histórico Persistente:** Todas as mensagens ficam salvas no sistema
|
||||
- **Interface Profissional:** Design similar ao WhatsApp
|
||||
|
||||
### **2. Chat Integrado nos Clientes**
|
||||
- **Botão WhatsApp:** Disponível apenas para clientes com WhatsApp cadastrado
|
||||
- **Acesso Direto:** Chat direto da lista de clientes
|
||||
- **Conversa Personalizada:** Nome do cliente automaticamente identificado
|
||||
|
||||
## 🚀 **Como Usar o Sistema de Chat**
|
||||
|
||||
### **📋 Pré-requisitos**
|
||||
1. **Evolution API Configurada:**
|
||||
- Acesse: Configurações → Evolution API - WhatsApp
|
||||
- Configure: URL, Instância e API Key
|
||||
- Teste a conexão
|
||||
|
||||
2. **Cliente com WhatsApp:**
|
||||
- Cliente deve ter número de WhatsApp cadastrado
|
||||
- Formato aceito: (43) 99976-2754 ou 43999762754
|
||||
|
||||
### **💬 Usando o Chat nas Vendas**
|
||||
|
||||
1. **Acesse:** Vendas → Lista de vendas
|
||||
2. **Localize:** Venda do cliente desejado
|
||||
3. **Clique:** Botão verde com ícone de mensagem (💬)
|
||||
4. **Chat Abre:** Interface completa de conversa
|
||||
5. **Digite:** Sua mensagem na caixa de texto
|
||||
6. **Envie:** Pressione Enter ou clique no botão enviar
|
||||
7. **Histórico:** Todas as mensagens ficam salvas
|
||||
|
||||
### **👥 Usando o Chat nos Clientes**
|
||||
|
||||
1. **Acesse:** Clientes → Lista de clientes
|
||||
2. **Localize:** Cliente com WhatsApp cadastrado
|
||||
3. **Clique:** Botão verde WhatsApp (💬)
|
||||
4. **Converse:** Interface igual ao das vendas
|
||||
5. **Histórico:** Mensagens organizadas por cliente
|
||||
|
||||
## 🎨 **Interface do Chat**
|
||||
|
||||
### **📱 Design Profissional**
|
||||
- **Header:** Nome e telefone do cliente
|
||||
- **Área de Mensagens:** Histórico completo da conversa
|
||||
- **Mensagens Enviadas:** Aparecem à direita (verde)
|
||||
- **Mensagens Recebidas:** Aparecem à esquerda (branco)
|
||||
- **Input:** Caixa de texto para digitar mensagens
|
||||
|
||||
### **⏰ Informações das Mensagens**
|
||||
- **Horário:** Cada mensagem mostra quando foi enviada
|
||||
- **Status:** Indicadores de entrega (✓ ✓)
|
||||
- **Data:** Separador automático por dia
|
||||
- **Nome:** Identificação do remetente
|
||||
|
||||
## 🔧 **Funcionalidades Técnicas**
|
||||
|
||||
### **📡 Integração Evolution API**
|
||||
- **Envio Automático:** Mensagens enviadas via Evolution API
|
||||
- **Fallback:** Se Evolution falhar, abre WhatsApp Web
|
||||
- **Configuração:** Credenciais salvas no sistema
|
||||
- **Teste:** Botão para testar conexão
|
||||
|
||||
### **💾 Armazenamento**
|
||||
- **Banco de Dados:** Histórico salvo no Supabase
|
||||
- **Tabela:** `mensagens_whatsapp`
|
||||
- **Campos:** telefone, mensagem, tipo, status, data
|
||||
- **Backup:** Dados seguros na nuvem
|
||||
|
||||
### **🔄 Estados das Mensagens**
|
||||
- **Enviando:** ⏳ (temporário)
|
||||
- **Enviada:** ✓ (confirmada)
|
||||
- **Entregue:** ✓✓ (futuramente)
|
||||
- **Lida:** ✓✓ (futuramente)
|
||||
|
||||
## 📋 **Estrutura do Sistema**
|
||||
|
||||
### **🗂️ Arquivos Principais**
|
||||
```
|
||||
/client/src/components/
|
||||
├── ChatWhatsApp.js # Componente principal do chat
|
||||
├── ChatWhatsApp.css # Estilos do chat
|
||||
|
||||
/client/src/pages/
|
||||
├── Vendas.js # Chat integrado nas vendas
|
||||
├── Clientes.js # Chat integrado nos clientes
|
||||
|
||||
/server-supabase.js # APIs do chat
|
||||
├── GET /api/chat/:telefone # Buscar histórico
|
||||
├── POST /api/chat/enviar # Enviar mensagem
|
||||
├── POST /api/webhook/evolution # Receber mensagens
|
||||
|
||||
/sql/
|
||||
├── create-mensagens-whatsapp.sql # Estrutura do banco
|
||||
```
|
||||
|
||||
### **🎯 APIs Disponíveis**
|
||||
|
||||
#### **1. Buscar Histórico**
|
||||
```bash
|
||||
GET /api/chat/5543999762754
|
||||
```
|
||||
**Resposta:**
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"telefone_cliente": "5543999762754",
|
||||
"cliente_nome": "Cliente",
|
||||
"mensagem": "Olá!",
|
||||
"tipo": "enviada",
|
||||
"status": "enviada",
|
||||
"created_at": "2025-01-08T20:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### **2. Enviar Mensagem**
|
||||
```bash
|
||||
POST /api/chat/enviar
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"telefone": "5543999762754",
|
||||
"mensagem": "Sua mensagem aqui",
|
||||
"clienteNome": "Nome do Cliente"
|
||||
}
|
||||
```
|
||||
|
||||
#### **3. Webhook (Futuro)**
|
||||
```bash
|
||||
POST /api/webhook/evolution
|
||||
```
|
||||
- Recebe mensagens enviadas pelos clientes
|
||||
- Salva automaticamente no histórico
|
||||
- Atualiza interface em tempo real
|
||||
|
||||
## 🎉 **Benefícios do Sistema**
|
||||
|
||||
### **✅ Para o Negócio**
|
||||
- **Comunicação Centralizada:** Tudo em um lugar
|
||||
- **Histórico Completo:** Nunca perca uma conversa
|
||||
- **Profissionalismo:** Interface moderna e organizada
|
||||
- **Agilidade:** Acesso direto das vendas e clientes
|
||||
- **Automação:** Integração com Evolution API
|
||||
|
||||
### **✅ Para o Usuário**
|
||||
- **Facilidade:** Interface intuitiva como WhatsApp
|
||||
- **Rapidez:** Sem precisar trocar de aplicativo
|
||||
- **Organização:** Conversas organizadas por cliente
|
||||
- **Confiabilidade:** Mensagens sempre entregues
|
||||
- **Flexibilidade:** Funciona mesmo sem Evolution API
|
||||
|
||||
## 🔧 **Configuração Inicial**
|
||||
|
||||
### **1. Evolution API (Recomendado)**
|
||||
1. Acesse: **Configurações → Evolution API**
|
||||
2. Preencha:
|
||||
- **URL Base:** https://sua-evolution-api.com
|
||||
- **Nome da Instância:** SuaInstancia
|
||||
- **API Key:** Sua-Chave-API
|
||||
3. Clique: **Testar Conexão**
|
||||
4. Salve as configurações
|
||||
|
||||
### **2. Teste o Sistema**
|
||||
1. Cadastre um cliente com WhatsApp
|
||||
2. Acesse a lista de clientes
|
||||
3. Clique no botão WhatsApp
|
||||
4. Envie uma mensagem de teste
|
||||
5. Verifique se chegou no WhatsApp
|
||||
|
||||
## 🆘 **Solução de Problemas**
|
||||
|
||||
### **❌ "Evolution API não configurada"**
|
||||
- Acesse Configurações → Evolution API
|
||||
- Verifique se todos os campos estão preenchidos
|
||||
- Teste a conexão com o botão "Testar"
|
||||
|
||||
### **❌ "Cliente não possui WhatsApp"**
|
||||
- Edite o cliente e adicione o número do WhatsApp
|
||||
- Formato: (43) 99976-2754 ou 43999762754
|
||||
|
||||
### **❌ "Erro ao enviar mensagem"**
|
||||
- Verifique sua conexão com a internet
|
||||
- Confirme se a Evolution API está funcionando
|
||||
- O sistema tentará WhatsApp Web como alternativa
|
||||
|
||||
### **❌ "Histórico não carrega"**
|
||||
- Verifique se a tabela mensagens_whatsapp existe
|
||||
- Execute o SQL em sql/create-mensagens-whatsapp.sql
|
||||
- Reinicie o servidor
|
||||
|
||||
## 🎯 **Próximas Funcionalidades**
|
||||
|
||||
### **🔮 Em Desenvolvimento**
|
||||
- **Recebimento Automático:** Mensagens dos clientes aparecem automaticamente
|
||||
- **Notificações:** Alertas de novas mensagens
|
||||
- **Busca:** Pesquisar no histórico de conversas
|
||||
- **Anexos:** Envio de imagens e documentos
|
||||
- **Templates:** Mensagens pré-definidas
|
||||
- **Grupos:** Conversas em grupo
|
||||
|
||||
### **📈 Melhorias Futuras**
|
||||
- **Dashboard de Conversas:** Visão geral de todas as conversas
|
||||
- **Métricas:** Estatísticas de mensagens enviadas/recebidas
|
||||
- **Automação:** Respostas automáticas
|
||||
- **CRM:** Integração completa com vendas
|
||||
- **Mobile:** App mobile nativo
|
||||
|
||||
---
|
||||
|
||||
## 🎉 **Sistema Pronto para Uso!**
|
||||
|
||||
O sistema de chat WhatsApp está **100% funcional** e integrado ao Liberi Kids.
|
||||
|
||||
**Comece a usar agora:**
|
||||
1. Configure a Evolution API
|
||||
2. Acesse Vendas ou Clientes
|
||||
3. Clique no botão WhatsApp
|
||||
4. Comece a conversar!
|
||||
|
||||
**Suporte:** Em caso de dúvidas, consulte este guia ou entre em contato.
|
||||
|
||||
---
|
||||
|
||||
*Sistema desenvolvido para Liberi Kids - Controle de Estoque Inteligente* 🎯✨
|
||||
309
DEPLOY-GUIDE.md
Normal file
309
DEPLOY-GUIDE.md
Normal file
@@ -0,0 +1,309 @@
|
||||
# 🚀 Guia de Deploy - Liberi Kids Sistema de Estoque
|
||||
|
||||
## 📋 Pré-requisitos
|
||||
|
||||
- Node.js 18+ instalado
|
||||
- Conta no Supabase (já configurada)
|
||||
- Dados de acesso ao Supabase
|
||||
|
||||
## 🌐 Opções de Deploy
|
||||
|
||||
### 1. 🖥️ **SERVIDOR LOCAL (Produção)**
|
||||
|
||||
#### Preparação do Build
|
||||
```bash
|
||||
# 1. Entre na pasta do projeto
|
||||
cd /home/tiago/Downloads/app_estoque
|
||||
|
||||
# 2. Instale dependências (se não instaladas)
|
||||
npm install
|
||||
|
||||
# 3. Entre na pasta do cliente
|
||||
cd client
|
||||
|
||||
# 4. Instale dependências do frontend
|
||||
npm install
|
||||
|
||||
# 5. Faça o build de produção
|
||||
npm run build
|
||||
|
||||
# 6. Volte para a pasta raiz
|
||||
cd ..
|
||||
```
|
||||
|
||||
#### Configuração do Servidor
|
||||
```bash
|
||||
# 1. Crie arquivo de configuração de produção
|
||||
cp .env.example .env.production
|
||||
|
||||
# 2. Edite as variáveis de ambiente
|
||||
nano .env.production
|
||||
```
|
||||
|
||||
**Conteúdo do .env.production:**
|
||||
```env
|
||||
NODE_ENV=production
|
||||
PORT=5000
|
||||
SUPABASE_URL=sua_url_do_supabase
|
||||
SUPABASE_ANON_KEY=sua_chave_anonima_do_supabase
|
||||
```
|
||||
|
||||
#### Executar em Produção
|
||||
```bash
|
||||
# Opção 1: Execução simples
|
||||
npm start
|
||||
|
||||
# Opção 2: Com PM2 (recomendado)
|
||||
npm install -g pm2
|
||||
pm2 start server-supabase.js --name "liberi-kids"
|
||||
pm2 startup
|
||||
pm2 save
|
||||
```
|
||||
|
||||
#### Configurar Nginx (Opcional)
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name seu-dominio.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:5000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. ☁️ **DEPLOY EM NUVEM**
|
||||
|
||||
#### A. **Vercel (Recomendado - Gratuito)**
|
||||
|
||||
1. **Preparação:**
|
||||
```bash
|
||||
# 1. Instale Vercel CLI
|
||||
npm install -g vercel
|
||||
|
||||
# 2. Faça login
|
||||
vercel login
|
||||
|
||||
# 3. Configure o projeto
|
||||
vercel
|
||||
```
|
||||
|
||||
2. **Configuração no Vercel:**
|
||||
- Build Command: `cd client && npm run build`
|
||||
- Output Directory: `client/build`
|
||||
- Install Command: `npm install && cd client && npm install`
|
||||
|
||||
3. **Variáveis de Ambiente no Vercel:**
|
||||
```
|
||||
SUPABASE_URL=sua_url_do_supabase
|
||||
SUPABASE_ANON_KEY=sua_chave_anonima_do_supabase
|
||||
```
|
||||
|
||||
#### B. **Netlify (Alternativa Gratuita)**
|
||||
|
||||
1. **Via Git:**
|
||||
- Conecte seu repositório GitHub
|
||||
- Build Command: `cd client && npm run build`
|
||||
- Publish Directory: `client/build`
|
||||
|
||||
2. **Via CLI:**
|
||||
```bash
|
||||
npm install -g netlify-cli
|
||||
netlify login
|
||||
netlify deploy --prod --dir=client/build
|
||||
```
|
||||
|
||||
#### C. **Railway (Backend + Frontend)**
|
||||
|
||||
1. **Preparação:**
|
||||
```bash
|
||||
# 1. Instale Railway CLI
|
||||
npm install -g @railway/cli
|
||||
|
||||
# 2. Login
|
||||
railway login
|
||||
|
||||
# 3. Deploy
|
||||
railway deploy
|
||||
```
|
||||
|
||||
#### D. **Heroku (Pago)**
|
||||
|
||||
1. **Preparação:**
|
||||
```bash
|
||||
# 1. Instale Heroku CLI
|
||||
# 2. Login
|
||||
heroku login
|
||||
|
||||
# 3. Crie app
|
||||
heroku create liberi-kids-estoque
|
||||
|
||||
# 4. Configure variáveis
|
||||
heroku config:set SUPABASE_URL=sua_url
|
||||
heroku config:set SUPABASE_ANON_KEY=sua_chave
|
||||
|
||||
# 5. Deploy
|
||||
git push heroku main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 🐳 **DOCKER (Containerização)**
|
||||
|
||||
#### Dockerfile
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copiar package.json
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copiar código do servidor
|
||||
COPY server-supabase.js ./
|
||||
COPY sql/ ./sql/
|
||||
|
||||
# Copiar e buildar frontend
|
||||
COPY client/ ./client/
|
||||
WORKDIR /app/client
|
||||
RUN npm install && npm run build
|
||||
|
||||
# Voltar para raiz
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["node", "server-supabase.js"]
|
||||
```
|
||||
|
||||
#### Docker Compose
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
services:
|
||||
liberi-kids:
|
||||
build: .
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- SUPABASE_URL=${SUPABASE_URL}
|
||||
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
#### Comandos Docker
|
||||
```bash
|
||||
# Build e run
|
||||
docker-compose up -d
|
||||
|
||||
# Ou build manual
|
||||
docker build -t liberi-kids .
|
||||
docker run -p 5000:5000 -e SUPABASE_URL=sua_url liberi-kids
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **Configurações Importantes**
|
||||
|
||||
### 1. **Variáveis de Ambiente**
|
||||
```env
|
||||
# Obrigatórias
|
||||
SUPABASE_URL=https://seu-projeto.supabase.co
|
||||
SUPABASE_ANON_KEY=sua_chave_anonima
|
||||
|
||||
# Opcionais
|
||||
NODE_ENV=production
|
||||
PORT=5000
|
||||
```
|
||||
|
||||
### 2. **Configuração do Supabase**
|
||||
- Certifique-se que as políticas RLS estão configuradas
|
||||
- Verifique se todas as tabelas foram criadas
|
||||
- Configure CORS se necessário
|
||||
|
||||
### 3. **Segurança**
|
||||
- Use HTTPS em produção
|
||||
- Configure firewall adequadamente
|
||||
- Mantenha as chaves do Supabase seguras
|
||||
- Use variáveis de ambiente para dados sensíveis
|
||||
|
||||
---
|
||||
|
||||
## 📱 **Acesso Móvel**
|
||||
|
||||
### PWA (Progressive Web App)
|
||||
A aplicação já está configurada como PWA:
|
||||
- Funciona offline (parcialmente)
|
||||
- Pode ser instalada no celular
|
||||
- Interface responsiva
|
||||
|
||||
### Configuração de Domínio
|
||||
```bash
|
||||
# Para usar domínio próprio
|
||||
# 1. Configure DNS apontando para seu servidor
|
||||
# 2. Configure SSL/TLS (Let's Encrypt)
|
||||
# 3. Atualize configurações do Supabase se necessário
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 **Troubleshooting**
|
||||
|
||||
### Problemas Comuns:
|
||||
1. **Erro de CORS:** Configure headers no servidor
|
||||
2. **Build falha:** Verifique dependências do Node.js
|
||||
3. **Supabase não conecta:** Verifique URLs e chaves
|
||||
4. **Arquivos não encontrados:** Verifique paths do build
|
||||
|
||||
### Logs e Monitoramento:
|
||||
```bash
|
||||
# PM2 logs
|
||||
pm2 logs liberi-kids
|
||||
|
||||
# Docker logs
|
||||
docker logs container-name
|
||||
|
||||
# Vercel logs
|
||||
vercel logs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 **Recomendações**
|
||||
|
||||
### Para Pequenas Empresas:
|
||||
1. **Vercel** (frontend) + **Supabase** (backend/DB) - **GRATUITO**
|
||||
2. **Railway** (fullstack) - **Barato e simples**
|
||||
|
||||
### Para Empresas Médias:
|
||||
1. **VPS** (DigitalOcean, Linode) + **Docker**
|
||||
2. **AWS/Google Cloud** com auto-scaling
|
||||
|
||||
### Para Desenvolvimento:
|
||||
1. **Servidor local** com PM2
|
||||
2. **Docker** para ambiente consistente
|
||||
|
||||
---
|
||||
|
||||
## 📞 **Suporte**
|
||||
|
||||
Se precisar de ajuda com o deploy:
|
||||
1. Verifique os logs de erro
|
||||
2. Confirme configurações do Supabase
|
||||
3. Teste APIs individualmente
|
||||
4. Verifique conectividade de rede
|
||||
|
||||
**A aplicação está pronta para produção! 🎉**
|
||||
178
DEPLOY-PIX-WORKFLOW.md
Normal file
178
DEPLOY-PIX-WORKFLOW.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# 🚀 Workflow Deploy PIX - Local para Servidor
|
||||
|
||||
## 📋 **Seu Fluxo de Trabalho**
|
||||
|
||||
Você desenvolve na **máquina local** e depois faz **deploy para o servidor**. Este guia é específico para essa situação.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ **Deploy Automático (Recomendado)**
|
||||
|
||||
### **1. Execute o Script de Deploy:**
|
||||
```bash
|
||||
# Na sua máquina local:
|
||||
cd /home/tiago/Downloads/app_estoque
|
||||
./deploy-pix-completo.sh tiago@192.168.195.145
|
||||
```
|
||||
|
||||
**O script fará automaticamente:**
|
||||
- ✅ Build do frontend React
|
||||
- ✅ Envio de todos os arquivos PIX
|
||||
- ✅ Instalação das dependências no servidor
|
||||
- ✅ Configuração inicial do .env
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **Configuração Final no Servidor**
|
||||
|
||||
### **2. Configurar Credenciais Mercado Pago:**
|
||||
```bash
|
||||
# Conecte no servidor:
|
||||
ssh tiago@192.168.195.145
|
||||
cd ~/app_estoque
|
||||
|
||||
# Edite o .env:
|
||||
nano .env
|
||||
|
||||
# Configure estas linhas:
|
||||
MERCADOPAGO_ACCESS_TOKEN=TEST-sua_access_token_aqui
|
||||
MERCADOPAGO_PUBLIC_KEY=pk_test_sua_public_key_aqui
|
||||
BASE_URL=http://192.168.195.145:5000
|
||||
```
|
||||
|
||||
### **3. Aplicar SQL no Supabase:**
|
||||
- Acesse: https://supabase.com/dashboard
|
||||
- Vá em **SQL Editor**
|
||||
- Execute o conteúdo do arquivo: `aplicar-pix-supabase.sql`
|
||||
|
||||
### **4. Reiniciar Servidor:**
|
||||
```bash
|
||||
# No servidor:
|
||||
pm2 restart liberi-kids
|
||||
# ou se não estiver rodando:
|
||||
pm2 start server-supabase.js --name liberi-kids
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏦 **Obter Credenciais Mercado Pago**
|
||||
|
||||
1. **Acesse:** https://www.mercadopago.com.br/developers
|
||||
2. **Faça login** ou crie conta
|
||||
3. **Vá em:** "Suas integrações" → "Criar aplicação"
|
||||
4. **Escolha:** "Pagamentos online"
|
||||
5. **Copie:**
|
||||
- **Access Token:** `TEST-1234567890-abcdef...`
|
||||
- **Public Key:** `pk_test_1234567890abcdef...`
|
||||
|
||||
---
|
||||
|
||||
## 📁 **Estrutura de Arquivos Enviados**
|
||||
|
||||
```
|
||||
servidor:~/app_estoque/
|
||||
├── server-supabase.js # Backend atualizado com PIX
|
||||
├── config/
|
||||
│ └── mercadopago.js # Serviço PIX
|
||||
├── client/build/ # Frontend compilado
|
||||
├── sql/
|
||||
│ └── add-pix-fields.sql # Migração banco
|
||||
├── scripts/
|
||||
│ └── migrate-pix-fields.js # Script migração
|
||||
├── aplicar-pix-supabase.sql # SQL direto Supabase
|
||||
├── configurar-pix-servidor.sh # Script configuração
|
||||
└── .env # Configurações (você edita)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 **Como Testar**
|
||||
|
||||
1. **Acesse o sistema** no servidor
|
||||
2. **Vá em Vendas**
|
||||
3. **Clique no botão PIX** (ícone 💳)
|
||||
4. **Verifique se:**
|
||||
- Modal PIX abre
|
||||
- QR Code é gerado
|
||||
- Código PIX aparece para copiar
|
||||
|
||||
---
|
||||
|
||||
## 🔄 **Próximos Deploys**
|
||||
|
||||
Para **atualizações futuras**, você pode:
|
||||
|
||||
### **Opção 1: Script Completo (Recomendado)**
|
||||
```bash
|
||||
./deploy-pix-completo.sh tiago@192.168.195.145
|
||||
```
|
||||
|
||||
### **Opção 2: Envio Manual**
|
||||
```bash
|
||||
# Build local:
|
||||
cd client && npm run build && cd ..
|
||||
|
||||
# Envio específico:
|
||||
rsync -avz --progress server-supabase.js tiago@192.168.195.145:~/app_estoque/
|
||||
rsync -avz --progress client/build/ tiago@192.168.195.145:~/app_estoque/client/build/
|
||||
rsync -avz --progress config/ tiago@192.168.195.145:~/app_estoque/config/
|
||||
|
||||
# Reiniciar no servidor:
|
||||
ssh tiago@192.168.195.145 "cd ~/app_estoque && pm2 restart liberi-kids"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ **Troubleshooting**
|
||||
|
||||
### **Erro de Conexão SSH:**
|
||||
```bash
|
||||
# Teste a conexão:
|
||||
ssh tiago@192.168.195.145
|
||||
|
||||
# Se não funcionar, verifique:
|
||||
# - Servidor ligado?
|
||||
# - SSH funcionando?
|
||||
# - Credenciais corretas?
|
||||
```
|
||||
|
||||
### **Erro no Build:**
|
||||
```bash
|
||||
# Na máquina local:
|
||||
cd client
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
### **Erro no Servidor:**
|
||||
```bash
|
||||
# No servidor:
|
||||
ssh tiago@192.168.195.145
|
||||
cd ~/app_estoque
|
||||
npm install
|
||||
pm2 logs liberi-kids
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 **Dicas Importantes**
|
||||
|
||||
1. **Sempre faça build local** antes do deploy
|
||||
2. **Mantenha credenciais seguras** no .env do servidor
|
||||
3. **Use credenciais TEST** para desenvolvimento
|
||||
4. **Configure webhook URL** no Mercado Pago para produção
|
||||
5. **Monitore logs** com `pm2 logs liberi-kids`
|
||||
|
||||
---
|
||||
|
||||
## 🎉 **Resultado Final**
|
||||
|
||||
Após seguir este workflow, você terá:
|
||||
|
||||
- ✅ **PIX funcionando** no servidor
|
||||
- ✅ **QR Code automático** nas vendas
|
||||
- ✅ **Confirmação em tempo real**
|
||||
- ✅ **Interface moderna** e responsiva
|
||||
- ✅ **Deploy automatizado** para futuras atualizações
|
||||
|
||||
**Seu sistema estará pronto para aceitar pagamentos PIX!** 🏦💳
|
||||
216
DEPLOY-SSH-GUIDE.md
Normal file
216
DEPLOY-SSH-GUIDE.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# 🚀 Guia de Deploy via SSH - Liberi Kids
|
||||
|
||||
## ⚡ Deploy Automático em 2 Comandos
|
||||
|
||||
### **Método 1: Script Automatizado (Recomendado)**
|
||||
|
||||
```bash
|
||||
# 1. Execute o script automático
|
||||
./deploy-to-server.sh usuario@ip-do-servidor
|
||||
|
||||
# Exemplo:
|
||||
./deploy-to-server.sh root@192.168.1.100
|
||||
./deploy-to-server.sh ubuntu@meuservidor.com
|
||||
```
|
||||
|
||||
### **Método 2: Manual Passo a Passo**
|
||||
|
||||
```bash
|
||||
# 1. Copiar arquivos para o servidor
|
||||
rsync -avz --progress --exclude 'node_modules' ./ usuario@servidor:~/app_estoque/
|
||||
|
||||
# 2. Conectar no servidor
|
||||
ssh usuario@servidor
|
||||
|
||||
# 3. Navegar para o projeto
|
||||
cd app_estoque
|
||||
|
||||
# 4. Configurar .env
|
||||
cp .env.example .env
|
||||
nano .env # Configure suas credenciais
|
||||
|
||||
# 5. Executar deploy
|
||||
chmod +x scripts/deploy-local.sh
|
||||
./scripts/deploy-local.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **Configuração do Arquivo .env**
|
||||
|
||||
Após o deploy, você DEVE configurar o arquivo `.env` no servidor:
|
||||
|
||||
```bash
|
||||
# Conecte no servidor e edite
|
||||
ssh usuario@servidor
|
||||
cd app_estoque
|
||||
nano .env
|
||||
```
|
||||
|
||||
**Configure estas variáveis:**
|
||||
```env
|
||||
# Suas credenciais do Supabase
|
||||
SUPABASE_URL=https://seu-projeto.supabase.co
|
||||
SUPABASE_ANON_KEY=sua_chave_anonima_aqui
|
||||
|
||||
# Configurações do servidor
|
||||
NODE_ENV=production
|
||||
PORT=5000
|
||||
```
|
||||
|
||||
**Após configurar, reinicie:**
|
||||
```bash
|
||||
pm2 restart liberi-kids
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 **Acessar a Aplicação**
|
||||
|
||||
Após o deploy bem-sucedido:
|
||||
|
||||
- **Local:** `http://localhost:5000` (no servidor)
|
||||
- **Rede:** `http://IP-DO-SERVIDOR:5000`
|
||||
- **Domínio:** `http://seu-dominio.com:5000`
|
||||
|
||||
---
|
||||
|
||||
## 📋 **Comandos Úteis**
|
||||
|
||||
### **Gerenciar Aplicação:**
|
||||
```bash
|
||||
# Ver status
|
||||
ssh usuario@servidor 'pm2 status'
|
||||
|
||||
# Ver logs
|
||||
ssh usuario@servidor 'pm2 logs liberi-kids'
|
||||
|
||||
# Reiniciar
|
||||
ssh usuario@servidor 'pm2 restart liberi-kids'
|
||||
|
||||
# Parar
|
||||
ssh usuario@servidor 'pm2 stop liberi-kids'
|
||||
```
|
||||
|
||||
### **Atualizar Aplicação:**
|
||||
```bash
|
||||
# Re-executar o script de deploy
|
||||
./deploy-to-server.sh usuario@servidor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 **Configurações de Segurança**
|
||||
|
||||
### **Firewall (Ubuntu/Debian):**
|
||||
```bash
|
||||
ssh usuario@servidor '
|
||||
sudo ufw allow 5000
|
||||
sudo ufw enable
|
||||
'
|
||||
```
|
||||
|
||||
### **Nginx (Opcional - Para usar porta 80):**
|
||||
```bash
|
||||
ssh usuario@servidor '
|
||||
sudo apt install nginx
|
||||
sudo nano /etc/nginx/sites-available/liberi-kids
|
||||
'
|
||||
```
|
||||
|
||||
**Configuração Nginx:**
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name seu-dominio.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:5000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 **Resolução de Problemas**
|
||||
|
||||
### **Erro: "Permission denied"**
|
||||
```bash
|
||||
# Solução: Configurar chaves SSH
|
||||
ssh-copy-id usuario@servidor
|
||||
```
|
||||
|
||||
### **Erro: "Port 5000 already in use"**
|
||||
```bash
|
||||
ssh usuario@servidor 'sudo lsof -ti:5000 | xargs kill -9'
|
||||
```
|
||||
|
||||
### **Erro: "Node.js not found"**
|
||||
```bash
|
||||
ssh usuario@servidor '
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
'
|
||||
```
|
||||
|
||||
### **Erro: "PM2 not found"**
|
||||
```bash
|
||||
ssh usuario@servidor 'npm install -g pm2'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 **Recursos da Aplicação**
|
||||
|
||||
Após o deploy, você terá acesso a:
|
||||
|
||||
- ✅ **Dashboard Completo** com contabilidade real
|
||||
- ✅ **Controle de Produtos** com fotos e variações
|
||||
- ✅ **Gestão de Vendas** com WhatsApp integrado
|
||||
- ✅ **Sistema de Empréstimos** para Maiara
|
||||
- ✅ **Exportação Google Sheets** automática
|
||||
- ✅ **Alertas WhatsApp** para cobranças
|
||||
- ✅ **Interface Responsiva** (funciona no celular)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **Checklist Pós-Deploy**
|
||||
|
||||
- [ ] ✅ Aplicação rodando (pm2 status)
|
||||
- [ ] ✅ Arquivo .env configurado
|
||||
- [ ] ✅ Supabase conectado
|
||||
- [ ] ✅ Firewall configurado
|
||||
- [ ] ✅ Backup configurado
|
||||
- [ ] ✅ Domínio configurado (opcional)
|
||||
- [ ] ✅ SSL configurado (opcional)
|
||||
- [ ] ✅ Usuários treinados
|
||||
|
||||
---
|
||||
|
||||
## 📞 **Suporte**
|
||||
|
||||
### **Logs de Erro:**
|
||||
```bash
|
||||
ssh usuario@servidor 'pm2 logs liberi-kids --lines 50'
|
||||
```
|
||||
|
||||
### **Monitoramento:**
|
||||
```bash
|
||||
ssh usuario@servidor 'pm2 monit'
|
||||
```
|
||||
|
||||
### **Backup Manual:**
|
||||
```bash
|
||||
ssh usuario@servidor 'cd app_estoque && tar -czf backup-$(date +%Y%m%d).tar.gz .'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**🎉 Sua aplicação Liberi Kids estará rodando em produção!**
|
||||
|
||||
Para suporte detalhado, consulte: `DEPLOY-GUIDE.md`
|
||||
81
DESPESAS-CAMPOS-LIVRES.md
Normal file
81
DESPESAS-CAMPOS-LIVRES.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 📝 Configuração de Campos Livres para Despesas
|
||||
|
||||
## 🎯 Objetivo
|
||||
Permitir que os campos "Tipo de Despesa" e "Fornecedor" sejam campos de texto livre, onde você pode digitar qualquer nome sem precisar cadastrar previamente.
|
||||
|
||||
## 🔧 Passo a Passo
|
||||
|
||||
### 1. Adicionar Colunas no Supabase Dashboard
|
||||
|
||||
Acesse o **Supabase Dashboard** → **Table Editor** → **despesas** e adicione as seguintes colunas:
|
||||
|
||||
#### Coluna: `tipo_nome`
|
||||
- **Nome:** `tipo_nome`
|
||||
- **Tipo:** `text`
|
||||
- **Nullable:** ✅ Sim
|
||||
- **Default:** (vazio)
|
||||
|
||||
#### Coluna: `fornecedor_nome`
|
||||
- **Nome:** `fornecedor_nome`
|
||||
- **Tipo:** `text`
|
||||
- **Nullable:** ✅ Sim
|
||||
- **Default:** (vazio)
|
||||
|
||||
### 2. SQL Alternativo (se preferir)
|
||||
|
||||
Se preferir executar via SQL Editor no Supabase:
|
||||
|
||||
```sql
|
||||
-- Adicionar colunas de texto livre
|
||||
ALTER TABLE despesas ADD COLUMN IF NOT EXISTS tipo_nome TEXT;
|
||||
ALTER TABLE despesas ADD COLUMN IF NOT EXISTS fornecedor_nome TEXT;
|
||||
|
||||
-- Migrar dados existentes (opcional)
|
||||
UPDATE despesas
|
||||
SET tipo_nome = (
|
||||
SELECT nome FROM tipos_despesas
|
||||
WHERE tipos_despesas.id = despesas.tipo_despesa_id
|
||||
)
|
||||
WHERE tipo_despesa_id IS NOT NULL AND tipo_nome IS NULL;
|
||||
|
||||
UPDATE despesas
|
||||
SET fornecedor_nome = (
|
||||
SELECT razao_social FROM fornecedores
|
||||
WHERE fornecedores.id = despesas.fornecedor_id
|
||||
)
|
||||
WHERE fornecedor_id IS NOT NULL AND fornecedor_nome IS NULL;
|
||||
|
||||
-- Criar índices para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_despesas_tipo_nome ON despesas(tipo_nome);
|
||||
CREATE INDEX IF NOT EXISTS idx_despesas_fornecedor_nome ON despesas(fornecedor_nome);
|
||||
```
|
||||
|
||||
## ✅ Verificação
|
||||
|
||||
Após adicionar as colunas, teste criando uma nova despesa:
|
||||
|
||||
1. Vá para **Despesas** → **Nova Despesa**
|
||||
2. Digite qualquer nome no campo **Tipo de Despesa** (ex: "Manutenção do Ar")
|
||||
3. Digite qualquer nome no campo **Fornecedor** (ex: "João Técnico")
|
||||
4. Preencha os outros campos e salve
|
||||
|
||||
## 🎉 Resultado
|
||||
|
||||
Agora você pode:
|
||||
- ✅ Digitar qualquer tipo de despesa sem cadastrar previamente
|
||||
- ✅ Digitar qualquer fornecedor sem cadastrar previamente
|
||||
- ✅ Ter total liberdade nos nomes
|
||||
- ✅ Continuar usando a interface normalmente
|
||||
|
||||
## 🔄 Status da Implementação
|
||||
|
||||
- ✅ **Frontend:** Campos alterados para input de texto livre
|
||||
- ✅ **Backend:** APIs atualizadas para aceitar texto livre
|
||||
- ⏳ **Banco:** Aguardando adição das colunas (manual)
|
||||
|
||||
## 📞 Suporte
|
||||
|
||||
Após adicionar as colunas, o sistema funcionará automaticamente. Se houver algum problema, verifique se:
|
||||
1. As colunas foram criadas corretamente
|
||||
2. O servidor foi reiniciado
|
||||
3. Não há erros no console do navegador
|
||||
171
DEVOLUCOES-CORRIGIDAS.md
Normal file
171
DEVOLUCOES-CORRIGIDAS.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# 🔧 Correções no Sistema de Devoluções/Trocas
|
||||
|
||||
## Problemas Identificados e Corrigidos
|
||||
|
||||
### ❌ **Problema Original:**
|
||||
- Devoluções acumulavam registros em vez de devolver produtos ao estoque
|
||||
- Trocas não controlavam corretamente o estoque (entrada/saída)
|
||||
- Possibilidade de devolver mais itens do que foi vendido
|
||||
- Registros duplicados de devoluções
|
||||
|
||||
### ✅ **Soluções Implementadas:**
|
||||
|
||||
## 1. **Controle de Estoque em Devoluções**
|
||||
|
||||
### **Antes:**
|
||||
```javascript
|
||||
// Sempre retornava ao estoque, mesmo em trocas
|
||||
const novoEstoque = estoqueAtual + quantidadeDevolvida;
|
||||
```
|
||||
|
||||
### **Depois:**
|
||||
```javascript
|
||||
// Só retorna ao estoque se for devolução pura (não troca)
|
||||
if (itemOriginal.variacao_id && tipo_operacao !== 'troca') {
|
||||
const novoEstoque = (variacaoAtual.quantidade || 0) + parseInt(quantidade_devolvida);
|
||||
// Atualizar estoque...
|
||||
console.log(`✅ Estoque atualizado: +${quantidade_devolvida} unidades`);
|
||||
}
|
||||
```
|
||||
|
||||
## 2. **Controle de Estoque em Trocas**
|
||||
|
||||
### **Lógica Corrigida:**
|
||||
1. **Produto Original (devolvido):** Volta ao estoque
|
||||
2. **Produto Novo (trocado):** Sai do estoque
|
||||
3. **Registro:** Apenas um registro de devolução + item novo na venda
|
||||
|
||||
### **Implementação:**
|
||||
```javascript
|
||||
// Para trocas, devolver produto original ao estoque
|
||||
if (itemOriginal && itemOriginal.variacao_id) {
|
||||
const novoEstoque = (variacaoAtual.quantidade || 0) + parseInt(quantidade_devolvida);
|
||||
console.log(`✅ Produto original retornado ao estoque na troca: +${quantidade_devolvida} unidades`);
|
||||
}
|
||||
|
||||
// Reduzir estoque do produto novo
|
||||
const novoEstoque = (variacaoAtual.quantidade || 0) - parseInt(quantidade);
|
||||
console.log(`✅ Estoque reduzido na troca: -${quantidade} unidades`);
|
||||
```
|
||||
|
||||
## 3. **Validação Anti-Duplicação**
|
||||
|
||||
### **Verificação de Devoluções Anteriores:**
|
||||
```javascript
|
||||
// Verificar se já foi devolvido anteriormente
|
||||
const { data: devolucaoExistente } = await supabase
|
||||
.from('devolucoes')
|
||||
.select('quantidade_devolvida')
|
||||
.eq('venda_id', venda_id)
|
||||
.eq('item_id', item_id);
|
||||
|
||||
const quantidadeJaDevolvida = devolucaoExistente?.reduce((total, dev) =>
|
||||
total + parseInt(dev.quantidade_devolvida), 0) || 0;
|
||||
|
||||
const quantidadeDisponivel = parseInt(itemOriginal.quantidade) - quantidadeJaDevolvida;
|
||||
|
||||
if (parseInt(quantidade_devolvida) > quantidadeDisponivel) {
|
||||
throw new Error(`Quantidade de devolução (${quantidade_devolvida}) maior que disponível (${quantidadeDisponivel})`);
|
||||
}
|
||||
```
|
||||
|
||||
## 4. **API de Limpeza de Duplicatas**
|
||||
|
||||
### **Nova Rota:**
|
||||
```
|
||||
POST /api/devolucoes/limpar-duplicadas
|
||||
```
|
||||
|
||||
### **Funcionalidade:**
|
||||
- Identifica devoluções duplicadas (mesmo venda_id, item_id, data)
|
||||
- Remove registros duplicados automaticamente
|
||||
- Retorna quantidade de registros removidos
|
||||
|
||||
## 5. **Logs Detalhados**
|
||||
|
||||
### **Monitoramento em Tempo Real:**
|
||||
```javascript
|
||||
console.log(`✅ Estoque atualizado: +${quantidade_devolvida} unidades (novo total: ${novoEstoque})`);
|
||||
console.log(`✅ Estoque reduzido na troca: -${quantidade} unidades (novo total: ${novoEstoque})`);
|
||||
console.log(`✅ Produto original retornado ao estoque na troca: +${quantidade_devolvida} unidades`);
|
||||
```
|
||||
|
||||
## 📋 **Fluxos Corrigidos**
|
||||
|
||||
### **Devolução Simples:**
|
||||
1. ✅ Verifica se quantidade não excede o vendido
|
||||
2. ✅ Retorna produto ao estoque
|
||||
3. ✅ Registra devolução única
|
||||
4. ✅ Atualiza valor da venda
|
||||
|
||||
### **Troca de Produto:**
|
||||
1. ✅ Verifica estoque do produto novo
|
||||
2. ✅ Retorna produto original ao estoque
|
||||
3. ✅ Reduz estoque do produto novo
|
||||
4. ✅ Adiciona item novo à venda
|
||||
5. ✅ Registra devolução do item original
|
||||
6. ✅ Calcula diferença de valores
|
||||
|
||||
## 🧪 **Como Testar**
|
||||
|
||||
### **1. Teste de Devolução:**
|
||||
```bash
|
||||
# Executar script de verificação
|
||||
node scripts/fix-devolucoes.js
|
||||
```
|
||||
|
||||
### **2. Teste Manual:**
|
||||
1. Acesse **Devoluções/Trocas**
|
||||
2. Selecione uma venda
|
||||
3. Escolha "Devolução"
|
||||
4. Verifique se o estoque aumentou
|
||||
5. Tente devolver o mesmo item novamente (deve dar erro)
|
||||
|
||||
### **3. Teste de Troca:**
|
||||
1. Selecione uma venda
|
||||
2. Escolha "Troca"
|
||||
3. Selecione produto para devolver
|
||||
4. Selecione produto novo
|
||||
5. Verifique:
|
||||
- Produto original voltou ao estoque
|
||||
- Produto novo saiu do estoque
|
||||
- Venda foi atualizada com novo item
|
||||
|
||||
## 🔍 **Verificações de Integridade**
|
||||
|
||||
### **Comandos Úteis:**
|
||||
```sql
|
||||
-- Verificar devoluções por venda
|
||||
SELECT venda_id, COUNT(*) as total_devolucoes
|
||||
FROM devolucoes
|
||||
GROUP BY venda_id
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- Verificar estoque negativo
|
||||
SELECT * FROM produto_variacoes WHERE quantidade < 0;
|
||||
|
||||
-- Verificar devoluções duplicadas
|
||||
SELECT venda_id, item_id, data_devolucao, COUNT(*)
|
||||
FROM devolucoes
|
||||
GROUP BY venda_id, item_id, data_devolucao
|
||||
HAVING COUNT(*) > 1;
|
||||
```
|
||||
|
||||
## 📊 **Benefícios das Correções**
|
||||
|
||||
- ✅ **Estoque Preciso:** Controle real de entrada/saída
|
||||
- ✅ **Sem Duplicatas:** Prevenção de registros duplicados
|
||||
- ✅ **Validação Robusta:** Impossível devolver mais que vendido
|
||||
- ✅ **Logs Detalhados:** Rastreamento completo das operações
|
||||
- ✅ **Trocas Corretas:** Fluxo de estoque adequado
|
||||
- ✅ **Limpeza Automática:** Ferramenta para corrigir dados históricos
|
||||
|
||||
## 🚀 **Status**
|
||||
|
||||
**✅ IMPLEMENTADO E FUNCIONANDO**
|
||||
|
||||
O sistema agora controla corretamente:
|
||||
- Devoluções simples com retorno ao estoque
|
||||
- Trocas com controle bidirecional de estoque
|
||||
- Prevenção de duplicatas e validações
|
||||
- Limpeza de dados históricos inconsistentes
|
||||
58
Dockerfile
Normal file
58
Dockerfile
Normal file
@@ -0,0 +1,58 @@
|
||||
# 🐳 Dockerfile - Liberi Kids Sistema de Estoque
|
||||
|
||||
# Usar imagem oficial do Node.js 18 Alpine (menor tamanho)
|
||||
FROM node:18-alpine
|
||||
|
||||
# Definir diretório de trabalho
|
||||
WORKDIR /app
|
||||
|
||||
# Instalar dependências do sistema (se necessário)
|
||||
RUN apk add --no-cache \
|
||||
python3 \
|
||||
make \
|
||||
g++
|
||||
|
||||
# Copiar arquivos de dependências do servidor
|
||||
COPY package*.json ./
|
||||
|
||||
# Instalar dependências do servidor
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copiar código do servidor
|
||||
COPY server-supabase.js ./
|
||||
COPY sql/ ./sql/
|
||||
|
||||
# Copiar arquivos do frontend
|
||||
COPY client/package*.json ./client/
|
||||
WORKDIR /app/client
|
||||
|
||||
# Instalar dependências do frontend
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copiar código do frontend
|
||||
COPY client/src/ ./src/
|
||||
COPY client/public/ ./public/
|
||||
|
||||
# Fazer build do frontend
|
||||
RUN npm run build
|
||||
|
||||
# Voltar para diretório raiz
|
||||
WORKDIR /app
|
||||
|
||||
# Criar usuário não-root para segurança
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S nextjs -u 1001
|
||||
|
||||
# Mudar ownership dos arquivos
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
USER nextjs
|
||||
|
||||
# Expor porta
|
||||
EXPOSE 5000
|
||||
|
||||
# Definir variáveis de ambiente padrão
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=5000
|
||||
|
||||
# Comando de inicialização
|
||||
CMD ["node", "server-supabase.js"]
|
||||
145
EMPRESTIMOS-SETUP.md
Normal file
145
EMPRESTIMOS-SETUP.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# 💰 Sistema de Empréstimos - Guia de Configuração
|
||||
|
||||
## 📋 Sobre o Sistema
|
||||
|
||||
O sistema de empréstimos permite controlar o dinheiro que a Maiara pega emprestado da empresa e suas devoluções parciais ou totais.
|
||||
|
||||
### 🎯 Funcionalidades:
|
||||
|
||||
- ✅ **Registro de empréstimos** com valor total e descrição
|
||||
- ✅ **Devoluções parciais** com qualquer valor até quitar
|
||||
- ✅ **Controle automático** do valor restante
|
||||
- ✅ **Histórico completo** de todas as devoluções
|
||||
- ✅ **Status automático** (Ativo/Quitado)
|
||||
- ✅ **Dashboard visual** com resumos financeiros
|
||||
|
||||
## 🛠️ Configuração do Banco de Dados
|
||||
|
||||
### 1. Executar Script SQL
|
||||
|
||||
Acesse seu painel do **Supabase** e execute o script SQL:
|
||||
|
||||
1. **Vá para o Supabase Dashboard**
|
||||
2. **Clique em "SQL Editor"**
|
||||
3. **Cole o conteúdo do arquivo** `sql/emprestimos-tables.sql`
|
||||
4. **Execute o script** clicando em "Run"
|
||||
|
||||
### 2. Verificar Tabelas Criadas
|
||||
|
||||
Após executar, você deve ter as seguintes tabelas:
|
||||
|
||||
- **`emprestimos`** - Registros principais dos empréstimos
|
||||
- **`emprestimo_devolucoes`** - Histórico de devoluções/parcelas
|
||||
|
||||
### 3. Estrutura das Tabelas
|
||||
|
||||
#### Tabela `emprestimos`:
|
||||
```sql
|
||||
- id (UUID) - Chave primária
|
||||
- pessoa (VARCHAR) - Nome da pessoa (padrão: 'Maiara')
|
||||
- valor_total (DECIMAL) - Valor total emprestado
|
||||
- valor_restante (DECIMAL) - Valor ainda não devolvido
|
||||
- descricao (TEXT) - Motivo/descrição do empréstimo
|
||||
- data_emprestimo (DATE) - Data do empréstimo
|
||||
- status (VARCHAR) - ativo, quitado, cancelado
|
||||
- created_at, updated_at (TIMESTAMP)
|
||||
```
|
||||
|
||||
#### Tabela `emprestimo_devolucoes`:
|
||||
```sql
|
||||
- id (UUID) - Chave primária
|
||||
- emprestimo_id (UUID) - Referência ao empréstimo
|
||||
- valor_devolvido (DECIMAL) - Valor desta devolução
|
||||
- data_devolucao (DATE) - Data da devolução
|
||||
- observacoes (TEXT) - Observações sobre a devolução
|
||||
- created_at, updated_at (TIMESTAMP)
|
||||
```
|
||||
|
||||
## 🎮 Como Usar o Sistema
|
||||
|
||||
### 1. **Acessar a Página**
|
||||
- No menu lateral, clique em **"Empréstimos"**
|
||||
- Você verá o dashboard com resumos financeiros
|
||||
|
||||
### 2. **Criar Novo Empréstimo**
|
||||
- Clique em **"Novo Empréstimo"**
|
||||
- Preencha os dados:
|
||||
- **Pessoa:** Maiara (padrão)
|
||||
- **Valor Total:** Valor emprestado
|
||||
- **Data:** Data do empréstimo
|
||||
- **Descrição:** Motivo (opcional)
|
||||
- Clique em **"Criar Empréstimo"**
|
||||
|
||||
### 3. **Registrar Devolução**
|
||||
- Na lista de empréstimos, clique no botão **💰** (verde)
|
||||
- Preencha:
|
||||
- **Valor da Devolução:** Qualquer valor até o restante
|
||||
- **Data da Devolução:** Data do pagamento
|
||||
- **Observações:** Detalhes (opcional)
|
||||
- Clique em **"Registrar Devolução"**
|
||||
|
||||
### 4. **Acompanhar Status**
|
||||
- **🟠 Ativo:** Ainda tem valor a receber
|
||||
- **🟢 Quitado:** Totalmente pago
|
||||
- **🔴 Cancelado:** Empréstimo cancelado
|
||||
|
||||
## 📊 Dashboard de Resumos
|
||||
|
||||
O sistema mostra automaticamente:
|
||||
|
||||
- **💰 Total em Aberto:** Soma de todos os valores restantes
|
||||
- **✅ Total Quitado:** Soma de todos os empréstimos pagos
|
||||
- **👤 Empréstimos Ativos:** Quantidade de empréstimos em aberto
|
||||
|
||||
## 🔄 Funcionalidades Automáticas
|
||||
|
||||
### ✨ Cálculos Automáticos:
|
||||
- **Valor restante** é atualizado automaticamente a cada devolução
|
||||
- **Status** muda para "quitado" quando valor restante = 0
|
||||
- **Histórico completo** de todas as movimentações
|
||||
|
||||
### 🛡️ Validações:
|
||||
- Não permite devolver mais que o valor restante
|
||||
- Campos obrigatórios validados
|
||||
- Datas não podem ser futuras (opcional)
|
||||
|
||||
## 🎯 Exemplo de Uso
|
||||
|
||||
### Cenário: Maiara pega R$ 1.000 emprestado
|
||||
|
||||
1. **Criar empréstimo:**
|
||||
- Valor: R$ 1.000,00
|
||||
- Descrição: "Empréstimo para despesas pessoais"
|
||||
|
||||
2. **Primeira devolução:**
|
||||
- Valor: R$ 300,00
|
||||
- Restante: R$ 700,00
|
||||
- Status: Ativo
|
||||
|
||||
3. **Segunda devolução:**
|
||||
- Valor: R$ 700,00
|
||||
- Restante: R$ 0,00
|
||||
- Status: Quitado ✅
|
||||
|
||||
## 🚀 Benefícios do Sistema
|
||||
|
||||
- ✅ **Controle total** do fluxo de caixa
|
||||
- ✅ **Transparência** nas movimentações
|
||||
- ✅ **Histórico completo** para auditoria
|
||||
- ✅ **Interface intuitiva** e fácil de usar
|
||||
- ✅ **Cálculos automáticos** sem erros
|
||||
- ✅ **Relatórios visuais** em tempo real
|
||||
|
||||
## 🔧 Manutenção
|
||||
|
||||
### Backup dos Dados:
|
||||
Os dados ficam seguros no Supabase com backup automático.
|
||||
|
||||
### Relatórios:
|
||||
Todos os dados podem ser exportados via SQL ou integração futura com Google Sheets.
|
||||
|
||||
---
|
||||
|
||||
**🎉 Sistema pronto para uso!**
|
||||
|
||||
Agora você tem controle total sobre os empréstimos da Maiara com uma interface profissional e funcionalidades completas.
|
||||
123
GOOGLE-DRIVE-FIX.md
Normal file
123
GOOGLE-DRIVE-FIX.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# 🔧 Correção do Erro de Autorização Google Drive
|
||||
|
||||
## Problema Identificado
|
||||
Erro: "Acesso bloqueado: erro de autorização" ao tentar autorizar o Google Drive.
|
||||
|
||||
## Solução Passo a Passo
|
||||
|
||||
### 1. Configurar Google Cloud Console Corretamente
|
||||
|
||||
#### A. Criar/Verificar Projeto
|
||||
1. Acesse [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Selecione ou crie um projeto
|
||||
3. Nome sugerido: `Liberi Kids Sistema`
|
||||
|
||||
#### B. Ativar APIs Necessárias
|
||||
1. Vá em **"APIs e serviços"** → **"Biblioteca"**
|
||||
2. Pesquise e ative:
|
||||
- **Google Drive API**
|
||||
- **Google Sheets API** (se usar)
|
||||
3. Clique em **"Ativar"** para cada uma
|
||||
|
||||
#### C. Configurar Tela de Consentimento OAuth
|
||||
1. Vá em **"APIs e serviços"** → **"Tela de consentimento OAuth"**
|
||||
2. Selecione **"Externo"** (para uso pessoal/pequenas empresas)
|
||||
3. Preencha os campos obrigatórios:
|
||||
- **Nome do app:** `Liberi Kids - Sistema de Estoque`
|
||||
- **Email de suporte:** Seu email
|
||||
- **Domínios autorizados:** `localhost` (adicionar se necessário)
|
||||
- **Email do desenvolvedor:** Seu email
|
||||
4. Clique em **"Salvar e continuar"**
|
||||
|
||||
#### D. Adicionar Escopos
|
||||
1. Na seção **"Escopos"**, clique em **"Adicionar ou remover escopos"**
|
||||
2. Adicione os escopos:
|
||||
```
|
||||
https://www.googleapis.com/auth/drive.file
|
||||
https://www.googleapis.com/auth/drive.readonly
|
||||
```
|
||||
3. Clique em **"Atualizar"** e **"Salvar e continuar"**
|
||||
|
||||
#### E. Adicionar Usuários de Teste (IMPORTANTE)
|
||||
1. Na seção **"Usuários de teste"**
|
||||
2. Clique em **"+ Adicionar usuários"**
|
||||
3. Adicione seu email (o mesmo que vai usar para autorizar)
|
||||
4. Clique em **"Salvar e continuar"**
|
||||
|
||||
### 2. Criar Credenciais OAuth 2.0
|
||||
|
||||
1. Vá em **"APIs e serviços"** → **"Credenciais"**
|
||||
2. Clique em **"+ Criar credenciais"** → **"ID do cliente OAuth"**
|
||||
3. Selecione **"Aplicação da Web"**
|
||||
4. Configure:
|
||||
- **Nome:** `Liberi Kids Drive Integration`
|
||||
- **URIs de origem autorizados:**
|
||||
```
|
||||
http://localhost:5000
|
||||
http://localhost:3000
|
||||
```
|
||||
- **URIs de redirecionamento autorizados:**
|
||||
```
|
||||
http://localhost:5000/auth/google-drive/callback
|
||||
```
|
||||
5. Clique em **"Criar"**
|
||||
6. **Copie o Client ID e Client Secret**
|
||||
|
||||
### 3. Configurar no Sistema
|
||||
|
||||
1. Acesse **Configurações** no sistema Liberi Kids
|
||||
2. Vá na seção **"📁 Google Drive - Armazenamento de Fotos"**
|
||||
3. Cole as credenciais:
|
||||
- **Client ID:** Cole o ID copiado
|
||||
- **Client Secret:** Cole o Secret copiado
|
||||
4. Clique em **"Salvar Credenciais"**
|
||||
5. Clique em **"Autorizar Google Drive"**
|
||||
|
||||
### 4. Autorização Correta
|
||||
|
||||
1. Uma nova janela abrirá com o Google
|
||||
2. **Faça login com o email que você adicionou como usuário de teste**
|
||||
3. Você verá um aviso: "Google hasn't verified this app"
|
||||
4. Clique em **"Advanced"** (Avançado)
|
||||
5. Clique em **"Go to Liberi Kids Sistema (unsafe)"**
|
||||
6. Autorize as permissões solicitadas
|
||||
7. A janela fechará automaticamente
|
||||
|
||||
## Dicas Importantes
|
||||
|
||||
### ✅ Checklist de Verificação
|
||||
- [ ] Google Drive API ativada
|
||||
- [ ] Tela de consentimento configurada
|
||||
- [ ] Email adicionado como usuário de teste
|
||||
- [ ] URIs de redirecionamento corretos
|
||||
- [ ] Credenciais copiadas corretamente
|
||||
|
||||
### 🔒 Segurança
|
||||
- Use apenas para desenvolvimento/uso pessoal
|
||||
- Para produção, considere verificar a aplicação com Google
|
||||
- Mantenha as credenciais seguras
|
||||
|
||||
### 🚨 Problemas Comuns
|
||||
1. **"Erro 400: redirect_uri_mismatch"**
|
||||
- Verifique se o URI está exatamente igual no Google Cloud
|
||||
|
||||
2. **"Acesso negado"**
|
||||
- Certifique-se de estar logado com o email de teste
|
||||
|
||||
3. **"App não verificado"**
|
||||
- Normal para desenvolvimento, clique em "Avançado"
|
||||
|
||||
## Testando a Configuração
|
||||
|
||||
Após configurar:
|
||||
1. Vá em **Produtos** → **Novo Produto**
|
||||
2. Adicione fotos ao produto
|
||||
3. Verifique se aparecem mensagens de upload para Google Drive
|
||||
4. Confirme se as fotos aparecem no seu Google Drive
|
||||
|
||||
## Suporte
|
||||
|
||||
Se ainda houver problemas:
|
||||
1. Verifique os logs do servidor
|
||||
2. Confirme se todas as APIs estão ativas
|
||||
3. Verifique se o email de teste está correto
|
||||
146
GOOGLE-DRIVE-SETUP.md
Normal file
146
GOOGLE-DRIVE-SETUP.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# 📁 Google Drive Integration - Guia de Configuração
|
||||
|
||||
Este guia explica como configurar a integração com Google Drive para armazenar as fotos dos produtos automaticamente na nuvem.
|
||||
|
||||
## 🎯 Benefícios da Integração
|
||||
|
||||
- **☁️ Armazenamento na Nuvem:** Fotos salvas automaticamente no Google Drive
|
||||
- **🔒 Backup Seguro:** Suas imagens ficam protegidas na nuvem do Google
|
||||
- **📱 Acesso Universal:** Visualize as fotos de qualquer dispositivo
|
||||
- **💾 Economia de Espaço:** Não ocupa espaço no servidor local
|
||||
- **🔗 URLs Públicas:** Links diretos para visualização das imagens
|
||||
|
||||
## 🚀 Configuração Passo a Passo
|
||||
|
||||
### 1. Criar Projeto no Google Cloud Console
|
||||
|
||||
1. Acesse [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Clique em **"Selecionar projeto"** → **"Novo projeto"**
|
||||
3. Digite o nome: `Liberi Kids - Sistema`
|
||||
4. Clique em **"Criar"**
|
||||
|
||||
### 2. Ativar Google Drive API
|
||||
|
||||
1. No menu lateral, vá em **"APIs e serviços"** → **"Biblioteca"**
|
||||
2. Pesquise por **"Google Drive API"**
|
||||
3. Clique na API e depois em **"Ativar"**
|
||||
|
||||
### 3. Criar Credenciais OAuth 2.0
|
||||
|
||||
1. Vá em **"APIs e serviços"** → **"Credenciais"**
|
||||
2. Clique em **"+ Criar credenciais"** → **"ID do cliente OAuth"**
|
||||
3. Selecione **"Aplicação da Web"**
|
||||
4. Configure:
|
||||
- **Nome:** `Liberi Kids Sistema`
|
||||
- **URIs de redirecionamento autorizados:**
|
||||
```
|
||||
http://localhost:5000/auth/google-drive/callback
|
||||
```
|
||||
5. Clique em **"Criar"**
|
||||
6. **Copie o Client ID e Client Secret** que aparecerão
|
||||
|
||||
### 4. Configurar no Sistema
|
||||
|
||||
1. Acesse **Configurações** no sistema Liberi Kids
|
||||
2. Encontre a seção **"📁 Google Drive - Armazenamento de Fotos"**
|
||||
3. Clique para expandir
|
||||
4. Cole as credenciais:
|
||||
- **Client ID:** Cole o ID copiado do Google Cloud
|
||||
- **Client Secret:** Cole o Secret copiado do Google Cloud
|
||||
5. Clique em **"Salvar Credenciais"**
|
||||
|
||||
### 5. Autorizar Acesso
|
||||
|
||||
1. Após salvar, clique em **"Autorizar Google Drive"**
|
||||
2. Uma nova janela abrirá com a tela de login do Google
|
||||
3. Faça login com sua conta Google
|
||||
4. Autorize o acesso aos arquivos do Google Drive
|
||||
5. A janela fechará automaticamente
|
||||
6. Verifique se o status mudou para **"✅ Conectado"**
|
||||
|
||||
## 📋 Como Funciona
|
||||
|
||||
### Upload Automático
|
||||
- Quando você cadastra um produto com fotos, o sistema detecta automaticamente se o Google Drive está configurado
|
||||
- Se estiver conectado, as fotos são enviadas para a pasta **"Liberi Kids - Fotos Produtos"**
|
||||
- Caso contrário, usa o armazenamento local como antes
|
||||
|
||||
### Organização das Fotos
|
||||
- **Pasta Principal:** `Liberi Kids - Fotos Produtos`
|
||||
- **Nome dos Arquivos:** `Marca_NomeProduto_Tamanho_Cor_Timestamp.jpg`
|
||||
- **Exemplo:** `Nike_Camiseta_M_Azul_1641234567890.jpg`
|
||||
|
||||
### URLs Públicas
|
||||
- Cada foto recebe uma URL pública do Google Drive
|
||||
- As URLs são salvas no banco de dados
|
||||
- As imagens são exibidas normalmente no sistema
|
||||
|
||||
## 🔧 Configurações Avançadas
|
||||
|
||||
### Informações de Armazenamento
|
||||
O sistema mostra:
|
||||
- **Espaço Usado:** Quanto você já utilizou
|
||||
- **Espaço Total:** Limite da sua conta Google
|
||||
- **Espaço Livre:** Quanto ainda pode usar
|
||||
|
||||
### Renovação Automática
|
||||
- Os tokens de acesso são renovados automaticamente
|
||||
- Não é necessário reautorizar frequentemente
|
||||
- O sistema mantém a conexão ativa
|
||||
|
||||
## 🛠️ Solução de Problemas
|
||||
|
||||
### Erro: "Credenciais não configuradas"
|
||||
- Verifique se copiou corretamente o Client ID e Client Secret
|
||||
- Certifique-se de que ativou a Google Drive API
|
||||
|
||||
### Erro: "Autorização pendente"
|
||||
- Clique em "Autorizar Google Drive" novamente
|
||||
- Verifique se não bloqueou pop-ups no navegador
|
||||
|
||||
### Erro: "Erro de conexão"
|
||||
- Clique em "Tentar Novamente"
|
||||
- Se persistir, clique em "Reconfigurar Tudo"
|
||||
|
||||
### Fotos não aparecem
|
||||
- Verifique se as URLs começam com `https://drive.google.com/`
|
||||
- Teste se consegue acessar a pasta no Google Drive
|
||||
|
||||
## 📊 Monitoramento
|
||||
|
||||
### Status da Conexão
|
||||
- **🟢 Conectado:** Tudo funcionando
|
||||
- **🟡 Não configurado:** Precisa configurar credenciais
|
||||
- **🔵 Autorização pendente:** Precisa autorizar acesso
|
||||
- **🔴 Erro de conexão:** Problema na conexão
|
||||
|
||||
### Ações Disponíveis
|
||||
- **Atualizar Status:** Verifica a conexão atual
|
||||
- **Abrir Google Drive:** Acessa sua pasta no navegador
|
||||
- **Desconectar:** Remove a integração
|
||||
|
||||
## 🔐 Segurança
|
||||
|
||||
### Dados Protegidos
|
||||
- Credenciais salvas criptografadas no Supabase
|
||||
- Tokens renovados automaticamente
|
||||
- Acesso limitado apenas às pastas necessárias
|
||||
|
||||
### Permissões
|
||||
O sistema solicita apenas:
|
||||
- **drive.file:** Criar e gerenciar arquivos criados pelo app
|
||||
- **drive.readonly:** Ler informações básicas do Drive
|
||||
|
||||
## 📝 Notas Importantes
|
||||
|
||||
1. **Conta Google:** Use uma conta Google com espaço suficiente
|
||||
2. **Backup Local:** O sistema mantém fallback para armazenamento local
|
||||
3. **Compatibilidade:** Funciona com produtos existentes
|
||||
4. **Performance:** Upload pode ser mais lento que armazenamento local
|
||||
5. **Dependência:** Requer conexão com internet para upload
|
||||
|
||||
## 🎉 Pronto!
|
||||
|
||||
Após seguir todos os passos, suas fotos de produtos serão automaticamente salvas no Google Drive, proporcionando backup seguro e acesso universal às imagens do seu estoque.
|
||||
|
||||
Para dúvidas ou problemas, verifique os logs do sistema ou entre em contato com o suporte técnico.
|
||||
148
GOOGLE-SHEETS-SETUP.md
Normal file
148
GOOGLE-SHEETS-SETUP.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# 📊 Configuração Google Sheets - Liberi Kids
|
||||
|
||||
Este guia explica como configurar a integração com Google Sheets para exportar dados do sistema.
|
||||
|
||||
## 🚀 Passo a Passo
|
||||
|
||||
### 1. Criar Projeto no Google Cloud Console
|
||||
|
||||
1. Acesse [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Clique em **"Criar Projeto"** ou selecione um existente
|
||||
3. Dê um nome ao projeto (ex: "Liberi Kids Sheets")
|
||||
4. Anote o **Project ID** gerado
|
||||
|
||||
### 2. Ativar APIs Necessárias
|
||||
|
||||
1. No menu lateral, vá em **"APIs e Serviços" > "Biblioteca"**
|
||||
2. Procure e ative as seguintes APIs:
|
||||
- **Google Sheets API**
|
||||
- **Google Drive API**
|
||||
|
||||
### 3. Criar Credenciais OAuth 2.0
|
||||
|
||||
1. Vá em **"APIs e Serviços" > "Credenciais"**
|
||||
2. Clique em **"+ CRIAR CREDENCIAIS" > "ID do cliente OAuth"**
|
||||
3. Escolha **"Aplicação da Web"**
|
||||
4. Configure:
|
||||
- **Nome:** Liberi Kids
|
||||
- **URIs de redirecionamento autorizados:**
|
||||
```
|
||||
http://localhost:5000/auth/google/callback
|
||||
```
|
||||
- Para produção, adicione também:
|
||||
```
|
||||
https://seu-dominio.com/auth/google/callback
|
||||
```
|
||||
|
||||
### 4. Copiar Credenciais
|
||||
|
||||
1. Após criar, você verá as credenciais na tela
|
||||
2. **Copie o Client ID** (formato: 123456789-abc123.apps.googleusercontent.com)
|
||||
3. **Copie o Client Secret** (formato: GOCSPX-abc123def456...)
|
||||
4. Guarde essas informações para inserir na interface do sistema
|
||||
|
||||
### 5. Configurar Tela de Consentimento OAuth
|
||||
|
||||
1. Vá em **"APIs e Serviços" > "Tela de consentimento OAuth"**
|
||||
2. Escolha **"Externo"** (para uso geral)
|
||||
3. Preencha as informações obrigatórias:
|
||||
- **Nome do app:** Liberi Kids
|
||||
- **Email de suporte:** seu-email@exemplo.com
|
||||
- **Domínios autorizados:** localhost (para desenvolvimento)
|
||||
|
||||
### 6. Adicionar Escopos
|
||||
|
||||
Na configuração da tela de consentimento, adicione os escopos:
|
||||
- `https://www.googleapis.com/auth/spreadsheets`
|
||||
- `https://www.googleapis.com/auth/drive.file`
|
||||
|
||||
## 🔧 Configuração no Sistema
|
||||
|
||||
Após obter as credenciais do Google Cloud Console:
|
||||
|
||||
1. **Acesse o sistema Liberi Kids**
|
||||
2. **Vá em Configurações > Google Sheets**
|
||||
3. **Preencha os campos:**
|
||||
- **Client ID:** Cole o Client ID copiado
|
||||
- **Client Secret:** Cole o Client Secret copiado
|
||||
- **URI de Redirecionamento:** Já preenchido automaticamente
|
||||
4. **Clique em "Salvar Credenciais"**
|
||||
5. **Clique em "Conectar com Google"** para autorizar
|
||||
|
||||
## 📋 Dados Exportados
|
||||
|
||||
### Aba "Produtos"
|
||||
- ID da Roupa
|
||||
- Nome do Produto
|
||||
- Fornecedor
|
||||
- Tamanho
|
||||
- Estação
|
||||
- Gênero
|
||||
- Valor da Compra
|
||||
- Valor da Venda
|
||||
- Data da Compra
|
||||
- Data da Venda
|
||||
- Estoque Atual
|
||||
- Marca
|
||||
|
||||
### Aba "Vendas"
|
||||
- ID da Venda
|
||||
- Cliente
|
||||
- Data da Venda
|
||||
- Tipo de Pagamento
|
||||
- Valor Total
|
||||
- Desconto
|
||||
- Valor Final
|
||||
- Status
|
||||
- Observações
|
||||
- Produtos Vendidos
|
||||
|
||||
## 🎯 Como Usar
|
||||
|
||||
1. **Configuração inicial:**
|
||||
- Configure as credenciais conforme descrito acima
|
||||
- Autorize o acesso ao Google na primeira vez
|
||||
|
||||
2. **Exportar dados:**
|
||||
- Vá em Configurações > Google Sheets
|
||||
- Escolha o tipo de exportação (Produtos, Vendas ou Tudo)
|
||||
- Defina um nome para a planilha (opcional)
|
||||
- Clique em exportar
|
||||
- A planilha será criada e aberta automaticamente
|
||||
|
||||
## 🔒 Segurança
|
||||
|
||||
- ✅ **Credenciais salvas no Supabase** de forma segura
|
||||
- ✅ **Tokens são salvos localmente** e renovados automaticamente
|
||||
- ✅ **Acesso limitado** apenas às planilhas criadas pelo app
|
||||
- ✅ **Dados criptografados** durante a transmissão
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Erro: "Credenciais do Google não configuradas"
|
||||
- Vá em Configurações > Google Sheets
|
||||
- Preencha os campos Client ID e Client Secret
|
||||
- Clique em "Salvar Credenciais"
|
||||
|
||||
### Erro: "redirect_uri_mismatch"
|
||||
- Verifique se o URI de redirecionamento está correto no Google Cloud Console
|
||||
- Para desenvolvimento: `http://localhost:5000/auth/google/callback`
|
||||
|
||||
### Erro: "access_denied"
|
||||
- Verifique se as APIs estão ativadas
|
||||
- Confirme se os escopos estão configurados corretamente
|
||||
|
||||
### Planilha não abre automaticamente
|
||||
- Verifique se o bloqueador de pop-ups está desabilitado
|
||||
- A URL da planilha aparece no toast de sucesso
|
||||
|
||||
## 📞 Suporte
|
||||
|
||||
Se encontrar problemas:
|
||||
1. Verifique os logs do servidor no terminal
|
||||
2. Confirme se todas as APIs estão ativadas
|
||||
3. Teste a conexão na página de Configurações
|
||||
|
||||
---
|
||||
|
||||
**Desenvolvido para Liberi Kids - Sistema de Controle de Estoque**
|
||||
387
INTEGRACAO-PIX-GUIDE.md
Normal file
387
INTEGRACAO-PIX-GUIDE.md
Normal file
@@ -0,0 +1,387 @@
|
||||
# 🏦 Integração PIX com QR Code - Liberi Kids
|
||||
|
||||
## 🎯 **Opções de Integração PIX**
|
||||
|
||||
### **1. Mercado Pago (Recomendado)**
|
||||
- ✅ **Gratuito** para começar
|
||||
- ✅ **QR Code automático**
|
||||
- ✅ **Webhook para confirmação**
|
||||
- ✅ **Documentação excelente**
|
||||
|
||||
### **2. PagSeguro/PagBank**
|
||||
- ✅ **PIX instantâneo**
|
||||
- ✅ **Taxas competitivas**
|
||||
|
||||
### **3. Asaas**
|
||||
- ✅ **Focado em pequenas empresas**
|
||||
- ✅ **PIX + Boleto + Cartão**
|
||||
|
||||
---
|
||||
|
||||
## 🚀 **Implementação Mercado Pago**
|
||||
|
||||
### **Passo 1: Criar Conta**
|
||||
1. Acesse: https://www.mercadopago.com.br/developers
|
||||
2. Crie conta de desenvolvedor
|
||||
3. Obtenha suas credenciais:
|
||||
- **Public Key** (pk_test_...)
|
||||
- **Access Token** (TEST-...)
|
||||
|
||||
### **Passo 2: Instalar SDK**
|
||||
```bash
|
||||
npm install mercadopago
|
||||
```
|
||||
|
||||
### **Passo 3: Configurar no .env**
|
||||
```env
|
||||
# Mercado Pago PIX
|
||||
MERCADOPAGO_ACCESS_TOKEN=TEST-sua_access_token_aqui
|
||||
MERCADOPAGO_PUBLIC_KEY=pk_test_sua_public_key_aqui
|
||||
```
|
||||
|
||||
### **Passo 4: API Backend**
|
||||
```javascript
|
||||
// server-supabase.js - Adicionar rota PIX
|
||||
|
||||
const mercadopago = require('mercadopago');
|
||||
|
||||
// Configurar Mercado Pago
|
||||
mercadopago.configurations.setAccessToken(process.env.MERCADOPAGO_ACCESS_TOKEN);
|
||||
|
||||
// Rota para gerar PIX
|
||||
app.post('/api/pix/gerar', async (req, res) => {
|
||||
try {
|
||||
const { valor, descricao, cliente_email, venda_id } = req.body;
|
||||
|
||||
const payment_data = {
|
||||
transaction_amount: parseFloat(valor),
|
||||
description: descricao || `Venda #${venda_id} - Liberi Kids`,
|
||||
payment_method_id: 'pix',
|
||||
payer: {
|
||||
email: cliente_email || 'cliente@liberikids.com'
|
||||
},
|
||||
external_reference: venda_id.toString(),
|
||||
notification_url: `${process.env.BASE_URL}/api/pix/webhook`
|
||||
};
|
||||
|
||||
const payment = await mercadopago.payment.create(payment_data);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
payment_id: payment.body.id,
|
||||
qr_code: payment.body.point_of_interaction.transaction_data.qr_code,
|
||||
qr_code_base64: payment.body.point_of_interaction.transaction_data.qr_code_base64,
|
||||
pix_copy_paste: payment.body.point_of_interaction.transaction_data.qr_code,
|
||||
expiration_date: payment.body.date_of_expiration
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar PIX:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Webhook para receber confirmação de pagamento
|
||||
app.post('/api/pix/webhook', async (req, res) => {
|
||||
try {
|
||||
const { type, data } = req.body;
|
||||
|
||||
if (type === 'payment') {
|
||||
const payment = await mercadopago.payment.get(data.id);
|
||||
|
||||
if (payment.body.status === 'approved') {
|
||||
const venda_id = payment.body.external_reference;
|
||||
|
||||
// Atualizar status da venda no banco
|
||||
const { error } = await supabase
|
||||
.from('vendas')
|
||||
.update({
|
||||
status_pagamento: 'pago',
|
||||
data_pagamento: new Date(),
|
||||
pix_payment_id: payment.body.id
|
||||
})
|
||||
.eq('id', venda_id);
|
||||
|
||||
if (!error) {
|
||||
console.log(`Pagamento confirmado para venda #${venda_id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).send('OK');
|
||||
} catch (error) {
|
||||
console.error('Erro no webhook PIX:', error);
|
||||
res.status(500).send('Error');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### **Passo 5: Frontend - Modal PIX**
|
||||
```javascript
|
||||
// Adicionar ao Vendas.js
|
||||
|
||||
const [showPixModal, setShowPixModal] = useState(false);
|
||||
const [pixData, setPixData] = useState(null);
|
||||
const [loadingPix, setLoadingPix] = useState(false);
|
||||
|
||||
const gerarPix = async (venda) => {
|
||||
setLoadingPix(true);
|
||||
try {
|
||||
const response = await fetch('/api/pix/gerar', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
valor: venda.valor_total,
|
||||
descricao: `Venda #${venda.id_venda} - Liberi Kids`,
|
||||
cliente_email: venda.cliente?.email,
|
||||
venda_id: venda.id
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setPixData(data);
|
||||
setShowPixModal(true);
|
||||
} else {
|
||||
toast.error('Erro ao gerar PIX');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Erro ao gerar PIX');
|
||||
}
|
||||
setLoadingPix(false);
|
||||
};
|
||||
|
||||
// Modal PIX
|
||||
{showPixModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal pix-modal">
|
||||
<div className="modal-header">
|
||||
<h3>🏦 Pagamento PIX</h3>
|
||||
<button onClick={() => setShowPixModal(false)}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="pix-content">
|
||||
<div className="qr-code-section">
|
||||
<h4>QR Code PIX</h4>
|
||||
<img
|
||||
src={`data:image/png;base64,${pixData.qr_code_base64}`}
|
||||
alt="QR Code PIX"
|
||||
className="qr-code-image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pix-copy-section">
|
||||
<h4>Código PIX (Copiar e Colar)</h4>
|
||||
<div className="pix-code-container">
|
||||
<input
|
||||
type="text"
|
||||
value={pixData.pix_copy_paste}
|
||||
readOnly
|
||||
className="pix-code-input"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(pixData.pix_copy_paste);
|
||||
toast.success('Código PIX copiado!');
|
||||
}}
|
||||
className="btn-copy-pix"
|
||||
>
|
||||
📋 Copiar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pix-info">
|
||||
<p><strong>Valor:</strong> R$ {parseFloat(pixData.transaction_amount).toFixed(2)}</p>
|
||||
<p><strong>Válido até:</strong> {new Date(pixData.expiration_date).toLocaleString()}</p>
|
||||
<p className="pix-instructions">
|
||||
📱 <strong>Como pagar:</strong><br/>
|
||||
1. Abra o app do seu banco<br/>
|
||||
2. Escaneie o QR Code ou cole o código PIX<br/>
|
||||
3. Confirme o pagamento
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### **Passo 6: CSS para Modal PIX**
|
||||
```css
|
||||
/* styles/pix-integration.css */
|
||||
|
||||
.pix-modal {
|
||||
max-width: 500px;
|
||||
width: 90vw;
|
||||
}
|
||||
|
||||
.pix-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-code-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.qr-code-image {
|
||||
max-width: 200px;
|
||||
height: auto;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.pix-copy-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pix-code-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.pix-code-input {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-copy-pix {
|
||||
background: #00d4aa;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-copy-pix:hover {
|
||||
background: #00b894;
|
||||
}
|
||||
|
||||
.pix-info {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pix-instructions {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn-pix {
|
||||
background: #00d4aa;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-pix:hover {
|
||||
background: #00b894;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **Integração no Sistema de Vendas**
|
||||
|
||||
### **1. Adicionar Botão PIX na Lista de Vendas**
|
||||
```javascript
|
||||
// Na tabela de vendas, adicionar botão PIX
|
||||
<button
|
||||
onClick={() => gerarPix(venda)}
|
||||
className="btn-icon btn-pix"
|
||||
title="Gerar PIX"
|
||||
disabled={loadingPix}
|
||||
>
|
||||
🏦 PIX
|
||||
</button>
|
||||
```
|
||||
|
||||
### **2. Adicionar Campo no Banco de Dados**
|
||||
```sql
|
||||
-- Adicionar colunas para controle de PIX
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS status_pagamento VARCHAR(20) DEFAULT 'pendente';
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS data_pagamento TIMESTAMP;
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS pix_payment_id VARCHAR(100);
|
||||
```
|
||||
|
||||
### **3. Dashboard com Status de Pagamentos**
|
||||
- Vendas pendentes de pagamento
|
||||
- Vendas pagas via PIX
|
||||
- Relatório de recebimentos
|
||||
|
||||
---
|
||||
|
||||
## 💰 **Custos e Taxas**
|
||||
|
||||
### **Mercado Pago:**
|
||||
- **PIX:** 0,99% por transação
|
||||
- **Sem mensalidade**
|
||||
- **Recebimento em 1 dia útil**
|
||||
|
||||
### **PagSeguro:**
|
||||
- **PIX:** 0,99% por transação
|
||||
- **Recebimento em 1 dia útil**
|
||||
|
||||
### **Asaas:**
|
||||
- **PIX:** R$ 0,20 por transação
|
||||
- **Melhor para alto volume**
|
||||
|
||||
---
|
||||
|
||||
## 🔒 **Segurança**
|
||||
|
||||
1. **Nunca exponha** as credenciais no frontend
|
||||
2. **Use HTTPS** sempre em produção
|
||||
3. **Valide webhooks** com assinatura
|
||||
4. **Monitore transações** suspeitas
|
||||
|
||||
---
|
||||
|
||||
## 📱 **Funcionalidades Extras**
|
||||
|
||||
### **1. WhatsApp com PIX**
|
||||
- Enviar QR Code via WhatsApp
|
||||
- Link de pagamento direto
|
||||
|
||||
### **2. Relatórios PIX**
|
||||
- Vendas pagas via PIX
|
||||
- Tempo médio de pagamento
|
||||
- Conversão PIX vs outros métodos
|
||||
|
||||
### **3. Notificações**
|
||||
- Email quando PIX for pago
|
||||
- Notificação no sistema
|
||||
- Atualização automática do status
|
||||
|
||||
---
|
||||
|
||||
## 🎉 **Benefícios da Integração**
|
||||
|
||||
- ✅ **Pagamento instantâneo**
|
||||
- ✅ **Sem taxas para o cliente**
|
||||
- ✅ **QR Code automático**
|
||||
- ✅ **Confirmação automática**
|
||||
- ✅ **Integração com WhatsApp**
|
||||
- ✅ **Relatórios detalhados**
|
||||
|
||||
---
|
||||
|
||||
**🚀 Com essa integração, seu sistema Liberi Kids terá pagamentos PIX completos com QR Code automático!**
|
||||
236
INTERFACE-TROCAS-CORRIGIDA.md
Normal file
236
INTERFACE-TROCAS-CORRIGIDA.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# ✅ Interface de Trocas/Devoluções Corrigida
|
||||
|
||||
## 🔧 Problemas Identificados e Solucionados
|
||||
|
||||
### **Problema Original:**
|
||||
- Modal de visualização não mostrava informações de troca
|
||||
- Produtos cortados quando havia mais de 2 itens
|
||||
- Status não indicava que houve troca
|
||||
- Falta de histórico de devoluções/trocas
|
||||
|
||||
### **Soluções Implementadas:**
|
||||
|
||||
## 1. **API Backend Aprimorada**
|
||||
|
||||
### **Busca de Vendas com Informações de Troca:**
|
||||
```javascript
|
||||
// Nova funcionalidade na API GET /api/vendas/:id
|
||||
const { data: devolucoes, error: devolucaoError } = await supabase
|
||||
.from('devolucoes')
|
||||
.select(`
|
||||
*,
|
||||
venda_itens(
|
||||
produtos(nome, marca),
|
||||
produto_variacoes(tamanho, cor)
|
||||
)
|
||||
`)
|
||||
.eq('venda_id', id)
|
||||
.order('data_devolucao', { ascending: false });
|
||||
|
||||
// Verificar se há trocas
|
||||
const temTrocas = devolucoes?.some(dev => dev.tipo_operacao === 'troca') || false;
|
||||
const statusVenda = temTrocas ? 'com_troca' : (data.status || 'concluida');
|
||||
```
|
||||
|
||||
### **Dados Retornados:**
|
||||
- ✅ **status:** 'com_troca' quando há trocas
|
||||
- ✅ **tem_trocas:** boolean indicando se há trocas
|
||||
- ✅ **devolucoes:** array com histórico completo
|
||||
- ✅ **itens:** todos os itens incluindo trocados
|
||||
|
||||
## 2. **Interface Frontend Melhorada**
|
||||
|
||||
### **Status Visual Atualizado:**
|
||||
```javascript
|
||||
// Novo badge para vendas com troca
|
||||
<span className={`badge ${
|
||||
selectedVenda.status === 'com_troca' ? 'badge-warning' :
|
||||
(selectedVenda.status || 'concluida') === 'concluida' ? 'badge-success' : 'badge-danger'
|
||||
}`}>
|
||||
{selectedVenda.status === 'com_troca' ? 'Com Troca' :
|
||||
(selectedVenda.status || 'concluida') === 'concluida' ? 'Concluída' : 'Cancelada'}
|
||||
</span>
|
||||
```
|
||||
|
||||
### **Nova Seção: Histórico de Devoluções/Trocas**
|
||||
```javascript
|
||||
{selectedVenda.devolucoes && selectedVenda.devolucoes.length > 0 && (
|
||||
<div className="view-section">
|
||||
<h3>Histórico de Devoluções/Trocas</h3>
|
||||
<div className="devolucoes-view">
|
||||
{selectedVenda.devolucoes.map((devolucao, index) => (
|
||||
<div key={index} className="devolucao-item">
|
||||
<div className="devolucao-header">
|
||||
<span className={`badge ${devolucao.tipo_operacao === 'troca' ? 'badge-warning' : 'badge-info'}`}>
|
||||
{devolucao.tipo_operacao === 'troca' ? 'Troca' : 'Devolução'}
|
||||
</span>
|
||||
<span className="devolucao-data">
|
||||
{new Date(devolucao.data_devolucao).toLocaleDateString('pt-BR')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="devolucao-details">
|
||||
<div>Quantidade: {devolucao.quantidade_devolvida}</div>
|
||||
<div>Valor: R$ {parseFloat(devolucao.valor_devolucao).toFixed(2)}</div>
|
||||
{devolucao.motivo && <div>Motivo: {devolucao.motivo}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### **Seção de Itens Aprimorada:**
|
||||
```javascript
|
||||
<h3>Itens da Venda {selectedVenda.tem_trocas ? '(Incluindo Trocas)' : ''}</h3>
|
||||
```
|
||||
|
||||
## 3. **Estilos CSS Implementados**
|
||||
|
||||
### **Modal Expandido:**
|
||||
```css
|
||||
.modal-lg {
|
||||
max-width: 900px !important;
|
||||
width: 95% !important;
|
||||
max-height: 90vh !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
```
|
||||
|
||||
### **Seção de Devoluções:**
|
||||
```css
|
||||
.devolucoes-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.devolucao-item {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.devolucao-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
```
|
||||
|
||||
### **Scroll para Muitos Itens:**
|
||||
```css
|
||||
.items-view {
|
||||
max-height: 400px !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
```
|
||||
|
||||
### **Badge de Troca:**
|
||||
```css
|
||||
.badge-warning {
|
||||
background-color: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
```
|
||||
|
||||
## 4. **Funcionalidades Implementadas**
|
||||
|
||||
### **✅ Status Correto:**
|
||||
- **"Concluída"** - Venda normal sem alterações
|
||||
- **"Com Troca"** - Venda que teve produtos trocados
|
||||
- **"Cancelada"** - Venda cancelada
|
||||
|
||||
### **✅ Histórico Completo:**
|
||||
- Data da devolução/troca
|
||||
- Tipo de operação (Devolução ou Troca)
|
||||
- Quantidade devolvida
|
||||
- Valor da operação
|
||||
- Motivo (quando informado)
|
||||
|
||||
### **✅ Visualização Melhorada:**
|
||||
- Modal maior para acomodar mais informações
|
||||
- Scroll automático quando há muitos itens
|
||||
- Seções organizadas e claras
|
||||
- Responsivo para mobile
|
||||
|
||||
### **✅ Informações Detalhadas:**
|
||||
- Todos os itens da venda (originais + trocados)
|
||||
- Histórico cronológico de operações
|
||||
- Valores atualizados após trocas
|
||||
- Status visual claro
|
||||
|
||||
## 5. **Exemplo de Uso**
|
||||
|
||||
### **Cenário: Troca do Produto 6-Branco pelo 4-Jeans**
|
||||
|
||||
**Antes da Correção:**
|
||||
- ❌ Status: "Concluída" (não indicava troca)
|
||||
- ❌ Não mostrava histórico de troca
|
||||
- ❌ Produtos cortados no modal
|
||||
- ❌ Sem informação sobre a operação
|
||||
|
||||
**Depois da Correção:**
|
||||
- ✅ **Status:** "Com Troca" (badge amarelo)
|
||||
- ✅ **Histórico:** Seção dedicada mostrando:
|
||||
- Data: 10/10/2025
|
||||
- Tipo: Troca
|
||||
- Quantidade: 1
|
||||
- Valor: R$ XX,XX
|
||||
- ✅ **Itens:** Mostra todos os produtos (original + trocado)
|
||||
- ✅ **Modal:** Expandido com scroll automático
|
||||
|
||||
## 6. **Fluxo Completo Corrigido**
|
||||
|
||||
### **1. Realizar Troca:**
|
||||
1. Cliente troca produto 6-Branco por 4-Jeans
|
||||
2. Sistema registra devolução + novo item
|
||||
3. Estoque atualizado corretamente
|
||||
|
||||
### **2. Visualizar Venda:**
|
||||
1. Status mostra "Com Troca"
|
||||
2. Histórico exibe a operação
|
||||
3. Itens mostram todos os produtos
|
||||
4. Modal expandido e organizado
|
||||
|
||||
### **3. Informações Disponíveis:**
|
||||
- ✅ Data e hora da troca
|
||||
- ✅ Produtos envolvidos
|
||||
- ✅ Valores atualizados
|
||||
- ✅ Status visual claro
|
||||
|
||||
## 🚀 **Benefícios Alcançados**
|
||||
|
||||
- **✅ Transparência Total:** Histórico completo de operações
|
||||
- **✅ Interface Profissional:** Modal organizado e responsivo
|
||||
- **✅ Status Claro:** Identificação visual de vendas com troca
|
||||
- **✅ Informações Completas:** Todos os dados em um local
|
||||
- **✅ Experiência Melhorada:** Navegação intuitiva
|
||||
- **✅ Controle Preciso:** Rastreamento de todas as operações
|
||||
|
||||
## 📋 **Como Testar**
|
||||
|
||||
1. **Acesse uma venda que teve troca**
|
||||
2. **Clique no ícone 👁️ (Visualizar)**
|
||||
3. **Verifique:**
|
||||
- Status "Com Troca" (badge amarelo)
|
||||
- Seção "Histórico de Devoluções/Trocas"
|
||||
- Todos os itens visíveis com scroll
|
||||
- Informações detalhadas da operação
|
||||
|
||||
## 🎯 **Status Final**
|
||||
|
||||
**✅ PROBLEMA COMPLETAMENTE RESOLVIDO**
|
||||
|
||||
A interface agora mostra:
|
||||
- ✅ Status correto das vendas com troca
|
||||
- ✅ Histórico completo de operações
|
||||
- ✅ Todos os produtos visíveis (sem corte)
|
||||
- ✅ Informações detalhadas e organizadas
|
||||
- ✅ Modal responsivo e profissional
|
||||
|
||||
**Teste agora a venda que teve a troca do produto 6-Branco pelo 4-Jeans e veja todas as informações organizadas e claras!** 🎉
|
||||
BIN
LogoLiberiKids.png
Normal file
BIN
LogoLiberiKids.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
252
MERCADOPAGO-PRODUCAO-GUIDE.md
Normal file
252
MERCADOPAGO-PRODUCAO-GUIDE.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# 🏦 Guia Completo: PIX Produção com Mercado Pago
|
||||
|
||||
## 📋 **REQUISITOS OBRIGATÓRIOS**
|
||||
|
||||
### **1. Conta Mercado Pago Empresarial**
|
||||
- ❌ **NÃO use conta pessoal**
|
||||
- ✅ **Conta empresarial com CNPJ**
|
||||
- ✅ **Verificação de identidade completa**
|
||||
- ✅ **Dados bancários confirmados**
|
||||
|
||||
### **2. Documentação Necessária**
|
||||
- **CNPJ** da empresa
|
||||
- **Comprovante de endereço** da empresa
|
||||
- **Documento do representante legal**
|
||||
- **Conta bancária** em nome da empresa
|
||||
|
||||
---
|
||||
|
||||
## 🚀 **PASSO A PASSO COMPLETO**
|
||||
|
||||
### **ETAPA 1: Criar Conta Empresarial**
|
||||
|
||||
1. **Acesse:** https://www.mercadopago.com.br/developers
|
||||
2. **Clique em:** "Criar conta"
|
||||
3. **Selecione:** "Conta empresarial"
|
||||
4. **Preencha:**
|
||||
- CNPJ da empresa
|
||||
- Razão social
|
||||
- Email empresarial
|
||||
- Telefone da empresa
|
||||
|
||||
### **ETAPA 2: Verificação de Identidade**
|
||||
|
||||
1. **Upload de documentos:**
|
||||
- Cartão CNPJ
|
||||
- Comprovante de endereço (máx 3 meses)
|
||||
- RG/CNH do representante legal
|
||||
|
||||
2. **Aguardar aprovação:**
|
||||
- Processo: 1-3 dias úteis
|
||||
- Email de confirmação
|
||||
|
||||
### **ETAPA 3: Configurar Conta Bancária**
|
||||
|
||||
1. **No painel Mercado Pago:**
|
||||
- Vá em "Conta" → "Dados bancários"
|
||||
- Adicione conta em nome da empresa
|
||||
- Confirme com micro depósitos
|
||||
|
||||
2. **Ativar PIX:**
|
||||
- Vá em "Conta" → "PIX"
|
||||
- Ative PIX para recebimentos
|
||||
- Configure chaves PIX
|
||||
|
||||
### **ETAPA 4: Criar Aplicação**
|
||||
|
||||
1. **No painel de desenvolvedores:**
|
||||
- Clique em "Criar aplicação"
|
||||
- **Nome:** Liberi Kids - Sistema de Vendas
|
||||
- **Produto:** Checkout Pro + Pagamentos online
|
||||
|
||||
2. **Configurar URLs:**
|
||||
```
|
||||
URL de sucesso: https://seudominio.com/sucesso
|
||||
URL de falha: https://seudominio.com/falha
|
||||
URL de webhook: https://seudominio.com/api/pix/webhook
|
||||
```
|
||||
|
||||
3. **Obter credenciais:**
|
||||
- Vá em "Credenciais"
|
||||
- Copie **Access Token** e **Public Key**
|
||||
|
||||
---
|
||||
|
||||
## 🔑 **TIPOS DE CREDENCIAIS**
|
||||
|
||||
### **Desenvolvimento (Sandbox):**
|
||||
```env
|
||||
MERCADOPAGO_ACCESS_TOKEN=TEST-1234567890-abcdef...
|
||||
MERCADOPAGO_PUBLIC_KEY=pk_test_1234567890abcdef...
|
||||
BASE_URL=http://localhost:5000
|
||||
```
|
||||
|
||||
### **Produção (Vendas Reais):**
|
||||
```env
|
||||
MERCADOPAGO_ACCESS_TOKEN=APP-1234567890-abcdef...
|
||||
MERCADOPAGO_PUBLIC_KEY=pk_live_1234567890abcdef...
|
||||
BASE_URL=https://seudominio.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ **CONFIGURAÇÃO NO SISTEMA**
|
||||
|
||||
### **Opção 1: Script Automático (Recomendado)**
|
||||
```bash
|
||||
# Execute o script de configuração:
|
||||
./configurar-producao-pix.sh
|
||||
```
|
||||
|
||||
### **Opção 2: Manual**
|
||||
```bash
|
||||
# Editar .env:
|
||||
nano .env
|
||||
|
||||
# Configurar:
|
||||
MERCADOPAGO_ACCESS_TOKEN=APP-sua_access_token_real
|
||||
MERCADOPAGO_PUBLIC_KEY=pk_live_sua_public_key_real
|
||||
BASE_URL=https://seudominio.com
|
||||
NODE_ENV=production
|
||||
|
||||
# Ativar versão de produção:
|
||||
sed -i "s|mercadopago-demo|mercadopago|g" server-supabase.js
|
||||
|
||||
# Reiniciar servidor:
|
||||
pm2 restart liberi-kids
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 **CONFIGURAR WEBHOOK**
|
||||
|
||||
### **1. No Painel Mercado Pago:**
|
||||
- Acesse sua aplicação
|
||||
- Vá em "Webhooks"
|
||||
- Clique em "Configurar webhook"
|
||||
|
||||
### **2. Configurações:**
|
||||
```
|
||||
URL: https://seudominio.com/api/pix/webhook
|
||||
Eventos: payment
|
||||
Versão: v1
|
||||
```
|
||||
|
||||
### **3. Testar Webhook:**
|
||||
- Use a ferramenta de teste do Mercado Pago
|
||||
- Verifique logs do servidor
|
||||
- Confirme recebimento das notificações
|
||||
|
||||
---
|
||||
|
||||
## 🧪 **TESTES OBRIGATÓRIOS**
|
||||
|
||||
### **1. Teste de Valor Baixo:**
|
||||
```bash
|
||||
# Gerar PIX de R$ 0,01:
|
||||
curl -X POST https://seudominio.com/api/pix/gerar \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"venda_id": "teste001", "valor": 0.01}'
|
||||
```
|
||||
|
||||
### **2. Verificações:**
|
||||
- ✅ QR Code gerado corretamente
|
||||
- ✅ Pagamento processado
|
||||
- ✅ Webhook recebido
|
||||
- ✅ Valor creditado na conta
|
||||
- ✅ Status atualizado no sistema
|
||||
|
||||
---
|
||||
|
||||
## 💰 **CUSTOS E TARIFAS**
|
||||
|
||||
### **PIX:**
|
||||
- **Taxa:** 0,99% por transação
|
||||
- **Sem mensalidade**
|
||||
- **Recebimento:** D+1 (1 dia útil)
|
||||
- **Limite:** Até R$ 1.000 por transação (padrão)
|
||||
|
||||
### **Outros Métodos:**
|
||||
- **Cartão de Crédito:** 2,99% + R$ 0,39
|
||||
- **Cartão de Débito:** 1,99% + R$ 0,39
|
||||
- **Boleto:** R$ 3,49 por boleto
|
||||
|
||||
---
|
||||
|
||||
## 🔒 **SEGURANÇA E BOAS PRÁTICAS**
|
||||
|
||||
### **1. Proteção de Credenciais:**
|
||||
```bash
|
||||
# Nunca commite .env no Git:
|
||||
echo ".env" >> .gitignore
|
||||
|
||||
# Use variáveis de ambiente no servidor:
|
||||
export MERCADOPAGO_ACCESS_TOKEN="APP-..."
|
||||
export MERCADOPAGO_PUBLIC_KEY="pk_live_..."
|
||||
```
|
||||
|
||||
### **2. HTTPS Obrigatório:**
|
||||
- Use certificado SSL válido
|
||||
- Configure redirect HTTP → HTTPS
|
||||
- Webhook só funciona com HTTPS
|
||||
|
||||
### **3. Monitoramento:**
|
||||
- Configure logs de transações
|
||||
- Monitore webhooks perdidos
|
||||
- Alerte para falhas de pagamento
|
||||
|
||||
### **4. Backup:**
|
||||
- Backup regular do banco de dados
|
||||
- Logs de todas as transações
|
||||
- Histórico de webhooks recebidos
|
||||
|
||||
---
|
||||
|
||||
## 🚨 **PROBLEMAS COMUNS**
|
||||
|
||||
### **Erro: "Unauthorized"**
|
||||
- ✅ Verificar se credenciais são de produção (APP-...)
|
||||
- ✅ Confirmar se conta está verificada
|
||||
- ✅ Verificar se PIX está ativado
|
||||
|
||||
### **Erro: "Invalid webhook URL"**
|
||||
- ✅ URL deve usar HTTPS
|
||||
- ✅ Servidor deve estar acessível publicamente
|
||||
- ✅ Endpoint /api/pix/webhook deve existir
|
||||
|
||||
### **PIX não aparece:**
|
||||
- ✅ Aplicar SQL no Supabase
|
||||
- ✅ Reiniciar servidor
|
||||
- ✅ Limpar cache do navegador
|
||||
|
||||
---
|
||||
|
||||
## 📞 **SUPORTE**
|
||||
|
||||
### **Mercado Pago:**
|
||||
- **Documentação:** https://www.mercadopago.com.br/developers/pt/docs
|
||||
- **Suporte:** https://www.mercadopago.com.br/ajuda
|
||||
- **Status:** https://status.mercadopago.com/
|
||||
|
||||
### **Liberi Kids:**
|
||||
- Verifique logs do servidor
|
||||
- Teste APIs individualmente
|
||||
- Confirme configurações do .env
|
||||
|
||||
---
|
||||
|
||||
## ✅ **CHECKLIST FINAL**
|
||||
|
||||
- [ ] Conta Mercado Pago empresarial criada
|
||||
- [ ] Verificação de identidade aprovada
|
||||
- [ ] Conta bancária configurada
|
||||
- [ ] PIX ativado na conta
|
||||
- [ ] Aplicação criada no painel
|
||||
- [ ] Credenciais de produção obtidas
|
||||
- [ ] Sistema configurado com credenciais reais
|
||||
- [ ] Webhook configurado e testado
|
||||
- [ ] Teste de pagamento realizado
|
||||
- [ ] HTTPS configurado
|
||||
- [ ] Monitoramento ativo
|
||||
|
||||
**Após completar todos os itens, seu PIX de produção estará funcionando!** 🎉
|
||||
161
README-DEPLOY.md
Normal file
161
README-DEPLOY.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# 🚀 Deploy Rápido - Liberi Kids
|
||||
|
||||
## ⚡ Deploy em 3 Comandos
|
||||
|
||||
### 1. 🖥️ **Servidor Local (Recomendado para começar)**
|
||||
|
||||
```bash
|
||||
# 1. Configure suas credenciais
|
||||
cp .env.example .env
|
||||
# Edite o .env com suas credenciais do Supabase
|
||||
|
||||
# 2. Execute o deploy automático
|
||||
npm run deploy:local
|
||||
|
||||
# 3. Acesse: http://localhost:5000
|
||||
```
|
||||
|
||||
### 2. ☁️ **Nuvem Gratuita (Vercel)**
|
||||
|
||||
```bash
|
||||
# 1. Deploy automático
|
||||
npm run deploy:vercel
|
||||
|
||||
# 2. Configure variáveis no dashboard do Vercel
|
||||
# 3. Acesse a URL fornecida
|
||||
```
|
||||
|
||||
### 3. 🐳 **Docker (Containerizado)**
|
||||
|
||||
```bash
|
||||
# 1. Configure o .env
|
||||
cp .env.example .env
|
||||
|
||||
# 2. Execute com Docker
|
||||
docker-compose up -d
|
||||
|
||||
# 3. Acesse: http://localhost:5000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 **Pré-requisitos Mínimos**
|
||||
|
||||
- ✅ Node.js 18+
|
||||
- ✅ Conta no Supabase (gratuita)
|
||||
- ✅ Credenciais do Supabase configuradas
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **Configuração Rápida do Supabase**
|
||||
|
||||
1. **Acesse:** https://supabase.com/dashboard
|
||||
2. **Crie um projeto** (ou use existente)
|
||||
3. **Vá em Settings > API**
|
||||
4. **Copie:**
|
||||
- Project URL → `SUPABASE_URL`
|
||||
- anon public key → `SUPABASE_ANON_KEY`
|
||||
5. **Cole no arquivo .env**
|
||||
|
||||
---
|
||||
|
||||
## 🌐 **Opções de Hospedagem**
|
||||
|
||||
| Plataforma | Custo | Facilidade | Recomendado Para |
|
||||
|------------|-------|------------|------------------|
|
||||
| **Vercel** | Gratuito | ⭐⭐⭐⭐⭐ | Pequenas empresas |
|
||||
| **Railway** | $5/mês | ⭐⭐⭐⭐ | Empresas médias |
|
||||
| **VPS** | $5-20/mês | ⭐⭐⭐ | Controle total |
|
||||
| **Local** | Gratuito | ⭐⭐⭐⭐ | Desenvolvimento |
|
||||
|
||||
---
|
||||
|
||||
## 🚨 **Resolução de Problemas**
|
||||
|
||||
### ❌ Erro: "SUPABASE_URL não definida"
|
||||
```bash
|
||||
# Solução: Configure o arquivo .env
|
||||
cp .env.example .env
|
||||
# Edite com suas credenciais
|
||||
```
|
||||
|
||||
### ❌ Erro: "Porta 5000 em uso"
|
||||
```bash
|
||||
# Solução: Mate processos na porta
|
||||
sudo lsof -ti:5000 | xargs kill -9
|
||||
```
|
||||
|
||||
### ❌ Erro: "Build falhou"
|
||||
```bash
|
||||
# Solução: Limpe e reinstale
|
||||
rm -rf node_modules client/node_modules
|
||||
npm install
|
||||
cd client && npm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 **Acesso Móvel**
|
||||
|
||||
A aplicação é **PWA** (Progressive Web App):
|
||||
- ✅ Funciona no celular
|
||||
- ✅ Pode ser "instalada"
|
||||
- ✅ Interface responsiva
|
||||
- ✅ Funciona offline (parcialmente)
|
||||
|
||||
---
|
||||
|
||||
## 🔒 **Segurança**
|
||||
|
||||
### Para Produção:
|
||||
- ✅ Use HTTPS sempre
|
||||
- ✅ Configure firewall
|
||||
- ✅ Mantenha Node.js atualizado
|
||||
- ✅ Use variáveis de ambiente
|
||||
- ✅ Backup regular do Supabase
|
||||
|
||||
---
|
||||
|
||||
## 📞 **Suporte Rápido**
|
||||
|
||||
### Logs de Erro:
|
||||
```bash
|
||||
# PM2 (local)
|
||||
pm2 logs liberi-kids
|
||||
|
||||
# Docker
|
||||
docker logs liberi-kids-app
|
||||
|
||||
# Vercel
|
||||
vercel logs
|
||||
```
|
||||
|
||||
### Comandos Úteis:
|
||||
```bash
|
||||
# Reiniciar aplicação
|
||||
pm2 restart liberi-kids
|
||||
|
||||
# Status do servidor
|
||||
pm2 status
|
||||
|
||||
# Parar aplicação
|
||||
pm2 stop liberi-kids
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **Próximos Passos**
|
||||
|
||||
Após o deploy:
|
||||
|
||||
1. ✅ **Teste todas as funcionalidades**
|
||||
2. ✅ **Configure backup automático**
|
||||
3. ✅ **Monitore performance**
|
||||
4. ✅ **Configure domínio próprio** (opcional)
|
||||
5. ✅ **Treine usuários**
|
||||
|
||||
---
|
||||
|
||||
**🎉 Sua aplicação está pronta para o mundo real!**
|
||||
|
||||
Para suporte detalhado, consulte: `DEPLOY-GUIDE.md`
|
||||
210
README.md
Normal file
210
README.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Liberi Kids - Sistema de Controle de Estoque
|
||||
|
||||
Sistema completo de controle de estoque desenvolvido especificamente para a **Liberi Kids - Moda Infantil**. Uma solução moderna e intuitiva para gerenciar produtos, clientes, fornecedores, despesas e vendas.
|
||||
|
||||
## 🚀 Funcionalidades
|
||||
|
||||
### 📦 Gestão de Produtos
|
||||
- Cadastro completo de produtos com marca, nome, estação e valores
|
||||
- Sistema de variações (tamanho, cor, quantidade)
|
||||
- Upload de fotos para cada variação
|
||||
- Controle de estoque em tempo real
|
||||
- Vinculação com fornecedores
|
||||
|
||||
### 👥 Gestão de Clientes
|
||||
- Cadastro completo com dados de contato
|
||||
- Histórico de compras
|
||||
- Informações de endereço e WhatsApp
|
||||
|
||||
### 🚛 Gestão de Fornecedores
|
||||
- Cadastro de fornecedores com dados comerciais
|
||||
- Controle de contatos (telefone, WhatsApp, e-mail)
|
||||
- Vinculação com produtos
|
||||
|
||||
### 💰 Controle de Despesas
|
||||
- Cadastro de diferentes tipos de despesas
|
||||
- Vinculação com fornecedores
|
||||
- Controle por data e valor
|
||||
- Relatórios de gastos mensais
|
||||
|
||||
### 🛒 Sistema de Vendas
|
||||
- Vendas à vista e parceladas
|
||||
- Controle de itens vendidos
|
||||
- Cálculo automático de totais
|
||||
- Aplicação de descontos
|
||||
- Histórico completo de vendas
|
||||
|
||||
### 📊 Dashboard Intuitivo
|
||||
- Métricas em tempo real
|
||||
- Gráficos de vendas por mês
|
||||
- Distribuição de produtos por estação
|
||||
- Resumo financeiro
|
||||
- Indicadores de performance
|
||||
|
||||
## 🛠️ Tecnologias Utilizadas
|
||||
|
||||
### Backend
|
||||
- **Node.js** - Runtime JavaScript
|
||||
- **Express.js** - Framework web
|
||||
- **SQLite** - Banco de dados
|
||||
- **Multer** - Upload de arquivos
|
||||
- **UUID** - Geração de IDs únicos
|
||||
|
||||
### Frontend
|
||||
- **React** - Biblioteca de interface
|
||||
- **React Router** - Roteamento
|
||||
- **Axios** - Cliente HTTP
|
||||
- **React Icons** - Ícones
|
||||
- **Recharts** - Gráficos
|
||||
- **React Hook Form** - Formulários
|
||||
- **React Hot Toast** - Notificações
|
||||
|
||||
## 📋 Pré-requisitos
|
||||
|
||||
- Node.js (versão 14 ou superior)
|
||||
- NPM ou Yarn
|
||||
|
||||
## 🔧 Instalação
|
||||
|
||||
1. **Clone o repositório ou navegue até a pasta do projeto:**
|
||||
```bash
|
||||
cd /home/tiago/Downloads/app_estoque
|
||||
```
|
||||
|
||||
2. **Instale as dependências do backend:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Instale as dependências do frontend:**
|
||||
```bash
|
||||
cd client
|
||||
npm install
|
||||
cd ..
|
||||
```
|
||||
|
||||
## 🚀 Como Executar
|
||||
|
||||
### Desenvolvimento
|
||||
|
||||
1. **Inicie o servidor backend:**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
O servidor será executado na porta 5000.
|
||||
|
||||
2. **Em outro terminal, inicie o frontend:**
|
||||
```bash
|
||||
npm run client
|
||||
```
|
||||
O frontend será executado na porta 3000.
|
||||
|
||||
3. **Acesse o sistema:**
|
||||
Abra seu navegador e vá para `http://localhost:3000`
|
||||
|
||||
### Produção
|
||||
|
||||
1. **Build do frontend:**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. **Inicie o servidor:**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
## 📁 Estrutura do Projeto
|
||||
|
||||
```
|
||||
app_estoque/
|
||||
├── client/ # Frontend React
|
||||
│ ├── public/
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Componentes reutilizáveis
|
||||
│ │ ├── pages/ # Páginas da aplicação
|
||||
│ │ ├── services/ # Serviços de API
|
||||
│ │ └── utils/ # Utilitários
|
||||
├── uploads/ # Arquivos enviados
|
||||
├── server.js # Servidor principal
|
||||
├── liberi_kids.db # Banco de dados SQLite
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 🎯 Como Usar
|
||||
|
||||
### 1. Dashboard
|
||||
- Visualize métricas gerais do negócio
|
||||
- Acompanhe vendas, estoque e despesas
|
||||
- Acesse ações rápidas
|
||||
|
||||
### 2. Produtos
|
||||
- Cadastre novos produtos com todas as informações
|
||||
- Adicione variações (tamanho, cor, quantidade)
|
||||
- Faça upload de fotos para cada variação
|
||||
- Gerencie o estoque
|
||||
|
||||
### 3. Clientes
|
||||
- Cadastre clientes com dados completos
|
||||
- Mantenha histórico de contatos
|
||||
- Organize informações para vendas
|
||||
|
||||
### 4. Fornecedores
|
||||
- Registre fornecedores e seus dados
|
||||
- Mantenha contatos organizados
|
||||
- Vincule produtos aos fornecedores
|
||||
|
||||
### 5. Despesas
|
||||
- Crie tipos de despesas personalizados
|
||||
- Registre todos os gastos da empresa
|
||||
- Acompanhe despesas por período
|
||||
|
||||
### 6. Vendas
|
||||
- Registre vendas à vista ou parceladas
|
||||
- Adicione múltiplos itens por venda
|
||||
- Aplique descontos
|
||||
- Controle o estoque automaticamente
|
||||
|
||||
## 🔒 Segurança
|
||||
|
||||
- Upload de arquivos com validação de tipo
|
||||
- Sanitização de dados de entrada
|
||||
- Controle de acesso às rotas da API
|
||||
|
||||
## 📱 Responsividade
|
||||
|
||||
O sistema é totalmente responsivo e funciona perfeitamente em:
|
||||
- Desktop
|
||||
- Tablets
|
||||
- Smartphones
|
||||
|
||||
## 🎨 Design
|
||||
|
||||
- Interface moderna e intuitiva
|
||||
- Cores e tipografia profissionais
|
||||
- Experiência de usuário otimizada
|
||||
- Animações suaves e feedback visual
|
||||
|
||||
## 📈 Próximas Funcionalidades
|
||||
|
||||
- [ ] Sistema de backup automático
|
||||
- [ ] Relatórios em PDF
|
||||
- [ ] Integração com WhatsApp
|
||||
- [ ] App mobile nativo
|
||||
- [ ] Sistema de usuários e permissões
|
||||
|
||||
## 🐛 Problemas Conhecidos
|
||||
|
||||
Nenhum problema conhecido no momento.
|
||||
|
||||
## 📞 Suporte
|
||||
|
||||
Para suporte técnico ou dúvidas sobre o sistema, entre em contato através dos canais oficiais da Liberi Kids.
|
||||
|
||||
## 📄 Licença
|
||||
|
||||
Este projeto foi desenvolvido exclusivamente para a **Liberi Kids - Moda Infantil**.
|
||||
|
||||
---
|
||||
|
||||
**Desenvolvido com ❤️ para a Liberi Kids - Moda Infantil**
|
||||
231
SISTEMA-DEVOLUCOES-CORRIGIDO.md
Normal file
231
SISTEMA-DEVOLUCOES-CORRIGIDO.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# 🔧 Sistema de Devoluções/Trocas - CORREÇÃO DEFINITIVA
|
||||
|
||||
## ❌ **Problemas Críticos Identificados e Resolvidos**
|
||||
|
||||
### **Problema Original:**
|
||||
- ✅ **RESOLVIDO:** Devoluções não zeravam quantidade do item na venda
|
||||
- ✅ **RESOLVIDO:** Sistema permitia devolver o mesmo item múltiplas vezes
|
||||
- ✅ **RESOLVIDO:** Valor da venda não era atualizado corretamente
|
||||
- ✅ **RESOLVIDO:** Falta de controle de disponibilidade para devolução
|
||||
- ✅ **RESOLVIDO:** Histórico incompleto das operações
|
||||
|
||||
## 🔧 **Soluções Implementadas**
|
||||
|
||||
### **1. Controle Correto de Quantidades**
|
||||
|
||||
**Antes:**
|
||||
```javascript
|
||||
// Item permanecia com quantidade original na venda
|
||||
// Permitia devoluções infinitas do mesmo item
|
||||
```
|
||||
|
||||
**Depois:**
|
||||
```javascript
|
||||
// ATUALIZAR ITEM DA VENDA - ZERAR QUANTIDADE DEVOLVIDA
|
||||
const novaQuantidadeItem = parseInt(itemOriginal.quantidade) - parseInt(quantidade_devolvida);
|
||||
const novoValorTotal = novaQuantidadeItem * parseFloat(itemOriginal.valor_unitario);
|
||||
|
||||
const { error: updateItemError } = await supabase
|
||||
.from('venda_itens')
|
||||
.update({
|
||||
quantidade: novaQuantidadeItem, // ZERA se devolução completa
|
||||
valor_total: novoValorTotal // ZERA valor proporcionalmente
|
||||
})
|
||||
.eq('id', item_id);
|
||||
```
|
||||
|
||||
### **2. Validação Anti-Duplicação**
|
||||
|
||||
```javascript
|
||||
// VERIFICAR SE JÁ FOI DEVOLVIDO COMPLETAMENTE
|
||||
const quantidadeDisponivel = parseInt(itemOriginal.quantidade) - quantidadeJaDevolvida;
|
||||
|
||||
if (quantidadeDisponivel <= 0) {
|
||||
throw new Error(`Este item já foi completamente devolvido/trocado anteriormente`);
|
||||
}
|
||||
```
|
||||
|
||||
### **3. Controle de Disponibilidade**
|
||||
|
||||
**API GET /api/devolucoes/vendas - VERSÃO CORRIGIDA:**
|
||||
```javascript
|
||||
// Só incluir se ainda há quantidade disponível para devolução
|
||||
if (quantidadeDisponivel > 0) {
|
||||
itensDisponiveis.push({
|
||||
...item,
|
||||
quantidade_disponivel: quantidadeDisponivel,
|
||||
quantidade_original: parseInt(item.quantidade),
|
||||
quantidade_devolvida: quantidadeDevolvida,
|
||||
pode_devolver: true
|
||||
});
|
||||
}
|
||||
|
||||
// Filtrar apenas vendas que ainda têm itens disponíveis
|
||||
const vendasComItens = vendasProcessadas.filter(venda => venda.tem_itens_disponiveis);
|
||||
```
|
||||
|
||||
### **4. Histórico Completo**
|
||||
|
||||
**Nova API GET /api/devolucoes/venda/:venda_id:**
|
||||
```javascript
|
||||
const historico = data.map(dev => ({
|
||||
...dev,
|
||||
produto_info: {
|
||||
nome: `${dev.venda_itens.produtos?.marca} - ${dev.venda_itens.produtos?.nome}`,
|
||||
codigo: dev.venda_itens.produtos?.id_produto,
|
||||
variacao: `${dev.venda_itens.produto_variacoes?.tamanho} - ${dev.venda_itens.produto_variacoes?.cor}`,
|
||||
quantidade_original: dev.venda_itens.quantidade_original,
|
||||
valor_unitario_original: dev.venda_itens.valor_unitario_original
|
||||
}
|
||||
}));
|
||||
```
|
||||
|
||||
## 🔄 **Fluxos Corrigidos**
|
||||
|
||||
### **Devolução Simples:**
|
||||
1. ✅ **Verificar disponibilidade:** Item ainda pode ser devolvido?
|
||||
2. ✅ **Atualizar venda:** Reduzir quantidade e valor do item
|
||||
3. ✅ **Retornar ao estoque:** Produto volta ao estoque
|
||||
4. ✅ **Registrar histórico:** Data, motivo, quantidades, valores
|
||||
5. ✅ **Atualizar valor da venda:** Novo total calculado
|
||||
|
||||
### **Troca de Produto:**
|
||||
1. ✅ **Validar estoque:** Produto novo tem estoque?
|
||||
2. ✅ **Processar devolução:** Item original zerado na venda
|
||||
3. ✅ **Retornar ao estoque:** Produto original volta
|
||||
4. ✅ **Reduzir estoque:** Produto novo sai do estoque
|
||||
5. ✅ **Adicionar à venda:** Novo item adicionado
|
||||
6. ✅ **Calcular diferença:** Atualizar valor total
|
||||
7. ✅ **Registrar histórico:** Operação completa documentada
|
||||
|
||||
## 📊 **Exemplo Prático**
|
||||
|
||||
### **Cenário: Devolução de 1 Camiseta de uma venda de 2**
|
||||
|
||||
**Antes da Correção:**
|
||||
- Venda: 2 camisetas por R$ 100,00
|
||||
- Após devolução: Ainda mostrava 2 camisetas na venda
|
||||
- Permitia devolver novamente as mesmas 2 camisetas
|
||||
|
||||
**Depois da Correção:**
|
||||
- Venda: 2 camisetas por R$ 100,00
|
||||
- Após devolução de 1: **1 camiseta por R$ 50,00**
|
||||
- Sistema só permite devolver a 1 camiseta restante
|
||||
- Histórico mostra: "1 camiseta devolvida em DD/MM/AAAA - Motivo: Defeito"
|
||||
|
||||
## 🛡️ **Validações Implementadas**
|
||||
|
||||
### **1. Controle de Quantidade:**
|
||||
```javascript
|
||||
if (parseInt(quantidade_devolvida) > quantidadeDisponivel) {
|
||||
throw new Error(`Quantidade de devolução (${quantidade_devolvida}) maior que disponível (${quantidadeDisponivel})`);
|
||||
}
|
||||
```
|
||||
|
||||
### **2. Verificação de Estoque (Trocas):**
|
||||
```javascript
|
||||
if (!variacao || variacao.quantidade < parseInt(quantidade)) {
|
||||
throw new Error(`Estoque insuficiente para troca. Disponível: ${estoqueDisponivel}, solicitado: ${quantidade}`);
|
||||
}
|
||||
```
|
||||
|
||||
### **3. Prevenção de Duplicatas:**
|
||||
```javascript
|
||||
if (quantidadeDisponivel <= 0) {
|
||||
throw new Error(`Este item já foi completamente devolvido/trocado anteriormente`);
|
||||
}
|
||||
```
|
||||
|
||||
## 📈 **Benefícios Alcançados**
|
||||
|
||||
### **✅ Controle Preciso:**
|
||||
- Quantidades corretas em tempo real
|
||||
- Impossível devolver mais que vendido
|
||||
- Valores sempre atualizados
|
||||
|
||||
### **✅ Histórico Completo:**
|
||||
- Data e hora de cada operação
|
||||
- Motivo informado pelo usuário
|
||||
- Produtos e quantidades envolvidas
|
||||
- Valores originais e devolvidos
|
||||
|
||||
### **✅ Interface Inteligente:**
|
||||
- Só mostra itens disponíveis para devolução
|
||||
- Quantidades disponíveis vs originais
|
||||
- Status visual claro
|
||||
|
||||
### **✅ Estoque Preciso:**
|
||||
- Entrada correta em devoluções
|
||||
- Controle bidirecional em trocas
|
||||
- Logs detalhados de movimentação
|
||||
|
||||
## 🧪 **Como Testar**
|
||||
|
||||
### **1. Teste de Devolução:**
|
||||
```bash
|
||||
# 1. Faça uma venda de 2 produtos iguais
|
||||
# 2. Vá em Devoluções/Trocas
|
||||
# 3. Devolva 1 produto
|
||||
# 4. Verifique: venda agora mostra apenas 1 produto
|
||||
# 5. Tente devolver novamente: só permite devolver o 1 restante
|
||||
```
|
||||
|
||||
### **2. Teste de Troca:**
|
||||
```bash
|
||||
# 1. Faça uma venda
|
||||
# 2. Vá em Devoluções/Trocas → Troca
|
||||
# 3. Selecione produto para devolver + produto novo
|
||||
# 4. Verifique:
|
||||
# - Produto original zerado na venda
|
||||
# - Produto novo adicionado à venda
|
||||
# - Estoque atualizado corretamente
|
||||
# - Valor da venda recalculado
|
||||
```
|
||||
|
||||
### **3. Teste de Histórico:**
|
||||
```bash
|
||||
# 1. Após qualquer devolução/troca
|
||||
# 2. Vá em Vendas → Visualizar venda
|
||||
# 3. Verifique seção "Histórico de Devoluções/Trocas"
|
||||
# 4. Confirme: data, motivo, produtos, quantidades
|
||||
```
|
||||
|
||||
## 🔍 **APIs Corrigidas**
|
||||
|
||||
### **GET /api/devolucoes/vendas**
|
||||
- ✅ Filtra apenas itens disponíveis para devolução
|
||||
- ✅ Mostra quantidade disponível vs original
|
||||
- ✅ Remove vendas sem itens disponíveis
|
||||
|
||||
### **POST /api/devolucoes**
|
||||
- ✅ Atualiza quantidade e valor do item na venda
|
||||
- ✅ Controla estoque corretamente
|
||||
- ✅ Registra histórico completo
|
||||
- ✅ Calcula valores corretamente
|
||||
|
||||
### **GET /api/devolucoes/venda/:id**
|
||||
- ✅ Histórico detalhado por venda
|
||||
- ✅ Informações completas dos produtos
|
||||
- ✅ Quantidades e valores originais vs devolvidos
|
||||
|
||||
## 🎯 **Status Final**
|
||||
|
||||
**✅ PROBLEMA COMPLETAMENTE RESOLVIDO**
|
||||
|
||||
O sistema agora:
|
||||
- ✅ **Zera quantidades** de itens devolvidos
|
||||
- ✅ **Impede devoluções duplicadas** do mesmo item
|
||||
- ✅ **Controla estoque precisamente** em devoluções e trocas
|
||||
- ✅ **Atualiza valores** da venda automaticamente
|
||||
- ✅ **Mantém histórico completo** de todas as operações
|
||||
- ✅ **Mostra apenas itens disponíveis** para devolução
|
||||
- ✅ **Valida todas as operações** antes de executar
|
||||
|
||||
## 🚀 **Teste Agora!**
|
||||
|
||||
1. **Faça uma devolução** e veja o item ser zerado na venda
|
||||
2. **Tente devolver novamente** - sistema impedirá
|
||||
3. **Faça uma troca** e veja o controle bidirecional de estoque
|
||||
4. **Verifique o histórico** completo na visualização da venda
|
||||
|
||||
**O sistema está funcionando perfeitamente!** 🎉
|
||||
148
SUPABASE-SETUP.md
Normal file
148
SUPABASE-SETUP.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# 🚀 Configuração do Supabase - Liberi Kids Estoque
|
||||
|
||||
## 📋 Instruções para Configurar o Banco de Dados
|
||||
|
||||
### 1. **Acesse o Supabase Dashboard**
|
||||
- Vá para: https://xyqmlesqdqybiyjofysb.supabase.co
|
||||
- Faça login na sua conta
|
||||
|
||||
### 2. **Execute o Script SQL**
|
||||
1. No dashboard do Supabase, vá para **SQL Editor**
|
||||
2. Clique em **New Query**
|
||||
3. Copie e cole todo o conteúdo do arquivo `sql/create-tables.sql`
|
||||
4. Clique em **Run** para executar
|
||||
|
||||
### 3. **Criar Função de Atualização de Estoque**
|
||||
1. Ainda no SQL Editor, crie uma nova query
|
||||
2. Copie e cole o conteúdo do arquivo `sql/functions.sql`
|
||||
3. Execute a query
|
||||
|
||||
### 4. **Verificar Tabelas Criadas**
|
||||
1. Vá para **Table Editor** no menu lateral
|
||||
2. Você deve ver as seguintes tabelas:
|
||||
- ✅ `fornecedores`
|
||||
- ✅ `produtos`
|
||||
- ✅ `produto_variacoes`
|
||||
- ✅ `clientes`
|
||||
- ✅ `tipos_despesas`
|
||||
- ✅ `despesas`
|
||||
- ✅ `vendas`
|
||||
- ✅ `venda_itens`
|
||||
|
||||
### 5. **Configurar Políticas RLS (Row Level Security)**
|
||||
As políticas já estão incluídas no script SQL, mas você pode ajustá-las conforme necessário:
|
||||
|
||||
1. Vá para **Authentication** > **Policies**
|
||||
2. Verifique se todas as tabelas têm políticas habilitadas
|
||||
3. As políticas atuais permitem todas as operações para usuários autenticados
|
||||
|
||||
### 6. **Testar a Conexão**
|
||||
1. Inicie o servidor: `npm start`
|
||||
2. Acesse: http://localhost:5000
|
||||
3. Clique no botão "Testar API" na página de produtos
|
||||
4. Deve aparecer: "API Supabase funcionando corretamente!"
|
||||
|
||||
## 🔧 **Estrutura das Tabelas**
|
||||
|
||||
### **Fornecedores**
|
||||
- `id` (UUID, PK)
|
||||
- `razao_social` (TEXT, NOT NULL)
|
||||
- `telefone`, `whatsapp`, `endereco`, `email` (TEXT)
|
||||
- `created_at`, `updated_at` (TIMESTAMP)
|
||||
|
||||
### **Produtos**
|
||||
- `id` (UUID, PK)
|
||||
- `id_produto` (TEXT) - ID personalizado
|
||||
- `marca`, `nome` (TEXT, NOT NULL)
|
||||
- `estacao` (TEXT, NOT NULL)
|
||||
- `genero` (TEXT, DEFAULT 'Unissex')
|
||||
- `fornecedor_id` (UUID, FK)
|
||||
- `valor_compra`, `valor_revenda` (DECIMAL)
|
||||
- `foto_principal_url` (TEXT)
|
||||
- `created_at`, `updated_at` (TIMESTAMP)
|
||||
|
||||
### **Produto Variações**
|
||||
- `id` (UUID, PK)
|
||||
- `produto_id` (UUID, FK, NOT NULL)
|
||||
- `tamanho`, `cor` (TEXT, NOT NULL)
|
||||
- `quantidade` (INTEGER, DEFAULT 0)
|
||||
- `foto_url` (TEXT)
|
||||
- `created_at`, `updated_at` (TIMESTAMP)
|
||||
|
||||
### **Clientes**
|
||||
- `id` (UUID, PK)
|
||||
- `nome_completo` (TEXT, NOT NULL)
|
||||
- `email`, `telefone`, `whatsapp`, `endereco` (TEXT)
|
||||
- `created_at`, `updated_at` (TIMESTAMP)
|
||||
|
||||
### **Tipos de Despesas**
|
||||
- `id` (UUID, PK)
|
||||
- `nome` (TEXT, NOT NULL, UNIQUE)
|
||||
- `descricao` (TEXT)
|
||||
- `created_at` (TIMESTAMP)
|
||||
|
||||
### **Despesas**
|
||||
- `id` (UUID, PK)
|
||||
- `tipo_despesa_id` (UUID, FK, NOT NULL)
|
||||
- `fornecedor_id` (UUID, FK)
|
||||
- `data_despesa` (DATE, NOT NULL)
|
||||
- `valor` (DECIMAL, NOT NULL)
|
||||
- `descricao` (TEXT)
|
||||
- `created_at`, `updated_at` (TIMESTAMP)
|
||||
|
||||
### **Vendas**
|
||||
- `id` (UUID, PK)
|
||||
- `cliente_id` (UUID, FK)
|
||||
- `tipo_pagamento` (TEXT, CHECK: 'vista' ou 'parcelado')
|
||||
- `valor_total` (DECIMAL, NOT NULL)
|
||||
- `desconto` (DECIMAL, DEFAULT 0)
|
||||
- `parcelas` (INTEGER, DEFAULT 1)
|
||||
- `valor_parcela` (DECIMAL, DEFAULT 0)
|
||||
- `data_venda` (DATE, NOT NULL)
|
||||
- `observacoes` (TEXT)
|
||||
- `created_at`, `updated_at` (TIMESTAMP)
|
||||
|
||||
### **Venda Itens**
|
||||
- `id` (UUID, PK)
|
||||
- `venda_id` (UUID, FK, NOT NULL)
|
||||
- `produto_id` (UUID, FK, NOT NULL)
|
||||
- `variacao_id` (UUID, FK)
|
||||
- `quantidade` (INTEGER, NOT NULL)
|
||||
- `valor_unitario`, `valor_total` (DECIMAL, NOT NULL)
|
||||
- `created_at` (TIMESTAMP)
|
||||
|
||||
## 🎯 **Vantagens do Supabase**
|
||||
|
||||
✅ **Banco PostgreSQL robusto**
|
||||
✅ **Backup automático**
|
||||
✅ **Escalabilidade**
|
||||
✅ **Interface web para gerenciamento**
|
||||
✅ **APIs REST automáticas**
|
||||
✅ **Segurança com RLS**
|
||||
✅ **Sem problemas de concorrência**
|
||||
|
||||
## 🚨 **Comandos Importantes**
|
||||
|
||||
```bash
|
||||
# Iniciar com Supabase (padrão)
|
||||
npm start
|
||||
|
||||
# Iniciar com SQLite (backup)
|
||||
npm run start-sqlite
|
||||
|
||||
# Desenvolvimento com Supabase
|
||||
npm run dev
|
||||
|
||||
# Testar conexão com Supabase
|
||||
npm run init-supabase
|
||||
```
|
||||
|
||||
## 📞 **Suporte**
|
||||
|
||||
Se houver algum problema:
|
||||
1. Verifique se as tabelas foram criadas corretamente
|
||||
2. Confirme se as políticas RLS estão ativas
|
||||
3. Teste a conexão com o botão "Testar API"
|
||||
4. Verifique os logs do servidor no terminal
|
||||
|
||||
**O sistema agora está usando Supabase e deve funcionar perfeitamente! 🎉**
|
||||
256
TESTAR-PIX-COMPLETO.md
Normal file
256
TESTAR-PIX-COMPLETO.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# 🧪 Como Testar PIX - Guia Completo
|
||||
|
||||
## 📋 **Pré-requisitos para Teste**
|
||||
|
||||
Antes de testar, certifique-se que:
|
||||
- ✅ Deploy foi feito para o servidor
|
||||
- ✅ SQL foi aplicado no Supabase
|
||||
- ✅ Credenciais Mercado Pago configuradas no .env
|
||||
- ✅ Servidor reiniciado com PM2
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **1. Verificar Configuração no Servidor**
|
||||
|
||||
### **Conectar no servidor:**
|
||||
```bash
|
||||
ssh tiago@192.168.195.145
|
||||
cd ~/app_estoque
|
||||
```
|
||||
|
||||
### **Verificar se .env está configurado:**
|
||||
```bash
|
||||
cat .env | grep MERCADOPAGO
|
||||
```
|
||||
|
||||
**Deve mostrar algo como:**
|
||||
```
|
||||
MERCADOPAGO_ACCESS_TOKEN=TEST-1234567890-abcdef...
|
||||
MERCADOPAGO_PUBLIC_KEY=pk_test_1234567890abcdef...
|
||||
BASE_URL=http://192.168.195.145:5000
|
||||
```
|
||||
|
||||
### **Verificar se servidor está rodando:**
|
||||
```bash
|
||||
pm2 status
|
||||
pm2 logs liberi-kids --lines 20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ **2. Verificar Banco de Dados**
|
||||
|
||||
### **No painel do Supabase:**
|
||||
1. Acesse: https://supabase.com/dashboard
|
||||
2. Vá em **SQL Editor**
|
||||
3. Execute para verificar se colunas foram criadas:
|
||||
|
||||
```sql
|
||||
SELECT column_name, data_type, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'vendas'
|
||||
AND column_name IN ('status_pagamento', 'data_pagamento', 'pix_payment_id', 'pix_qr_code', 'metodo_pagamento')
|
||||
ORDER BY column_name;
|
||||
```
|
||||
|
||||
**Deve retornar 5 linhas com as colunas PIX.**
|
||||
|
||||
---
|
||||
|
||||
## 🌐 **3. Teste da Interface Web**
|
||||
|
||||
### **Acesse o sistema:**
|
||||
```
|
||||
http://192.168.195.145:5000
|
||||
```
|
||||
|
||||
### **Navegue para Vendas:**
|
||||
1. **Menu lateral** → **Vendas**
|
||||
2. **Procure uma venda** na lista
|
||||
3. **Verifique se aparece o botão PIX** (ícone 💳)
|
||||
|
||||
---
|
||||
|
||||
## 💳 **4. Teste do Modal PIX**
|
||||
|
||||
### **Clique no botão PIX de uma venda:**
|
||||
|
||||
**✅ O que deve acontecer:**
|
||||
- Modal PIX abre instantaneamente
|
||||
- Título: "Pagamento PIX - Venda #123"
|
||||
- **QR Code** aparece (imagem quadrada)
|
||||
- **Código PIX** aparece (texto longo para copiar)
|
||||
- **Timer** mostra 30:00 (30 minutos)
|
||||
- **Status:** "Aguardando pagamento..."
|
||||
|
||||
### **❌ Se der erro, verifique:**
|
||||
- Console do navegador (F12)
|
||||
- Logs do servidor: `pm2 logs liberi-kids`
|
||||
- Se credenciais estão corretas no .env
|
||||
|
||||
---
|
||||
|
||||
## 🔍 **5. Teste das APIs Diretamente**
|
||||
|
||||
### **No servidor, teste as APIs:**
|
||||
|
||||
```bash
|
||||
# Teste 1: Gerar PIX para venda ID 1
|
||||
curl -X POST http://localhost:5000/api/pix/gerar \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"vendaId": 1}'
|
||||
|
||||
# Deve retornar: payment_id, qr_code, qr_code_base64
|
||||
```
|
||||
|
||||
```bash
|
||||
# Teste 2: Consultar status (use o payment_id do teste anterior)
|
||||
curl http://localhost:5000/api/pix/status/SEU_PAYMENT_ID
|
||||
|
||||
# Deve retornar: status, payment_id, etc.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 **6. Teste de Pagamento Real (Sandbox)**
|
||||
|
||||
### **Com credenciais TEST do Mercado Pago:**
|
||||
|
||||
1. **Gere um PIX** no sistema
|
||||
2. **Use o app de teste** do Mercado Pago
|
||||
3. **Escaneie o QR Code** ou **copie o código PIX**
|
||||
4. **Simule o pagamento** no ambiente de teste
|
||||
|
||||
### **Cartões de teste Mercado Pago:**
|
||||
```
|
||||
Cartão aprovado: 4111 1111 1111 1111
|
||||
CVV: 123
|
||||
Vencimento: 12/25
|
||||
Nome: APRO
|
||||
CPF: 12345678909
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 **7. Teste do Webhook (Confirmação Automática)**
|
||||
|
||||
### **Configurar webhook no Mercado Pago:**
|
||||
1. Acesse: https://www.mercadopago.com.br/developers
|
||||
2. **Sua aplicação** → **Webhooks**
|
||||
3. **URL:** `http://192.168.195.145:5000/api/pix/webhook`
|
||||
4. **Eventos:** `payment`
|
||||
|
||||
### **Teste a confirmação:**
|
||||
1. **Faça um pagamento** de teste
|
||||
2. **Aguarde alguns segundos**
|
||||
3. **Verifique se status mudou** para "Pago" no sistema
|
||||
|
||||
---
|
||||
|
||||
## 📊 **8. Verificar Logs e Monitoramento**
|
||||
|
||||
### **Logs do servidor:**
|
||||
```bash
|
||||
# Ver logs em tempo real:
|
||||
pm2 logs liberi-kids --lines 50
|
||||
|
||||
# Ver apenas erros:
|
||||
pm2 logs liberi-kids --err
|
||||
|
||||
# Ver logs específicos do PIX:
|
||||
pm2 logs liberi-kids | grep -i pix
|
||||
```
|
||||
|
||||
### **Console do navegador:**
|
||||
- **F12** → **Console**
|
||||
- Procure por erros em vermelho
|
||||
- Verifique chamadas de API na aba **Network**
|
||||
|
||||
---
|
||||
|
||||
## ✅ **Checklist de Teste Completo**
|
||||
|
||||
### **Configuração:**
|
||||
- [ ] Credenciais Mercado Pago no .env
|
||||
- [ ] SQL aplicado no Supabase
|
||||
- [ ] Servidor rodando (pm2 status)
|
||||
- [ ] Build do frontend atualizado
|
||||
|
||||
### **Interface:**
|
||||
- [ ] Botão PIX aparece nas vendas
|
||||
- [ ] Modal PIX abre sem erro
|
||||
- [ ] QR Code é exibido
|
||||
- [ ] Código PIX aparece para copiar
|
||||
- [ ] Timer funciona (30 minutos)
|
||||
|
||||
### **APIs:**
|
||||
- [ ] POST /api/pix/gerar retorna payment_id
|
||||
- [ ] GET /api/pix/status retorna dados corretos
|
||||
- [ ] Webhook recebe confirmações
|
||||
|
||||
### **Pagamento:**
|
||||
- [ ] QR Code pode ser escaneado
|
||||
- [ ] Código PIX pode ser colado no banco
|
||||
- [ ] Status atualiza automaticamente
|
||||
- [ ] Venda fica marcada como "Paga"
|
||||
|
||||
---
|
||||
|
||||
## 🚨 **Problemas Comuns e Soluções**
|
||||
|
||||
### **Erro: "Access token inválido"**
|
||||
```bash
|
||||
# Verifique se token está correto:
|
||||
cat .env | grep MERCADOPAGO_ACCESS_TOKEN
|
||||
# Deve começar com TEST- ou APP-
|
||||
```
|
||||
|
||||
### **Erro: "QR Code não aparece"**
|
||||
```bash
|
||||
# Verifique logs:
|
||||
pm2 logs liberi-kids | grep -i error
|
||||
# Reinstale dependência:
|
||||
npm install mercadopago
|
||||
```
|
||||
|
||||
### **Erro: "Webhook não funciona"**
|
||||
- Verifique se URL está correta no Mercado Pago
|
||||
- Teste se servidor está acessível externamente
|
||||
- Configure firewall se necessário
|
||||
|
||||
### **Erro: "Modal não abre"**
|
||||
- Verifique console do navegador (F12)
|
||||
- Limpe cache do navegador (Ctrl+F5)
|
||||
- Verifique se build foi feito corretamente
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **Teste Rápido (2 minutos)**
|
||||
|
||||
```bash
|
||||
# 1. Conectar no servidor
|
||||
ssh tiago@192.168.195.145
|
||||
|
||||
# 2. Verificar status
|
||||
cd ~/app_estoque && pm2 status
|
||||
|
||||
# 3. Testar API
|
||||
curl -X POST http://localhost:5000/api/pix/gerar -H "Content-Type: application/json" -d '{"vendaId": 1}'
|
||||
|
||||
# 4. Acessar sistema
|
||||
# http://192.168.195.145:5000 → Vendas → Botão PIX
|
||||
```
|
||||
|
||||
**Se tudo funcionar, seu PIX está 100% operacional!** 🎉
|
||||
|
||||
---
|
||||
|
||||
## 📞 **Suporte**
|
||||
|
||||
Se encontrar problemas:
|
||||
1. **Verifique os logs** primeiro
|
||||
2. **Teste as APIs** diretamente
|
||||
3. **Confirme as credenciais** do Mercado Pago
|
||||
4. **Reinicie o servidor** se necessário
|
||||
|
||||
**Seu sistema PIX está pronto para processar pagamentos reais!** 💳🏦
|
||||
27
aplicar-pix-supabase.sql
Normal file
27
aplicar-pix-supabase.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- Script para aplicar campos PIX diretamente no Supabase
|
||||
-- Execute este SQL no painel do Supabase ou via psql
|
||||
|
||||
-- Adicionar campos PIX na tabela vendas
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS status_pagamento VARCHAR(20) DEFAULT 'pendente';
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS data_pagamento TIMESTAMP;
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS pix_payment_id VARCHAR(100);
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS pix_qr_code TEXT;
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS metodo_pagamento VARCHAR(20) DEFAULT 'dinheiro';
|
||||
|
||||
-- Criar índices para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_vendas_status_pagamento ON vendas(status_pagamento);
|
||||
CREATE INDEX IF NOT EXISTS idx_vendas_pix_payment_id ON vendas(pix_payment_id);
|
||||
|
||||
-- Comentários para documentação
|
||||
COMMENT ON COLUMN vendas.status_pagamento IS 'Status do pagamento: pendente, pago, cancelado, expirado';
|
||||
COMMENT ON COLUMN vendas.data_pagamento IS 'Data e hora da confirmação do pagamento';
|
||||
COMMENT ON COLUMN vendas.pix_payment_id IS 'ID do pagamento no Mercado Pago';
|
||||
COMMENT ON COLUMN vendas.pix_qr_code IS 'Código PIX para copiar e colar';
|
||||
COMMENT ON COLUMN vendas.metodo_pagamento IS 'Método: dinheiro, cartao, pix, transferencia';
|
||||
|
||||
-- Verificar se as colunas foram criadas
|
||||
SELECT column_name, data_type, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'vendas'
|
||||
AND column_name IN ('status_pagamento', 'data_pagamento', 'pix_payment_id', 'pix_qr_code', 'metodo_pagamento')
|
||||
ORDER BY column_name;
|
||||
159
backup-projeto-completo.sh
Executable file
159
backup-projeto-completo.sh
Executable file
@@ -0,0 +1,159 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 💾 Backup COMPLETO do Projeto Liberi Kids
|
||||
# Cria backup de tudo antes de fazer deploy
|
||||
# Execute: ./backup-projeto-completo.sh
|
||||
|
||||
echo "💾 BACKUP COMPLETO - Liberi Kids"
|
||||
echo "================================"
|
||||
|
||||
# Cores para output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
echo -e "${GREEN}[✅ OK]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[⚠️ WARN]${NC} $1"
|
||||
}
|
||||
|
||||
info() {
|
||||
echo -e "${BLUE}[ℹ️ INFO]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[❌ ERRO]${NC} $1"
|
||||
}
|
||||
|
||||
# Configurações
|
||||
BACKUP_DIR="$HOME/liberi-backups"
|
||||
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||
BACKUP_NAME="liberi-kids-backup-$TIMESTAMP"
|
||||
BACKUP_PATH="$BACKUP_DIR/$BACKUP_NAME"
|
||||
|
||||
echo ""
|
||||
info "📋 ESTE BACKUP INCLUIRÁ:"
|
||||
echo " 📁 Código fonte completo"
|
||||
echo " 🔧 Configurações (.env)"
|
||||
echo " 📦 Frontend compilado"
|
||||
echo " 🗄️ Scripts e documentação"
|
||||
echo " 🏦 Configurações PIX"
|
||||
echo " 📚 Todos os arquivos do projeto"
|
||||
echo ""
|
||||
|
||||
# Criar diretório de backup
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
echo ""
|
||||
info "💾 CRIANDO BACKUP COMPLETO..."
|
||||
|
||||
# Criar backup
|
||||
cp -r . $BACKUP_PATH
|
||||
|
||||
# Remover arquivos desnecessários do backup
|
||||
rm -rf $BACKUP_PATH/node_modules 2>/dev/null || true
|
||||
rm -rf $BACKUP_PATH/client/node_modules 2>/dev/null || true
|
||||
rm -rf $BACKUP_PATH/.git 2>/dev/null || true
|
||||
rm -rf $BACKUP_PATH/logs 2>/dev/null || true
|
||||
rm -rf $BACKUP_PATH/uploads/* 2>/dev/null || true
|
||||
|
||||
log "✅ Backup criado: $BACKUP_PATH"
|
||||
|
||||
echo ""
|
||||
info "📊 ESTATÍSTICAS DO BACKUP:"
|
||||
|
||||
# Contar arquivos
|
||||
TOTAL_FILES=$(find $BACKUP_PATH -type f | wc -l)
|
||||
TOTAL_SIZE=$(du -sh $BACKUP_PATH | cut -f1)
|
||||
|
||||
echo " 📁 Total de arquivos: $TOTAL_FILES"
|
||||
echo " 💾 Tamanho total: $TOTAL_SIZE"
|
||||
echo " 📅 Data: $(date)"
|
||||
echo ""
|
||||
|
||||
info "📋 CONTEÚDO DO BACKUP:"
|
||||
echo " ✅ server-supabase.js - Servidor principal"
|
||||
echo " ✅ client/ - Frontend React"
|
||||
echo " ✅ config/ - Configurações (Supabase, PIX, Google)"
|
||||
echo " ✅ package.json - Dependências"
|
||||
echo " ✅ .env - Credenciais (se existir)"
|
||||
echo " ✅ *.sh - Scripts de deploy"
|
||||
echo " ✅ *.md - Documentação"
|
||||
echo " ✅ *.sql - Scripts de banco"
|
||||
echo ""
|
||||
|
||||
# Criar arquivo de informações do backup
|
||||
cat > $BACKUP_PATH/BACKUP-INFO.txt << EOF
|
||||
BACKUP LIBERI KIDS
|
||||
==================
|
||||
|
||||
Data: $(date)
|
||||
Versão: Completa com PIX
|
||||
Servidor: Supabase
|
||||
Frontend: React compilado
|
||||
|
||||
CONTEÚDO:
|
||||
- Código fonte completo
|
||||
- Configurações PIX Mercado Pago
|
||||
- Frontend compilado
|
||||
- Scripts de deploy
|
||||
- Documentação completa
|
||||
|
||||
RESTAURAR:
|
||||
1. Extrair backup
|
||||
2. npm install
|
||||
3. Configurar .env
|
||||
4. ./deploy-completo-servidor.sh
|
||||
|
||||
SUPORTE:
|
||||
- Banco: Supabase (não incluído no backup)
|
||||
- PIX: Mercado Pago
|
||||
- Deploy: Scripts automatizados
|
||||
EOF
|
||||
|
||||
log "✅ Arquivo de informações criado"
|
||||
|
||||
echo ""
|
||||
info "🗜️ COMPACTANDO BACKUP..."
|
||||
|
||||
# Criar arquivo compactado
|
||||
cd $BACKUP_DIR
|
||||
tar -czf "$BACKUP_NAME.tar.gz" "$BACKUP_NAME"
|
||||
rm -rf "$BACKUP_NAME"
|
||||
|
||||
COMPRESSED_SIZE=$(du -sh "$BACKUP_NAME.tar.gz" | cut -f1)
|
||||
|
||||
log "✅ Backup compactado: $BACKUP_DIR/$BACKUP_NAME.tar.gz"
|
||||
|
||||
echo ""
|
||||
log "🎉 BACKUP COMPLETO FINALIZADO!"
|
||||
echo ""
|
||||
|
||||
info "📋 RESUMO DO BACKUP:"
|
||||
echo " 📁 Localização: $BACKUP_DIR/$BACKUP_NAME.tar.gz"
|
||||
echo " 💾 Tamanho: $COMPRESSED_SIZE"
|
||||
echo " 📅 Data: $(date)"
|
||||
echo " 🔢 Arquivos: $TOTAL_FILES"
|
||||
echo ""
|
||||
|
||||
info "🔄 COMO RESTAURAR:"
|
||||
echo " 1. cd $BACKUP_DIR"
|
||||
echo " 2. tar -xzf $BACKUP_NAME.tar.gz"
|
||||
echo " 3. cd $BACKUP_NAME"
|
||||
echo " 4. npm install"
|
||||
echo " 5. ./deploy-completo-servidor.sh"
|
||||
echo ""
|
||||
|
||||
info "📂 BACKUPS DISPONÍVEIS:"
|
||||
ls -la $BACKUP_DIR/*.tar.gz 2>/dev/null | tail -5 || echo " (Este é o primeiro backup)"
|
||||
|
||||
echo ""
|
||||
warn "💡 DICA: Mantenha backups regulares"
|
||||
warn "💡 O banco Supabase não está incluído (é na nuvem)"
|
||||
|
||||
log "✨ BACKUP SEGURO CRIADO!"
|
||||
20879
client/package-lock.json
generated
Normal file
20879
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
client/package.json
Normal file
45
client/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "liberi-kids-client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"axios": "^1.5.0",
|
||||
"react-icons": "^4.11.0",
|
||||
"recharts": "^2.8.0",
|
||||
"react-hook-form": "^7.46.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"proxy": "http://localhost:5000"
|
||||
}
|
||||
2693
client/src/App.css
Normal file
2693
client/src/App.css
Normal file
File diff suppressed because it is too large
Load Diff
51
client/src/App.js
Normal file
51
client/src/App.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import Layout from './components/Layout';
|
||||
import { NotificationProvider } from './components/NotificationCenter';
|
||||
import Dashboard from './pages/DashboardSimples';
|
||||
import Produtos from './pages/Produtos';
|
||||
import Clientes from './pages/Clientes';
|
||||
import Fornecedores from './pages/Fornecedores';
|
||||
import Despesas from './pages/Despesas';
|
||||
import Vendas from './pages/Vendas';
|
||||
import Devolucoes from './pages/Devolucoes';
|
||||
import Emprestimos from './pages/Emprestimos';
|
||||
import Configuracoes from './pages/Configuracoes';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<NotificationProvider>
|
||||
<div className="App">
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#363636',
|
||||
color: '#fff',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/produtos" element={<Produtos />} />
|
||||
<Route path="/clientes" element={<Clientes />} />
|
||||
<Route path="/fornecedores" element={<Fornecedores />} />
|
||||
<Route path="/despesas" element={<Despesas />} />
|
||||
<Route path="/vendas" element={<Vendas />} />
|
||||
<Route path="/devolucoes" element={<Devolucoes />} />
|
||||
<Route path="/emprestimos" element={<Emprestimos />} />
|
||||
<Route path="/configuracoes" element={<Configuracoes />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</div>
|
||||
</NotificationProvider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
343
client/src/components/ChatWhatsApp.css
Normal file
343
client/src/components/ChatWhatsApp.css
Normal file
@@ -0,0 +1,343 @@
|
||||
/* Chat WhatsApp Overlay */
|
||||
.chat-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
height: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.chat-header {
|
||||
background: #075e54;
|
||||
color: white;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.chat-client-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chat-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat-client-details h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-phone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.chat-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.chat-close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Messages Area */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: #e5ddd5;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='40' height='40' viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23d4d4d4' fill-opacity='0.1'%3E%3Cpath d='M20 20c0 11.046-8.954 20-20 20v-40c11.046 0 20 8.954 20 20z'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.chat-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #e0e0e0;
|
||||
border-top: 3px solid #25d366;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-empty svg {
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.chat-empty p {
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chat-empty span {
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Date Divider */
|
||||
.chat-date-divider {
|
||||
text-align: center;
|
||||
margin: 16px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-date-divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.chat-date-divider::after {
|
||||
content: attr(data-date);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.chat-message {
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chat-message.enviada {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-message.recebida {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 70%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-message.enviada .message-content {
|
||||
background: #dcf8c6;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
.chat-message.recebida .message-content {
|
||||
background: white;
|
||||
border-bottom-left-radius: 2px;
|
||||
}
|
||||
|
||||
.message-content p {
|
||||
margin: 0 0 4px 0;
|
||||
word-wrap: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.message-status {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.message-status.enviando {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.message-status.enviada {
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
.message-status.entregue {
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
.message-status.lida {
|
||||
color: #25d366;
|
||||
}
|
||||
|
||||
/* Input Area */
|
||||
.chat-input-area {
|
||||
background: white;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 24px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
max-height: 100px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.chat-input::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.chat-send-btn {
|
||||
background: #25d366;
|
||||
border: none;
|
||||
color: white;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-send-btn:hover:not(:disabled) {
|
||||
background: #128c7e;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.chat-send-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.chat-overlay {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
height: 100vh;
|
||||
max-width: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar personalizada */
|
||||
.chat-messages::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
218
client/src/components/ChatWhatsApp.js
Normal file
218
client/src/components/ChatWhatsApp.js
Normal file
@@ -0,0 +1,218 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { FiX, FiSend, FiPhone, FiMessageCircle } from 'react-icons/fi';
|
||||
import './ChatWhatsApp.css';
|
||||
|
||||
const ChatWhatsApp = ({ isOpen, onClose, cliente }) => {
|
||||
const [mensagens, setMensagens] = useState([]);
|
||||
const [novaMensagem, setNovaMensagem] = useState('');
|
||||
const [enviando, setEnviando] = useState(false);
|
||||
const [carregando, setCarregando] = useState(false);
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [mensagens]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && cliente?.telefone) {
|
||||
carregarHistorico();
|
||||
}
|
||||
}, [isOpen, cliente]);
|
||||
|
||||
const carregarHistorico = async () => {
|
||||
setCarregando(true);
|
||||
try {
|
||||
const response = await fetch(`/api/chat/${cliente.telefone}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setMensagens(data.data || []);
|
||||
} else {
|
||||
console.error('Erro ao carregar histórico:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar histórico:', error);
|
||||
} finally {
|
||||
setCarregando(false);
|
||||
}
|
||||
};
|
||||
|
||||
const enviarMensagem = async () => {
|
||||
if (!novaMensagem.trim() || enviando) return;
|
||||
|
||||
setEnviando(true);
|
||||
const mensagemTemp = {
|
||||
id: Date.now(),
|
||||
mensagem: novaMensagem,
|
||||
tipo: 'enviada',
|
||||
created_at: new Date().toISOString(),
|
||||
status: 'enviando'
|
||||
};
|
||||
|
||||
// Adicionar mensagem temporária
|
||||
setMensagens(prev => [...prev, mensagemTemp]);
|
||||
setNovaMensagem('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/chat/enviar', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
telefone: cliente.telefone,
|
||||
mensagem: novaMensagem,
|
||||
clienteNome: cliente.nome
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Atualizar mensagem temporária com dados reais
|
||||
setMensagens(prev =>
|
||||
prev.map(msg =>
|
||||
msg.id === mensagemTemp.id
|
||||
? { ...data.data, status: 'enviada' }
|
||||
: msg
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Remover mensagem temporária e mostrar erro
|
||||
setMensagens(prev => prev.filter(msg => msg.id !== mensagemTemp.id));
|
||||
alert(`Erro ao enviar mensagem: ${data.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao enviar mensagem:', error);
|
||||
setMensagens(prev => prev.filter(msg => msg.id !== mensagemTemp.id));
|
||||
alert('Erro ao enviar mensagem. Tente novamente.');
|
||||
} finally {
|
||||
setEnviando(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
enviarMensagem();
|
||||
}
|
||||
};
|
||||
|
||||
const formatarHora = (timestamp) => {
|
||||
return new Date(timestamp).toLocaleTimeString('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const formatarData = (timestamp) => {
|
||||
return new Date(timestamp).toLocaleDateString('pt-BR');
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="chat-overlay">
|
||||
<div className="chat-container">
|
||||
{/* Header */}
|
||||
<div className="chat-header">
|
||||
<div className="chat-client-info">
|
||||
<div className="chat-avatar">
|
||||
<FiMessageCircle size={24} />
|
||||
</div>
|
||||
<div className="chat-client-details">
|
||||
<h3>{cliente?.nome || 'Cliente'}</h3>
|
||||
<span className="chat-phone">
|
||||
<FiPhone size={14} />
|
||||
{cliente?.telefone}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="chat-close-btn" onClick={onClose}>
|
||||
<FiX size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="chat-messages">
|
||||
{carregando ? (
|
||||
<div className="chat-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Carregando histórico...</p>
|
||||
</div>
|
||||
) : mensagens.length === 0 ? (
|
||||
<div className="chat-empty">
|
||||
<FiMessageCircle size={48} />
|
||||
<p>Nenhuma mensagem ainda</p>
|
||||
<span>Inicie uma conversa com {cliente?.nome}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{mensagens.map((mensagem, index) => {
|
||||
const showDate = index === 0 ||
|
||||
formatarData(mensagem.created_at) !== formatarData(mensagens[index - 1].created_at);
|
||||
|
||||
return (
|
||||
<React.Fragment key={mensagem.id || index}>
|
||||
{showDate && (
|
||||
<div className="chat-date-divider">
|
||||
{formatarData(mensagem.created_at)}
|
||||
</div>
|
||||
)}
|
||||
<div className={`chat-message ${mensagem.tipo}`}>
|
||||
<div className="message-content">
|
||||
<p>{mensagem.mensagem}</p>
|
||||
<div className="message-info">
|
||||
<span className="message-time">
|
||||
{formatarHora(mensagem.created_at)}
|
||||
</span>
|
||||
{mensagem.tipo === 'enviada' && (
|
||||
<span className={`message-status ${mensagem.status}`}>
|
||||
{mensagem.status === 'enviando' ? '⏳' :
|
||||
mensagem.status === 'enviada' ? '✓' :
|
||||
mensagem.status === 'entregue' ? '✓✓' :
|
||||
mensagem.status === 'lida' ? '✓✓' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="chat-input-area">
|
||||
<div className="chat-input-container">
|
||||
<textarea
|
||||
value={novaMensagem}
|
||||
onChange={(e) => setNovaMensagem(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Digite sua mensagem..."
|
||||
className="chat-input"
|
||||
rows="1"
|
||||
disabled={enviando}
|
||||
/>
|
||||
<button
|
||||
onClick={enviarMensagem}
|
||||
disabled={!novaMensagem.trim() || enviando}
|
||||
className="chat-send-btn"
|
||||
>
|
||||
<FiSend size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatWhatsApp;
|
||||
325
client/src/components/Layout.css
Normal file
325
client/src/components/Layout.css
Normal file
@@ -0,0 +1,325 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background: linear-gradient(180deg, #1e293b 0%, #334155 100%);
|
||||
color: white;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.logo-text h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.logo-text span {
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.sidebar-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
color: #cbd5e1;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item-active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border-left-color: #667eea;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: white;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
color: #374151;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.menu-toggle:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.header-title h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-title p {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.sidebar {
|
||||
position: static;
|
||||
transform: translateX(0);
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-close {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet */
|
||||
@media (max-width: 1023px) and (min-width: 768px) {
|
||||
.sidebar-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar-close {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 767px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar-close {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.header-title h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.header-title p {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.logo-text h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.connection-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
STATUS DE CONEXÃO
|
||||
===================================================== */
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.connection-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.connection-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.connection-connected {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.connection-disconnected {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.connection-error {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #f59e0b;
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
/* Animação de pulso para status offline */
|
||||
.connection-disconnected,
|
||||
.connection-error {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
126
client/src/components/Layout.js
Normal file
126
client/src/components/Layout.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
FiHome,
|
||||
FiPackage,
|
||||
FiUsers,
|
||||
FiTruck,
|
||||
FiDollarSign,
|
||||
FiShoppingCart,
|
||||
FiRotateCcw,
|
||||
FiSettings,
|
||||
FiMenu,
|
||||
FiX,
|
||||
FiWifi,
|
||||
FiWifiOff,
|
||||
FiCreditCard
|
||||
} from 'react-icons/fi';
|
||||
import './Layout.css';
|
||||
|
||||
const Layout = ({ children }) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const menuItems = [
|
||||
{ path: '/', icon: FiHome, label: 'Dashboard' },
|
||||
{ path: '/produtos', icon: FiPackage, label: 'Produtos' },
|
||||
{ path: '/clientes', icon: FiUsers, label: 'Clientes' },
|
||||
{ path: '/fornecedores', icon: FiTruck, label: 'Fornecedores' },
|
||||
{ path: '/despesas', icon: FiDollarSign, label: 'Despesas' },
|
||||
{ path: '/vendas', icon: FiShoppingCart, label: 'Vendas' },
|
||||
{ path: '/devolucoes', icon: FiRotateCcw, label: 'Devolução/Troca' },
|
||||
{ path: '/emprestimos', icon: FiCreditCard, label: 'Empréstimos' },
|
||||
{ path: '/configuracoes', icon: FiSettings, label: 'Configurações' },
|
||||
];
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setSidebarOpen(!sidebarOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
{/* Sidebar */}
|
||||
<aside className={`sidebar ${sidebarOpen ? 'sidebar-open' : ''}`}>
|
||||
<div className="sidebar-header">
|
||||
<div className="logo">
|
||||
<img
|
||||
src="/LogoLiberiKids.png"
|
||||
alt="Liberi Kids"
|
||||
className="logo-img"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'block';
|
||||
}}
|
||||
/>
|
||||
<div className="logo-text" style={{ display: 'none' }}>
|
||||
<h2>Liberi Kids</h2>
|
||||
<span>Moda Infantil</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="sidebar-close"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<FiX />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname === item.path;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`nav-item ${isActive ? 'nav-item-active' : ''}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<Icon className="nav-icon" />
|
||||
<span className="nav-label">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Overlay para mobile */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="sidebar-overlay"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="main-content">
|
||||
{/* Header */}
|
||||
<header className="header">
|
||||
<button
|
||||
className="menu-toggle"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<FiMenu />
|
||||
</button>
|
||||
|
||||
<div className="header-title">
|
||||
<h1>Sistema de Controle de Estoque</h1>
|
||||
<p>Liberi Kids - Moda Infantil</p>
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
{/* Espaço para futuras ações */}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="page-content">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
263
client/src/components/NotificationCenter.css
Normal file
263
client/src/components/NotificationCenter.css
Normal file
@@ -0,0 +1,263 @@
|
||||
/* =====================================================
|
||||
CENTRO DE NOTIFICAÇÕES
|
||||
===================================================== */
|
||||
|
||||
.notification-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notification {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
min-width: 320px;
|
||||
max-width: 400px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1), 0 4px 10px rgba(0, 0, 0, 0.05);
|
||||
border-left: 4px solid;
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
transform: translateX(100%);
|
||||
animation: slideIn 0.3s ease forwards;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.notification:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.15), 0 6px 15px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.notification-success {
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
.notification-error {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.notification-warning {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.notification-info {
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.notification-success .notification-icon {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.notification-error .notification-icon {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.notification-warning .notification-icon {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.notification-info .notification-icon {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
margin: 0 0 4px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.notification-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-close:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
INDICADOR DE CONEXÃO
|
||||
===================================================== */
|
||||
|
||||
.connection-indicator {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: white;
|
||||
border-radius: 25px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
z-index: 9998;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.connection-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.connection-icon.connected {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.connection-icon.disconnected {
|
||||
color: #ef4444;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.connection-icon.error {
|
||||
color: #f59e0b;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.connection-connected {
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
background: rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
|
||||
.connection-disconnected {
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.connection-error {
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
background: rgba(245, 158, 11, 0.05);
|
||||
}
|
||||
|
||||
.connection-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.connection-connected .connection-text {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.connection-disconnected .connection-text {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.connection-error .connection-text {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
ANIMAÇÕES
|
||||
===================================================== */
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
RESPONSIVO
|
||||
===================================================== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.notification-container {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.notification {
|
||||
min-width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.connection-indicator {
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.connection-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.connection-indicator {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.notification {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
190
client/src/components/NotificationCenter.js
Normal file
190
client/src/components/NotificationCenter.js
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, { useState, useEffect, createContext, useContext } from 'react';
|
||||
import { FiCheck, FiX, FiAlertTriangle, FiInfo, FiWifi, FiWifiOff } from 'react-icons/fi';
|
||||
import './NotificationCenter.css';
|
||||
|
||||
// Context para notificações globais
|
||||
const NotificationContext = createContext();
|
||||
|
||||
export const useNotification = () => {
|
||||
const context = useContext(NotificationContext);
|
||||
if (!context) {
|
||||
throw new Error('useNotification deve ser usado dentro de NotificationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Provider de notificações
|
||||
export const NotificationProvider = ({ children }) => {
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [connectionStatus, setConnectionStatus] = useState('connected');
|
||||
|
||||
// Adicionar notificação
|
||||
const addNotification = (message, type = 'info', duration = 5000) => {
|
||||
const id = Date.now() + Math.random();
|
||||
const notification = {
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
timestamp: new Date(),
|
||||
duration
|
||||
};
|
||||
|
||||
setNotifications(prev => [...prev, notification]);
|
||||
|
||||
// Remover automaticamente após o tempo especificado
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeNotification(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
// Remover notificação
|
||||
const removeNotification = (id) => {
|
||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||
};
|
||||
|
||||
// Notificações de conveniência
|
||||
const success = (message, duration) => addNotification(message, 'success', duration);
|
||||
const error = (message, duration) => addNotification(message, 'error', duration);
|
||||
const warning = (message, duration) => addNotification(message, 'warning', duration);
|
||||
const info = (message, duration) => addNotification(message, 'info', duration);
|
||||
|
||||
// Monitorar conexão
|
||||
useEffect(() => {
|
||||
const checkConnection = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard', {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
const newStatus = response.ok ? 'connected' : 'error';
|
||||
|
||||
// Notificar mudanças de status
|
||||
if (connectionStatus !== newStatus) {
|
||||
if (newStatus === 'connected' && connectionStatus !== 'connected') {
|
||||
success('Conexão restaurada!', 3000);
|
||||
} else if (newStatus === 'error') {
|
||||
warning('Problemas de conexão detectados', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
setConnectionStatus(newStatus);
|
||||
} catch (error) {
|
||||
const newStatus = 'disconnected';
|
||||
|
||||
if (connectionStatus !== newStatus) {
|
||||
error('Conexão perdida com o servidor', 5000);
|
||||
}
|
||||
|
||||
setConnectionStatus(newStatus);
|
||||
}
|
||||
};
|
||||
|
||||
// Verificar imediatamente
|
||||
checkConnection();
|
||||
|
||||
// Verificar a cada 30 segundos
|
||||
const interval = setInterval(checkConnection, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [connectionStatus]);
|
||||
|
||||
const value = {
|
||||
notifications,
|
||||
connectionStatus,
|
||||
addNotification,
|
||||
removeNotification,
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={value}>
|
||||
{children}
|
||||
<NotificationCenter />
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Componente de notificações
|
||||
const NotificationCenter = () => {
|
||||
const { notifications, removeNotification, connectionStatus } = useNotification();
|
||||
|
||||
const getIcon = (type) => {
|
||||
switch (type) {
|
||||
case 'success': return <FiCheck />;
|
||||
case 'error': return <FiX />;
|
||||
case 'warning': return <FiAlertTriangle />;
|
||||
default: return <FiInfo />;
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionIcon = () => {
|
||||
switch (connectionStatus) {
|
||||
case 'connected': return <FiWifi className="connection-icon connected" />;
|
||||
case 'disconnected': return <FiWifiOff className="connection-icon disconnected" />;
|
||||
case 'error': return <FiWifiOff className="connection-icon error" />;
|
||||
default: return <FiWifi className="connection-icon" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionText = () => {
|
||||
switch (connectionStatus) {
|
||||
case 'connected': return 'Online';
|
||||
case 'disconnected': return 'Offline';
|
||||
case 'error': return 'Instável';
|
||||
default: return 'Verificando...';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Status de conexão fixo */}
|
||||
<div className={`connection-indicator connection-${connectionStatus}`}>
|
||||
{getConnectionIcon()}
|
||||
<span className="connection-text">{getConnectionText()}</span>
|
||||
</div>
|
||||
|
||||
{/* Container de notificações */}
|
||||
<div className="notification-container">
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`notification notification-${notification.type}`}
|
||||
onClick={() => removeNotification(notification.id)}
|
||||
>
|
||||
<div className="notification-icon">
|
||||
{getIcon(notification.type)}
|
||||
</div>
|
||||
<div className="notification-content">
|
||||
<p className="notification-message">{notification.message}</p>
|
||||
<span className="notification-time">
|
||||
{notification.timestamp.toLocaleTimeString('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="notification-close"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeNotification(notification.id);
|
||||
}}
|
||||
>
|
||||
<FiX />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationCenter;
|
||||
25
client/src/components/ViewToggle.js
Normal file
25
client/src/components/ViewToggle.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { FiGrid, FiList } from 'react-icons/fi';
|
||||
|
||||
const ViewToggle = ({ viewMode, onViewModeChange }) => {
|
||||
return (
|
||||
<div className="view-toggle">
|
||||
<button
|
||||
className={`view-toggle-btn ${viewMode === 'cards' ? 'active' : ''}`}
|
||||
onClick={() => onViewModeChange('cards')}
|
||||
title="Visualização em Cards"
|
||||
>
|
||||
<FiGrid />
|
||||
</button>
|
||||
<button
|
||||
className={`view-toggle-btn ${viewMode === 'list' ? 'active' : ''}`}
|
||||
onClick={() => onViewModeChange('list')}
|
||||
title="Visualização em Lista"
|
||||
>
|
||||
<FiList />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewToggle;
|
||||
134
client/src/index.css
Normal file
134
client/src/index.css
Normal file
@@ -0,0 +1,134 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f8fafc;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Scrollbar personalizada */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Animações */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Utilitários */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mb-1 { margin-bottom: 0.25rem; }
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.mb-3 { margin-bottom: 0.75rem; }
|
||||
.mb-4 { margin-bottom: 1rem; }
|
||||
.mb-6 { margin-bottom: 1.5rem; }
|
||||
.mb-8 { margin-bottom: 2rem; }
|
||||
|
||||
.mt-1 { margin-top: 0.25rem; }
|
||||
.mt-2 { margin-top: 0.5rem; }
|
||||
.mt-3 { margin-top: 0.75rem; }
|
||||
.mt-4 { margin-top: 1rem; }
|
||||
.mt-6 { margin-top: 1.5rem; }
|
||||
.mt-8 { margin-top: 2rem; }
|
||||
|
||||
.p-2 { padding: 0.5rem; }
|
||||
.p-3 { padding: 0.75rem; }
|
||||
.p-4 { padding: 1rem; }
|
||||
.p-6 { padding: 1.5rem; }
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.gap-4 {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.rounded-lg {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.shadow-lg {
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
11
client/src/index.js
Normal file
11
client/src/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
411
client/src/pages/Clientes.js
Normal file
411
client/src/pages/Clientes.js
Normal file
@@ -0,0 +1,411 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
FiPlus,
|
||||
FiEdit,
|
||||
FiTrash2,
|
||||
FiSearch,
|
||||
FiUsers,
|
||||
FiMail,
|
||||
FiPhone,
|
||||
FiMapPin,
|
||||
FiMessageSquare
|
||||
} from 'react-icons/fi';
|
||||
import { clientesAPI } from '../services/api';
|
||||
import ViewToggle from '../components/ViewToggle';
|
||||
import ChatWhatsApp from '../components/ChatWhatsApp';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Clientes = () => {
|
||||
const [clientes, setClientes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [viewMode, setViewMode] = useState('list');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingClient, setEditingClient] = useState(null);
|
||||
const [showChatModal, setShowChatModal] = useState(false);
|
||||
const [selectedCliente, setSelectedCliente] = useState(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
nome_completo: '',
|
||||
email: '',
|
||||
whatsapp: '',
|
||||
endereco: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
carregarClientes();
|
||||
}, []);
|
||||
|
||||
const carregarClientes = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await clientesAPI.listar();
|
||||
setClientes(response.data);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar clientes:', error);
|
||||
toast.error('Erro ao carregar clientes');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingClient) {
|
||||
await clientesAPI.atualizar(editingClient.id, formData);
|
||||
toast.success('Cliente atualizado com sucesso!');
|
||||
} else {
|
||||
await clientesAPI.criar(formData);
|
||||
toast.success('Cliente cadastrado com sucesso!');
|
||||
}
|
||||
|
||||
setShowModal(false);
|
||||
setEditingClient(null);
|
||||
setFormData({
|
||||
nome_completo: '',
|
||||
email: '',
|
||||
whatsapp: '',
|
||||
endereco: ''
|
||||
});
|
||||
carregarClientes();
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar cliente:', error);
|
||||
toast.error('Erro ao salvar cliente');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (cliente) => {
|
||||
setEditingClient(cliente);
|
||||
setFormData({
|
||||
nome_completo: cliente.nome_completo,
|
||||
email: cliente.email || '',
|
||||
whatsapp: cliente.whatsapp || '',
|
||||
endereco: cliente.endereco || ''
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (window.confirm('Tem certeza que deseja excluir este cliente?')) {
|
||||
try {
|
||||
await clientesAPI.deletar(id);
|
||||
toast.success('Cliente excluído com sucesso!');
|
||||
carregarClientes();
|
||||
} catch (error) {
|
||||
console.error('Erro ao excluir cliente:', error);
|
||||
toast.error('Erro ao excluir cliente');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const abrirChat = (cliente) => {
|
||||
if (!cliente.whatsapp) {
|
||||
toast.error('Cliente não possui WhatsApp cadastrado');
|
||||
return;
|
||||
}
|
||||
|
||||
const clienteFormatado = {
|
||||
nome: cliente.nome_completo,
|
||||
telefone: cliente.whatsapp.replace(/\D/g, ''),
|
||||
id: cliente.id
|
||||
};
|
||||
|
||||
setSelectedCliente(clienteFormatado);
|
||||
setShowChatModal(true);
|
||||
};
|
||||
|
||||
const filteredClientes = clientes.filter(cliente =>
|
||||
cliente.nome_completo.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(cliente.email && cliente.email.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
(cliente.whatsapp && cliente.whatsapp.includes(searchTerm)) ||
|
||||
(cliente.id_cliente && cliente.id_cliente.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div>Carregando clientes...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="clientes fade-in">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Clientes</h1>
|
||||
<p>Gerencie os clientes da loja</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<ViewToggle
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
<FiPlus />
|
||||
Novo Cliente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barra de Pesquisa */}
|
||||
<div className="search-box">
|
||||
<FiSearch className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pesquisar clientes..."
|
||||
className="search-input"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Lista de Clientes */}
|
||||
{filteredClientes.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<FiUsers size={48} />
|
||||
<h3>Nenhum cliente encontrado</h3>
|
||||
<p>Comece adicionando seu primeiro cliente</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
<FiPlus />
|
||||
Adicionar Cliente
|
||||
</button>
|
||||
</div>
|
||||
) : viewMode === 'cards' ? (
|
||||
<div className="clients-grid">
|
||||
{filteredClientes.map((cliente) => (
|
||||
<div key={cliente.id} className="client-card">
|
||||
<div className="client-header">
|
||||
<div className="client-avatar">
|
||||
<FiUsers />
|
||||
</div>
|
||||
<div className="client-actions">
|
||||
{cliente.whatsapp && (
|
||||
<button
|
||||
className="btn-icon btn-success"
|
||||
onClick={() => abrirChat(cliente)}
|
||||
title="Abrir Chat WhatsApp"
|
||||
>
|
||||
<FiMessageSquare />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => handleEdit(cliente)}
|
||||
title="Editar"
|
||||
>
|
||||
<FiEdit />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon btn-danger"
|
||||
onClick={() => handleDelete(cliente.id)}
|
||||
title="Excluir"
|
||||
>
|
||||
<FiTrash2 />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="client-info">
|
||||
<h3 className="client-name">{cliente.nome_completo}</h3>
|
||||
|
||||
{cliente.id_cliente && (
|
||||
<div className="client-detail">
|
||||
<span className="client-id">#{cliente.id_cliente}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cliente.email && (
|
||||
<div className="client-detail">
|
||||
<FiMail className="detail-icon" />
|
||||
<span>{cliente.email}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cliente.whatsapp && (
|
||||
<div className="client-detail">
|
||||
<FiPhone className="detail-icon" />
|
||||
<span>{cliente.whatsapp}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cliente.endereco && (
|
||||
<div className="client-detail">
|
||||
<FiMapPin className="detail-icon" />
|
||||
<span>{cliente.endereco}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="client-meta">
|
||||
<span className="client-date">
|
||||
Cadastrado em {new Date(cliente.created_at).toLocaleDateString('pt-BR')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="list-view">
|
||||
<div className="list-header clientes-list-header">
|
||||
<span>ID / Nome</span>
|
||||
<span>Email</span>
|
||||
<span>WhatsApp</span>
|
||||
<span>Endereço</span>
|
||||
<span>Ações</span>
|
||||
</div>
|
||||
{filteredClientes.map((cliente) => (
|
||||
<div key={cliente.id} className="list-item clientes-list-item">
|
||||
<div>
|
||||
<div style={{ fontWeight: '600' }}>
|
||||
{cliente.id_cliente && <span style={{ color: '#3b82f6', fontSize: '12px', fontWeight: '700' }}>#{cliente.id_cliente} - </span>}
|
||||
{cliente.nome_completo}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b7280' }}>
|
||||
Cadastrado em {new Date(cliente.created_at).toLocaleDateString('pt-BR')}
|
||||
</div>
|
||||
</div>
|
||||
<span>{cliente.email || '-'}</span>
|
||||
<span>{cliente.whatsapp || '-'}</span>
|
||||
<span title={cliente.endereco} style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{cliente.endereco || '-'}
|
||||
</span>
|
||||
<div className="list-item-actions">
|
||||
{cliente.whatsapp && (
|
||||
<button
|
||||
className="btn-icon btn-sm btn-success"
|
||||
onClick={() => abrirChat(cliente)}
|
||||
title="Abrir Chat WhatsApp"
|
||||
>
|
||||
<FiMessageSquare />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn-icon btn-sm"
|
||||
onClick={() => handleEdit(cliente)}
|
||||
title="Editar"
|
||||
>
|
||||
<FiEdit />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon btn-sm btn-danger"
|
||||
onClick={() => handleDelete(cliente.id)}
|
||||
title="Excluir"
|
||||
>
|
||||
<FiTrash2 />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de Cliente */}
|
||||
{showModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">
|
||||
{editingClient ? 'Editar Cliente' : 'Novo Cliente'}
|
||||
</h2>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
setEditingClient(null);
|
||||
setFormData({
|
||||
nome_completo: '',
|
||||
email: '',
|
||||
whatsapp: '',
|
||||
endereco: ''
|
||||
});
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Nome Completo *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={formData.nome_completo}
|
||||
onChange={(e) => setFormData({...formData, nome_completo: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-2">
|
||||
<div className="form-group">
|
||||
<label className="form-label">E-mail</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-input"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({...formData, email: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">WhatsApp</label>
|
||||
<input
|
||||
type="tel"
|
||||
className="form-input"
|
||||
value={formData.whatsapp}
|
||||
onChange={(e) => setFormData({...formData, whatsapp: e.target.value})}
|
||||
placeholder="(11) 99999-9999"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Endereço</label>
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
value={formData.endereco}
|
||||
onChange={(e) => setFormData({...formData, endereco: e.target.value})}
|
||||
placeholder="Rua, número, bairro, cidade, CEP"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
setEditingClient(null);
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{editingClient ? 'Atualizar' : 'Cadastrar'} Cliente
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat WhatsApp */}
|
||||
<ChatWhatsApp
|
||||
isOpen={showChatModal}
|
||||
onClose={() => setShowChatModal(false)}
|
||||
cliente={selectedCliente}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Clientes;
|
||||
1720
client/src/pages/Configuracoes.js
Normal file
1720
client/src/pages/Configuracoes.js
Normal file
File diff suppressed because it is too large
Load Diff
33
client/src/pages/ConfiguracoesSimples.js
Normal file
33
client/src/pages/ConfiguracoesSimples.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { FiSettings } from 'react-icons/fi';
|
||||
|
||||
const Configuracoes = () => {
|
||||
return (
|
||||
<div className="configuracoes fade-in">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Configurações</h1>
|
||||
<p>Configure as integrações e preferências do sistema</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="config-header">
|
||||
<div className="config-title">
|
||||
<FiSettings className="config-icon" />
|
||||
<div>
|
||||
<h2>Evolution API - WhatsApp</h2>
|
||||
<p>Configure a integração com Evolution API para envio de mensagens WhatsApp</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-placeholder">
|
||||
<p>Configuração da Evolution API em desenvolvimento...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Configuracoes;
|
||||
924
client/src/pages/Dashboard.js
Normal file
924
client/src/pages/Dashboard.js
Normal file
@@ -0,0 +1,924 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
FiPackage,
|
||||
FiUsers,
|
||||
FiTruck,
|
||||
FiDollarSign,
|
||||
FiShoppingCart,
|
||||
FiTrendingUp,
|
||||
FiTrendingDown,
|
||||
FiBox,
|
||||
FiX,
|
||||
FiPlus,
|
||||
FiMessageCircle,
|
||||
FiCalendar,
|
||||
FiClock,
|
||||
FiAlertCircle
|
||||
} from 'react-icons/fi';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell
|
||||
} from 'recharts';
|
||||
import { dashboardAPI, clientesAPI, despesasAPI, fornecedoresAPI } from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [dashboardData, setDashboardData] = useState({
|
||||
contabilidade: {
|
||||
receitaBruta: 0,
|
||||
custosProdutos: 0,
|
||||
totalDespesas: 0,
|
||||
lucroReal: 0,
|
||||
margemLucro: 0
|
||||
},
|
||||
resumoFinanceiro: {
|
||||
receitasMes: 0,
|
||||
despesasMes: 0,
|
||||
lucroEstimado: 0,
|
||||
totalVendas: 0
|
||||
},
|
||||
emprestimos: {
|
||||
totalAberto: 0,
|
||||
totalQuitado: 0,
|
||||
quantidade: 0
|
||||
},
|
||||
vendasPrazo: {
|
||||
total: 0,
|
||||
quantidade: 0,
|
||||
vendas: []
|
||||
},
|
||||
parcelasPendentes: {
|
||||
quantidade: 0,
|
||||
parcelas: []
|
||||
},
|
||||
estatisticas: {
|
||||
totalProdutos: 0,
|
||||
totalClientes: 0,
|
||||
totalFornecedores: 0,
|
||||
estoqueTotal: 0
|
||||
}
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Estados dos modais
|
||||
const [showProdutoModal, setShowProdutoModal] = useState(false);
|
||||
const [showClienteModal, setShowClienteModal] = useState(false);
|
||||
const [showDespesaModal, setShowDespesaModal] = useState(false);
|
||||
const [showVendaModal, setShowVendaModal] = useState(false);
|
||||
|
||||
// Estados dos formulários
|
||||
const [fornecedores, setFornecedores] = useState([]);
|
||||
const [tiposDespesas, setTiposDespesas] = useState([]);
|
||||
const [clientes, setClientes] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
carregarDashboard();
|
||||
carregarVendasPrazo();
|
||||
}, []);
|
||||
|
||||
const carregarDashboard = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [dashboardRes, fornecedoresRes, tiposRes, clientesRes] = await Promise.all([
|
||||
dashboardAPI.obterEstatisticas(),
|
||||
fornecedoresAPI.listar(),
|
||||
despesasAPI.listarTipos(),
|
||||
clientesAPI.listar()
|
||||
]);
|
||||
|
||||
setDashboardData(dashboardRes.data);
|
||||
setFornecedores(fornecedoresRes.data);
|
||||
setTiposDespesas(tiposRes.data);
|
||||
setClientes(clientesRes.data);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar dashboard:', error);
|
||||
toast.error('Erro ao carregar dados do dashboard');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const carregarVendasPrazo = async () => {
|
||||
try {
|
||||
setLoadingVendasPrazo(true);
|
||||
const response = await fetch('/api/vendas/prazo');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setVendasPrazo(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar vendas a prazo:', error);
|
||||
} finally {
|
||||
setLoadingVendasPrazo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const enviarWhatsApp = async (venda) => {
|
||||
try {
|
||||
const response = await fetch('/api/whatsapp/enviar-cobranca', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
vendaId: venda.id,
|
||||
clienteId: venda.cliente_id,
|
||||
telefone: venda.cliente_whatsapp || venda.cliente_telefone
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Mensagem enviada com sucesso!');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(error.message || 'Erro ao enviar mensagem');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao enviar WhatsApp:', error);
|
||||
toast.error('Erro ao enviar mensagem');
|
||||
}
|
||||
};
|
||||
|
||||
const formatarDataVencimento = (data) => {
|
||||
const dataVencimento = new Date(data);
|
||||
const hoje = new Date();
|
||||
const diffTime = dataVencimento - hoje;
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) {
|
||||
return { texto: `${Math.abs(diffDays)} dias em atraso`, classe: 'vencido' };
|
||||
} else if (diffDays === 0) {
|
||||
return { texto: 'Vence hoje', classe: 'hoje' };
|
||||
} else if (diffDays === 1) {
|
||||
return { texto: 'Vence amanhã', classe: 'amanha' };
|
||||
} else if (diffDays <= 7) {
|
||||
return { texto: `${diffDays} dias`, classe: 'proximo' };
|
||||
} else {
|
||||
return { texto: `${diffDays} dias`, classe: 'futuro' };
|
||||
}
|
||||
};
|
||||
|
||||
// Dados mockados para os gráficos (em um cenário real, viriam da API)
|
||||
const vendasPorMes = [
|
||||
{ mes: 'Jan', vendas: 12, valor: 2400 },
|
||||
{ mes: 'Fev', vendas: 19, valor: 3800 },
|
||||
{ mes: 'Mar', vendas: 15, valor: 3200 },
|
||||
{ mes: 'Abr', vendas: 25, valor: 5100 },
|
||||
{ mes: 'Mai', vendas: 22, valor: 4600 },
|
||||
{ mes: 'Jun', vendas: 30, valor: 6200 },
|
||||
];
|
||||
|
||||
const produtosPorCategoria = [
|
||||
{ name: 'Verão', value: 45, color: '#667eea' },
|
||||
{ name: 'Inverno', value: 35, color: '#764ba2' },
|
||||
{ name: 'Meia Estação', value: 20, color: '#f093fb' },
|
||||
];
|
||||
|
||||
const estatisticas = [
|
||||
{
|
||||
title: 'Total de Produtos',
|
||||
value: stats.totalProdutos?.count || 0,
|
||||
icon: FiPackage,
|
||||
color: '#667eea',
|
||||
bgColor: '#eef2ff',
|
||||
trend: '+12%',
|
||||
trendUp: true
|
||||
},
|
||||
{
|
||||
title: 'Clientes Cadastrados',
|
||||
value: stats.totalClientes?.count || 0,
|
||||
icon: FiUsers,
|
||||
color: '#10b981',
|
||||
bgColor: '#ecfdf5',
|
||||
trend: '+8%',
|
||||
trendUp: true
|
||||
},
|
||||
{
|
||||
title: 'Fornecedores',
|
||||
value: stats.totalFornecedores?.count || 0,
|
||||
icon: FiTruck,
|
||||
color: '#f59e0b',
|
||||
bgColor: '#fffbeb',
|
||||
trend: '+2%',
|
||||
trendUp: true
|
||||
},
|
||||
{
|
||||
title: 'Vendas do Mês',
|
||||
value: stats.vendasMes?.count || 0,
|
||||
icon: FiShoppingCart,
|
||||
color: '#ef4444',
|
||||
bgColor: '#fef2f2',
|
||||
trend: '-5%',
|
||||
trendUp: false
|
||||
},
|
||||
{
|
||||
title: 'Estoque Total',
|
||||
value: stats.estoqueTotal?.total || 0,
|
||||
icon: FiBox,
|
||||
color: '#8b5cf6',
|
||||
bgColor: '#f5f3ff',
|
||||
trend: '+15%',
|
||||
trendUp: true
|
||||
},
|
||||
{
|
||||
title: 'Faturamento Mensal',
|
||||
value: `R$ ${(stats.vendasMes?.total || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}`,
|
||||
icon: FiDollarSign,
|
||||
color: '#06b6d4',
|
||||
bgColor: '#ecfeff',
|
||||
trend: '+18%',
|
||||
trendUp: true
|
||||
}
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div>Carregando dashboard...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard fade-in">
|
||||
<div className="dashboard-header">
|
||||
<h1>Dashboard</h1>
|
||||
<p>Visão geral do seu negócio</p>
|
||||
</div>
|
||||
|
||||
{/* Resumo Financeiro e Ações Rápidas - MOVIDO PARA O TOPO */}
|
||||
<div className="quick-summary">
|
||||
<div className="summary-card">
|
||||
<h3>Resumo Financeiro</h3>
|
||||
<div className="summary-items">
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">Receita do Mês:</span>
|
||||
<span className="summary-value positive">
|
||||
R$ {(stats.vendasMes?.total || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">Despesas do Mês:</span>
|
||||
<span className="summary-value negative">
|
||||
R$ {(stats.despesasMes?.total || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">Lucro Estimado:</span>
|
||||
<span className="summary-value positive">
|
||||
R$ {((stats.vendasMes?.total || 0) - (stats.despesasMes?.total || 0)).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vendas a Prazo e Recebimentos */}
|
||||
<div className="summary-card vendas-prazo-card">
|
||||
<div className="vendas-prazo-header">
|
||||
<h3>
|
||||
<FiCalendar />
|
||||
Vendas a Prazo & Recebimentos
|
||||
</h3>
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={carregarVendasPrazo}
|
||||
disabled={loadingVendasPrazo}
|
||||
>
|
||||
{loadingVendasPrazo ? 'Carregando...' : 'Atualizar'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="vendas-prazo-content">
|
||||
{loadingVendasPrazo ? (
|
||||
<div className="loading-vendas">Carregando vendas...</div>
|
||||
) : vendasPrazo.length === 0 ? (
|
||||
<div className="empty-vendas">
|
||||
<FiClock />
|
||||
<p>Nenhuma venda a prazo encontrada</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="vendas-prazo-list">
|
||||
{vendasPrazo.slice(0, 5).map((venda) => {
|
||||
const vencimento = formatarDataVencimento(venda.data_vencimento);
|
||||
return (
|
||||
<div key={venda.id} className={`venda-prazo-item ${vencimento.classe}`}>
|
||||
<div className="venda-info">
|
||||
<div className="cliente-nome">{venda.cliente_nome}</div>
|
||||
<div className="venda-detalhes">
|
||||
<span className="valor">
|
||||
R$ {parseFloat(venda.valor_parcela).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className="parcela">
|
||||
{venda.parcela_atual}/{venda.total_parcelas}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="venda-vencimento">
|
||||
<span className={`vencimento-badge ${vencimento.classe}`}>
|
||||
{vencimento.texto}
|
||||
</span>
|
||||
{(venda.cliente_whatsapp || venda.cliente_telefone) && (
|
||||
<button
|
||||
className="btn-whatsapp"
|
||||
onClick={() => enviarWhatsApp(venda)}
|
||||
title="Enviar lembrete via WhatsApp"
|
||||
>
|
||||
<FiMessageCircle />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{vendasPrazo.length > 5 && (
|
||||
<div className="ver-mais">
|
||||
<button className="btn btn-link">
|
||||
Ver todas as {vendasPrazo.length} vendas a prazo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Gráficos */}
|
||||
<div className="charts-grid">
|
||||
{/* Gráfico de Vendas por Mês */}
|
||||
<div className="chart-card">
|
||||
<div className="chart-header">
|
||||
<h3>Vendas por Mês</h3>
|
||||
<p>Últimos 6 meses</p>
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={vendasPorMes}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey="mes"
|
||||
stroke="#6b7280"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#6b7280"
|
||||
fontSize={12}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)'
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="vendas"
|
||||
fill="url(#colorGradient)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#667eea" stopOpacity={0.9}/>
|
||||
<stop offset="95%" stopColor="#764ba2" stopOpacity={0.9}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gráfico de Produtos por Categoria */}
|
||||
<div className="chart-card">
|
||||
<div className="chart-header">
|
||||
<h3>Produtos por Estação</h3>
|
||||
<p>Distribuição do estoque</p>
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={produtosPorCategoria}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
>
|
||||
{produtosPorCategoria.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)'
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="pie-legend">
|
||||
{produtosPorCategoria.map((item, index) => (
|
||||
<div key={index} className="legend-item">
|
||||
<div
|
||||
className="legend-color"
|
||||
style={{ backgroundColor: item.color }}
|
||||
></div>
|
||||
<span>{item.name}</span>
|
||||
<span className="legend-value">{item.value}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modais */}
|
||||
{showClienteModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2>Novo Cliente</h2>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setShowClienteModal(false)}
|
||||
>
|
||||
<FiX />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-content">
|
||||
<form onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
try {
|
||||
await clientesAPI.criar({
|
||||
nome_completo: formData.get('nome_completo'),
|
||||
email: formData.get('email'),
|
||||
telefone: formData.get('telefone'),
|
||||
whatsapp: formData.get('whatsapp'),
|
||||
endereco: formData.get('endereco')
|
||||
});
|
||||
toast.success('Cliente criado com sucesso!');
|
||||
setShowClienteModal(false);
|
||||
carregarDashboard();
|
||||
} catch (error) {
|
||||
toast.error('Erro ao criar cliente');
|
||||
}
|
||||
}}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Nome Completo *</label>
|
||||
<input type="text" name="nome_completo" className="form-input" required />
|
||||
</div>
|
||||
<div className="grid grid-2">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Email</label>
|
||||
<input type="email" name="email" className="form-input" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Telefone</label>
|
||||
<input type="text" name="telefone" className="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-2">
|
||||
<div className="form-group">
|
||||
<label className="form-label">WhatsApp</label>
|
||||
<input type="text" name="whatsapp" className="form-input" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Endereço</label>
|
||||
<input type="text" name="endereco" className="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowClienteModal(false)}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<FiPlus />
|
||||
Criar Cliente
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDespesaModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2>Nova Despesa</h2>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setShowDespesaModal(false)}
|
||||
>
|
||||
<FiX />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-content">
|
||||
<form onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
try {
|
||||
await despesasAPI.criar({
|
||||
tipo_despesa_id: formData.get('tipo_despesa_id'),
|
||||
fornecedor_id: formData.get('fornecedor_id') || null,
|
||||
data_despesa: formData.get('data_despesa'),
|
||||
valor: parseFloat(formData.get('valor')),
|
||||
descricao: formData.get('descricao')
|
||||
});
|
||||
toast.success('Despesa criada com sucesso!');
|
||||
setShowDespesaModal(false);
|
||||
carregarDashboard();
|
||||
} catch (error) {
|
||||
toast.error('Erro ao criar despesa');
|
||||
}
|
||||
}}>
|
||||
<div className="grid grid-2">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Tipo de Despesa *</label>
|
||||
<select name="tipo_despesa_id" className="form-select" required>
|
||||
<option value="">Selecione o tipo</option>
|
||||
{tiposDespesas.map(tipo => (
|
||||
<option key={tipo.id} value={tipo.id}>{tipo.nome}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Fornecedor</label>
|
||||
<select name="fornecedor_id" className="form-select">
|
||||
<option value="">Selecione o fornecedor</option>
|
||||
{fornecedores.map(fornecedor => (
|
||||
<option key={fornecedor.id} value={fornecedor.id}>{fornecedor.razao_social}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-2">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Data da Despesa *</label>
|
||||
<input type="date" name="data_despesa" className="form-input" required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Valor *</label>
|
||||
<input type="number" step="0.01" name="valor" className="form-input" required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Descrição</label>
|
||||
<textarea name="descricao" className="form-textarea" rows="3"></textarea>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowDespesaModal(false)}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<FiPlus />
|
||||
Criar Despesa
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal Novo Produto */}
|
||||
{showProdutoModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2>Novo Produto</h2>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setShowProdutoModal(false)}
|
||||
>
|
||||
<FiX />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-content">
|
||||
<form onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
// Criar FormData para envio
|
||||
const produtoFormData = new FormData();
|
||||
produtoFormData.append('id_produto', formData.get('id_produto') || '');
|
||||
produtoFormData.append('marca', formData.get('marca'));
|
||||
produtoFormData.append('nome', formData.get('nome'));
|
||||
produtoFormData.append('estacao', formData.get('estacao'));
|
||||
produtoFormData.append('genero', formData.get('genero'));
|
||||
produtoFormData.append('fornecedor_id', formData.get('fornecedor_id') || '');
|
||||
produtoFormData.append('valor_compra', formData.get('valor_compra'));
|
||||
produtoFormData.append('valor_revenda', formData.get('valor_revenda'));
|
||||
|
||||
// Adicionar variação básica
|
||||
const variacaoBasica = [{
|
||||
tamanho: formData.get('tamanho') || 'Único',
|
||||
cor: formData.get('cor') || 'Padrão',
|
||||
quantidade: parseInt(formData.get('quantidade')) || 1
|
||||
}];
|
||||
produtoFormData.append('variacoes_data', JSON.stringify(variacaoBasica));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/produtos', {
|
||||
method: 'POST',
|
||||
body: produtoFormData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Produto criado com sucesso!');
|
||||
setShowProdutoModal(false);
|
||||
carregarDashboard();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || 'Erro ao criar produto');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar produto:', error);
|
||||
toast.error('Erro ao criar produto');
|
||||
}
|
||||
}}>
|
||||
<div className="grid grid-2">
|
||||
<div className="form-group">
|
||||
<label className="form-label">ID do Produto</label>
|
||||
<input type="text" name="id_produto" className="form-input" placeholder="Ex: LK001" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Marca *</label>
|
||||
<input type="text" name="marca" className="form-input" required placeholder="Ex: Nike" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Nome do Produto *</label>
|
||||
<input type="text" name="nome" className="form-input" required placeholder="Ex: Camiseta Infantil" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-3">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Estação *</label>
|
||||
<select name="estacao" className="form-select" required>
|
||||
<option value="Verão">Verão</option>
|
||||
<option value="Inverno">Inverno</option>
|
||||
<option value="Meia Estação">Meia Estação</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Gênero *</label>
|
||||
<select name="genero" className="form-select" required>
|
||||
<option value="Menino">Menino</option>
|
||||
<option value="Menina">Menina</option>
|
||||
<option value="Unissex">Unissex</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Fornecedor</label>
|
||||
<select name="fornecedor_id" className="form-select">
|
||||
<option value="">Selecione</option>
|
||||
{fornecedores.map(fornecedor => (
|
||||
<option key={fornecedor.id} value={fornecedor.id}>{fornecedor.razao_social}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-2">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Valor de Compra *</label>
|
||||
<input type="number" step="0.01" name="valor_compra" className="form-input" required placeholder="0,00" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Valor de Revenda *</label>
|
||||
<input type="number" step="0.01" name="valor_revenda" className="form-input" required placeholder="0,00" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h4>Variação Básica</h4>
|
||||
<div className="grid grid-3">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Tamanho</label>
|
||||
<input type="text" name="tamanho" className="form-input" placeholder="Ex: M, 6, 8" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Cor</label>
|
||||
<input type="text" name="cor" className="form-input" placeholder="Ex: Azul, Rosa" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Quantidade</label>
|
||||
<input type="number" name="quantidade" className="form-input" placeholder="1" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowProdutoModal(false)}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<FiPlus />
|
||||
Criar Produto
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal Nova Venda */}
|
||||
{showVendaModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2>Nova Venda</h2>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setShowVendaModal(false)}
|
||||
>
|
||||
<FiX />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-content">
|
||||
<form onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
const vendaData = {
|
||||
cliente_id: formData.get('cliente_id') || null,
|
||||
tipo_pagamento: formData.get('tipo_pagamento'),
|
||||
valor_total: parseFloat(formData.get('valor_total')),
|
||||
desconto: parseFloat(formData.get('desconto')) || 0,
|
||||
parcelas: parseInt(formData.get('parcelas')) || 1,
|
||||
valor_parcela: parseFloat(formData.get('valor_parcela')) || 0,
|
||||
data_venda: formData.get('data_venda'),
|
||||
observacoes: formData.get('observacoes') || '',
|
||||
itens: [] // Venda simples sem itens específicos
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/vendas', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(vendaData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Venda registrada com sucesso!');
|
||||
setShowVendaModal(false);
|
||||
carregarDashboard();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || 'Erro ao registrar venda');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao registrar venda:', error);
|
||||
toast.error('Erro ao registrar venda');
|
||||
}
|
||||
}}>
|
||||
<div className="grid grid-2">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Cliente</label>
|
||||
<select name="cliente_id" className="form-select">
|
||||
<option value="">Venda sem cliente cadastrado</option>
|
||||
{clientes.map(cliente => (
|
||||
<option key={cliente.id} value={cliente.id}>{cliente.nome_completo}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Data da Venda *</label>
|
||||
<input
|
||||
type="date"
|
||||
name="data_venda"
|
||||
className="form-input"
|
||||
required
|
||||
defaultValue={new Date().toISOString().split('T')[0]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-2">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Tipo de Pagamento *</label>
|
||||
<select name="tipo_pagamento" className="form-select" required onChange={(e) => {
|
||||
const parcelasField = document.querySelector('input[name="parcelas"]');
|
||||
const valorParcelaField = document.querySelector('input[name="valor_parcela"]');
|
||||
if (e.target.value === 'vista') {
|
||||
parcelasField.value = '1';
|
||||
parcelasField.disabled = true;
|
||||
valorParcelaField.disabled = true;
|
||||
} else {
|
||||
parcelasField.disabled = false;
|
||||
valorParcelaField.disabled = false;
|
||||
}
|
||||
}}>
|
||||
<option value="">Selecione</option>
|
||||
<option value="vista">À Vista</option>
|
||||
<option value="parcelado">Parcelado</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Valor Total *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
name="valor_total"
|
||||
className="form-input"
|
||||
required
|
||||
placeholder="0,00"
|
||||
onChange={(e) => {
|
||||
const parcelas = parseInt(document.querySelector('input[name="parcelas"]').value) || 1;
|
||||
const desconto = parseFloat(document.querySelector('input[name="desconto"]').value) || 0;
|
||||
const valorTotal = parseFloat(e.target.value) || 0;
|
||||
const valorFinal = valorTotal - desconto;
|
||||
const valorParcela = valorFinal / parcelas;
|
||||
document.querySelector('input[name="valor_parcela"]').value = valorParcela.toFixed(2);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-3">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Desconto</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
name="desconto"
|
||||
className="form-input"
|
||||
placeholder="0,00"
|
||||
onChange={(e) => {
|
||||
const parcelas = parseInt(document.querySelector('input[name="parcelas"]').value) || 1;
|
||||
const valorTotal = parseFloat(document.querySelector('input[name="valor_total"]').value) || 0;
|
||||
const desconto = parseFloat(e.target.value) || 0;
|
||||
const valorFinal = valorTotal - desconto;
|
||||
const valorParcela = valorFinal / parcelas;
|
||||
document.querySelector('input[name="valor_parcela"]').value = valorParcela.toFixed(2);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Parcelas</label>
|
||||
<input
|
||||
type="number"
|
||||
name="parcelas"
|
||||
className="form-input"
|
||||
min="1"
|
||||
defaultValue="1"
|
||||
onChange={(e) => {
|
||||
const valorTotal = parseFloat(document.querySelector('input[name="valor_total"]').value) || 0;
|
||||
const desconto = parseFloat(document.querySelector('input[name="desconto"]').value) || 0;
|
||||
const parcelas = parseInt(e.target.value) || 1;
|
||||
const valorFinal = valorTotal - desconto;
|
||||
const valorParcela = valorFinal / parcelas;
|
||||
document.querySelector('input[name="valor_parcela"]').value = valorParcela.toFixed(2);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Valor por Parcela</label>
|
||||
<input type="number" step="0.01" name="valor_parcela" className="form-input" readOnly />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Observações</label>
|
||||
<textarea name="observacoes" className="form-textarea" rows="3" placeholder="Observações sobre a venda..."></textarea>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowVendaModal(false)}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<FiPlus />
|
||||
Registrar Venda
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
333
client/src/pages/DashboardNovo.js
Normal file
333
client/src/pages/DashboardNovo.js
Normal file
@@ -0,0 +1,333 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
FiDollarSign,
|
||||
FiTrendingUp,
|
||||
FiTrendingDown,
|
||||
FiClock,
|
||||
FiAlertCircle,
|
||||
FiPackage,
|
||||
FiUsers,
|
||||
FiTruck,
|
||||
FiCreditCard,
|
||||
FiCalendar,
|
||||
FiMessageSquare
|
||||
} from 'react-icons/fi';
|
||||
import { dashboardAPI } from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import '../styles/dashboard-contabilidade.css';
|
||||
|
||||
const DashboardNovo = () => {
|
||||
const [dashboardData, setDashboardData] = useState({
|
||||
contabilidade: {
|
||||
receitaBruta: 0,
|
||||
custosProdutos: 0,
|
||||
totalDespesas: 0,
|
||||
lucroReal: 0,
|
||||
margemLucro: 0
|
||||
},
|
||||
resumoFinanceiro: {
|
||||
receitasMes: 0,
|
||||
despesasMes: 0,
|
||||
lucroEstimado: 0,
|
||||
totalVendas: 0
|
||||
},
|
||||
emprestimos: {
|
||||
totalAberto: 0,
|
||||
totalQuitado: 0,
|
||||
quantidade: 0
|
||||
},
|
||||
vendasPrazo: {
|
||||
total: 0,
|
||||
quantidade: 0,
|
||||
vendas: []
|
||||
},
|
||||
parcelasPendentes: {
|
||||
quantidade: 0,
|
||||
parcelas: []
|
||||
},
|
||||
estatisticas: {
|
||||
totalProdutos: 0,
|
||||
totalClientes: 0,
|
||||
totalFornecedores: 0,
|
||||
estoqueTotal: 0
|
||||
}
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
carregarDashboard();
|
||||
}, []);
|
||||
|
||||
const carregarDashboard = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await dashboardAPI.obterEstatisticas();
|
||||
setDashboardData(response.data);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar dashboard:', error);
|
||||
toast.error('Erro ao carregar dados do dashboard');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatarMoeda = (valor) => {
|
||||
return new Intl.NumberFormat('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL'
|
||||
}).format(valor);
|
||||
};
|
||||
|
||||
const formatarData = (data) => {
|
||||
return new Date(data).toLocaleDateString('pt-BR');
|
||||
};
|
||||
|
||||
const enviarWhatsApp = (venda) => {
|
||||
const telefone = venda.cliente_whatsapp || venda.cliente_telefone;
|
||||
if (!telefone) {
|
||||
toast.error('Cliente não possui WhatsApp cadastrado');
|
||||
return;
|
||||
}
|
||||
|
||||
const numeroLimpo = telefone.replace(/\D/g, '');
|
||||
const mensagem = `Olá! Sua compra no valor de ${formatarMoeda(venda.valor)} vence em ${formatarData(venda.vencimento)}. Obrigado!`;
|
||||
const mensagemCodificada = encodeURIComponent(mensagem);
|
||||
const url = `https://wa.me/55${numeroLimpo}?text=${mensagemCodificada}`;
|
||||
|
||||
window.open(url, '_blank');
|
||||
toast.success('WhatsApp aberto com sucesso!');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="dashboard-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Carregando dashboard...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard-header">
|
||||
<h1>Dashboard</h1>
|
||||
<p>Visão geral do seu negócio</p>
|
||||
</div>
|
||||
|
||||
{/* CONTABILIDADE COMPLETA */}
|
||||
<div className="contabilidade-section">
|
||||
<h2>📊 Contabilidade Completa - {new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' })}</h2>
|
||||
<div className="contabilidade-grid">
|
||||
<div className="contabilidade-card receita">
|
||||
<div className="card-header">
|
||||
<FiTrendingUp className="card-icon" />
|
||||
<span>Receita Bruta</span>
|
||||
</div>
|
||||
<div className="card-value">{formatarMoeda(dashboardData.contabilidade.receitaBruta)}</div>
|
||||
<div className="card-subtitle">Vendas do mês</div>
|
||||
</div>
|
||||
|
||||
<div className="contabilidade-card custo">
|
||||
<div className="card-header">
|
||||
<FiPackage className="card-icon" />
|
||||
<span>Custos dos Produtos</span>
|
||||
</div>
|
||||
<div className="card-value">{formatarMoeda(dashboardData.contabilidade.custosProdutos)}</div>
|
||||
<div className="card-subtitle">Custo das mercadorias vendidas</div>
|
||||
</div>
|
||||
|
||||
<div className="contabilidade-card despesa">
|
||||
<div className="card-header">
|
||||
<FiTrendingDown className="card-icon" />
|
||||
<span>Despesas</span>
|
||||
</div>
|
||||
<div className="card-value">{formatarMoeda(dashboardData.contabilidade.totalDespesas)}</div>
|
||||
<div className="card-subtitle">Gastos operacionais</div>
|
||||
</div>
|
||||
|
||||
<div className="contabilidade-card lucro">
|
||||
<div className="card-header">
|
||||
<FiDollarSign className="card-icon" />
|
||||
<span>Lucro Real</span>
|
||||
</div>
|
||||
<div className="card-value">{formatarMoeda(dashboardData.contabilidade.lucroReal)}</div>
|
||||
<div className="card-subtitle">
|
||||
Margem: {dashboardData.contabilidade.margemLucro}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RESUMO FINANCEIRO */}
|
||||
<div className="resumo-section">
|
||||
<h2>💰 Resumo Financeiro</h2>
|
||||
<div className="resumo-grid">
|
||||
<div className="resumo-card">
|
||||
<div className="resumo-header">
|
||||
<span>Receitas do Mês</span>
|
||||
<FiTrendingUp className="resumo-icon success" />
|
||||
</div>
|
||||
<div className="resumo-value success">
|
||||
{formatarMoeda(dashboardData.resumoFinanceiro.receitasMes)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="resumo-card">
|
||||
<div className="resumo-header">
|
||||
<span>Despesas do Mês</span>
|
||||
<FiTrendingDown className="resumo-icon danger" />
|
||||
</div>
|
||||
<div className="resumo-value danger">
|
||||
{formatarMoeda(dashboardData.resumoFinanceiro.despesasMes)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="resumo-card">
|
||||
<div className="resumo-header">
|
||||
<span>Lucro Estimado</span>
|
||||
<FiDollarSign className="resumo-icon primary" />
|
||||
</div>
|
||||
<div className="resumo-value primary">
|
||||
{formatarMoeda(dashboardData.resumoFinanceiro.lucroEstimado)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VENDAS A PRAZO & RECEBIMENTOS */}
|
||||
<div className="vendas-prazo-section">
|
||||
<h2>📅 Vendas a Prazo & Recebimentos</h2>
|
||||
|
||||
{dashboardData.vendasPrazo.quantidade > 0 ? (
|
||||
<div className="vendas-prazo-container">
|
||||
<div className="vendas-prazo-header">
|
||||
<div className="prazo-summary">
|
||||
<span className="prazo-count">{dashboardData.vendasPrazo.quantidade} vendas</span>
|
||||
<span className="prazo-total">{formatarMoeda(dashboardData.vendasPrazo.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vendas-prazo-list">
|
||||
{dashboardData.vendasPrazo.vendas.map((venda, index) => (
|
||||
<div key={index} className="venda-prazo-item">
|
||||
<div className="venda-info">
|
||||
<div className="cliente-nome">{venda.cliente}</div>
|
||||
<div className="venda-valor">{formatarMoeda(venda.valor)}</div>
|
||||
</div>
|
||||
<div className="venda-vencimento">
|
||||
<FiCalendar className="vencimento-icon" />
|
||||
<span>Vence em: {formatarData(venda.vencimento)}</span>
|
||||
</div>
|
||||
<div className="venda-actions">
|
||||
<button
|
||||
className="btn-whatsapp"
|
||||
onClick={() => enviarWhatsApp(venda)}
|
||||
title="Enviar cobrança via WhatsApp"
|
||||
>
|
||||
<FiMessageSquare />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<FiClock className="empty-icon" />
|
||||
<p>Nenhuma venda a prazo encontrada</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PARCELAS PENDENTES */}
|
||||
{dashboardData.parcelasPendentes.quantidade > 0 && (
|
||||
<div className="parcelas-section">
|
||||
<h2>💳 Parcelas Pendentes</h2>
|
||||
<div className="parcelas-list">
|
||||
{dashboardData.parcelasPendentes.parcelas.map((parcela, index) => (
|
||||
<div key={index} className="parcela-item">
|
||||
<div className="parcela-info">
|
||||
<div className="parcela-cliente">{parcela.cliente}</div>
|
||||
<div className="parcela-valor">{formatarMoeda(parcela.valor)}</div>
|
||||
</div>
|
||||
<div className="parcela-detalhes">
|
||||
<span>{parcela.parcelas}x parcelas</span>
|
||||
<span>Próximo: {formatarData(parcela.proximoVencimento)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EMPRÉSTIMOS */}
|
||||
<div className="emprestimos-section">
|
||||
<h2>🏦 Empréstimos</h2>
|
||||
<div className="emprestimos-grid">
|
||||
<div className="emprestimo-card aberto">
|
||||
<div className="emprestimo-header">
|
||||
<FiAlertCircle className="emprestimo-icon" />
|
||||
<span>Em Aberto</span>
|
||||
</div>
|
||||
<div className="emprestimo-value">
|
||||
{formatarMoeda(dashboardData.emprestimos.totalAberto)}
|
||||
</div>
|
||||
<div className="emprestimo-count">
|
||||
{dashboardData.emprestimos.quantidade} empréstimos
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="emprestimo-card quitado">
|
||||
<div className="emprestimo-header">
|
||||
<FiTrendingUp className="emprestimo-icon" />
|
||||
<span>Quitados</span>
|
||||
</div>
|
||||
<div className="emprestimo-value">
|
||||
{formatarMoeda(dashboardData.emprestimos.totalQuitado)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ESTATÍSTICAS GERAIS */}
|
||||
<div className="estatisticas-section">
|
||||
<h2>📈 Estatísticas Gerais</h2>
|
||||
<div className="estatisticas-grid">
|
||||
<div className="stat-card">
|
||||
<FiPackage className="stat-icon" />
|
||||
<div className="stat-info">
|
||||
<div className="stat-value">{dashboardData.estatisticas.totalProdutos}</div>
|
||||
<div className="stat-label">Produtos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<FiUsers className="stat-icon" />
|
||||
<div className="stat-info">
|
||||
<div className="stat-value">{dashboardData.estatisticas.totalClientes}</div>
|
||||
<div className="stat-label">Clientes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<FiTruck className="stat-icon" />
|
||||
<div className="stat-info">
|
||||
<div className="stat-value">{dashboardData.estatisticas.totalFornecedores}</div>
|
||||
<div className="stat-label">Fornecedores</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<FiPackage className="stat-icon" />
|
||||
<div className="stat-info">
|
||||
<div className="stat-value">{dashboardData.estatisticas.estoqueTotal}</div>
|
||||
<div className="stat-label">Estoque Total</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardNovo;
|
||||
246
client/src/pages/DashboardSimples.js
Normal file
246
client/src/pages/DashboardSimples.js
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
FiDollarSign,
|
||||
FiTrendingUp,
|
||||
FiTrendingDown,
|
||||
FiClock,
|
||||
FiPackage,
|
||||
FiUsers,
|
||||
FiCalendar,
|
||||
FiMessageSquare
|
||||
} from 'react-icons/fi';
|
||||
import { dashboardAPI } from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import '../styles/dashboard-simples.css';
|
||||
|
||||
const DashboardSimples = () => {
|
||||
const [dashboardData, setDashboardData] = useState({
|
||||
contabilidade: {
|
||||
receitaBruta: 0,
|
||||
custosProdutos: 0,
|
||||
totalDespesas: 0,
|
||||
lucroReal: 0,
|
||||
margemLucro: 0
|
||||
},
|
||||
vendasPrazo: {
|
||||
total: 0,
|
||||
quantidade: 0,
|
||||
vendas: []
|
||||
},
|
||||
parcelasPendentes: {
|
||||
quantidade: 0,
|
||||
parcelas: []
|
||||
},
|
||||
estatisticas: {
|
||||
totalProdutos: 0,
|
||||
totalClientes: 0,
|
||||
totalFornecedores: 0,
|
||||
estoqueTotal: 0
|
||||
}
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
carregarDashboard();
|
||||
}, []);
|
||||
|
||||
const carregarDashboard = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await dashboardAPI.obterEstatisticas();
|
||||
setDashboardData(response.data);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar dashboard:', error);
|
||||
toast.error('Erro ao carregar dados do dashboard');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatarMoeda = (valor) => {
|
||||
return new Intl.NumberFormat('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL'
|
||||
}).format(valor);
|
||||
};
|
||||
|
||||
const formatarData = (data) => {
|
||||
return new Date(data).toLocaleDateString('pt-BR');
|
||||
};
|
||||
|
||||
const enviarWhatsApp = (venda) => {
|
||||
const telefone = venda.cliente_whatsapp || venda.cliente_telefone;
|
||||
if (!telefone) {
|
||||
toast.error('Cliente não possui WhatsApp cadastrado');
|
||||
return;
|
||||
}
|
||||
|
||||
const numeroLimpo = telefone.replace(/\D/g, '');
|
||||
const mensagem = `Olá! Sua compra no valor de ${formatarMoeda(venda.valor)} vence em ${formatarData(venda.vencimento)}. Obrigado!`;
|
||||
const mensagemCodificada = encodeURIComponent(mensagem);
|
||||
const url = `https://wa.me/55${numeroLimpo}?text=${mensagemCodificada}`;
|
||||
|
||||
window.open(url, '_blank');
|
||||
toast.success('WhatsApp aberto com sucesso!');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="dashboard-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Carregando dashboard...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mesAtual = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
||||
|
||||
return (
|
||||
<div className="dashboard-simples">
|
||||
<div className="dashboard-header">
|
||||
<h1>Dashboard</h1>
|
||||
<p>Visão geral do seu negócio - {mesAtual}</p>
|
||||
</div>
|
||||
|
||||
{/* RESUMO FINANCEIRO PRINCIPAL */}
|
||||
<div className="resumo-principal">
|
||||
<div className="card-principal receitas">
|
||||
<div className="card-icon">
|
||||
<FiTrendingUp />
|
||||
</div>
|
||||
<div className="card-content">
|
||||
<h3>Receitas do Mês</h3>
|
||||
<div className="valor-principal">{formatarMoeda(dashboardData.contabilidade.receitaBruta)}</div>
|
||||
<div className="subtexto">Vendas realizadas</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-principal despesas">
|
||||
<div className="card-icon">
|
||||
<FiTrendingDown />
|
||||
</div>
|
||||
<div className="card-content">
|
||||
<h3>Despesas do Mês</h3>
|
||||
<div className="valor-principal">{formatarMoeda(dashboardData.contabilidade.totalDespesas)}</div>
|
||||
<div className="subtexto">Gastos operacionais</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-principal lucro">
|
||||
<div className="card-icon">
|
||||
<FiDollarSign />
|
||||
</div>
|
||||
<div className="card-content">
|
||||
<h3>Lucro Real</h3>
|
||||
<div className="valor-principal">{formatarMoeda(dashboardData.contabilidade.lucroReal)}</div>
|
||||
<div className="subtexto">Margem: {dashboardData.contabilidade.margemLucro}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VENDAS A PRAZO */}
|
||||
{dashboardData.vendasPrazo.quantidade > 0 && (
|
||||
<div className="secao-vendas-prazo">
|
||||
<div className="secao-header">
|
||||
<h2>📅 Vendas a Prazo & Recebimentos</h2>
|
||||
<div className="total-prazo">
|
||||
Total: {formatarMoeda(dashboardData.vendasPrazo.total)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lista-vendas-prazo">
|
||||
{dashboardData.vendasPrazo.vendas.slice(0, 5).map((venda, index) => (
|
||||
<div key={index} className="item-venda-prazo">
|
||||
<div className="venda-info">
|
||||
<div className="cliente">{venda.cliente}</div>
|
||||
<div className="valor">{formatarMoeda(venda.valor)}</div>
|
||||
</div>
|
||||
<div className="venda-vencimento">
|
||||
<FiCalendar />
|
||||
<span>{formatarData(venda.vencimento)}</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn-whatsapp-simples"
|
||||
onClick={() => enviarWhatsApp(venda)}
|
||||
title="Enviar WhatsApp"
|
||||
>
|
||||
<FiMessageSquare />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{dashboardData.vendasPrazo.quantidade > 5 && (
|
||||
<div className="mais-vendas">
|
||||
+{dashboardData.vendasPrazo.quantidade - 5} vendas a prazo
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PARCELAS PENDENTES */}
|
||||
{dashboardData.parcelasPendentes.quantidade > 0 && (
|
||||
<div className="secao-parcelas">
|
||||
<div className="secao-header">
|
||||
<h2>💳 Parcelas Pendentes</h2>
|
||||
<div className="total-parcelas">
|
||||
{dashboardData.parcelasPendentes.quantidade} parcelas
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lista-parcelas">
|
||||
{dashboardData.parcelasPendentes.parcelas.slice(0, 3).map((parcela, index) => (
|
||||
<div key={index} className="item-parcela">
|
||||
<div className="parcela-info">
|
||||
<div className="cliente">{parcela.cliente}</div>
|
||||
<div className="valor">{formatarMoeda(parcela.valor)}</div>
|
||||
</div>
|
||||
<div className="parcela-detalhes">
|
||||
<span>{parcela.parcelas}x parcelas</span>
|
||||
<span>Próximo: {formatarData(parcela.proximoVencimento)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ESTATÍSTICAS RÁPIDAS */}
|
||||
<div className="estatisticas-rapidas">
|
||||
<div className="stat-item">
|
||||
<FiPackage className="stat-icon" />
|
||||
<div className="stat-info">
|
||||
<div className="stat-numero">{dashboardData.estatisticas.totalProdutos}</div>
|
||||
<div className="stat-label">Produtos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-item">
|
||||
<FiUsers className="stat-icon" />
|
||||
<div className="stat-info">
|
||||
<div className="stat-numero">{dashboardData.estatisticas.totalClientes}</div>
|
||||
<div className="stat-label">Clientes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-item">
|
||||
<FiPackage className="stat-icon" />
|
||||
<div className="stat-info">
|
||||
<div className="stat-numero">{dashboardData.estatisticas.estoqueTotal}</div>
|
||||
<div className="stat-label">Estoque</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EMPTY STATES */}
|
||||
{dashboardData.vendasPrazo.quantidade === 0 && dashboardData.parcelasPendentes.quantidade === 0 && (
|
||||
<div className="empty-recebimentos">
|
||||
<FiClock className="empty-icon" />
|
||||
<h3>Nenhum recebimento pendente</h3>
|
||||
<p>Todas as vendas foram pagas à vista</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardSimples;
|
||||
447
client/src/pages/Despesas.js
Normal file
447
client/src/pages/Despesas.js
Normal file
@@ -0,0 +1,447 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
FiPlus,
|
||||
FiEdit,
|
||||
FiTrash2,
|
||||
FiSearch,
|
||||
FiDollarSign,
|
||||
FiCalendar,
|
||||
FiTag
|
||||
} from 'react-icons/fi';
|
||||
import { despesasAPI, fornecedoresAPI } from '../services/api';
|
||||
import ViewToggle from '../components/ViewToggle';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Despesas = () => {
|
||||
const [despesas, setDespesas] = useState([]);
|
||||
const [tiposDespesas, setTiposDespesas] = useState([]);
|
||||
const [fornecedores, setFornecedores] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [viewMode, setViewMode] = useState('list');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showTipoModal, setShowTipoModal] = useState(false);
|
||||
const [editingExpense, setEditingExpense] = useState(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
tipo_despesa: '',
|
||||
fornecedor: '',
|
||||
data: new Date().toISOString().split('T')[0],
|
||||
valor: '',
|
||||
descricao: ''
|
||||
});
|
||||
|
||||
const [novoTipo, setNovoTipo] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
carregarDados();
|
||||
}, []);
|
||||
|
||||
const carregarDados = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [despesasRes, tiposRes, fornecedoresRes] = await Promise.all([
|
||||
despesasAPI.listar(),
|
||||
despesasAPI.listarTipos(),
|
||||
fornecedoresAPI.listar()
|
||||
]);
|
||||
setDespesas(despesasRes.data);
|
||||
setTiposDespesas(tiposRes.data);
|
||||
setFornecedores(fornecedoresRes.data);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar dados:', error);
|
||||
toast.error('Erro ao carregar despesas');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingExpense) {
|
||||
await despesasAPI.atualizar(editingExpense.id, formData);
|
||||
toast.success('Despesa atualizada com sucesso!');
|
||||
} else {
|
||||
await despesasAPI.criar(formData);
|
||||
toast.success('Despesa cadastrada com sucesso!');
|
||||
}
|
||||
|
||||
setShowModal(false);
|
||||
setEditingExpense(null);
|
||||
setFormData({
|
||||
tipo_despesa: '',
|
||||
fornecedor: '',
|
||||
data: new Date().toISOString().split('T')[0],
|
||||
valor: '',
|
||||
descricao: ''
|
||||
});
|
||||
carregarDados();
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar despesa:', error);
|
||||
toast.error('Erro ao salvar despesa');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTipo = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await despesasAPI.criarTipo({ nome: novoTipo });
|
||||
toast.success('Tipo de despesa criado com sucesso!');
|
||||
setShowTipoModal(false);
|
||||
setNovoTipo('');
|
||||
carregarDados();
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar tipo de despesa:', error);
|
||||
toast.error('Erro ao criar tipo de despesa');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (despesa) => {
|
||||
setEditingExpense(despesa);
|
||||
setFormData({
|
||||
tipo_despesa: despesa.tipo_nome || '',
|
||||
fornecedor: despesa.fornecedor_nome || '',
|
||||
data: despesa.data,
|
||||
valor: despesa.valor,
|
||||
descricao: despesa.descricao || ''
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (window.confirm('Tem certeza que deseja excluir esta despesa?')) {
|
||||
try {
|
||||
await despesasAPI.deletar(id);
|
||||
toast.success('Despesa excluída com sucesso!');
|
||||
carregarDados();
|
||||
} catch (error) {
|
||||
console.error('Erro ao excluir despesa:', error);
|
||||
toast.error('Erro ao excluir despesa');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filteredDespesas = despesas.filter(despesa =>
|
||||
(despesa.descricao && despesa.descricao.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
(despesa.tipo_nome && despesa.tipo_nome.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
(despesa.fornecedor_nome && despesa.fornecedor_nome.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
|
||||
const totalDespesas = filteredDespesas.reduce((total, despesa) => total + parseFloat(despesa.valor), 0);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div>Carregando despesas...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="despesas fade-in">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Despesas</h1>
|
||||
<p>Controle todos os gastos da empresa</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<ViewToggle
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setShowTipoModal(true)}
|
||||
>
|
||||
<FiTag />
|
||||
Tipos de Despesa
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
<FiPlus />
|
||||
Nova Despesa
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resumo */}
|
||||
<div className="expenses-summary">
|
||||
<div className="summary-card">
|
||||
<div className="summary-icon">
|
||||
<FiDollarSign />
|
||||
</div>
|
||||
<div className="summary-content">
|
||||
<div className="summary-value">
|
||||
R$ {totalDespesas.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
|
||||
</div>
|
||||
<div className="summary-label">Total em Despesas</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barra de Pesquisa */}
|
||||
<div className="search-box">
|
||||
<FiSearch className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pesquisar despesas..."
|
||||
className="search-input"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Lista de Despesas */}
|
||||
{filteredDespesas.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<FiDollarSign size={48} />
|
||||
<h3>Nenhuma despesa encontrada</h3>
|
||||
<p>Comece registrando sua primeira despesa</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
<FiPlus />
|
||||
Adicionar Despesa
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="expenses-table">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data</th>
|
||||
<th>Tipo</th>
|
||||
<th>Fornecedor</th>
|
||||
<th>Descrição</th>
|
||||
<th>Valor</th>
|
||||
<th>Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredDespesas.map((despesa) => (
|
||||
<tr key={despesa.id}>
|
||||
<td>
|
||||
<div className="expense-date">
|
||||
<FiCalendar className="date-icon" />
|
||||
{new Date(despesa.data).toLocaleDateString('pt-BR')}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-info">
|
||||
{despesa.tipo_nome}
|
||||
</span>
|
||||
</td>
|
||||
<td>{despesa.fornecedor_nome || '-'}</td>
|
||||
<td>
|
||||
<div className="expense-description">
|
||||
{despesa.descricao || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="expense-value">
|
||||
R$ {parseFloat(despesa.valor).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="table-actions">
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => handleEdit(despesa)}
|
||||
title="Editar"
|
||||
>
|
||||
<FiEdit />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon btn-danger"
|
||||
onClick={() => handleDelete(despesa.id)}
|
||||
title="Excluir"
|
||||
>
|
||||
<FiTrash2 />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de Despesa */}
|
||||
{showModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">
|
||||
{editingExpense ? 'Editar Despesa' : 'Nova Despesa'}
|
||||
</h2>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
setEditingExpense(null);
|
||||
setFormData({
|
||||
tipo_despesa: '',
|
||||
fornecedor: '',
|
||||
data: new Date().toISOString().split('T')[0],
|
||||
valor: '',
|
||||
descricao: ''
|
||||
});
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-2">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Tipo de Despesa *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={formData.tipo_despesa}
|
||||
onChange={(e) => setFormData({...formData, tipo_despesa: e.target.value})}
|
||||
placeholder="Ex: Aluguel, Energia, Marketing..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Fornecedor</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={formData.fornecedor}
|
||||
onChange={(e) => setFormData({...formData, fornecedor: e.target.value})}
|
||||
placeholder="Nome do fornecedor..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Data *</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-input"
|
||||
value={formData.data}
|
||||
onChange={(e) => setFormData({...formData, data: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Valor *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="form-input"
|
||||
value={formData.valor}
|
||||
onChange={(e) => setFormData({...formData, valor: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Descrição</label>
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
value={formData.descricao}
|
||||
onChange={(e) => setFormData({...formData, descricao: e.target.value})}
|
||||
placeholder="Descreva a despesa..."
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
setEditingExpense(null);
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{editingExpense ? 'Atualizar' : 'Cadastrar'} Despesa
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de Tipo de Despesa */}
|
||||
{showTipoModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal modal-sm">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Novo Tipo de Despesa</h2>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={() => {
|
||||
setShowTipoModal(false);
|
||||
setNovoTipo('');
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAddTipo}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Nome do Tipo *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={novoTipo}
|
||||
onChange={(e) => setNovoTipo(e.target.value)}
|
||||
placeholder="Ex: Aluguel, Energia, Marketing..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setShowTipoModal(false);
|
||||
setNovoTipo('');
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Criar Tipo
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Lista de tipos existentes */}
|
||||
{tiposDespesas.length > 0 && (
|
||||
<div className="existing-types">
|
||||
<h4>Tipos Existentes:</h4>
|
||||
<div className="types-list">
|
||||
{tiposDespesas.map((tipo) => (
|
||||
<span key={tipo.id} className="badge badge-info">
|
||||
{tipo.nome}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Despesas;
|
||||
739
client/src/pages/Devolucoes.js
Normal file
739
client/src/pages/Devolucoes.js
Normal file
@@ -0,0 +1,739 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
FiArrowLeft,
|
||||
FiPackage,
|
||||
FiCalendar,
|
||||
FiUser,
|
||||
FiDollarSign,
|
||||
FiRotateCcw,
|
||||
FiCheck,
|
||||
FiX,
|
||||
FiAlertTriangle,
|
||||
FiPlus
|
||||
} from 'react-icons/fi';
|
||||
import toast from 'react-hot-toast';
|
||||
import '../styles/devolucoes.css';
|
||||
|
||||
const Devolucoes = () => {
|
||||
const [vendas, setVendas] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [vendaSelecionada, setVendaSelecionada] = useState(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showHistoricoModal, setShowHistoricoModal] = useState(false);
|
||||
const [historicoVenda, setHistoricoVenda] = useState([]);
|
||||
const [itensDevolucao, setItensDevolucao] = useState([]);
|
||||
const [motivo, setMotivo] = useState('');
|
||||
const [processando, setProcessando] = useState(false);
|
||||
const [tipoOperacao, setTipoOperacao] = useState('devolucao');
|
||||
const [produtos, setProdutos] = useState([]);
|
||||
const [itensTroca, setItensTroca] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
carregarVendas();
|
||||
}, []);
|
||||
|
||||
const carregarVendas = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/devolucoes/vendas');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setVendas(data);
|
||||
} else {
|
||||
toast.error('Erro ao carregar vendas');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar vendas:', error);
|
||||
toast.error('Erro ao carregar vendas');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const carregarHistoricoVenda = async (vendaId) => {
|
||||
try {
|
||||
const response = await fetch(`/api/devolucoes/venda/${vendaId}`);
|
||||
if (response.ok) {
|
||||
const historico = await response.json();
|
||||
setHistoricoVenda(historico);
|
||||
setShowHistoricoModal(true);
|
||||
} else {
|
||||
toast.error('Erro ao carregar histórico');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar histórico:', error);
|
||||
toast.error('Erro ao carregar histórico');
|
||||
}
|
||||
};
|
||||
|
||||
const carregarProdutos = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/devolucoes/produtos');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setProdutos(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar produtos:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const abrirModalDevolucao = (venda) => {
|
||||
setVendaSelecionada(venda);
|
||||
setItensDevolucao(venda.itens.map(item => ({
|
||||
...item,
|
||||
quantidade_devolver: 0,
|
||||
selecionado: false
|
||||
})));
|
||||
setItensTroca([]);
|
||||
setMotivo('');
|
||||
setTipoOperacao('devolucao');
|
||||
carregarProdutos();
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleItemChange = (itemId, quantidade) => {
|
||||
setItensDevolucao(prev => prev.map(item => {
|
||||
if (item.id === itemId) {
|
||||
const quantidadeDevolver = Math.min(Math.max(0, parseInt(quantidade) || 0), item.quantidade);
|
||||
return {
|
||||
...item,
|
||||
quantidade_devolver: quantidadeDevolver,
|
||||
selecionado: quantidadeDevolver > 0
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
};
|
||||
|
||||
const calcularValorDevolucao = () => {
|
||||
return itensDevolucao.reduce((total, item) => {
|
||||
if (item.selecionado && item.quantidade_devolver > 0) {
|
||||
return total + (parseFloat(item.valor_unitario) * item.quantidade_devolver);
|
||||
}
|
||||
return total;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const calcularValorTroca = () => {
|
||||
return itensTroca.reduce((total, item) => {
|
||||
return total + (parseFloat(item.valor_unitario || 0) * parseInt(item.quantidade || 0));
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const adicionarItemTroca = () => {
|
||||
setItensTroca(prev => [...prev, {
|
||||
id: Date.now(),
|
||||
produto_id: '',
|
||||
variacao_id: '',
|
||||
quantidade: 1,
|
||||
valor_unitario: 0,
|
||||
produto_nome: '',
|
||||
variacao_info: ''
|
||||
}]);
|
||||
};
|
||||
|
||||
const removerItemTroca = (id) => {
|
||||
setItensTroca(prev => prev.filter(item => item.id !== id));
|
||||
};
|
||||
|
||||
const handleTrocaChange = (id, field, value) => {
|
||||
setItensTroca(prev => prev.map(item => {
|
||||
if (item.id === id) {
|
||||
if (field === 'produto_id') {
|
||||
const produto = produtos.find(p => p.id === value);
|
||||
return {
|
||||
...item,
|
||||
produto_id: value,
|
||||
produto_nome: produto ? `${produto.marca} - ${produto.nome}` : '',
|
||||
variacao_id: '',
|
||||
variacao_info: '',
|
||||
valor_unitario: produto ? produto.valor_revenda : 0
|
||||
};
|
||||
} else if (field === 'variacao_id') {
|
||||
const produto = produtos.find(p => p.id === item.produto_id);
|
||||
const variacao = produto?.variacoes.find(v => v.id === value);
|
||||
return {
|
||||
...item,
|
||||
variacao_id: value,
|
||||
variacao_info: variacao ? `${variacao.tamanho} - ${variacao.cor}` : '',
|
||||
valor_unitario: variacao ? variacao.preco_venda || produto.valor_revenda : item.valor_unitario
|
||||
};
|
||||
} else if (field === 'quantidade') {
|
||||
const produto = produtos.find(p => p.id === item.produto_id);
|
||||
const variacao = produto?.variacoes.find(v => v.id === item.variacao_id);
|
||||
const quantidadeSolicitada = parseInt(value) || 0;
|
||||
|
||||
if (variacao && quantidadeSolicitada > variacao.quantidade) {
|
||||
toast.error(`Estoque insuficiente! Disponível: ${variacao.quantidade}, solicitado: ${quantidadeSolicitada}`);
|
||||
return { ...item, [field]: variacao.quantidade };
|
||||
}
|
||||
|
||||
return { ...item, [field]: value };
|
||||
} else {
|
||||
return { ...item, [field]: value };
|
||||
}
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
};
|
||||
|
||||
const processarDevolucao = async () => {
|
||||
const itensSelecionados = itensDevolucao.filter(item => item.selecionado && item.quantidade_devolver > 0);
|
||||
|
||||
if (tipoOperacao === 'devolucao' && itensSelecionados.length === 0) {
|
||||
toast.error('Selecione pelo menos um item para devolução');
|
||||
return;
|
||||
}
|
||||
|
||||
if (tipoOperacao === 'troca') {
|
||||
if (itensSelecionados.length === 0) {
|
||||
toast.error('Selecione pelo menos um item para devolver na troca');
|
||||
return;
|
||||
}
|
||||
|
||||
const itensValidosTroca = itensTroca.filter(item =>
|
||||
item.produto_id && item.variacao_id && item.quantidade > 0
|
||||
);
|
||||
|
||||
if (itensValidosTroca.length === 0) {
|
||||
toast.error('Adicione pelo menos um produto para troca');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!motivo.trim()) {
|
||||
toast.error(`Informe o motivo da ${tipoOperacao}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessando(true);
|
||||
|
||||
const requestBody = {
|
||||
venda_id: vendaSelecionada.id,
|
||||
tipo_operacao: tipoOperacao,
|
||||
motivo: motivo.trim()
|
||||
};
|
||||
|
||||
if (itensSelecionados.length > 0) {
|
||||
requestBody.itens_devolucao = itensSelecionados.map(item => ({
|
||||
item_id: item.id,
|
||||
quantidade_devolvida: item.quantidade_devolver
|
||||
}));
|
||||
}
|
||||
|
||||
if (tipoOperacao === 'troca' && itensTroca.length > 0) {
|
||||
requestBody.itens_troca = itensTroca
|
||||
.filter(item => item.produto_id && item.variacao_id && item.quantidade > 0)
|
||||
.map(item => ({
|
||||
produto_id: item.produto_id,
|
||||
variacao_id: item.variacao_id,
|
||||
quantidade: parseInt(item.quantidade),
|
||||
valor_unitario: parseFloat(item.valor_unitario)
|
||||
}));
|
||||
}
|
||||
|
||||
const response = await fetch('/api/devolucoes', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
toast.success(result.message);
|
||||
setShowModal(false);
|
||||
carregarVendas();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(error.message || `Erro ao processar ${tipoOperacao}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Erro ao processar ${tipoOperacao}:`, error);
|
||||
toast.error(`Erro ao processar ${tipoOperacao}`);
|
||||
} finally {
|
||||
setProcessando(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatarMoeda = (valor) => {
|
||||
return new Intl.NumberFormat('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL'
|
||||
}).format(valor);
|
||||
};
|
||||
|
||||
const formatarData = (data) => {
|
||||
return new Date(data).toLocaleDateString('pt-BR');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Carregando vendas...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="devolucoes-page">
|
||||
<div className="page-header">
|
||||
<div className="header-content">
|
||||
<div className="header-info">
|
||||
<h1>
|
||||
<FiRotateCcw className="page-icon" />
|
||||
Devolução/Troca
|
||||
</h1>
|
||||
<p>Gerencie devoluções e trocas de produtos vendidos</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vendas-container">
|
||||
{vendas.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<FiPackage className="empty-icon" />
|
||||
<h3>Nenhuma venda encontrada</h3>
|
||||
<p>Não há vendas dos últimos 30 dias disponíveis para devolução</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="vendas-grid">
|
||||
{vendas.map(venda => (
|
||||
<div key={venda.id} className="venda-card">
|
||||
<div className="venda-header">
|
||||
<div className="venda-info">
|
||||
<h3>Venda #{venda.id_venda || venda.id.slice(-8)}</h3>
|
||||
<div className="venda-meta">
|
||||
<span className="venda-data">
|
||||
<FiCalendar />
|
||||
{formatarData(venda.data_venda)}
|
||||
</span>
|
||||
<span className="venda-cliente">
|
||||
<FiUser />
|
||||
{venda.cliente_nome}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="venda-valor">
|
||||
{formatarMoeda(venda.valor_total)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="venda-itens">
|
||||
<h4>Produtos ({venda.itens.length})</h4>
|
||||
<div className="itens-lista">
|
||||
{venda.itens.slice(0, 3).map(item => (
|
||||
<div key={item.id} className="item-resumo">
|
||||
<div className="item-info-completa">
|
||||
<span className="item-nome">{item.produto_nome}</span>
|
||||
<span className="item-variacao">{item.variacao_info}</span>
|
||||
<div className="item-detalhes">
|
||||
<span className="item-quantidade">Qtd: {item.quantidade}</span>
|
||||
<span className="item-valor">R$ {parseFloat(item.valor_unitario).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{venda.itens.length > 3 && (
|
||||
<div className="item-resumo mais-itens">
|
||||
+{venda.itens.length - 3} produtos
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="venda-actions">
|
||||
<button
|
||||
className="btn-detalhes"
|
||||
onClick={() => carregarHistoricoVenda(venda.id)}
|
||||
title="Ver histórico de devoluções/trocas"
|
||||
>
|
||||
<FiPackage />
|
||||
Detalhes
|
||||
</button>
|
||||
<button
|
||||
className="btn-devolucao"
|
||||
onClick={() => abrirModalDevolucao(venda)}
|
||||
>
|
||||
<FiRotateCcw />
|
||||
Devolução/Troca
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal de Devolução */}
|
||||
{showModal && vendaSelecionada && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-devolucao">
|
||||
<div className="modal-header">
|
||||
<h2>
|
||||
<FiRotateCcw />
|
||||
Devolução/Troca
|
||||
</h2>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
<FiX />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="venda-info-modal">
|
||||
<h3>Venda #{vendaSelecionada.id_venda || vendaSelecionada.id.slice(-8)}</h3>
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<FiCalendar />
|
||||
<span>{formatarData(vendaSelecionada.data_venda)}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<FiUser />
|
||||
<span>{vendaSelecionada.cliente_nome}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<FiDollarSign />
|
||||
<span>{formatarMoeda(vendaSelecionada.valor_total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tipo-operacao">
|
||||
<h4>Tipo de Operação:</h4>
|
||||
<div className="radio-group">
|
||||
<label className="radio-option">
|
||||
<input
|
||||
type="radio"
|
||||
value="devolucao"
|
||||
checked={tipoOperacao === 'devolucao'}
|
||||
onChange={(e) => setTipoOperacao(e.target.value)}
|
||||
/>
|
||||
<span>Devolução (retorno do dinheiro)</span>
|
||||
</label>
|
||||
<label className="radio-option">
|
||||
<input
|
||||
type="radio"
|
||||
value="troca"
|
||||
checked={tipoOperacao === 'troca'}
|
||||
onChange={(e) => setTipoOperacao(e.target.value)}
|
||||
/>
|
||||
<span>Troca (por outro produto/tamanho)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="itens-devolucao">
|
||||
<h4>Selecione os itens para devolução:</h4>
|
||||
<div className="itens-lista-modal">
|
||||
{itensDevolucao.map(item => (
|
||||
<div key={item.id} className="item-devolucao">
|
||||
<div className="item-info">
|
||||
{item.produto_foto && (
|
||||
<div className="item-foto">
|
||||
<img src={item.produto_foto} alt={item.produto_nome} />
|
||||
</div>
|
||||
)}
|
||||
<div className="item-dados">
|
||||
<div className="item-nome">{item.produto_nome}</div>
|
||||
<div className="item-codigo">Código: {item.produto_codigo || 'N/A'}</div>
|
||||
<div className="item-detalhes">
|
||||
<span className="variacao-badge">{item.variacao_info}</span>
|
||||
<span className="valor-badge">{formatarMoeda(item.valor_unitario)}</span>
|
||||
<span className="quantidade-badge">Qtd: {item.quantidade}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-controles">
|
||||
<label>Qtd. a devolver:</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max={item.quantidade}
|
||||
value={item.quantidade_devolver}
|
||||
onChange={(e) => handleItemChange(item.id, e.target.value)}
|
||||
className="quantidade-input"
|
||||
/>
|
||||
<div className="valor-item">
|
||||
{item.quantidade_devolver > 0 && (
|
||||
<span className="valor-devolucao">
|
||||
= {formatarMoeda(item.valor_unitario * item.quantidade_devolver)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seção de Troca */}
|
||||
{tipoOperacao === 'troca' && (
|
||||
<div className="itens-troca">
|
||||
<div className="troca-header">
|
||||
<h4>Produtos para Troca:</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-adicionar-item"
|
||||
onClick={adicionarItemTroca}
|
||||
>
|
||||
<FiPlus />
|
||||
Adicionar Produto
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="lista-troca">
|
||||
{itensTroca.map(item => (
|
||||
<div key={item.id} className="item-troca">
|
||||
<div className="troca-selects">
|
||||
<div className="select-group">
|
||||
<label>Produto:</label>
|
||||
<select
|
||||
value={item.produto_id}
|
||||
onChange={(e) => handleTrocaChange(item.id, 'produto_id', e.target.value)}
|
||||
className="select-produto"
|
||||
>
|
||||
<option value="">Selecione um produto</option>
|
||||
{produtos.map(produto => (
|
||||
<option key={produto.id} value={produto.id}>
|
||||
{produto.marca} - {produto.nome}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{item.produto_id && (
|
||||
<div className="select-group">
|
||||
<label>Variação:</label>
|
||||
<select
|
||||
value={item.variacao_id}
|
||||
onChange={(e) => handleTrocaChange(item.id, 'variacao_id', e.target.value)}
|
||||
className="select-variacao"
|
||||
>
|
||||
<option value="">Selecione uma variação</option>
|
||||
{produtos.find(p => p.id === item.produto_id)?.variacoes.map(variacao => (
|
||||
<option key={variacao.id} value={variacao.id}>
|
||||
{variacao.tamanho} - {variacao.cor} (Estoque: {variacao.quantidade})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="select-group">
|
||||
<label>Quantidade:</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={item.quantidade}
|
||||
onChange={(e) => handleTrocaChange(item.id, 'quantidade', e.target.value)}
|
||||
className="input-quantidade"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="select-group">
|
||||
<label>Valor Unit.:</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={item.valor_unitario}
|
||||
onChange={(e) => handleTrocaChange(item.id, 'valor_unitario', e.target.value)}
|
||||
className="input-valor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="troca-actions">
|
||||
<div className="valor-total-item">
|
||||
Total: {formatarMoeda(item.valor_unitario * item.quantidade)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-remover-item"
|
||||
onClick={() => removerItemTroca(item.id)}
|
||||
>
|
||||
<FiX />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{itensTroca.length === 0 && (
|
||||
<div className="empty-troca">
|
||||
<p>Nenhum produto adicionado para troca</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="motivo-devolucao">
|
||||
<label>Motivo da {tipoOperacao} *</label>
|
||||
<textarea
|
||||
value={motivo}
|
||||
onChange={(e) => setMotivo(e.target.value)}
|
||||
placeholder={`Descreva o motivo da ${tipoOperacao}...`}
|
||||
rows="3"
|
||||
className="motivo-textarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="resumo-devolucao">
|
||||
{tipoOperacao === 'devolucao' ? (
|
||||
<>
|
||||
<div className="resumo-item">
|
||||
<span>Valor total da devolução:</span>
|
||||
<span className="valor-total">{formatarMoeda(calcularValorDevolucao())}</span>
|
||||
</div>
|
||||
<div className="resumo-item">
|
||||
<span>Novo valor da venda:</span>
|
||||
<span className="novo-valor">
|
||||
{formatarMoeda(vendaSelecionada.valor_total - calcularValorDevolucao())}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="resumo-item">
|
||||
<span>Valor dos itens devolvidos:</span>
|
||||
<span className="valor-devolucao">{formatarMoeda(calcularValorDevolucao())}</span>
|
||||
</div>
|
||||
<div className="resumo-item">
|
||||
<span>Valor dos itens de troca:</span>
|
||||
<span className="valor-troca">{formatarMoeda(calcularValorTroca())}</span>
|
||||
</div>
|
||||
<div className="resumo-item">
|
||||
<span>Diferença:</span>
|
||||
<span className={`diferenca-valor ${calcularValorTroca() - calcularValorDevolucao() >= 0 ? 'positiva' : 'negativa'}`}>
|
||||
{calcularValorTroca() - calcularValorDevolucao() >= 0 ? '+' : ''}
|
||||
{formatarMoeda(calcularValorTroca() - calcularValorDevolucao())}
|
||||
</span>
|
||||
</div>
|
||||
<div className="resumo-item">
|
||||
<span>Novo valor da venda:</span>
|
||||
<span className="novo-valor">
|
||||
{formatarMoeda(vendaSelecionada.valor_total + (calcularValorTroca() - calcularValorDevolucao()))}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="btn-cancelar"
|
||||
onClick={() => setShowModal(false)}
|
||||
disabled={processando}
|
||||
>
|
||||
<FiX />
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
className="btn-processar"
|
||||
onClick={processarDevolucao}
|
||||
disabled={processando || (tipoOperacao === 'devolucao' && calcularValorDevolucao() === 0)}
|
||||
>
|
||||
{processando ? (
|
||||
<>
|
||||
<div className="spinner-small"></div>
|
||||
Processando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FiCheck />
|
||||
{tipoOperacao === 'devolucao' ? 'Processar Devolução' : 'Processar Troca'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de Histórico de Devoluções/Trocas */}
|
||||
{showHistoricoModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content modal-historico">
|
||||
<div className="modal-header">
|
||||
<h2>📋 Histórico de Devoluções/Trocas</h2>
|
||||
<button
|
||||
className="btn-fechar"
|
||||
onClick={() => setShowHistoricoModal(false)}
|
||||
>
|
||||
<FiX />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{historicoVenda.length === 0 ? (
|
||||
<div className="historico-vazio">
|
||||
<FiPackage size={48} />
|
||||
<h3>Nenhuma devolução ou troca encontrada</h3>
|
||||
<p>Esta venda ainda não teve nenhuma devolução ou troca.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="historico-lista">
|
||||
{historicoVenda.map((evento, index) => (
|
||||
<div key={index} className="historico-item">
|
||||
<div className="historico-header">
|
||||
<div className="evento-tipo">
|
||||
<span className={`badge ${evento.tipo_operacao === 'troca' ? 'badge-warning' : 'badge-info'}`}>
|
||||
{evento.tipo_operacao === 'troca' ? '🔄 TROCA' : '↩️ DEVOLUÇÃO'}
|
||||
</span>
|
||||
<span className="evento-data">
|
||||
📅 {new Date(evento.data_devolucao).toLocaleDateString('pt-BR')} às {new Date(evento.data_devolucao).toLocaleTimeString('pt-BR', {hour: '2-digit', minute: '2-digit'})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="evento-produto">
|
||||
<h4>🔙 Produto {evento.tipo_operacao === 'troca' ? 'Trocado' : 'Devolvido'}:</h4>
|
||||
{evento.produto_info && (
|
||||
<div className="produto-card">
|
||||
<div className="produto-nome">{evento.produto_info.nome}</div>
|
||||
<div className="produto-detalhes">
|
||||
<span>📦 Código: {evento.produto_info.codigo || 'N/A'}</span>
|
||||
<span>📏 Variação: {evento.produto_info.variacao}</span>
|
||||
<span>🔢 Qtd. Devolvida: {evento.quantidade_devolvida}</span>
|
||||
<span>💸 Valor: R$ {parseFloat(evento.valor_devolucao).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{evento.motivo && (
|
||||
<div className="evento-motivo">
|
||||
<h4>📝 Motivo:</h4>
|
||||
<div className="motivo-texto">{evento.motivo}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="btn-cancelar"
|
||||
onClick={() => setShowHistoricoModal(false)}
|
||||
>
|
||||
<FiX />
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Devolucoes;
|
||||
520
client/src/pages/Emprestimos.js
Normal file
520
client/src/pages/Emprestimos.js
Normal file
@@ -0,0 +1,520 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiDollarSign,
|
||||
FiCalendar,
|
||||
FiUser,
|
||||
FiCheckCircle,
|
||||
FiClock,
|
||||
FiArrowLeft,
|
||||
FiEye
|
||||
} from 'react-icons/fi';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Emprestimos = () => {
|
||||
const [emprestimos, setEmprestimos] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showDevolucaoModal, setShowDevolucaoModal] = useState(false);
|
||||
const [emprestimoSelecionado, setEmprestimoSelecionado] = useState(null);
|
||||
const [editando, setEditando] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
pessoa: 'Maiara',
|
||||
valor_total: '',
|
||||
descricao: '',
|
||||
data_emprestimo: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
|
||||
const [devolucaoData, setDevolucaoData] = useState({
|
||||
valor_devolvido: '',
|
||||
data_devolucao: new Date().toISOString().split('T')[0],
|
||||
observacoes: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
carregarEmprestimos();
|
||||
}, []);
|
||||
|
||||
const carregarEmprestimos = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/emprestimos');
|
||||
const data = await response.json();
|
||||
setEmprestimos(data);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar empréstimos:', error);
|
||||
toast.error('Erro ao carregar empréstimos');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const url = editando ? `/api/emprestimos/${emprestimoSelecionado.id}` : '/api/emprestimos';
|
||||
const method = editando ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(result.message);
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
carregarEmprestimos();
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar empréstimo:', error);
|
||||
toast.error('Erro ao salvar empréstimo');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDevolucao = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/emprestimos/${emprestimoSelecionado.id}/devolucoes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(devolucaoData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(result.message);
|
||||
setShowDevolucaoModal(false);
|
||||
setDevolucaoData({
|
||||
valor_devolvido: '',
|
||||
data_devolucao: new Date().toISOString().split('T')[0],
|
||||
observacoes: ''
|
||||
});
|
||||
carregarEmprestimos();
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao registrar devolução:', error);
|
||||
toast.error('Erro ao registrar devolução');
|
||||
}
|
||||
};
|
||||
|
||||
const excluirEmprestimo = async (id) => {
|
||||
if (!window.confirm('Tem certeza que deseja excluir este empréstimo?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/emprestimos/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(result.message);
|
||||
carregarEmprestimos();
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao excluir empréstimo:', error);
|
||||
toast.error('Erro ao excluir empréstimo');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
pessoa: 'Maiara',
|
||||
valor_total: '',
|
||||
descricao: '',
|
||||
data_emprestimo: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
setEditando(false);
|
||||
setEmprestimoSelecionado(null);
|
||||
};
|
||||
|
||||
const abrirModalEdicao = (emprestimo) => {
|
||||
setFormData({
|
||||
pessoa: emprestimo.pessoa,
|
||||
valor_total: emprestimo.valor_total,
|
||||
descricao: emprestimo.descricao || '',
|
||||
data_emprestimo: emprestimo.data_emprestimo
|
||||
});
|
||||
setEmprestimoSelecionado(emprestimo);
|
||||
setEditando(true);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const abrirModalDevolucao = (emprestimo) => {
|
||||
setEmprestimoSelecionado(emprestimo);
|
||||
setDevolucaoData({
|
||||
valor_devolvido: emprestimo.valor_restante.toString(),
|
||||
data_devolucao: new Date().toISOString().split('T')[0],
|
||||
observacoes: ''
|
||||
});
|
||||
setShowDevolucaoModal(true);
|
||||
};
|
||||
|
||||
const formatarMoeda = (valor) => {
|
||||
return new Intl.NumberFormat('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL'
|
||||
}).format(valor);
|
||||
};
|
||||
|
||||
const formatarData = (data) => {
|
||||
return new Date(data + 'T00:00:00').toLocaleDateString('pt-BR');
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const badges = {
|
||||
ativo: { color: 'orange', icon: FiClock, text: 'Ativo' },
|
||||
quitado: { color: 'green', icon: FiCheckCircle, text: 'Quitado' },
|
||||
cancelado: { color: 'red', icon: FiTrash2, text: 'Cancelado' }
|
||||
};
|
||||
|
||||
const badge = badges[status] || badges.ativo;
|
||||
const Icon = badge.icon;
|
||||
|
||||
return (
|
||||
<span className={`badge badge-${badge.color}`}>
|
||||
<Icon size={12} />
|
||||
{badge.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const calcularTotalEmprestado = () => {
|
||||
return emprestimos
|
||||
.filter(emp => emp.status === 'ativo')
|
||||
.reduce((total, emp) => total + parseFloat(emp.valor_restante), 0);
|
||||
};
|
||||
|
||||
const calcularTotalQuitado = () => {
|
||||
return emprestimos
|
||||
.filter(emp => emp.status === 'quitado')
|
||||
.reduce((total, emp) => total + parseFloat(emp.valor_total), 0);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div>Carregando empréstimos...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="emprestimos fade-in">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Controle de Empréstimos</h1>
|
||||
<p>Gerencie os empréstimos da Maiara</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
<FiPlus /> Novo Empréstimo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cards de Resumo */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon orange">
|
||||
<FiDollarSign />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<h3>Total em Aberto</h3>
|
||||
<p className="stat-value">{formatarMoeda(calcularTotalEmprestado())}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon green">
|
||||
<FiCheckCircle />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<h3>Total Quitado</h3>
|
||||
<p className="stat-value">{formatarMoeda(calcularTotalQuitado())}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon blue">
|
||||
<FiUser />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<h3>Empréstimos Ativos</h3>
|
||||
<p className="stat-value">{emprestimos.filter(emp => emp.status === 'ativo').length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de Empréstimos */}
|
||||
<div className="content-card">
|
||||
<div className="card-header">
|
||||
<h2>Lista de Empréstimos</h2>
|
||||
</div>
|
||||
|
||||
{emprestimos.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<FiDollarSign size={48} />
|
||||
<h3>Nenhum empréstimo encontrado</h3>
|
||||
<p>Clique em "Novo Empréstimo" para começar</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pessoa</th>
|
||||
<th>Descrição</th>
|
||||
<th>Valor Total</th>
|
||||
<th>Valor Restante</th>
|
||||
<th>Data</th>
|
||||
<th>Status</th>
|
||||
<th>Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{emprestimos.map((emprestimo) => (
|
||||
<tr key={emprestimo.id}>
|
||||
<td>
|
||||
<div className="user-info">
|
||||
<FiUser className="user-icon" />
|
||||
<strong>{emprestimo.pessoa}</strong>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="description-text">
|
||||
{emprestimo.descricao || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{formatarMoeda(emprestimo.valor_total)}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<span className={emprestimo.valor_restante > 0 ? 'text-orange' : 'text-green'}>
|
||||
<strong>{formatarMoeda(emprestimo.valor_restante)}</strong>
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatarData(emprestimo.data_emprestimo)}</td>
|
||||
<td>{getStatusBadge(emprestimo.status)}</td>
|
||||
<td>
|
||||
<div className="action-buttons">
|
||||
{emprestimo.status === 'ativo' && emprestimo.valor_restante > 0 && (
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => abrirModalDevolucao(emprestimo)}
|
||||
title="Registrar Devolução"
|
||||
>
|
||||
<FiDollarSign />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={() => abrirModalEdicao(emprestimo)}
|
||||
title="Editar"
|
||||
>
|
||||
<FiEdit2 />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => excluirEmprestimo(emprestimo.id)}
|
||||
title="Excluir"
|
||||
>
|
||||
<FiTrash2 />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Novo/Editar Empréstimo */}
|
||||
{showModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h3>{editando ? 'Editar Empréstimo' : 'Novo Empréstimo'}</h3>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Pessoa</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={formData.pessoa}
|
||||
onChange={(e) => setFormData({...formData, pessoa: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Valor Total</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="form-input"
|
||||
value={formData.valor_total}
|
||||
onChange={(e) => setFormData({...formData, valor_total: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Data do Empréstimo</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-input"
|
||||
value={formData.data_emprestimo}
|
||||
onChange={(e) => setFormData({...formData, data_emprestimo: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group form-group-full">
|
||||
<label className="form-label">Descrição</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows="3"
|
||||
value={formData.descricao}
|
||||
onChange={(e) => setFormData({...formData, descricao: e.target.value})}
|
||||
placeholder="Motivo do empréstimo (opcional)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{editando ? 'Atualizar' : 'Criar'} Empréstimo
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal Devolução */}
|
||||
{showDevolucaoModal && emprestimoSelecionado && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h3>Registrar Devolução</h3>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={() => setShowDevolucaoModal(false)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleDevolucao}>
|
||||
<div className="modal-body">
|
||||
<div className="emprestimo-info">
|
||||
<h4>{emprestimoSelecionado.pessoa}</h4>
|
||||
<p>Valor restante: <strong>{formatarMoeda(emprestimoSelecionado.valor_restante)}</strong></p>
|
||||
</div>
|
||||
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Valor da Devolução</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="form-input"
|
||||
value={devolucaoData.valor_devolvido}
|
||||
onChange={(e) => setDevolucaoData({...devolucaoData, valor_devolvido: e.target.value})}
|
||||
max={emprestimoSelecionado.valor_restante}
|
||||
required
|
||||
/>
|
||||
<small className="form-help">
|
||||
Máximo: {formatarMoeda(emprestimoSelecionado.valor_restante)}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Data da Devolução</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-input"
|
||||
value={devolucaoData.data_devolucao}
|
||||
onChange={(e) => setDevolucaoData({...devolucaoData, data_devolucao: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group form-group-full">
|
||||
<label className="form-label">Observações</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows="3"
|
||||
value={devolucaoData.observacoes}
|
||||
onChange={(e) => setDevolucaoData({...devolucaoData, observacoes: e.target.value})}
|
||||
placeholder="Observações sobre a devolução (opcional)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => setShowDevolucaoModal(false)}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn btn-success">
|
||||
Registrar Devolução
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Emprestimos;
|
||||
338
client/src/pages/Fornecedores.js
Normal file
338
client/src/pages/Fornecedores.js
Normal file
@@ -0,0 +1,338 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
FiPlus,
|
||||
FiEdit,
|
||||
FiTrash2,
|
||||
FiSearch,
|
||||
FiTruck,
|
||||
FiMail,
|
||||
FiPhone,
|
||||
FiMapPin,
|
||||
FiMessageSquare
|
||||
} from 'react-icons/fi';
|
||||
import { fornecedoresAPI } from '../services/api';
|
||||
import ViewToggle from '../components/ViewToggle';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Fornecedores = () => {
|
||||
const [fornecedores, setFornecedores] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [viewMode, setViewMode] = useState('list');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingSupplier, setEditingSupplier] = useState(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
razao_social: '',
|
||||
telefone: '',
|
||||
whatsapp: '',
|
||||
endereco: '',
|
||||
email: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
carregarFornecedores();
|
||||
}, []);
|
||||
|
||||
const carregarFornecedores = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fornecedoresAPI.listar();
|
||||
setFornecedores(response.data);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar fornecedores:', error);
|
||||
toast.error('Erro ao carregar fornecedores');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingSupplier) {
|
||||
await fornecedoresAPI.atualizar(editingSupplier.id, formData);
|
||||
toast.success('Fornecedor atualizado com sucesso!');
|
||||
} else {
|
||||
await fornecedoresAPI.criar(formData);
|
||||
toast.success('Fornecedor cadastrado com sucesso!');
|
||||
}
|
||||
|
||||
setShowModal(false);
|
||||
setEditingSupplier(null);
|
||||
setFormData({
|
||||
razao_social: '',
|
||||
telefone: '',
|
||||
whatsapp: '',
|
||||
endereco: '',
|
||||
email: ''
|
||||
});
|
||||
carregarFornecedores();
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar fornecedor:', error);
|
||||
toast.error('Erro ao salvar fornecedor');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (fornecedor) => {
|
||||
setEditingSupplier(fornecedor);
|
||||
setFormData({
|
||||
razao_social: fornecedor.razao_social,
|
||||
telefone: fornecedor.telefone || '',
|
||||
whatsapp: fornecedor.whatsapp || '',
|
||||
endereco: fornecedor.endereco || '',
|
||||
email: fornecedor.email || ''
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (window.confirm('Tem certeza que deseja excluir este fornecedor?')) {
|
||||
try {
|
||||
await fornecedoresAPI.deletar(id);
|
||||
toast.success('Fornecedor excluído com sucesso!');
|
||||
carregarFornecedores();
|
||||
} catch (error) {
|
||||
console.error('Erro ao excluir fornecedor:', error);
|
||||
toast.error('Erro ao excluir fornecedor');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFornecedores = fornecedores.filter(fornecedor =>
|
||||
fornecedor.razao_social.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(fornecedor.email && fornecedor.email.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
(fornecedor.telefone && fornecedor.telefone.includes(searchTerm))
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div>Carregando fornecedores...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fornecedores fade-in">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Fornecedores</h1>
|
||||
<p>Gerencie os fornecedores da loja</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<ViewToggle
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
<FiPlus />
|
||||
Novo Fornecedor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barra de Pesquisa */}
|
||||
<div className="search-box">
|
||||
<FiSearch className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pesquisar fornecedores..."
|
||||
className="search-input"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Lista de Fornecedores */}
|
||||
{filteredFornecedores.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<FiTruck size={48} />
|
||||
<h3>Nenhum fornecedor encontrado</h3>
|
||||
<p>Comece adicionando seu primeiro fornecedor</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
<FiPlus />
|
||||
Adicionar Fornecedor
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="suppliers-grid">
|
||||
{filteredFornecedores.map((fornecedor) => (
|
||||
<div key={fornecedor.id} className="supplier-card">
|
||||
<div className="supplier-header">
|
||||
<div className="supplier-avatar">
|
||||
<FiTruck />
|
||||
</div>
|
||||
<div className="supplier-actions">
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => handleEdit(fornecedor)}
|
||||
title="Editar"
|
||||
>
|
||||
<FiEdit />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon btn-danger"
|
||||
onClick={() => handleDelete(fornecedor.id)}
|
||||
title="Excluir"
|
||||
>
|
||||
<FiTrash2 />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="supplier-info">
|
||||
<h3 className="supplier-name">{fornecedor.razao_social}</h3>
|
||||
|
||||
{fornecedor.email && (
|
||||
<div className="supplier-detail">
|
||||
<FiMail className="detail-icon" />
|
||||
<span>{fornecedor.email}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fornecedor.telefone && (
|
||||
<div className="supplier-detail">
|
||||
<FiPhone className="detail-icon" />
|
||||
<span>{fornecedor.telefone}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fornecedor.whatsapp && (
|
||||
<div className="supplier-detail">
|
||||
<FiMessageSquare className="detail-icon" />
|
||||
<span>{fornecedor.whatsapp}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fornecedor.endereco && (
|
||||
<div className="supplier-detail">
|
||||
<FiMapPin className="detail-icon" />
|
||||
<span>{fornecedor.endereco}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="supplier-meta">
|
||||
<span className="supplier-date">
|
||||
Cadastrado em {new Date(fornecedor.created_at).toLocaleDateString('pt-BR')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de Fornecedor */}
|
||||
{showModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">
|
||||
{editingSupplier ? 'Editar Fornecedor' : 'Novo Fornecedor'}
|
||||
</h2>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
setEditingSupplier(null);
|
||||
setFormData({
|
||||
razao_social: '',
|
||||
telefone: '',
|
||||
whatsapp: '',
|
||||
endereco: '',
|
||||
email: ''
|
||||
});
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Razão Social *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={formData.razao_social}
|
||||
onChange={(e) => setFormData({...formData, razao_social: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-2">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Telefone</label>
|
||||
<input
|
||||
type="tel"
|
||||
className="form-input"
|
||||
value={formData.telefone}
|
||||
onChange={(e) => setFormData({...formData, telefone: e.target.value})}
|
||||
placeholder="(11) 3333-3333"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">WhatsApp</label>
|
||||
<input
|
||||
type="tel"
|
||||
className="form-input"
|
||||
value={formData.whatsapp}
|
||||
onChange={(e) => setFormData({...formData, whatsapp: e.target.value})}
|
||||
placeholder="(11) 99999-9999"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">E-mail</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-input"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({...formData, email: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Endereço</label>
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
value={formData.endereco}
|
||||
onChange={(e) => setFormData({...formData, endereco: e.target.value})}
|
||||
placeholder="Rua, número, bairro, cidade, CEP"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
setEditingSupplier(null);
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{editingSupplier ? 'Atualizar' : 'Cadastrar'} Fornecedor
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Fornecedores;
|
||||
1283
client/src/pages/Produtos.js
Normal file
1283
client/src/pages/Produtos.js
Normal file
File diff suppressed because it is too large
Load Diff
1471
client/src/pages/Vendas.js
Normal file
1471
client/src/pages/Vendas.js
Normal file
File diff suppressed because it is too large
Load Diff
110
client/src/services/api.js
Normal file
110
client/src/services/api.js
Normal file
@@ -0,0 +1,110 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
||||
? ''
|
||||
: 'http://localhost:5000';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${API_BASE_URL}/api`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Interceptor para tratamento de erros
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
console.error('Erro na API:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// === PRODUTOS ===
|
||||
export const produtosAPI = {
|
||||
listar: () => api.get('/produtos'),
|
||||
criar: (produto) => api.post('/produtos', produto),
|
||||
criarComFoto: (formData) => {
|
||||
return api.post('/produtos', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
},
|
||||
buscarPorId: (id) => api.get(`/produtos/${id}`),
|
||||
atualizar: (id, produto) => api.put(`/produtos/${id}`, produto),
|
||||
atualizarComFoto: (id, formData) => {
|
||||
return api.put(`/produtos/${id}`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
},
|
||||
deletar: (id) => api.delete(`/produtos/${id}`),
|
||||
|
||||
// Variações
|
||||
listarVariacoes: (produtoId) => api.get(`/produtos/${produtoId}/variacoes`),
|
||||
adicionarVariacao: (produtoId, formData) => {
|
||||
return api.post(`/produtos/${produtoId}/variacoes`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
},
|
||||
atualizarVariacao: (produtoId, variacaoId, dados) =>
|
||||
api.put(`/produtos/${produtoId}/variacoes/${variacaoId}`, dados),
|
||||
deletarVariacao: (produtoId, variacaoId) =>
|
||||
api.delete(`/produtos/${produtoId}/variacoes/${variacaoId}`),
|
||||
};
|
||||
|
||||
// === CLIENTES ===
|
||||
export const clientesAPI = {
|
||||
listar: () => api.get('/clientes'),
|
||||
criar: (cliente) => api.post('/clientes', cliente),
|
||||
buscarPorId: (id) => api.get(`/clientes/${id}`),
|
||||
atualizar: (id, cliente) => api.put(`/clientes/${id}`, cliente),
|
||||
deletar: (id) => api.delete(`/clientes/${id}`),
|
||||
};
|
||||
|
||||
// === FORNECEDORES ===
|
||||
export const fornecedoresAPI = {
|
||||
listar: () => api.get('/fornecedores'),
|
||||
criar: (fornecedor) => api.post('/fornecedores', fornecedor),
|
||||
buscarPorId: (id) => api.get(`/fornecedores/${id}`),
|
||||
atualizar: (id, fornecedor) => api.put(`/fornecedores/${id}`, fornecedor),
|
||||
deletar: (id) => api.delete(`/fornecedores/${id}`),
|
||||
};
|
||||
|
||||
// === DESPESAS ===
|
||||
export const despesasAPI = {
|
||||
listar: () => api.get('/despesas'),
|
||||
criar: (despesa) => api.post('/despesas', despesa),
|
||||
buscarPorId: (id) => api.get(`/despesas/${id}`),
|
||||
atualizar: (id, despesa) => api.put(`/despesas/${id}`, despesa),
|
||||
deletar: (id) => api.delete(`/despesas/${id}`),
|
||||
|
||||
// Tipos de despesas
|
||||
listarTipos: () => api.get('/tipos-despesas'),
|
||||
criarTipo: (tipo) => api.post('/tipos-despesas', tipo),
|
||||
deletarTipo: (id) => api.delete(`/tipos-despesas/${id}`),
|
||||
};
|
||||
|
||||
// === VENDAS ===
|
||||
export const vendasAPI = {
|
||||
listar: () => api.get('/vendas'),
|
||||
criar: (venda) => api.post('/vendas', venda),
|
||||
buscarPorId: (id) => api.get(`/vendas/${id}`),
|
||||
atualizar: (id, venda) => api.put(`/vendas/${id}`, venda),
|
||||
deletar: (id) => api.delete(`/vendas/${id}`),
|
||||
|
||||
// Itens da venda
|
||||
adicionarItem: (vendaId, item) => api.post(`/vendas/${vendaId}/itens`, item),
|
||||
removerItem: (vendaId, itemId) => api.delete(`/vendas/${vendaId}/itens/${itemId}`),
|
||||
};
|
||||
|
||||
// === DASHBOARD ===
|
||||
export const dashboardAPI = {
|
||||
obterEstatisticas: () => api.get('/dashboard'),
|
||||
};
|
||||
|
||||
export default api;
|
||||
551
client/src/styles/dashboard-contabilidade.css
Normal file
551
client/src/styles/dashboard-contabilidade.css
Normal file
@@ -0,0 +1,551 @@
|
||||
/* Dashboard Contabilidade Completa */
|
||||
.dashboard {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-header p {
|
||||
color: #6b7280;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.dashboard-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #e5e7eb;
|
||||
border-top: 4px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* CONTABILIDADE COMPLETA */
|
||||
.contabilidade-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.contabilidade-section h2 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.contabilidade-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.contabilidade-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border-left: 4px solid;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.contabilidade-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.contabilidade-card.receita {
|
||||
border-left-color: #10b981;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
|
||||
}
|
||||
|
||||
.contabilidade-card.custo {
|
||||
border-left-color: #f59e0b;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fffbeb 100%);
|
||||
}
|
||||
|
||||
.contabilidade-card.despesa {
|
||||
border-left-color: #ef4444;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fef2f2 100%);
|
||||
}
|
||||
|
||||
.contabilidade-card.lucro {
|
||||
border-left-color: #3b82f6;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #eff6ff 100%);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 1.5rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.receita .card-icon { color: #10b981; }
|
||||
.custo .card-icon { color: #f59e0b; }
|
||||
.despesa .card-icon { color: #ef4444; }
|
||||
.lucro .card-icon { color: #3b82f6; }
|
||||
|
||||
.card-header span {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* RESUMO FINANCEIRO */
|
||||
.resumo-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.resumo-section h2 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.resumo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.resumo-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.resumo-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.resumo-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.resumo-header span {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.resumo-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.resumo-icon.success { color: #10b981; }
|
||||
.resumo-icon.danger { color: #ef4444; }
|
||||
.resumo-icon.primary { color: #3b82f6; }
|
||||
|
||||
.resumo-value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.resumo-value.success { color: #10b981; }
|
||||
.resumo-value.danger { color: #ef4444; }
|
||||
.resumo-value.primary { color: #3b82f6; }
|
||||
|
||||
/* VENDAS A PRAZO */
|
||||
.vendas-prazo-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.vendas-prazo-section h2 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.vendas-prazo-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.vendas-prazo-header {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.prazo-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.prazo-count {
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prazo-total {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.vendas-prazo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.venda-prazo-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.venda-prazo-item:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.venda-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cliente-nome {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.venda-valor {
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.venda-vencimento {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #6b7280;
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.vencimento-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.venda-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-whatsapp {
|
||||
background: #25d366;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-whatsapp:hover {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
/* PARCELAS PENDENTES */
|
||||
.parcelas-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.parcelas-section h2 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.parcelas-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.parcela-item {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.parcela-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.parcela-cliente {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.parcela-valor {
|
||||
color: #f59e0b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.parcela-detalhes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.25rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* EMPRÉSTIMOS */
|
||||
.emprestimos-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.emprestimos-section h2 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.emprestimos-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.emprestimo-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
.emprestimo-card.aberto {
|
||||
border-left-color: #f59e0b;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fffbeb 100%);
|
||||
}
|
||||
|
||||
.emprestimo-card.quitado {
|
||||
border-left-color: #10b981;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
|
||||
}
|
||||
|
||||
.emprestimo-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.emprestimo-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.aberto .emprestimo-icon { color: #f59e0b; }
|
||||
.quitado .emprestimo-icon { color: #10b981; }
|
||||
|
||||
.emprestimo-header span {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.emprestimo-value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.emprestimo-count {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ESTATÍSTICAS GERAIS */
|
||||
.estatisticas-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.estatisticas-section h2 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.estatisticas-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2rem;
|
||||
color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* EMPTY STATE */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* RESPONSIVIDADE */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.contabilidade-grid,
|
||||
.resumo-grid,
|
||||
.emprestimos-grid,
|
||||
.estatisticas-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.venda-prazo-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.venda-vencimento {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.venda-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.parcela-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.parcela-detalhes {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
404
client/src/styles/dashboard-simples.css
Normal file
404
client/src/styles/dashboard-simples.css
Normal file
@@ -0,0 +1,404 @@
|
||||
/* Dashboard Simples e Limpo */
|
||||
.dashboard-simples {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-header p {
|
||||
color: #64748b;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.dashboard-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #e2e8f0;
|
||||
border-top: 4px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* RESUMO FINANCEIRO PRINCIPAL */
|
||||
.resumo-principal {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.card-principal {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
border-left: 5px solid;
|
||||
}
|
||||
|
||||
.card-principal:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 20px -4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.card-principal.receitas {
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
.card-principal.despesas {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.card-principal.lucro {
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
background: #f1f5f9;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.receitas .card-icon {
|
||||
background: #ecfdf5;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.despesas .card-icon {
|
||||
background: #fef2f2;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.lucro .card-icon {
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.card-icon svg {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.card-content h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.valor-principal {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.subtexto {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* SEÇÕES */
|
||||
.secao-vendas-prazo,
|
||||
.secao-parcelas {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.secao-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.secao-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.total-prazo,
|
||||
.total-parcelas {
|
||||
background: #f1f5f9;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* LISTA DE VENDAS A PRAZO */
|
||||
.lista-vendas-prazo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.item-venda-prazo {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.item-venda-prazo:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.venda-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cliente {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.valor {
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.venda-vencimento {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #64748b;
|
||||
margin: 0 1.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-whatsapp-simples {
|
||||
background: #25d366;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-whatsapp-simples:hover {
|
||||
background: #22c55e;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.btn-whatsapp-simples svg {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.mais-vendas {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
}
|
||||
|
||||
/* LISTA DE PARCELAS */
|
||||
.lista-parcelas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.item-parcela {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.parcela-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.parcela-detalhes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.25rem;
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ESTATÍSTICAS RÁPIDAS */
|
||||
.estatisticas-rapidas {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2rem;
|
||||
color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
padding: 0.75rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.stat-numero {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* EMPTY STATE */
|
||||
.empty-recebimentos {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-recebimentos h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-recebimentos p {
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* RESPONSIVIDADE */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-simples {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.resumo-principal {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card-principal {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.valor-principal {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.item-venda-prazo,
|
||||
.item-parcela {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.venda-vencimento {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-whatsapp-simples {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.parcela-detalhes {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.estatisticas-rapidas {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.secao-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
1005
client/src/styles/devolucoes.css
Normal file
1005
client/src/styles/devolucoes.css
Normal file
File diff suppressed because it is too large
Load Diff
156
client/src/styles/pix-integration.css
Normal file
156
client/src/styles/pix-integration.css
Normal file
@@ -0,0 +1,156 @@
|
||||
/* PIX Integration Styles */
|
||||
|
||||
.pix-modal {
|
||||
max-width: 500px;
|
||||
width: 90vw;
|
||||
}
|
||||
|
||||
.pix-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-code-section {
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.qr-code-image {
|
||||
max-width: 200px;
|
||||
height: auto;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.pix-copy-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pix-code-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.pix-code-input {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.btn-copy-pix {
|
||||
background: #00d4aa;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-copy-pix:hover {
|
||||
background: #00b894;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.pix-info {
|
||||
background: #e8f5e8;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
border-left: 4px solid #00d4aa;
|
||||
}
|
||||
|
||||
.pix-instructions {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn-pix {
|
||||
background: #00d4aa;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-pix:hover {
|
||||
background: #00b894;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-pix:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.pix-status {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pix-status.pending {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeaa7;
|
||||
}
|
||||
|
||||
.pix-status.approved {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.pix-status.rejected {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.pix-timer {
|
||||
background: #fff3cd;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pix-modal {
|
||||
width: 95vw;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.qr-code-image {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.pix-code-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-copy-pix {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
595
client/src/styles/vendas-melhorias.css
Normal file
595
client/src/styles/vendas-melhorias.css
Normal file
@@ -0,0 +1,595 @@
|
||||
/* Estilos específicos para melhorias nas vendas */
|
||||
|
||||
/* Botões WhatsApp */
|
||||
.whatsapp-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-outline-success {
|
||||
background: transparent;
|
||||
border: 1px solid #28a745;
|
||||
color: #28a745;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-outline-success:hover {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Container das parcelas */
|
||||
.parcelas-container {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.parcela-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #dee2e6;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.parcela-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.parcela-numero {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.parcela-valor {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.parcela-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.parcela-data .form-label {
|
||||
font-size: 12px;
|
||||
margin-bottom: 2px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.form-input-sm {
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Responsividade para parcelas */
|
||||
@media (max-width: 768px) {
|
||||
.parcela-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.parcela-data {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.parcela-data input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Estilos para coluna de produtos na tabela de vendas */
|
||||
.sale-products {
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.products-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #007bff;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.product-variation {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-qty {
|
||||
font-size: 11px;
|
||||
color: #28a745;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.more-products {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 4px;
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.no-products {
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Responsividade para tabela de vendas */
|
||||
@media (max-width: 1200px) {
|
||||
.sale-products {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.product-variation {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sale-products {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.product-item {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.product-variation,
|
||||
.product-qty {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Melhorias nos totais */
|
||||
.sale-totals .total-final {
|
||||
background: #e8f5e8;
|
||||
border-color: #28a745;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
/* Indicador de valor automático */
|
||||
.valor-automatico {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.valor-automatico::after {
|
||||
content: "Auto";
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: #007bff;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Seção de informações de pagamento */
|
||||
.payment-section {
|
||||
background: #f8f9fa;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #007bff;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.payment-section h4 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #495057;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.payment-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.payment-info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.payment-info-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.payment-label {
|
||||
font-weight: 500;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.payment-value {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
/* Destaque para compra a prazo */
|
||||
.prazo-highlight {
|
||||
background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%);
|
||||
border: 1px solid #ffc107;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.prazo-highlight .form-label {
|
||||
color: #856404;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Animações */
|
||||
.parcela-item {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.parcela-item:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Estados de validação */
|
||||
.form-input.error {
|
||||
border-color: #dc3545;
|
||||
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
|
||||
}
|
||||
|
||||
.form-input.success {
|
||||
border-color: #28a745;
|
||||
box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);
|
||||
}
|
||||
|
||||
/* Botões de ação adicionais */
|
||||
.btn-icon.btn-info {
|
||||
background: #17a2b8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-icon.btn-info:hover {
|
||||
background: #138496;
|
||||
}
|
||||
|
||||
.btn-icon.btn-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-icon.btn-success:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
/* ID da venda */
|
||||
.sale-id {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
background: #e9ecef;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.item-quantity {
|
||||
color: #495057;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.item-price {
|
||||
color: #28a745;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Estilos para devoluções/trocas detalhadas */
|
||||
.devolucoes-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.devolucao-item-completa {
|
||||
background: linear-gradient(135deg, #fff 0%, #f8f9fa 100%);
|
||||
border-radius: 12px;
|
||||
border: 2px solid #e9ecef;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.devolucao-header-completa {
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.operacao-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.devolucao-data {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.produto-devolvido {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.produto-devolvido h4 {
|
||||
color: #dc3545;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.produto-info-card {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.produto-nome-dev {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
color: #333;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.produto-detalhes-dev {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.produto-detalhes-dev span {
|
||||
background: #fff;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.valores-dev {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.valores-dev span {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.motivo-operacao {
|
||||
margin: 1rem 0;
|
||||
background: #e7f3ff;
|
||||
border: 1px solid #b3d9ff;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.motivo-operacao h4 {
|
||||
color: #0066cc;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.motivo-texto {
|
||||
background: #fff;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ccc;
|
||||
font-style: italic;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.info-troca {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.troca-aviso {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #bee5eb;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Estilos para itens com status */
|
||||
.item-header-status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.item-status-badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-devolvido {
|
||||
background: #fff5f5 !important;
|
||||
border-left: 4px solid #dc3545 !important;
|
||||
}
|
||||
|
||||
.item-normal {
|
||||
background: #f0fff4 !important;
|
||||
border-left: 4px solid #28a745 !important;
|
||||
}
|
||||
|
||||
.item-evento {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%);
|
||||
border: 1px solid #bbdefb;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.evento-info {
|
||||
color: #1565c0;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.evento-hora {
|
||||
color: #666;
|
||||
font-weight: 400;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.evento-observacao {
|
||||
color: #666;
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.item-observacao {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Badge para status com troca */
|
||||
.badge-warning {
|
||||
background-color: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Modal maior para acomodar mais informações */
|
||||
.modal-lg {
|
||||
max-width: 900px !important;
|
||||
width: 95% !important;
|
||||
max-height: 90vh !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
/* Scroll para itens quando há muitos */
|
||||
.items-view {
|
||||
max-height: 400px !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
/* Modal de WhatsApp */
|
||||
.whatsapp-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.whatsapp-info {
|
||||
background: #e8f5e8;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #28a745;
|
||||
}
|
||||
|
||||
.whatsapp-info p {
|
||||
margin: 4px 0;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
/* Responsividade */
|
||||
@media (max-width: 768px) {
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.item-view {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item-photo {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
}
|
||||
}
|
||||
13
config/google-credentials.example.json
Normal file
13
config/google-credentials.example.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"web": {
|
||||
"client_id": "SEU_CLIENT_ID_AQUI.apps.googleusercontent.com",
|
||||
"project_id": "seu-projeto-id",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_secret": "SUA_CLIENT_SECRET_AQUI",
|
||||
"redirect_uris": [
|
||||
"http://localhost:5000/auth/google/callback"
|
||||
]
|
||||
}
|
||||
}
|
||||
295
config/google-drive.js
Normal file
295
config/google-drive.js
Normal file
@@ -0,0 +1,295 @@
|
||||
const { google } = require('googleapis');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
class GoogleDriveService {
|
||||
constructor() {
|
||||
this.auth = null;
|
||||
this.drive = null;
|
||||
this.credentialsPath = path.join(__dirname, 'google-credentials.json');
|
||||
this.tokensPath = path.join(__dirname, 'google-tokens.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicializa a autenticação com credenciais salvas no Supabase ou arquivo
|
||||
*/
|
||||
async initializeAuth(credentials = null) {
|
||||
try {
|
||||
let creds = credentials;
|
||||
|
||||
// Se não foram passadas credenciais, tenta carregar do arquivo
|
||||
if (!creds && fs.existsSync(this.credentialsPath)) {
|
||||
creds = JSON.parse(fs.readFileSync(this.credentialsPath, 'utf8'));
|
||||
}
|
||||
|
||||
if (!creds) {
|
||||
throw new Error('Credenciais do Google não encontradas');
|
||||
}
|
||||
|
||||
// Configurar OAuth2 com scopes
|
||||
this.auth = new google.auth.OAuth2(
|
||||
creds.client_id,
|
||||
creds.client_secret,
|
||||
creds.redirect_uris[0]
|
||||
);
|
||||
|
||||
// Definir scopes para Google Drive
|
||||
this.scopes = [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive.readonly'
|
||||
];
|
||||
|
||||
// Carregar tokens salvos se existirem
|
||||
if (fs.existsSync(this.tokensPath)) {
|
||||
const tokens = JSON.parse(fs.readFileSync(this.tokensPath, 'utf8'));
|
||||
this.auth.setCredentials(tokens);
|
||||
}
|
||||
|
||||
// Inicializar Google Drive API
|
||||
this.drive = google.drive({ version: 'v3', auth: this.auth });
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Erro ao inicializar Google Drive:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera URL de autenticação para o usuário
|
||||
*/
|
||||
getAuthUrl() {
|
||||
if (!this.auth) {
|
||||
throw new Error('Autenticação não inicializada');
|
||||
}
|
||||
|
||||
return this.auth.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
scope: this.scopes,
|
||||
prompt: 'consent'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processa o código de autorização retornado pelo Google
|
||||
*/
|
||||
async handleAuthCallback(code) {
|
||||
try {
|
||||
const { tokens } = await this.auth.getToken(code);
|
||||
this.auth.setCredentials(tokens);
|
||||
|
||||
// Salvar tokens para uso futuro
|
||||
fs.writeFileSync(this.tokensPath, JSON.stringify(tokens, null, 2));
|
||||
|
||||
console.log('✅ Autenticação Google Drive realizada com sucesso');
|
||||
return tokens;
|
||||
} catch (error) {
|
||||
console.error('❌ Erro na autenticação Google Drive:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se o usuário está autenticado
|
||||
*/
|
||||
isAuthenticated() {
|
||||
return this.auth && this.auth.credentials && this.auth.credentials.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria uma pasta no Google Drive (se não existir)
|
||||
*/
|
||||
async createFolder(folderName, parentFolderId = null) {
|
||||
try {
|
||||
// Verificar se a pasta já existe
|
||||
const query = `name='${folderName}' and mimeType='application/vnd.google-apps.folder' and trashed=false`;
|
||||
const existingFolders = await this.drive.files.list({
|
||||
q: parentFolderId ? `${query} and '${parentFolderId}' in parents` : query,
|
||||
fields: 'files(id, name)'
|
||||
});
|
||||
|
||||
if (existingFolders.data.files.length > 0) {
|
||||
return existingFolders.data.files[0].id;
|
||||
}
|
||||
|
||||
// Criar nova pasta
|
||||
const folderMetadata = {
|
||||
name: folderName,
|
||||
mimeType: 'application/vnd.google-apps.folder',
|
||||
parents: parentFolderId ? [parentFolderId] : undefined
|
||||
};
|
||||
|
||||
const folder = await this.drive.files.create({
|
||||
resource: folderMetadata,
|
||||
fields: 'id'
|
||||
});
|
||||
|
||||
console.log(`📁 Pasta '${folderName}' criada no Google Drive: ${folder.data.id}`);
|
||||
return folder.data.id;
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar pasta no Google Drive:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Faz upload de um arquivo para o Google Drive
|
||||
*/
|
||||
async uploadFile(filePath, fileName, folderId = null, mimeType = 'image/jpeg') {
|
||||
try {
|
||||
if (!this.isAuthenticated()) {
|
||||
throw new Error('Usuário não autenticado no Google Drive');
|
||||
}
|
||||
|
||||
const fileMetadata = {
|
||||
name: fileName,
|
||||
parents: folderId ? [folderId] : undefined
|
||||
};
|
||||
|
||||
const media = {
|
||||
mimeType: mimeType,
|
||||
body: fs.createReadStream(filePath)
|
||||
};
|
||||
|
||||
const file = await this.drive.files.create({
|
||||
resource: fileMetadata,
|
||||
media: media,
|
||||
fields: 'id, name, webViewLink, webContentLink'
|
||||
});
|
||||
|
||||
// Tornar o arquivo público para visualização
|
||||
await this.drive.permissions.create({
|
||||
fileId: file.data.id,
|
||||
resource: {
|
||||
role: 'reader',
|
||||
type: 'anyone'
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`📤 Arquivo '${fileName}' enviado para Google Drive: ${file.data.id}`);
|
||||
|
||||
return {
|
||||
id: file.data.id,
|
||||
name: file.data.name,
|
||||
webViewLink: file.data.webViewLink,
|
||||
webContentLink: file.data.webContentLink,
|
||||
publicUrl: `https://drive.google.com/uc?id=${file.data.id}`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erro ao fazer upload para Google Drive:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Faz upload de múltiplos arquivos
|
||||
*/
|
||||
async uploadMultipleFiles(files, folderId = null) {
|
||||
const uploadPromises = files.map(file =>
|
||||
this.uploadFile(file.path, file.name, folderId, file.mimeType)
|
||||
);
|
||||
|
||||
try {
|
||||
const results = await Promise.all(uploadPromises);
|
||||
console.log(`📤 ${results.length} arquivos enviados para Google Drive`);
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Erro ao fazer upload múltiplo:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleta um arquivo do Google Drive
|
||||
*/
|
||||
async deleteFile(fileId) {
|
||||
try {
|
||||
await this.drive.files.delete({
|
||||
fileId: fileId
|
||||
});
|
||||
console.log(`🗑️ Arquivo deletado do Google Drive: ${fileId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Erro ao deletar arquivo do Google Drive:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista arquivos de uma pasta
|
||||
*/
|
||||
async listFiles(folderId = null, pageSize = 10) {
|
||||
try {
|
||||
const query = folderId ? `'${folderId}' in parents and trashed=false` : 'trashed=false';
|
||||
|
||||
const response = await this.drive.files.list({
|
||||
q: query,
|
||||
pageSize: pageSize,
|
||||
fields: 'files(id, name, mimeType, createdTime, size, webViewLink)'
|
||||
});
|
||||
|
||||
return response.data.files;
|
||||
} catch (error) {
|
||||
console.error('Erro ao listar arquivos do Google Drive:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se o token está próximo do vencimento
|
||||
*/
|
||||
isTokenExpiringSoon() {
|
||||
if (!this.auth.credentials || !this.auth.credentials.expiry_date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiryTime = new Date(this.auth.credentials.expiry_date);
|
||||
const now = new Date();
|
||||
const fiveMinutesFromNow = new Date(now.getTime() + 5 * 60 * 1000);
|
||||
|
||||
return expiryTime <= fiveMinutesFromNow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renova o token se necessário
|
||||
*/
|
||||
async refreshTokenIfNeeded() {
|
||||
if (this.isTokenExpiringSoon()) {
|
||||
try {
|
||||
const { credentials } = await this.auth.refreshAccessToken();
|
||||
this.auth.setCredentials(credentials);
|
||||
|
||||
// Salvar tokens atualizados
|
||||
fs.writeFileSync(this.tokensPath, JSON.stringify(credentials, null, 2));
|
||||
console.log('🔄 Token Google Drive renovado automaticamente');
|
||||
} catch (error) {
|
||||
console.error('Erro ao renovar token Google Drive:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém informações sobre o espaço de armazenamento
|
||||
*/
|
||||
async getStorageInfo() {
|
||||
try {
|
||||
const response = await this.drive.about.get({
|
||||
fields: 'storageQuota'
|
||||
});
|
||||
|
||||
const quota = response.data.storageQuota;
|
||||
return {
|
||||
limit: parseInt(quota.limit),
|
||||
usage: parseInt(quota.usage),
|
||||
usageInDrive: parseInt(quota.usageInDrive),
|
||||
usageInDriveTrash: parseInt(quota.usageInDriveTrash)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erro ao obter informações de armazenamento:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GoogleDriveService;
|
||||
384
config/google-sheets.js
Normal file
384
config/google-sheets.js
Normal file
@@ -0,0 +1,384 @@
|
||||
const { google } = require('googleapis');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
class GoogleSheetsService {
|
||||
constructor() {
|
||||
this.auth = null;
|
||||
this.sheets = null;
|
||||
this.drive = null;
|
||||
}
|
||||
|
||||
// Inicializar autenticação OAuth 2.0
|
||||
async initializeAuth(credentialsData = null) {
|
||||
try {
|
||||
let credentials;
|
||||
|
||||
if (credentialsData) {
|
||||
// Usar credenciais fornecidas via parâmetro
|
||||
credentials = credentialsData;
|
||||
} else {
|
||||
// Tentar carregar do arquivo (fallback)
|
||||
const credentialsPath = path.join(__dirname, 'google-credentials.json');
|
||||
|
||||
if (!fs.existsSync(credentialsPath)) {
|
||||
throw new Error('Credenciais do Google não configuradas. Configure na página de Configurações.');
|
||||
}
|
||||
|
||||
credentials = JSON.parse(fs.readFileSync(credentialsPath, 'utf8'));
|
||||
}
|
||||
|
||||
const { client_id, client_secret, redirect_uris } = credentials.web || credentials;
|
||||
|
||||
this.auth = new google.auth.OAuth2(
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uris[0]
|
||||
);
|
||||
|
||||
this.sheets = google.sheets({ version: 'v4', auth: this.auth });
|
||||
this.drive = google.drive({ version: 'v3', auth: this.auth });
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Erro ao inicializar autenticação Google:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Gerar URL de autorização
|
||||
getAuthUrl() {
|
||||
const scopes = [
|
||||
'https://www.googleapis.com/auth/spreadsheets',
|
||||
'https://www.googleapis.com/auth/drive.file'
|
||||
];
|
||||
|
||||
return this.auth.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
scope: scopes,
|
||||
prompt: 'consent'
|
||||
});
|
||||
}
|
||||
|
||||
// Processar código de autorização
|
||||
async handleAuthCallback(code) {
|
||||
try {
|
||||
const { tokens } = await this.auth.getToken(code);
|
||||
this.auth.setCredentials(tokens);
|
||||
|
||||
// Salvar tokens para uso futuro
|
||||
const tokensPath = path.join(__dirname, 'google-tokens.json');
|
||||
fs.writeFileSync(tokensPath, JSON.stringify(tokens, null, 2));
|
||||
|
||||
return tokens;
|
||||
} catch (error) {
|
||||
console.error('Erro ao processar callback de autorização:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Carregar tokens salvos
|
||||
async loadSavedTokens() {
|
||||
try {
|
||||
const tokensPath = path.join(__dirname, 'google-tokens.json');
|
||||
|
||||
if (fs.existsSync(tokensPath)) {
|
||||
const tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf8'));
|
||||
this.auth.setCredentials(tokens);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar tokens salvos:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Criar ou atualizar planilha persistente
|
||||
async createOrUpdatePersistentSpreadsheet(spreadsheetId = null, title = 'Liberi Kids - Sistema de Estoque') {
|
||||
try {
|
||||
if (spreadsheetId) {
|
||||
// Tentar verificar se planilha existente ainda existe
|
||||
try {
|
||||
const existingSheet = await this.sheets.spreadsheets.get({
|
||||
spreadsheetId: spreadsheetId
|
||||
});
|
||||
|
||||
console.log('Planilha existente encontrada, usando a mesma...');
|
||||
return {
|
||||
spreadsheetId,
|
||||
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit`,
|
||||
exists: true
|
||||
};
|
||||
} catch (error) {
|
||||
console.log('Planilha não encontrada ou inacessível, criando nova...');
|
||||
}
|
||||
}
|
||||
|
||||
// Criar nova planilha
|
||||
const spreadsheet = await this.sheets.spreadsheets.create({
|
||||
resource: {
|
||||
properties: {
|
||||
title: title
|
||||
},
|
||||
sheets: [
|
||||
{
|
||||
properties: {
|
||||
sheetId: 0,
|
||||
title: 'Produtos',
|
||||
gridProperties: {
|
||||
rowCount: 1000,
|
||||
columnCount: 20
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
sheetId: 1,
|
||||
title: 'Vendas',
|
||||
gridProperties: {
|
||||
rowCount: 1000,
|
||||
columnCount: 15
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const newSpreadsheetId = spreadsheet.data.spreadsheetId;
|
||||
|
||||
// Aguardar um pouco para garantir que a planilha foi criada
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Configurar cabeçalhos
|
||||
await this.setupHeaders(newSpreadsheetId);
|
||||
|
||||
return {
|
||||
spreadsheetId: newSpreadsheetId,
|
||||
url: `https://docs.google.com/spreadsheets/d/${newSpreadsheetId}/edit`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar planilha:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Configurar cabeçalhos das abas
|
||||
async setupHeaders(spreadsheetId) {
|
||||
try {
|
||||
const produtosHeaders = [
|
||||
'ID da Roupa',
|
||||
'Nome do Produto',
|
||||
'Fornecedor',
|
||||
'Tamanho',
|
||||
'Estação',
|
||||
'Gênero',
|
||||
'Valor da Compra',
|
||||
'Valor da Venda',
|
||||
'Data da Compra',
|
||||
'Data da Venda',
|
||||
'Estoque Atual',
|
||||
'Marca'
|
||||
];
|
||||
|
||||
const vendasHeaders = [
|
||||
'ID da Venda',
|
||||
'Cliente',
|
||||
'Data da Venda',
|
||||
'Tipo de Pagamento',
|
||||
'Valor Total',
|
||||
'Desconto',
|
||||
'Valor Final',
|
||||
'Status',
|
||||
'Observações',
|
||||
'Produtos Vendidos'
|
||||
];
|
||||
|
||||
// Configurar cabeçalhos da aba Produtos
|
||||
await this.sheets.spreadsheets.values.update({
|
||||
spreadsheetId,
|
||||
range: 'Produtos!A1:L1',
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
resource: {
|
||||
values: [produtosHeaders]
|
||||
}
|
||||
});
|
||||
|
||||
// Configurar cabeçalhos da aba Vendas
|
||||
await this.sheets.spreadsheets.values.update({
|
||||
spreadsheetId,
|
||||
range: 'Vendas!A1:J1',
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
resource: {
|
||||
values: [vendasHeaders]
|
||||
}
|
||||
});
|
||||
|
||||
// Formatar cabeçalhos (opcional, pode falhar sem quebrar)
|
||||
try {
|
||||
const requests = [
|
||||
{
|
||||
repeatCell: {
|
||||
range: {
|
||||
sheetId: 0,
|
||||
startRowIndex: 0,
|
||||
endRowIndex: 1,
|
||||
startColumnIndex: 0,
|
||||
endColumnIndex: produtosHeaders.length
|
||||
},
|
||||
cell: {
|
||||
userEnteredFormat: {
|
||||
backgroundColor: { red: 0.2, green: 0.6, blue: 1.0 },
|
||||
textFormat: { bold: true, foregroundColor: { red: 1, green: 1, blue: 1 } }
|
||||
}
|
||||
},
|
||||
fields: 'userEnteredFormat'
|
||||
}
|
||||
},
|
||||
{
|
||||
repeatCell: {
|
||||
range: {
|
||||
sheetId: 1,
|
||||
startRowIndex: 0,
|
||||
endRowIndex: 1,
|
||||
startColumnIndex: 0,
|
||||
endColumnIndex: vendasHeaders.length
|
||||
},
|
||||
cell: {
|
||||
userEnteredFormat: {
|
||||
backgroundColor: { red: 0.2, green: 0.8, blue: 0.2 },
|
||||
textFormat: { bold: true, foregroundColor: { red: 1, green: 1, blue: 1 } }
|
||||
}
|
||||
},
|
||||
fields: 'userEnteredFormat'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
await this.sheets.spreadsheets.batchUpdate({
|
||||
spreadsheetId,
|
||||
resource: { requests }
|
||||
});
|
||||
} catch (formatError) {
|
||||
console.log('Aviso: Não foi possível formatar cabeçalhos, mas planilha foi criada:', formatError.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao configurar cabeçalhos:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Exportar dados de produtos
|
||||
async exportProdutos(spreadsheetId, produtos) {
|
||||
try {
|
||||
const values = produtos.map(produto => [
|
||||
produto.id || '',
|
||||
produto.nome || '',
|
||||
produto.fornecedor_nome || '',
|
||||
produto.tamanho || '',
|
||||
produto.estacao || '',
|
||||
produto.genero || '',
|
||||
produto.valor_compra || 0,
|
||||
produto.valor_revenda || 0,
|
||||
produto.created_at ? new Date(produto.created_at).toLocaleDateString('pt-BR') : '',
|
||||
'', // Data da venda será preenchida quando houver venda
|
||||
produto.quantidade_total || 0,
|
||||
produto.marca || ''
|
||||
]);
|
||||
|
||||
await this.sheets.spreadsheets.values.update({
|
||||
spreadsheetId,
|
||||
range: 'Produtos!A2:L' + (values.length + 1),
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
resource: { values }
|
||||
});
|
||||
|
||||
return { success: true, rowsUpdated: values.length };
|
||||
} catch (error) {
|
||||
console.error('Erro ao exportar produtos:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Exportar dados de vendas
|
||||
async exportVendas(spreadsheetId, vendas) {
|
||||
try {
|
||||
const values = vendas.map(venda => [
|
||||
venda.id || '',
|
||||
venda.cliente_nome || 'Cliente não informado',
|
||||
venda.data_venda ? new Date(venda.data_venda).toLocaleDateString('pt-BR') : '',
|
||||
venda.tipo_pagamento || '',
|
||||
venda.valor_total || 0,
|
||||
venda.desconto || 0,
|
||||
venda.valor_final || 0,
|
||||
venda.status || 'Concluída',
|
||||
venda.observacoes || '',
|
||||
venda.produtos ? venda.produtos.map(p => `${p.nome} (${p.quantidade}x)`).join(', ') : ''
|
||||
]);
|
||||
|
||||
await this.sheets.spreadsheets.values.update({
|
||||
spreadsheetId,
|
||||
range: 'Vendas!A2:J' + (values.length + 1),
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
resource: { values }
|
||||
});
|
||||
|
||||
return { success: true, rowsUpdated: values.length };
|
||||
} catch (error) {
|
||||
console.error('Erro ao exportar vendas:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar se está autenticado
|
||||
isAuthenticated() {
|
||||
return this.auth && this.auth.credentials && this.auth.credentials.access_token;
|
||||
}
|
||||
|
||||
// Verificar se o token está próximo do vencimento
|
||||
isTokenExpiringSoon() {
|
||||
if (!this.auth || !this.auth.credentials || !this.auth.credentials.expiry_date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date().getTime();
|
||||
const expiry = this.auth.credentials.expiry_date;
|
||||
const timeUntilExpiry = expiry - now;
|
||||
|
||||
// Considera que está expirando se faltam menos de 5 minutos
|
||||
return timeUntilExpiry < 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
// Renovar token automaticamente se necessário
|
||||
async refreshTokenIfNeeded() {
|
||||
if (this.isTokenExpiringSoon()) {
|
||||
try {
|
||||
const { credentials } = await this.auth.refreshAccessToken();
|
||||
this.auth.setCredentials(credentials);
|
||||
|
||||
// Salvar tokens atualizados
|
||||
const tokensPath = path.join(__dirname, 'google-tokens.json');
|
||||
fs.writeFileSync(tokensPath, JSON.stringify(credentials, null, 2));
|
||||
|
||||
console.log('Token Google renovado automaticamente');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Erro ao renovar token Google:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Resetar o serviço (limpar autenticação)
|
||||
reset() {
|
||||
this.auth = null;
|
||||
this.sheets = null;
|
||||
this.drive = null;
|
||||
console.log('Serviço Google Sheets resetado');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new GoogleSheetsService();
|
||||
7
config/google-tokens.json
Normal file
7
config/google-tokens.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"access_token": "ya29.a0AQQ_BDTf90ixT9y_x6M_zK1qmBhM88FscZ9xmiUDv0acKlSDkge5lxiUEYIi3yxFFiwxv3Az9P-PnV1wWnfEscTcht6z5uvp3LT0uzS3uFWndQ3HWN3X-YVxnvpyBD7P3gCc6YGHz0zHeN1QtyXTAIM9iW0hwLf8R5d88b_EI2DftLLZ7F68jo_MOQwN9pwUkmb6DWlaaCgYKAVoSARcSFQHGX2Milg4J_31KZx9930lNX7B8Hg0207",
|
||||
"scope": "https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/spreadsheets",
|
||||
"token_type": "Bearer",
|
||||
"expiry_date": 1759955985106,
|
||||
"refresh_token": "1//0hZ-EhpsmoFVGCgYIARAAGBESNwF-L9IrIlcr7zua7Q-wDP3Eg6-hje3BoKCDYdaMVWJMqa3VGDx57VgI0O40iu5hYtsbIsg_bTc"
|
||||
}
|
||||
82
config/mercadopago-demo.js
Normal file
82
config/mercadopago-demo.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// Versão DEMO do Mercado Pago para desenvolvimento
|
||||
// Simula a geração de PIX sem credenciais reais
|
||||
|
||||
class MercadoPagoServiceDemo {
|
||||
constructor() {
|
||||
console.log('🎭 Modo DEMO: Simulando Mercado Pago para desenvolvimento');
|
||||
}
|
||||
|
||||
async gerarPix(dados) {
|
||||
try {
|
||||
console.log('🏦 [DEMO] Gerando PIX com dados:', dados);
|
||||
|
||||
// Simular delay da API
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Gerar dados fictícios mas realistas
|
||||
const payment_id = `demo_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const qr_code = this.gerarQRCodeDemo();
|
||||
const qr_code_base64 = this.gerarQRCodeBase64Demo();
|
||||
|
||||
console.log('✅ [DEMO] PIX gerado com sucesso!');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
payment_id: payment_id,
|
||||
qr_code: qr_code,
|
||||
qr_code_base64: qr_code_base64,
|
||||
pix_copy_paste: qr_code,
|
||||
expiration_date: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
|
||||
transaction_amount: parseFloat(dados.valor),
|
||||
status: 'pending'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [DEMO] Erro ao gerar PIX:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async consultarPagamento(payment_id) {
|
||||
try {
|
||||
console.log('🔍 [DEMO] Consultando pagamento:', payment_id);
|
||||
|
||||
// Simular delay da API
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Para demo, sempre retorna pendente
|
||||
return {
|
||||
success: true,
|
||||
status: 'pending',
|
||||
status_detail: 'pending_waiting_payment',
|
||||
transaction_amount: 10.00,
|
||||
date_approved: null,
|
||||
external_reference: payment_id.replace('demo_', '')
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ [DEMO] Erro ao consultar pagamento:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
gerarQRCodeDemo() {
|
||||
// QR Code PIX fictício mas com formato real
|
||||
const timestamp = Date.now().toString();
|
||||
const random = Math.random().toString(36).substr(2, 10);
|
||||
|
||||
return `00020126580014br.gov.bcb.pix0136${random}5204000053039865802BR5925LIBERI KIDS DEMO STORE6009SAO PAULO62070503***6304${timestamp.substr(-4)}`;
|
||||
}
|
||||
|
||||
gerarQRCodeBase64Demo() {
|
||||
// QR Code base64 fictício (imagem 1x1 pixel transparente)
|
||||
return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new MercadoPagoServiceDemo();
|
||||
81
config/mercadopago.js
Normal file
81
config/mercadopago.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const { MercadoPagoConfig, Payment } = require('mercadopago');
|
||||
|
||||
class MercadoPagoService {
|
||||
constructor() {
|
||||
if (process.env.MERCADOPAGO_ACCESS_TOKEN) {
|
||||
this.client = new MercadoPagoConfig({
|
||||
accessToken: process.env.MERCADOPAGO_ACCESS_TOKEN,
|
||||
options: { timeout: 5000 }
|
||||
});
|
||||
this.payment = new Payment(this.client);
|
||||
}
|
||||
}
|
||||
|
||||
async gerarPix(dados) {
|
||||
try {
|
||||
console.log('🏦 Gerando PIX com dados:', dados);
|
||||
console.log('🔑 Access Token:', process.env.MERCADOPAGO_ACCESS_TOKEN ? 'Configurado' : 'NÃO CONFIGURADO');
|
||||
|
||||
const payment_data = {
|
||||
transaction_amount: parseFloat(dados.valor),
|
||||
description: dados.descricao || `Venda #${dados.venda_id} - Liberi Kids`,
|
||||
payment_method_id: 'pix',
|
||||
payer: {
|
||||
email: dados.cliente_email || 'cliente@liberikids.com',
|
||||
first_name: dados.cliente_nome || 'Cliente',
|
||||
identification: {
|
||||
type: 'CPF',
|
||||
number: dados.cliente_cpf || '00000000000'
|
||||
}
|
||||
},
|
||||
external_reference: dados.venda_id.toString(),
|
||||
// notification_url removida para desenvolvimento local
|
||||
date_of_expiration: new Date(Date.now() + 30 * 60 * 1000).toISOString() // 30 minutos
|
||||
};
|
||||
|
||||
console.log('📤 Enviando dados para Mercado Pago:', JSON.stringify(payment_data, null, 2));
|
||||
const payment = await this.payment.create({ body: payment_data });
|
||||
console.log('✅ Resposta do Mercado Pago:', payment);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
payment_id: payment.id,
|
||||
qr_code: payment.point_of_interaction.transaction_data.qr_code,
|
||||
qr_code_base64: payment.point_of_interaction.transaction_data.qr_code_base64,
|
||||
pix_copy_paste: payment.point_of_interaction.transaction_data.qr_code,
|
||||
expiration_date: payment.date_of_expiration,
|
||||
transaction_amount: payment.transaction_amount,
|
||||
status: payment.status
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar PIX:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async consultarPagamento(payment_id) {
|
||||
try {
|
||||
const payment = await this.payment.get({ id: payment_id });
|
||||
return {
|
||||
success: true,
|
||||
status: payment.status,
|
||||
status_detail: payment.status_detail,
|
||||
transaction_amount: payment.transaction_amount,
|
||||
date_approved: payment.date_approved,
|
||||
external_reference: payment.external_reference
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erro ao consultar pagamento:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new MercadoPagoService();
|
||||
8
config/supabase.js
Normal file
8
config/supabase.js
Normal file
@@ -0,0 +1,8 @@
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
|
||||
const supabaseUrl = 'https://xyqmlesqdqybiyjofysb.supabase.co';
|
||||
const supabaseKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inh5cW1sZXNxZHF5Yml5am9meXNiIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTk2NjEzMzcsImV4cCI6MjA3NTIzNzMzN30.uXPONkstd_xXbzX1ZwlB9gK05zjwQL0Ymj94_3NnOGE';
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
module.exports = supabase;
|
||||
129
configurar-env-local.sh
Executable file
129
configurar-env-local.sh
Executable file
@@ -0,0 +1,129 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 🔧 Script para Configurar .env Local Automaticamente
|
||||
# Execute: ./configurar-env-local.sh
|
||||
|
||||
echo "🔧 Configurando .env Local - Liberi Kids"
|
||||
echo "======================================="
|
||||
|
||||
# Cores para output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
echo -e "${GREEN}[✅ OK]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[⚠️ WARN]${NC} $1"
|
||||
}
|
||||
|
||||
info() {
|
||||
echo -e "${BLUE}[ℹ️ INFO]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[❌ ERRO]${NC} $1"
|
||||
}
|
||||
|
||||
# Verificar se .env existe
|
||||
if [ ! -f ".env" ]; then
|
||||
error "Arquivo .env não encontrado!"
|
||||
info "Criando .env a partir do .env.example..."
|
||||
cp .env.example .env
|
||||
log ".env criado com sucesso"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "📝 CONFIGURANDO CREDENCIAIS..."
|
||||
|
||||
# Solicitar credenciais do Supabase
|
||||
echo ""
|
||||
info "🗄️ CONFIGURAÇÕES SUPABASE:"
|
||||
echo "Acesse: https://supabase.com/dashboard/project/SEU_PROJETO/settings/api"
|
||||
echo ""
|
||||
|
||||
read -p "Digite a URL do Supabase (https://seu-projeto.supabase.co): " SUPABASE_URL
|
||||
read -p "Digite a ANON KEY do Supabase: " SUPABASE_ANON_KEY
|
||||
|
||||
# Solicitar credenciais do Mercado Pago (opcional)
|
||||
echo ""
|
||||
info "🏦 CONFIGURAÇÕES PIX - MERCADO PAGO (Opcional):"
|
||||
echo "Acesse: https://www.mercadopago.com.br/developers"
|
||||
echo "Deixe em branco para pular esta configuração"
|
||||
echo ""
|
||||
|
||||
read -p "Digite o ACCESS TOKEN (TEST-...): " MERCADO_PAGO_TOKEN
|
||||
read -p "Digite a PUBLIC KEY (pk_test_...): " MERCADO_PAGO_PUBLIC_KEY
|
||||
|
||||
# Aplicar configurações no .env
|
||||
echo ""
|
||||
info "⚙️ APLICANDO CONFIGURAÇÕES..."
|
||||
|
||||
# Configurar Supabase
|
||||
if [ ! -z "$SUPABASE_URL" ]; then
|
||||
sed -i "s|SUPABASE_URL=.*|SUPABASE_URL=$SUPABASE_URL|g" .env
|
||||
log "URL Supabase configurada"
|
||||
fi
|
||||
|
||||
if [ ! -z "$SUPABASE_ANON_KEY" ]; then
|
||||
sed -i "s|SUPABASE_ANON_KEY=.*|SUPABASE_ANON_KEY=$SUPABASE_ANON_KEY|g" .env
|
||||
log "ANON KEY Supabase configurada"
|
||||
fi
|
||||
|
||||
# Configurar Mercado Pago (se fornecido)
|
||||
if [ ! -z "$MERCADO_PAGO_TOKEN" ]; then
|
||||
sed -i "s|MERCADOPAGO_ACCESS_TOKEN=.*|MERCADOPAGO_ACCESS_TOKEN=$MERCADO_PAGO_TOKEN|g" .env
|
||||
log "Access Token Mercado Pago configurado"
|
||||
fi
|
||||
|
||||
if [ ! -z "$MERCADO_PAGO_PUBLIC_KEY" ]; then
|
||||
sed -i "s|MERCADOPAGO_PUBLIC_KEY=.*|MERCADOPAGO_PUBLIC_KEY=$MERCADO_PAGO_PUBLIC_KEY|g" .env
|
||||
log "Public Key Mercado Pago configurada"
|
||||
fi
|
||||
|
||||
# Configurar ambiente local
|
||||
sed -i "s|NODE_ENV=production|NODE_ENV=development|g" .env
|
||||
sed -i "s|BASE_URL=http://localhost:5000|BASE_URL=http://localhost:5000|g" .env
|
||||
|
||||
log "Ambiente configurado para desenvolvimento local"
|
||||
|
||||
echo ""
|
||||
log "🎉 CONFIGURAÇÃO CONCLUÍDA!"
|
||||
echo ""
|
||||
|
||||
info "📋 PRÓXIMOS PASSOS:"
|
||||
echo ""
|
||||
echo "1. 🗄️ APLICAR SQL NO SUPABASE:"
|
||||
echo " - Acesse: https://supabase.com/dashboard"
|
||||
echo " - Vá em SQL Editor"
|
||||
echo " - Execute o conteúdo de: aplicar-pix-supabase.sql"
|
||||
echo ""
|
||||
|
||||
echo "2. 🚀 EXECUTAR SERVIDOR LOCAL:"
|
||||
echo " node server-supabase.js"
|
||||
echo ""
|
||||
|
||||
echo "3. 🌐 ACESSAR SISTEMA:"
|
||||
echo " http://localhost:5000"
|
||||
echo ""
|
||||
|
||||
echo "4. 🧪 TESTAR PIX:"
|
||||
echo " - Vá em Vendas"
|
||||
echo " - Clique no botão PIX (💳)"
|
||||
echo " - Verifique se QR Code é gerado"
|
||||
echo ""
|
||||
|
||||
if [ -z "$MERCADO_PAGO_TOKEN" ]; then
|
||||
warn "⚠️ PIX não configurado - configure depois se necessário"
|
||||
echo " Para configurar PIX:"
|
||||
echo " 1. Acesse: https://www.mercadopago.com.br/developers"
|
||||
echo " 2. Crie conta e aplicação"
|
||||
echo " 3. Execute este script novamente"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log "✨ SEU AMBIENTE LOCAL ESTÁ PRONTO!"
|
||||
157
configurar-pix-servidor.sh
Executable file
157
configurar-pix-servidor.sh
Executable file
@@ -0,0 +1,157 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 🏦 Script para Configurar PIX no Servidor
|
||||
# Execute no servidor: ./configurar-pix-servidor.sh
|
||||
|
||||
echo "🏦 Configurando PIX no Servidor - Liberi Kids"
|
||||
echo "============================================"
|
||||
|
||||
# Cores para output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Verificar se estamos no diretório correto
|
||||
if [ ! -f "server-supabase.js" ]; then
|
||||
error "Arquivo server-supabase.js não encontrado!"
|
||||
echo "Execute este script no diretório raiz do projeto (onde está o server-supabase.js)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info "📁 Diretório correto encontrado"
|
||||
|
||||
# Verificar se .env existe
|
||||
if [ ! -f ".env" ]; then
|
||||
warn "⚠️ Arquivo .env não encontrado!"
|
||||
echo "Criando .env a partir do .env.example..."
|
||||
|
||||
if [ -f ".env.example" ]; then
|
||||
cp .env.example .env
|
||||
log "✅ Arquivo .env criado"
|
||||
else
|
||||
error "❌ Arquivo .env.example não encontrado!"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Verificar se as credenciais PIX estão no .env
|
||||
if ! grep -q "MERCADOPAGO_ACCESS_TOKEN" .env; then
|
||||
warn "⚠️ Credenciais PIX não encontradas no .env"
|
||||
echo ""
|
||||
echo "Adicionando configurações PIX ao .env..."
|
||||
|
||||
cat >> .env << 'EOF'
|
||||
|
||||
# =====================================================
|
||||
# CONFIGURAÇÕES PIX - MERCADO PAGO
|
||||
# =====================================================
|
||||
|
||||
# Access Token do Mercado Pago (TEST para desenvolvimento, PROD para produção)
|
||||
MERCADOPAGO_ACCESS_TOKEN=TEST-sua_access_token_aqui
|
||||
|
||||
# Public Key do Mercado Pago
|
||||
MERCADOPAGO_PUBLIC_KEY=pk_test_sua_public_key_aqui
|
||||
|
||||
# URL base para webhooks (importante para produção)
|
||||
BASE_URL=http://localhost:5000
|
||||
EOF
|
||||
|
||||
log "✅ Configurações PIX adicionadas ao .env"
|
||||
warn "📝 EDITE O ARQUIVO .env E CONFIGURE SUAS CREDENCIAIS!"
|
||||
fi
|
||||
|
||||
# Verificar se node_modules existe
|
||||
if [ ! -d "node_modules" ]; then
|
||||
warn "⚠️ node_modules não encontrado. Instalando dependências..."
|
||||
npm install
|
||||
fi
|
||||
|
||||
# Verificar se mercadopago está instalado
|
||||
if [ ! -d "node_modules/mercadopago" ]; then
|
||||
info "📦 Instalando SDK do Mercado Pago..."
|
||||
npm install mercadopago
|
||||
log "✅ Mercado Pago SDK instalado"
|
||||
fi
|
||||
|
||||
# Verificar se o build do frontend existe
|
||||
if [ ! -d "client/build" ]; then
|
||||
warn "⚠️ Build do frontend não encontrado"
|
||||
info "🔨 Fazendo build do frontend..."
|
||||
cd client
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
log "✅ Build do frontend concluído"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "🎯 CONFIGURAÇÃO PIX FINALIZADA!"
|
||||
echo ""
|
||||
|
||||
log "✅ Arquivos PIX criados:"
|
||||
echo " - config/mercadopago.js (Serviço PIX)"
|
||||
echo " - client/src/styles/pix-integration.css (Estilos)"
|
||||
echo " - aplicar-pix-supabase.sql (SQL para Supabase)"
|
||||
echo ""
|
||||
|
||||
warn "📝 PRÓXIMOS PASSOS OBRIGATÓRIOS:"
|
||||
echo ""
|
||||
echo "1. 🏦 CRIAR CONTA MERCADO PAGO:"
|
||||
echo " - Acesse: https://www.mercadopago.com.br/developers"
|
||||
echo " - Crie conta e aplicação de 'Pagamentos online'"
|
||||
echo ""
|
||||
|
||||
echo "2. 📝 CONFIGURAR CREDENCIAIS:"
|
||||
echo " - Edite o arquivo .env:"
|
||||
echo " nano .env"
|
||||
echo " - Configure:"
|
||||
echo " MERCADOPAGO_ACCESS_TOKEN=TEST-sua_access_token"
|
||||
echo " MERCADOPAGO_PUBLIC_KEY=pk_test_sua_public_key"
|
||||
echo " BASE_URL=http://$(hostname -I | awk '{print $1}'):5000"
|
||||
echo ""
|
||||
|
||||
echo "3. 🗄️ APLICAR SQL NO SUPABASE:"
|
||||
echo " - Acesse o painel do Supabase"
|
||||
echo " - Vá em SQL Editor"
|
||||
echo " - Execute o conteúdo do arquivo: aplicar-pix-supabase.sql"
|
||||
echo ""
|
||||
|
||||
echo "4. 🔄 REINICIAR SERVIDOR:"
|
||||
echo " - pm2 restart liberi-kids"
|
||||
echo " - Ou: pm2 start server-supabase.js --name liberi-kids"
|
||||
echo ""
|
||||
|
||||
echo "5. 🧪 TESTAR:"
|
||||
echo " - Acesse o sistema"
|
||||
echo " - Vá em Vendas"
|
||||
echo " - Clique no botão PIX (💳)"
|
||||
echo " - Verifique se QR Code é gerado"
|
||||
echo ""
|
||||
|
||||
info "💡 DICAS:"
|
||||
echo " - Use credenciais TEST para desenvolvimento"
|
||||
echo " - Configure webhook URL no Mercado Pago"
|
||||
echo " - Teste pagamentos no ambiente sandbox"
|
||||
echo ""
|
||||
|
||||
log "🎉 PIX PRONTO PARA CONFIGURAÇÃO!"
|
||||
echo ""
|
||||
warn "Configure as credenciais e execute o SQL no Supabase!"
|
||||
158
configurar-producao-pix.sh
Executable file
158
configurar-producao-pix.sh
Executable file
@@ -0,0 +1,158 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 🏦 Script para Configurar PIX Produção - Mercado Pago
|
||||
# Execute: ./configurar-producao-pix.sh
|
||||
|
||||
echo "🏦 Configuração PIX Produção - Liberi Kids"
|
||||
echo "========================================="
|
||||
|
||||
# Cores para output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
echo -e "${GREEN}[✅ OK]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[⚠️ WARN]${NC} $1"
|
||||
}
|
||||
|
||||
info() {
|
||||
echo -e "${BLUE}[ℹ️ INFO]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[❌ ERRO]${NC} $1"
|
||||
}
|
||||
|
||||
echo ""
|
||||
warn "⚠️ ATENÇÃO: Este script configura PIX para PRODUÇÃO"
|
||||
warn "⚠️ Certifique-se de ter credenciais VÁLIDAS do Mercado Pago"
|
||||
echo ""
|
||||
|
||||
info "📋 REQUISITOS OBRIGATÓRIOS:"
|
||||
echo " ✅ Conta Mercado Pago EMPRESARIAL (não pessoal)"
|
||||
echo " ✅ CNPJ verificado"
|
||||
echo " ✅ Conta bancária confirmada"
|
||||
echo " ✅ PIX ativado na conta"
|
||||
echo " ✅ Aplicação criada no painel de desenvolvedores"
|
||||
echo ""
|
||||
|
||||
read -p "Você tem TODOS os requisitos acima? (s/N): " confirma
|
||||
if [[ $confirma != "s" && $confirma != "S" ]]; then
|
||||
error "❌ Configure os requisitos primeiro!"
|
||||
echo ""
|
||||
info "📖 Guia completo:"
|
||||
echo " 1. Acesse: https://www.mercadopago.com.br/developers"
|
||||
echo " 2. Crie conta empresarial com CNPJ"
|
||||
echo " 3. Complete verificação de identidade"
|
||||
echo " 4. Ative PIX na conta"
|
||||
echo " 5. Crie aplicação 'Checkout Pro'"
|
||||
echo " 6. Execute este script novamente"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "🔑 CONFIGURANDO CREDENCIAIS DE PRODUÇÃO..."
|
||||
|
||||
# Solicitar credenciais
|
||||
echo ""
|
||||
info "📝 Digite suas credenciais do Mercado Pago:"
|
||||
echo "Encontre em: https://www.mercadopago.com.br/developers/panel/app"
|
||||
echo ""
|
||||
|
||||
read -p "Access Token (APP-...): " ACCESS_TOKEN
|
||||
read -p "Public Key (pk_live_...): " PUBLIC_KEY
|
||||
read -p "URL do seu servidor (https://seudominio.com): " BASE_URL
|
||||
|
||||
# Validar credenciais
|
||||
if [[ ! $ACCESS_TOKEN =~ ^APP- ]] && [[ ! $ACCESS_TOKEN =~ ^APP_USR- ]]; then
|
||||
error "❌ Access Token deve começar com 'APP-' ou 'APP_USR-' para produção"
|
||||
warn "💡 Para teste, use 'TEST-...'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! $PUBLIC_KEY =~ ^pk_live_ ]] && [[ ! $PUBLIC_KEY =~ ^APP_USR- ]]; then
|
||||
error "❌ Public Key deve começar com 'pk_live_' ou 'APP_USR-' para produção"
|
||||
warn "💡 Para teste, use 'pk_test_...'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! $BASE_URL =~ ^https:// ]]; then
|
||||
error "❌ URL deve usar HTTPS para produção"
|
||||
warn "💡 Para desenvolvimento local, use 'http://localhost:5000'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verificar se .env existe
|
||||
if [ ! -f ".env" ]; then
|
||||
info "📝 Criando arquivo .env..."
|
||||
cp .env.example .env
|
||||
fi
|
||||
|
||||
# Aplicar configurações
|
||||
info "⚙️ APLICANDO CREDENCIAIS..."
|
||||
|
||||
sed -i "s|MERCADOPAGO_ACCESS_TOKEN=.*|MERCADOPAGO_ACCESS_TOKEN=$ACCESS_TOKEN|g" .env
|
||||
sed -i "s|MERCADOPAGO_PUBLIC_KEY=.*|MERCADOPAGO_PUBLIC_KEY=$PUBLIC_KEY|g" .env
|
||||
sed -i "s|BASE_URL=.*|BASE_URL=$BASE_URL|g" .env
|
||||
sed -i "s|NODE_ENV=.*|NODE_ENV=production|g" .env
|
||||
|
||||
log "✅ Credenciais configuradas"
|
||||
|
||||
# Substituir versão demo por produção
|
||||
info "🔄 ATIVANDO VERSÃO DE PRODUÇÃO..."
|
||||
|
||||
if [ -f "server-supabase.js" ]; then
|
||||
# Trocar mercadopago-demo por mercadopago
|
||||
sed -i "s|require('./config/mercadopago-demo')|require('./config/mercadopago')|g" server-supabase.js
|
||||
sed -i "s|// Usar versão demo para desenvolvimento|// Versão de produção ativada|g" server-supabase.js
|
||||
log "✅ Servidor configurado para produção"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log "🎉 CONFIGURAÇÃO DE PRODUÇÃO CONCLUÍDA!"
|
||||
echo ""
|
||||
|
||||
info "📋 PRÓXIMOS PASSOS:"
|
||||
echo ""
|
||||
echo "1. 🧪 TESTAR EM AMBIENTE SEGURO:"
|
||||
echo " - Faça um PIX de R$ 0,01 primeiro"
|
||||
echo " - Verifique se recebe na conta"
|
||||
echo " - Confirme se webhook funciona"
|
||||
echo ""
|
||||
|
||||
echo "2. 🌐 CONFIGURAR WEBHOOK NO MERCADO PAGO:"
|
||||
echo " - Acesse: https://www.mercadopago.com.br/developers"
|
||||
echo " - Vá em sua aplicação → Webhooks"
|
||||
echo " - Configure: $BASE_URL/api/pix/webhook"
|
||||
echo " - Eventos: payment"
|
||||
echo ""
|
||||
|
||||
echo "3. 🚀 REINICIAR SERVIDOR:"
|
||||
if command -v pm2 &> /dev/null; then
|
||||
echo " pm2 restart liberi-kids"
|
||||
else
|
||||
echo " node server-supabase.js"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "4. 🔒 SEGURANÇA:"
|
||||
echo " - Use HTTPS obrigatoriamente"
|
||||
echo " - Mantenha .env seguro"
|
||||
echo " - Monitore transações"
|
||||
echo ""
|
||||
|
||||
warn "⚠️ IMPORTANTE:"
|
||||
echo " - PIX de produção cobra 0,99% por transação"
|
||||
echo " - Recebimento em 1 dia útil"
|
||||
echo " - Teste com valores baixos primeiro"
|
||||
echo ""
|
||||
|
||||
log "🏦 SEU PIX DE PRODUÇÃO ESTÁ CONFIGURADO!"
|
||||
echo ""
|
||||
info "💡 Para voltar ao modo demo: ./configurar-env-local.sh"
|
||||
173
deploy-completo-servidor.sh
Executable file
173
deploy-completo-servidor.sh
Executable file
@@ -0,0 +1,173 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 🚀 Deploy COMPLETO: PC → Servidor Limpo
|
||||
# Este script envia TUDO do seu PC para o servidor
|
||||
# Execute: ./deploy-completo-servidor.sh
|
||||
|
||||
echo "🚀 DEPLOY COMPLETO - Liberi Kids"
|
||||
echo "================================"
|
||||
|
||||
# Cores para output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
echo -e "${GREEN}[✅ OK]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[⚠️ WARN]${NC} $1"
|
||||
}
|
||||
|
||||
info() {
|
||||
echo -e "${BLUE}[ℹ️ INFO]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[❌ ERRO]${NC} $1"
|
||||
}
|
||||
|
||||
# Configurações do servidor
|
||||
SERVER_IP="192.168.195.145"
|
||||
SERVER_USER="tiago"
|
||||
SERVER_PATH="/home/tiago/app_estoque"
|
||||
|
||||
echo ""
|
||||
info "📋 ESTE SCRIPT VAI ENVIAR:"
|
||||
echo " 📁 Todos os arquivos do projeto"
|
||||
echo " 🔧 Configurações e credenciais"
|
||||
echo " 📦 Frontend compilado"
|
||||
echo " 🏦 Configurações PIX"
|
||||
echo " 🗄️ Scripts SQL"
|
||||
echo " 📚 Documentação completa"
|
||||
echo ""
|
||||
|
||||
read -p "Continuar com o deploy completo? (s/N): " confirma
|
||||
if [[ $confirma != "s" && $confirma != "S" ]]; then
|
||||
error "❌ Deploy cancelado!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "🔨 PREPARANDO DEPLOY..."
|
||||
|
||||
# Verificar se o build existe
|
||||
if [ ! -d "client/build" ]; then
|
||||
info "🏗️ Compilando frontend..."
|
||||
cd client
|
||||
npm run build
|
||||
cd ..
|
||||
log "✅ Frontend compilado"
|
||||
fi
|
||||
|
||||
# Criar diretório temporário para deploy
|
||||
TEMP_DIR="/tmp/liberi-deploy-$(date +%s)"
|
||||
mkdir -p $TEMP_DIR
|
||||
|
||||
echo ""
|
||||
info "📦 CRIANDO PACOTE DE DEPLOY..."
|
||||
|
||||
# Copiar arquivos essenciais
|
||||
cp -r client/build $TEMP_DIR/
|
||||
cp server-supabase.js $TEMP_DIR/
|
||||
cp package.json $TEMP_DIR/
|
||||
cp -r config $TEMP_DIR/
|
||||
cp .env $TEMP_DIR/ 2>/dev/null || cp .env.example $TEMP_DIR/.env
|
||||
|
||||
# Copiar scripts e documentação
|
||||
cp *.sh $TEMP_DIR/ 2>/dev/null || true
|
||||
cp *.md $TEMP_DIR/ 2>/dev/null || true
|
||||
cp *.sql $TEMP_DIR/ 2>/dev/null || true
|
||||
|
||||
# Criar estrutura de diretórios
|
||||
mkdir -p $TEMP_DIR/uploads
|
||||
mkdir -p $TEMP_DIR/logs
|
||||
|
||||
log "✅ Pacote criado: $TEMP_DIR"
|
||||
|
||||
echo ""
|
||||
info "🌐 ENVIANDO PARA SERVIDOR..."
|
||||
|
||||
# Enviar tudo via rsync
|
||||
rsync -avz --progress $TEMP_DIR/ $SERVER_USER@$SERVER_IP:$SERVER_PATH/
|
||||
|
||||
log "✅ Arquivos enviados"
|
||||
|
||||
echo ""
|
||||
info "📦 INSTALANDO DEPENDÊNCIAS NO SERVIDOR..."
|
||||
|
||||
# Instalar dependências
|
||||
ssh $SERVER_USER@$SERVER_IP "cd $SERVER_PATH && npm install"
|
||||
|
||||
log "✅ Dependências instaladas"
|
||||
|
||||
echo ""
|
||||
info "🔧 CONFIGURANDO SERVIDOR..."
|
||||
|
||||
# Configurar permissões
|
||||
ssh $SERVER_USER@$SERVER_IP "cd $SERVER_PATH && chmod +x *.sh"
|
||||
|
||||
# Verificar se .env existe
|
||||
ssh $SERVER_USER@$SERVER_IP "cd $SERVER_PATH && [ ! -f .env ] && cp .env.example .env || true"
|
||||
|
||||
log "✅ Servidor configurado"
|
||||
|
||||
echo ""
|
||||
info "🚀 INICIANDO APLICAÇÃO..."
|
||||
|
||||
# Iniciar com PM2
|
||||
ssh $SERVER_USER@$SERVER_IP "cd $SERVER_PATH && pm2 start server-supabase.js --name liberi-kids"
|
||||
ssh $SERVER_USER@$SERVER_IP "pm2 save"
|
||||
|
||||
log "✅ Aplicação iniciada"
|
||||
|
||||
echo ""
|
||||
info "🧪 TESTANDO SERVIDOR..."
|
||||
|
||||
# Aguardar inicialização
|
||||
sleep 3
|
||||
|
||||
# Testar se está respondendo
|
||||
if ssh $SERVER_USER@$SERVER_IP "curl -s http://localhost:5000 > /dev/null"; then
|
||||
log "✅ Servidor respondendo"
|
||||
else
|
||||
warn "⚠️ Servidor pode não estar respondendo"
|
||||
fi
|
||||
|
||||
# Limpar arquivos temporários
|
||||
rm -rf $TEMP_DIR
|
||||
|
||||
echo ""
|
||||
log "🎉 DEPLOY COMPLETO FINALIZADO!"
|
||||
echo ""
|
||||
|
||||
info "📋 RESUMO DO DEPLOY:"
|
||||
echo " ✅ Arquivos enviados: $(ssh $SERVER_USER@$SERVER_IP "cd $SERVER_PATH && find . -type f | wc -l") arquivos"
|
||||
echo " ✅ Dependências: Instaladas"
|
||||
echo " ✅ PM2: Configurado"
|
||||
echo " ✅ Servidor: Rodando"
|
||||
echo ""
|
||||
|
||||
info "🌐 ACESSO:"
|
||||
echo " Frontend: http://$SERVER_IP:5000"
|
||||
echo " Status: pm2 status"
|
||||
echo " Logs: pm2 logs liberi-kids"
|
||||
echo ""
|
||||
|
||||
info "🔧 COMANDOS ÚTEIS NO SERVIDOR:"
|
||||
echo " ssh $SERVER_USER@$SERVER_IP"
|
||||
echo " cd $SERVER_PATH"
|
||||
echo " pm2 restart liberi-kids"
|
||||
echo " pm2 logs liberi-kids"
|
||||
echo ""
|
||||
|
||||
warn "💡 LEMBRE-SE:"
|
||||
echo " - Configure credenciais no .env se necessário"
|
||||
echo " - Aplique SQL no Supabase se for primeira vez"
|
||||
echo " - Teste todas as funcionalidades"
|
||||
echo ""
|
||||
|
||||
log "✨ SEU PROJETO ESTÁ NO AR!"
|
||||
221
deploy-pix-completo.sh
Executable file
221
deploy-pix-completo.sh
Executable file
@@ -0,0 +1,221 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 🚀 Deploy PIX Completo - Local para Servidor
|
||||
# Execute: ./deploy-pix-completo.sh usuario@servidor
|
||||
|
||||
echo "🚀 Deploy PIX - Liberi Kids (Local → Servidor)"
|
||||
echo "=============================================="
|
||||
|
||||
# Cores para output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Verificar parâmetros
|
||||
if [ -z "$1" ]; then
|
||||
error "❌ Uso: ./deploy-pix-completo.sh usuario@servidor"
|
||||
echo "Exemplo: ./deploy-pix-completo.sh tiago@192.168.195.145"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVIDOR=$1
|
||||
info "🎯 Servidor destino: $SERVIDOR"
|
||||
|
||||
# Verificar se estamos no diretório correto
|
||||
if [ ! -f "server-supabase.js" ]; then
|
||||
error "❌ Execute este script no diretório raiz do projeto!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "📦 ETAPA 1: Preparando arquivos locais..."
|
||||
|
||||
# Build do frontend
|
||||
if [ -d "client" ]; then
|
||||
info "🔨 Fazendo build do frontend..."
|
||||
cd client
|
||||
npm run build
|
||||
if [ $? -eq 0 ]; then
|
||||
log "✅ Build do frontend concluído"
|
||||
else
|
||||
error "❌ Erro no build do frontend"
|
||||
exit 1
|
||||
fi
|
||||
cd ..
|
||||
else
|
||||
warn "⚠️ Diretório client não encontrado"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "📤 ETAPA 2: Enviando arquivos para o servidor..."
|
||||
|
||||
# Criar lista de arquivos para enviar
|
||||
ARQUIVOS_ENVIAR=(
|
||||
"server-supabase.js"
|
||||
"config/"
|
||||
"sql/"
|
||||
"scripts/"
|
||||
"client/build/"
|
||||
"package.json"
|
||||
".env.example"
|
||||
"aplicar-pix-supabase.sql"
|
||||
"configurar-pix-servidor.sh"
|
||||
"INTEGRACAO-PIX-GUIDE.md"
|
||||
)
|
||||
|
||||
# Verificar conectividade SSH
|
||||
info "🔗 Testando conexão SSH..."
|
||||
ssh -o ConnectTimeout=10 -o BatchMode=yes $SERVIDOR exit
|
||||
if [ $? -ne 0 ]; then
|
||||
error "❌ Não foi possível conectar ao servidor $SERVIDOR"
|
||||
echo "Verifique:"
|
||||
echo " - Se o servidor está ligado"
|
||||
echo " - Se o SSH está funcionando"
|
||||
echo " - Se as credenciais estão corretas"
|
||||
exit 1
|
||||
fi
|
||||
log "✅ Conexão SSH OK"
|
||||
|
||||
# Criar diretório no servidor se não existir
|
||||
info "📁 Criando diretório no servidor..."
|
||||
ssh $SERVIDOR "mkdir -p ~/app_estoque"
|
||||
|
||||
# Enviar arquivos
|
||||
info "📤 Enviando arquivos PIX..."
|
||||
for arquivo in "${ARQUIVOS_ENVIAR[@]}"; do
|
||||
if [ -e "$arquivo" ]; then
|
||||
info "📤 Enviando: $arquivo"
|
||||
rsync -avz --progress "$arquivo" $SERVIDOR:~/app_estoque/
|
||||
if [ $? -eq 0 ]; then
|
||||
log "✅ $arquivo enviado"
|
||||
else
|
||||
warn "⚠️ Erro ao enviar $arquivo"
|
||||
fi
|
||||
else
|
||||
warn "⚠️ Arquivo não encontrado: $arquivo"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
info "🔧 ETAPA 3: Configurando no servidor..."
|
||||
|
||||
# Executar configuração no servidor
|
||||
ssh $SERVIDOR << 'EOF'
|
||||
cd ~/app_estoque
|
||||
|
||||
echo "🏦 Configurando PIX no servidor..."
|
||||
|
||||
# Dar permissão de execução
|
||||
chmod +x configurar-pix-servidor.sh
|
||||
|
||||
# Instalar dependências se necessário
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "📦 Instalando dependências..."
|
||||
npm install
|
||||
fi
|
||||
|
||||
# Instalar mercadopago se não existir
|
||||
if [ ! -d "node_modules/mercadopago" ]; then
|
||||
echo "📦 Instalando Mercado Pago SDK..."
|
||||
npm install mercadopago
|
||||
fi
|
||||
|
||||
# Verificar se .env existe
|
||||
if [ ! -f ".env" ]; then
|
||||
echo "📝 Criando arquivo .env..."
|
||||
cp .env.example .env
|
||||
fi
|
||||
|
||||
# Verificar se credenciais PIX estão no .env
|
||||
if ! grep -q "MERCADOPAGO_ACCESS_TOKEN" .env; then
|
||||
echo "📝 Adicionando configurações PIX ao .env..."
|
||||
cat >> .env << 'ENVEOF'
|
||||
|
||||
# =====================================================
|
||||
# CONFIGURAÇÕES PIX - MERCADO PAGO
|
||||
# =====================================================
|
||||
|
||||
# Access Token do Mercado Pago (TEST para desenvolvimento, PROD para produção)
|
||||
MERCADOPAGO_ACCESS_TOKEN=TEST-sua_access_token_aqui
|
||||
|
||||
# Public Key do Mercado Pago
|
||||
MERCADOPAGO_PUBLIC_KEY=pk_test_sua_public_key_aqui
|
||||
|
||||
# URL base para webhooks (importante para produção)
|
||||
BASE_URL=http://localhost:5000
|
||||
ENVEOF
|
||||
fi
|
||||
|
||||
echo "✅ Configuração no servidor concluída!"
|
||||
EOF
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
log "✅ Configuração no servidor concluída"
|
||||
else
|
||||
error "❌ Erro na configuração do servidor"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "🎉 DEPLOY PIX CONCLUÍDO COM SUCESSO!"
|
||||
echo ""
|
||||
|
||||
log "✅ Arquivos enviados para: $SERVIDOR:~/app_estoque/"
|
||||
log "✅ Dependências instaladas"
|
||||
log "✅ Configurações preparadas"
|
||||
echo ""
|
||||
|
||||
warn "📝 PRÓXIMOS PASSOS NO SERVIDOR:"
|
||||
echo ""
|
||||
echo "1. 🏦 CONFIGURAR CREDENCIAIS MERCADO PAGO:"
|
||||
echo " ssh $SERVIDOR"
|
||||
echo " cd ~/app_estoque"
|
||||
echo " nano .env"
|
||||
echo " # Configure MERCADOPAGO_ACCESS_TOKEN e MERCADOPAGO_PUBLIC_KEY"
|
||||
echo ""
|
||||
|
||||
echo "2. 🗄️ APLICAR SQL NO SUPABASE:"
|
||||
echo " - Acesse: https://supabase.com/dashboard"
|
||||
echo " - SQL Editor → Execute: aplicar-pix-supabase.sql"
|
||||
echo ""
|
||||
|
||||
echo "3. 🔄 REINICIAR SERVIDOR:"
|
||||
echo " ssh $SERVIDOR"
|
||||
echo " cd ~/app_estoque"
|
||||
echo " pm2 restart liberi-kids"
|
||||
echo " # ou: pm2 start server-supabase.js --name liberi-kids"
|
||||
echo ""
|
||||
|
||||
echo "4. 🧪 TESTAR PIX:"
|
||||
echo " - Acesse o sistema no servidor"
|
||||
echo " - Vá em Vendas → Clique no botão PIX (💳)"
|
||||
echo " - Verifique se QR Code é gerado"
|
||||
echo ""
|
||||
|
||||
info "💡 CREDENCIAIS MERCADO PAGO:"
|
||||
echo " - Acesse: https://www.mercadopago.com.br/developers"
|
||||
echo " - Crie aplicação de 'Pagamentos online'"
|
||||
echo " - Copie Access Token e Public Key"
|
||||
echo ""
|
||||
|
||||
log "🎉 SEU SISTEMA PIX ESTÁ PRONTO PARA CONFIGURAÇÃO FINAL!"
|
||||
echo ""
|
||||
warn "Execute os próximos passos no servidor para ativar o PIX!"
|
||||
66
deploy-pix-simples.sh
Executable file
66
deploy-pix-simples.sh
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 🚀 Deploy PIX Simples - Com Senha Interativa
|
||||
# Execute: ./deploy-pix-simples.sh
|
||||
|
||||
echo "🚀 Deploy PIX Simples - Liberi Kids"
|
||||
echo "=================================="
|
||||
|
||||
# Verificar se build existe
|
||||
if [ ! -d "client/build" ]; then
|
||||
echo "🔨 Fazendo build do frontend..."
|
||||
cd client && npm run build && cd ..
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📤 ENVIANDO ARQUIVOS PARA O SERVIDOR..."
|
||||
echo "Digite a senha quando solicitado:"
|
||||
echo ""
|
||||
|
||||
# Enviar arquivos um por vez (mais confiável)
|
||||
echo "📁 Enviando config/..."
|
||||
scp -r config/ tiago@192.168.195.145:~/app_estoque/
|
||||
|
||||
echo "📁 Enviando client/build/..."
|
||||
scp -r client/build/ tiago@192.168.195.145:~/app_estoque/client/
|
||||
|
||||
echo "📄 Enviando server-supabase.js..."
|
||||
scp server-supabase.js tiago@192.168.195.145:~/app_estoque/
|
||||
|
||||
echo "📄 Enviando package.json..."
|
||||
scp package.json tiago@192.168.195.145:~/app_estoque/
|
||||
|
||||
echo "📄 Enviando arquivos PIX..."
|
||||
scp aplicar-pix-supabase.sql tiago@192.168.195.145:~/app_estoque/
|
||||
scp teste-rapido-pix.sh tiago@192.168.195.145:~/app_estoque/
|
||||
scp .env.example tiago@192.168.195.145:~/app_estoque/
|
||||
|
||||
echo ""
|
||||
echo "✅ ARQUIVOS ENVIADOS COM SUCESSO!"
|
||||
echo ""
|
||||
echo "📋 PRÓXIMOS PASSOS NO SERVIDOR:"
|
||||
echo ""
|
||||
echo "1. Conectar no servidor:"
|
||||
echo " ssh tiago@192.168.195.145"
|
||||
echo ""
|
||||
echo "2. Navegar para o diretório:"
|
||||
echo " cd ~/app_estoque"
|
||||
echo ""
|
||||
echo "3. Instalar dependências PIX:"
|
||||
echo " npm install mercadopago"
|
||||
echo ""
|
||||
echo "4. Configurar .env:"
|
||||
echo " cp .env.example .env"
|
||||
echo " nano .env"
|
||||
echo " # Configure MERCADOPAGO_ACCESS_TOKEN e MERCADOPAGO_PUBLIC_KEY"
|
||||
echo ""
|
||||
echo "5. Aplicar SQL no Supabase:"
|
||||
echo " # Execute aplicar-pix-supabase.sql no painel do Supabase"
|
||||
echo ""
|
||||
echo "6. Reiniciar servidor:"
|
||||
echo " pm2 restart liberi-kids"
|
||||
echo ""
|
||||
echo "7. Testar PIX:"
|
||||
echo " ./teste-rapido-pix.sh"
|
||||
echo ""
|
||||
echo "🎉 SEU PIX ESTARÁ FUNCIONANDO!"
|
||||
154
deploy-to-server.sh
Executable file
154
deploy-to-server.sh
Executable file
@@ -0,0 +1,154 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 🚀 Script para Deploy Automático no Servidor via SSH
|
||||
# Execute: ./deploy-to-server.sh usuario@servidor
|
||||
|
||||
echo "🚀 Deploy Automático - Liberi Kids"
|
||||
echo "=================================="
|
||||
|
||||
# Verificar se foi fornecido o servidor
|
||||
if [ -z "$1" ]; then
|
||||
echo "❌ Uso: ./deploy-to-server.sh usuario@servidor"
|
||||
echo " Exemplo: ./deploy-to-server.sh root@192.168.1.100"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVER=$1
|
||||
PROJECT_DIR="app_estoque"
|
||||
|
||||
echo "🔗 Conectando ao servidor: $SERVER"
|
||||
|
||||
# Cores para output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Função para executar comando no servidor
|
||||
run_remote() {
|
||||
ssh $SERVER "$1"
|
||||
}
|
||||
|
||||
# Função para copiar arquivos
|
||||
copy_files() {
|
||||
log "📁 Copiando arquivos para o servidor..."
|
||||
rsync -avz --progress --exclude 'node_modules' --exclude '.git' --exclude 'client/node_modules' ./ $SERVER:~/$PROJECT_DIR/
|
||||
if [ $? -eq 0 ]; then
|
||||
log "✅ Arquivos copiados com sucesso!"
|
||||
else
|
||||
error "❌ Erro ao copiar arquivos"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Função para configurar ambiente no servidor
|
||||
setup_environment() {
|
||||
log "🔧 Configurando ambiente no servidor..."
|
||||
|
||||
run_remote "
|
||||
cd $PROJECT_DIR
|
||||
|
||||
# Verificar se Node.js está instalado
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo '📦 Instalando Node.js...'
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
fi
|
||||
|
||||
# Verificar versão do Node.js
|
||||
NODE_VERSION=\$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
|
||||
if [ \"\$NODE_VERSION\" -lt 18 ]; then
|
||||
echo '❌ Node.js versão 18+ é necessária'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '✅ Node.js \$(node -v) encontrado'
|
||||
|
||||
# Criar arquivo .env se não existir
|
||||
if [ ! -f '.env' ]; then
|
||||
echo '📝 Criando arquivo .env...'
|
||||
cp .env.example .env
|
||||
echo '⚠️ IMPORTANTE: Configure o arquivo .env com suas credenciais!'
|
||||
echo ' Edite: nano .env'
|
||||
fi
|
||||
|
||||
# Tornar script executável
|
||||
chmod +x scripts/deploy-local.sh
|
||||
|
||||
echo '✅ Ambiente configurado!'
|
||||
"
|
||||
}
|
||||
|
||||
# Função para fazer deploy
|
||||
deploy_application() {
|
||||
log "🚀 Fazendo deploy da aplicação..."
|
||||
|
||||
run_remote "
|
||||
cd $PROJECT_DIR
|
||||
|
||||
# Executar script de deploy
|
||||
./scripts/deploy-local.sh
|
||||
"
|
||||
}
|
||||
|
||||
# Função para verificar status
|
||||
check_status() {
|
||||
log "📊 Verificando status da aplicação..."
|
||||
|
||||
run_remote "
|
||||
pm2 status
|
||||
echo ''
|
||||
echo '🌐 Aplicação disponível em:'
|
||||
echo ' Local: http://localhost:5000'
|
||||
echo ' Rede: http://\$(hostname -I | awk '{print \$1}'):5000'
|
||||
"
|
||||
}
|
||||
|
||||
# Executar deploy completo
|
||||
main() {
|
||||
echo ""
|
||||
log "Iniciando deploy completo..."
|
||||
echo ""
|
||||
|
||||
# Passo 1: Copiar arquivos
|
||||
copy_files
|
||||
echo ""
|
||||
|
||||
# Passo 2: Configurar ambiente
|
||||
setup_environment
|
||||
echo ""
|
||||
|
||||
# Passo 3: Fazer deploy
|
||||
deploy_application
|
||||
echo ""
|
||||
|
||||
# Passo 4: Verificar status
|
||||
check_status
|
||||
echo ""
|
||||
|
||||
log "🎉 Deploy concluído com sucesso!"
|
||||
echo ""
|
||||
warn "📝 PRÓXIMOS PASSOS:"
|
||||
echo "1. Configure o arquivo .env no servidor:"
|
||||
echo " ssh $SERVER 'cd $PROJECT_DIR && nano .env'"
|
||||
echo ""
|
||||
echo "2. Reinicie a aplicação após configurar:"
|
||||
echo " ssh $SERVER 'pm2 restart liberi-kids'"
|
||||
echo ""
|
||||
echo "3. Acesse a aplicação no navegador"
|
||||
}
|
||||
|
||||
# Executar função principal
|
||||
main
|
||||
56
docker-compose.yml
Normal file
56
docker-compose.yml
Normal file
@@ -0,0 +1,56 @@
|
||||
# 🐳 Docker Compose - Liberi Kids Sistema de Estoque
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Aplicação principal
|
||||
liberi-kids:
|
||||
build: .
|
||||
container_name: liberi-kids-app
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=5000
|
||||
- SUPABASE_URL=${SUPABASE_URL}
|
||||
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Volume para uploads (se necessário)
|
||||
- ./uploads:/app/uploads
|
||||
networks:
|
||||
- liberi-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Nginx como proxy reverso (opcional)
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: liberi-kids-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
- liberi-kids
|
||||
networks:
|
||||
- liberi-network
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- with-nginx
|
||||
|
||||
networks:
|
||||
liberi-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
uploads:
|
||||
driver: local
|
||||
144
finalizar-pix.sh
Executable file
144
finalizar-pix.sh
Executable file
@@ -0,0 +1,144 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 🎉 Script Final - Integração PIX Completa
|
||||
# Execute: ./finalizar-pix.sh
|
||||
|
||||
echo "🎉 Finalizando Integração PIX - Liberi Kids"
|
||||
echo "=========================================="
|
||||
|
||||
# Cores para output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
echo ""
|
||||
info "🏦 INTEGRAÇÃO PIX IMPLEMENTADA COM SUCESSO!"
|
||||
echo ""
|
||||
|
||||
log "✅ Arquivos Criados:"
|
||||
echo " - config/mercadopago.js (Serviço PIX)"
|
||||
echo " - client/src/styles/pix-integration.css (Estilos)"
|
||||
echo " - sql/add-pix-fields.sql (Migração banco)"
|
||||
echo " - scripts/migrate-pix-fields.js (Script migração)"
|
||||
echo " - INTEGRACAO-PIX-GUIDE.md (Documentação)"
|
||||
echo ""
|
||||
|
||||
log "✅ Backend Implementado:"
|
||||
echo " - POST /api/pix/gerar (Gerar PIX)"
|
||||
echo " - GET /api/pix/status/:id (Consultar status)"
|
||||
echo " - POST /api/pix/webhook (Webhook Mercado Pago)"
|
||||
echo " - GET /api/pix/pendentes (Listar pendentes)"
|
||||
echo ""
|
||||
|
||||
log "✅ Frontend Implementado:"
|
||||
echo " - Botão PIX na lista de vendas"
|
||||
echo " - Modal PIX com QR Code"
|
||||
echo " - Função copiar código PIX"
|
||||
echo " - Consulta automática de status"
|
||||
echo " - Timer de expiração"
|
||||
echo ""
|
||||
|
||||
log "✅ Build do Frontend:"
|
||||
echo " - React build atualizado com PIX"
|
||||
echo " - Estilos CSS incluídos"
|
||||
echo " - Componentes funcionais"
|
||||
echo ""
|
||||
|
||||
warn "📝 PRÓXIMOS PASSOS OBRIGATÓRIOS:"
|
||||
echo ""
|
||||
echo "1. 🏦 CRIAR CONTA MERCADO PAGO:"
|
||||
echo " - Acesse: https://www.mercadopago.com.br/developers"
|
||||
echo " - Crie conta de desenvolvedor"
|
||||
echo " - Vá em 'Suas integrações' > 'Criar aplicação'"
|
||||
echo " - Escolha 'Pagamentos online'"
|
||||
echo ""
|
||||
|
||||
echo "2. 📝 CONFIGURAR CREDENCIAIS:"
|
||||
echo " - Copie Access Token e Public Key"
|
||||
echo " - Adicione no arquivo .env do servidor:"
|
||||
echo " MERCADOPAGO_ACCESS_TOKEN=TEST-sua_access_token"
|
||||
echo " MERCADOPAGO_PUBLIC_KEY=pk_test_sua_public_key"
|
||||
echo " BASE_URL=http://seu-servidor:5000"
|
||||
echo ""
|
||||
|
||||
echo "3. 🗄️ EXECUTAR MIGRAÇÃO DO BANCO:"
|
||||
echo " - No servidor, execute:"
|
||||
echo " node scripts/migrate-pix-fields.js"
|
||||
echo " - Ou execute o SQL manualmente no Supabase"
|
||||
echo ""
|
||||
|
||||
echo "4. 🔄 REINICIAR SERVIDOR:"
|
||||
echo " - pm2 restart liberi-kids"
|
||||
echo " - Ou reinicie o servidor Node.js"
|
||||
echo ""
|
||||
|
||||
echo "5. 🧪 TESTAR INTEGRAÇÃO:"
|
||||
echo " - Acesse uma venda no sistema"
|
||||
echo " - Clique no botão PIX (ícone cartão)"
|
||||
echo " - Verifique se QR Code é gerado"
|
||||
echo " - Teste pagamento no ambiente de teste"
|
||||
echo ""
|
||||
|
||||
info "🎯 FUNCIONALIDADES PIX DISPONÍVEIS:"
|
||||
echo ""
|
||||
echo " 🏦 Geração de PIX com QR Code automático"
|
||||
echo " 📱 Código PIX para copiar e colar"
|
||||
echo " ⏰ Expiração automática em 30 minutos"
|
||||
echo " 🔔 Confirmação automática via webhook"
|
||||
echo " 📊 Status em tempo real (pendente/pago/cancelado)"
|
||||
echo " 📋 Lista de PIX pendentes"
|
||||
echo " 💰 Integração com sistema de vendas"
|
||||
echo ""
|
||||
|
||||
info "💰 CUSTOS MERCADO PAGO:"
|
||||
echo " - PIX: 0,99% por transação"
|
||||
echo " - Sem mensalidade"
|
||||
echo " - Recebimento em 1 dia útil"
|
||||
echo ""
|
||||
|
||||
warn "🔒 SEGURANÇA:"
|
||||
echo " - Credenciais protegidas no .env"
|
||||
echo " - Webhook validado"
|
||||
echo " - HTTPS obrigatório em produção"
|
||||
echo ""
|
||||
|
||||
info "📖 DOCUMENTAÇÃO COMPLETA:"
|
||||
echo " - Consulte: INTEGRACAO-PIX-GUIDE.md"
|
||||
echo " - Guia passo a passo detalhado"
|
||||
echo " - Exemplos de código"
|
||||
echo " - Troubleshooting"
|
||||
echo ""
|
||||
|
||||
echo ""
|
||||
log "🎉 INTEGRAÇÃO PIX 100% IMPLEMENTADA!"
|
||||
echo ""
|
||||
warn "Configure as credenciais do Mercado Pago para começar a usar!"
|
||||
echo ""
|
||||
|
||||
# Verificar se .env existe
|
||||
if [ ! -f ".env" ]; then
|
||||
warn "⚠️ Arquivo .env não encontrado!"
|
||||
echo "Execute: cp .env.example .env"
|
||||
echo "E configure suas credenciais"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "🚀 Seu sistema agora aceita pagamentos PIX com QR Code automático!"
|
||||
337
implementar-pix.sh
Executable file
337
implementar-pix.sh
Executable file
@@ -0,0 +1,337 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 🏦 Script para Implementar PIX no Liberi Kids
|
||||
# Execute: ./implementar-pix.sh
|
||||
|
||||
echo "🏦 Implementando PIX no Liberi Kids"
|
||||
echo "=================================="
|
||||
|
||||
# Cores para output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
info() {
|
||||
echo -e "${BLUE}[PIX]${NC} $1"
|
||||
}
|
||||
|
||||
# Instalar dependência do Mercado Pago
|
||||
log "Instalando SDK do Mercado Pago..."
|
||||
npm install mercadopago
|
||||
|
||||
# Criar arquivo de configuração PIX
|
||||
log "Criando configuração PIX..."
|
||||
cat > config/mercadopago.js << 'EOF'
|
||||
const mercadopago = require('mercadopago');
|
||||
|
||||
class MercadoPagoService {
|
||||
constructor() {
|
||||
if (process.env.MERCADOPAGO_ACCESS_TOKEN) {
|
||||
mercadopago.configurations.setAccessToken(process.env.MERCADOPAGO_ACCESS_TOKEN);
|
||||
}
|
||||
}
|
||||
|
||||
async gerarPix(dados) {
|
||||
try {
|
||||
const payment_data = {
|
||||
transaction_amount: parseFloat(dados.valor),
|
||||
description: dados.descricao || `Venda #${dados.venda_id} - Liberi Kids`,
|
||||
payment_method_id: 'pix',
|
||||
payer: {
|
||||
email: dados.cliente_email || 'cliente@liberikids.com',
|
||||
first_name: dados.cliente_nome || 'Cliente',
|
||||
identification: {
|
||||
type: 'CPF',
|
||||
number: dados.cliente_cpf || '00000000000'
|
||||
}
|
||||
},
|
||||
external_reference: dados.venda_id.toString(),
|
||||
notification_url: `${process.env.BASE_URL || 'http://localhost:5000'}/api/pix/webhook`,
|
||||
date_of_expiration: new Date(Date.now() + 30 * 60 * 1000).toISOString() // 30 minutos
|
||||
};
|
||||
|
||||
const payment = await mercadopago.payment.create(payment_data);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
payment_id: payment.body.id,
|
||||
qr_code: payment.body.point_of_interaction.transaction_data.qr_code,
|
||||
qr_code_base64: payment.body.point_of_interaction.transaction_data.qr_code_base64,
|
||||
pix_copy_paste: payment.body.point_of_interaction.transaction_data.qr_code,
|
||||
expiration_date: payment.body.date_of_expiration,
|
||||
transaction_amount: payment.body.transaction_amount,
|
||||
status: payment.body.status
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar PIX:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async consultarPagamento(payment_id) {
|
||||
try {
|
||||
const payment = await mercadopago.payment.get(payment_id);
|
||||
return {
|
||||
success: true,
|
||||
status: payment.body.status,
|
||||
status_detail: payment.body.status_detail,
|
||||
transaction_amount: payment.body.transaction_amount,
|
||||
date_approved: payment.body.date_approved,
|
||||
external_reference: payment.body.external_reference
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erro ao consultar pagamento:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new MercadoPagoService();
|
||||
EOF
|
||||
|
||||
# Criar CSS para PIX
|
||||
log "Criando estilos PIX..."
|
||||
cat > client/src/styles/pix-integration.css << 'EOF'
|
||||
/* PIX Integration Styles */
|
||||
|
||||
.pix-modal {
|
||||
max-width: 500px;
|
||||
width: 90vw;
|
||||
}
|
||||
|
||||
.pix-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-code-section {
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.qr-code-image {
|
||||
max-width: 200px;
|
||||
height: auto;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.pix-copy-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pix-code-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.pix-code-input {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.btn-copy-pix {
|
||||
background: #00d4aa;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-copy-pix:hover {
|
||||
background: #00b894;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.pix-info {
|
||||
background: #e8f5e8;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
border-left: 4px solid #00d4aa;
|
||||
}
|
||||
|
||||
.pix-instructions {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn-pix {
|
||||
background: #00d4aa;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-pix:hover {
|
||||
background: #00b894;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-pix:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.pix-status {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pix-status.pending {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeaa7;
|
||||
}
|
||||
|
||||
.pix-status.approved {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.pix-status.rejected {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.pix-timer {
|
||||
background: #fff3cd;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pix-modal {
|
||||
width: 95vw;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.qr-code-image {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.pix-code-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-copy-pix {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Atualizar .env.example
|
||||
log "Atualizando .env.example com configurações PIX..."
|
||||
cat >> .env.example << 'EOF'
|
||||
|
||||
# =====================================================
|
||||
# CONFIGURAÇÕES PIX - MERCADO PAGO
|
||||
# =====================================================
|
||||
|
||||
# Access Token do Mercado Pago (TEST para desenvolvimento, PROD para produção)
|
||||
MERCADOPAGO_ACCESS_TOKEN=TEST-sua_access_token_aqui
|
||||
|
||||
# Public Key do Mercado Pago
|
||||
MERCADOPAGO_PUBLIC_KEY=pk_test_sua_public_key_aqui
|
||||
|
||||
# URL base para webhooks (importante para produção)
|
||||
BASE_URL=http://localhost:5000
|
||||
|
||||
# =====================================================
|
||||
# INSTRUÇÕES PIX
|
||||
# =====================================================
|
||||
|
||||
# 1. Acesse: https://www.mercadopago.com.br/developers
|
||||
# 2. Crie uma conta de desenvolvedor
|
||||
# 3. Vá em "Suas integrações" > "Criar aplicação"
|
||||
# 4. Escolha "Pagamentos online" e "Checkout Pro"
|
||||
# 5. Copie as credenciais de TEST para desenvolvimento
|
||||
# 6. Para produção, use as credenciais PROD
|
||||
# 7. Configure o webhook URL no painel do Mercado Pago
|
||||
EOF
|
||||
|
||||
# Criar script SQL para PIX
|
||||
log "Criando migração do banco para PIX..."
|
||||
cat > sql/add-pix-fields.sql << 'EOF'
|
||||
-- Adicionar campos PIX na tabela vendas
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS status_pagamento VARCHAR(20) DEFAULT 'pendente';
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS data_pagamento TIMESTAMP;
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS pix_payment_id VARCHAR(100);
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS pix_qr_code TEXT;
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS metodo_pagamento VARCHAR(20) DEFAULT 'dinheiro';
|
||||
|
||||
-- Criar índices para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_vendas_status_pagamento ON vendas(status_pagamento);
|
||||
CREATE INDEX IF NOT EXISTS idx_vendas_pix_payment_id ON vendas(pix_payment_id);
|
||||
|
||||
-- Comentários para documentação
|
||||
COMMENT ON COLUMN vendas.status_pagamento IS 'Status do pagamento: pendente, pago, cancelado, expirado';
|
||||
COMMENT ON COLUMN vendas.data_pagamento IS 'Data e hora da confirmação do pagamento';
|
||||
COMMENT ON COLUMN vendas.pix_payment_id IS 'ID do pagamento no Mercado Pago';
|
||||
COMMENT ON COLUMN vendas.pix_qr_code IS 'Código PIX para copiar e colar';
|
||||
COMMENT ON COLUMN vendas.metodo_pagamento IS 'Método: dinheiro, cartao, pix, transferencia';
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
info "🎉 Implementação PIX configurada com sucesso!"
|
||||
echo ""
|
||||
warn "📝 PRÓXIMOS PASSOS:"
|
||||
echo "1. Crie conta no Mercado Pago: https://www.mercadopago.com.br/developers"
|
||||
echo "2. Configure as credenciais no arquivo .env"
|
||||
echo "3. Execute a migração do banco: psql -f sql/add-pix-fields.sql"
|
||||
echo "4. Adicione as rotas PIX no server-supabase.js"
|
||||
echo "5. Implemente o modal PIX no frontend"
|
||||
echo ""
|
||||
info "📖 Consulte o arquivo INTEGRACAO-PIX-GUIDE.md para instruções completas"
|
||||
echo ""
|
||||
log "Arquivos criados:"
|
||||
echo " - config/mercadopago.js (Serviço PIX)"
|
||||
echo " - client/src/styles/pix-integration.css (Estilos)"
|
||||
echo " - sql/add-pix-fields.sql (Migração banco)"
|
||||
echo " - .env.example (Configurações atualizadas)"
|
||||
46
install-nodejs-server.sh
Executable file
46
install-nodejs-server.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 🚀 Script para Instalar Node.js no Servidor Ubuntu
|
||||
# Execute: ./install-nodejs-server.sh usuario@servidor
|
||||
|
||||
echo "📦 Instalando Node.js no Servidor Ubuntu"
|
||||
echo "========================================"
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "❌ Uso: ./install-nodejs-server.sh usuario@servidor"
|
||||
echo " Exemplo: ./install-nodejs-server.sh tiago@192.168.195.145"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVER=$1
|
||||
|
||||
echo "🔗 Conectando ao servidor: $SERVER"
|
||||
|
||||
# Instalar Node.js no servidor
|
||||
ssh -t $SERVER "
|
||||
echo '📦 Atualizando sistema...'
|
||||
sudo apt update
|
||||
|
||||
echo '📦 Instalando curl...'
|
||||
sudo apt install -y curl
|
||||
|
||||
echo '📦 Baixando Node.js 18...'
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
|
||||
echo '📦 Instalando Node.js...'
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
echo '📦 Instalando PM2 globalmente...'
|
||||
sudo npm install -g pm2
|
||||
|
||||
echo '✅ Verificando instalação:'
|
||||
echo 'Node.js:' \$(node -v)
|
||||
echo 'NPM:' \$(npm -v)
|
||||
echo 'PM2:' \$(pm2 -v)
|
||||
|
||||
echo '🎉 Node.js instalado com sucesso!'
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "✅ Node.js instalado! Agora execute o deploy novamente:"
|
||||
echo "./deploy-to-server.sh $SERVER"
|
||||
119
limpar-servidor-completo.sh
Executable file
119
limpar-servidor-completo.sh
Executable file
@@ -0,0 +1,119 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 🧹 Script para Limpeza COMPLETA do Servidor
|
||||
# ⚠️ CUIDADO: Este script APAGA TUDO do servidor!
|
||||
# Execute: ./limpar-servidor-completo.sh
|
||||
|
||||
echo "🧹 LIMPEZA COMPLETA DO SERVIDOR - Liberi Kids"
|
||||
echo "============================================="
|
||||
|
||||
# Cores para output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
echo -e "${GREEN}[✅ OK]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[⚠️ WARN]${NC} $1"
|
||||
}
|
||||
|
||||
info() {
|
||||
echo -e "${BLUE}[ℹ️ INFO]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[❌ ERRO]${NC} $1"
|
||||
}
|
||||
|
||||
# Configurações do servidor
|
||||
SERVER_IP="192.168.195.145"
|
||||
SERVER_USER="tiago"
|
||||
SERVER_PATH="/home/tiago/app_estoque"
|
||||
|
||||
echo ""
|
||||
warn "⚠️ ATENÇÃO: Este script vai APAGAR COMPLETAMENTE:"
|
||||
echo " 🗂️ Todos os arquivos do projeto no servidor"
|
||||
echo " 🔄 Processos PM2 do Liberi Kids"
|
||||
echo " 📦 Dependências Node.js"
|
||||
echo " 🔧 Configurações locais"
|
||||
echo ""
|
||||
warn "⚠️ O BANCO DE DADOS SUPABASE NÃO SERÁ AFETADO"
|
||||
echo ""
|
||||
|
||||
read -p "Tem CERTEZA que quer limpar TUDO do servidor? (digite 'CONFIRMO'): " confirma
|
||||
if [[ $confirma != "CONFIRMO" ]]; then
|
||||
error "❌ Operação cancelada!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "🧹 INICIANDO LIMPEZA COMPLETA..."
|
||||
|
||||
# Função para executar comandos no servidor
|
||||
run_remote() {
|
||||
ssh $SERVER_USER@$SERVER_IP "$1"
|
||||
}
|
||||
|
||||
echo ""
|
||||
info "1️⃣ PARANDO PROCESSOS PM2..."
|
||||
|
||||
run_remote "pm2 stop all 2>/dev/null || true"
|
||||
run_remote "pm2 delete all 2>/dev/null || true"
|
||||
run_remote "pm2 kill 2>/dev/null || true"
|
||||
|
||||
log "✅ Processos PM2 parados"
|
||||
|
||||
echo ""
|
||||
info "2️⃣ REMOVENDO DIRETÓRIO DO PROJETO..."
|
||||
|
||||
run_remote "rm -rf $SERVER_PATH"
|
||||
run_remote "mkdir -p $SERVER_PATH"
|
||||
|
||||
log "✅ Diretório limpo: $SERVER_PATH"
|
||||
|
||||
echo ""
|
||||
info "3️⃣ LIMPANDO CACHE NODE.JS..."
|
||||
|
||||
run_remote "npm cache clean --force 2>/dev/null || true"
|
||||
run_remote "rm -rf ~/.npm 2>/dev/null || true"
|
||||
|
||||
log "✅ Cache Node.js limpo"
|
||||
|
||||
echo ""
|
||||
info "4️⃣ REMOVENDO LOGS ANTIGOS..."
|
||||
|
||||
run_remote "rm -rf ~/.pm2/logs/* 2>/dev/null || true"
|
||||
run_remote "rm -rf /tmp/liberi-* 2>/dev/null || true"
|
||||
|
||||
log "✅ Logs removidos"
|
||||
|
||||
echo ""
|
||||
info "5️⃣ VERIFICANDO PORTAS..."
|
||||
|
||||
run_remote "pkill -f 'node.*server' 2>/dev/null || true"
|
||||
run_remote "fuser -k 5000/tcp 2>/dev/null || true"
|
||||
|
||||
log "✅ Portas liberadas"
|
||||
|
||||
echo ""
|
||||
log "🎉 LIMPEZA COMPLETA FINALIZADA!"
|
||||
echo ""
|
||||
|
||||
info "📋 SERVIDOR LIMPO:"
|
||||
echo " ✅ Todos os arquivos removidos"
|
||||
echo " ✅ Processos parados"
|
||||
echo " ✅ Cache limpo"
|
||||
echo " ✅ Portas liberadas"
|
||||
echo ""
|
||||
|
||||
info "🚀 PRÓXIMO PASSO:"
|
||||
echo " Execute: ./deploy-completo-servidor.sh"
|
||||
echo ""
|
||||
|
||||
warn "💡 DICA: O banco Supabase não foi afetado"
|
||||
warn "💡 Suas vendas e dados estão seguros na nuvem"
|
||||
3645
package-lock.json
generated
Normal file
3645
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
package.json
Normal file
47
package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "liberi-kids-estoque",
|
||||
"version": "1.0.0",
|
||||
"description": "Sistema de controle de estoque para Liberi Kids - Moda Infantil",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server-supabase.js",
|
||||
"start-sqlite": "node server.js",
|
||||
"dev": "nodemon server-supabase.js",
|
||||
"dev-sqlite": "nodemon server.js",
|
||||
"client": "cd client && npm start",
|
||||
"server": "nodemon server-supabase.js",
|
||||
"build": "cd client && npm run build",
|
||||
"install-client": "cd client && npm install",
|
||||
"init-supabase": "node scripts/init-supabase.js",
|
||||
"deploy:local": "chmod +x scripts/deploy-local.sh && ./scripts/deploy-local.sh",
|
||||
"deploy:vercel": "chmod +x scripts/deploy-vercel.sh && ./scripts/deploy-vercel.sh",
|
||||
"deploy:build": "npm run install-client && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.58.0",
|
||||
"axios": "^1.12.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"google-auth-library": "^10.4.0",
|
||||
"googleapis": "^161.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mercadopago": "^2.9.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"sqlite3": "^5.1.6",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
},
|
||||
"keywords": [
|
||||
"estoque",
|
||||
"moda infantil",
|
||||
"controle",
|
||||
"vendas"
|
||||
],
|
||||
"author": "Liberi Kids",
|
||||
"license": "MIT"
|
||||
}
|
||||
135
redeploy-completo.sh
Executable file
135
redeploy-completo.sh
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 🔄 REDEPLOY COMPLETO - Workflow Automatizado
|
||||
# Backup → Limpeza → Deploy → Teste
|
||||
# Execute: ./redeploy-completo.sh
|
||||
|
||||
echo "🔄 REDEPLOY COMPLETO - Liberi Kids"
|
||||
echo "=================================="
|
||||
|
||||
# Cores para output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
echo -e "${GREEN}[✅ OK]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[⚠️ WARN]${NC} $1"
|
||||
}
|
||||
|
||||
info() {
|
||||
echo -e "${BLUE}[ℹ️ INFO]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[❌ ERRO]${NC} $1"
|
||||
}
|
||||
|
||||
echo ""
|
||||
info "🔄 WORKFLOW COMPLETO:"
|
||||
echo " 1️⃣ Backup do projeto atual"
|
||||
echo " 2️⃣ Limpeza total do servidor"
|
||||
echo " 3️⃣ Deploy completo"
|
||||
echo " 4️⃣ Teste de funcionamento"
|
||||
echo ""
|
||||
|
||||
warn "⚠️ ATENÇÃO: Este processo vai:"
|
||||
echo " 🧹 Limpar TUDO do servidor"
|
||||
echo " 🚀 Reenviar TUDO do seu PC"
|
||||
echo " ⏱️ Levar alguns minutos"
|
||||
echo ""
|
||||
|
||||
read -p "Continuar com redeploy completo? (s/N): " confirma
|
||||
if [[ $confirma != "s" && $confirma != "S" ]]; then
|
||||
error "❌ Redeploy cancelado!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log "🚀 INICIANDO REDEPLOY COMPLETO..."
|
||||
|
||||
# Tornar scripts executáveis
|
||||
chmod +x *.sh
|
||||
|
||||
echo ""
|
||||
info "1️⃣ CRIANDO BACKUP..."
|
||||
./backup-projeto-completo.sh
|
||||
if [ $? -ne 0 ]; then
|
||||
error "❌ Falha no backup!"
|
||||
exit 1
|
||||
fi
|
||||
log "✅ Backup criado com sucesso"
|
||||
|
||||
echo ""
|
||||
info "2️⃣ LIMPANDO SERVIDOR..."
|
||||
./limpar-servidor-completo.sh
|
||||
if [ $? -ne 0 ]; then
|
||||
error "❌ Falha na limpeza!"
|
||||
exit 1
|
||||
fi
|
||||
log "✅ Servidor limpo"
|
||||
|
||||
echo ""
|
||||
info "3️⃣ FAZENDO DEPLOY COMPLETO..."
|
||||
./deploy-completo-servidor.sh
|
||||
if [ $? -ne 0 ]; then
|
||||
error "❌ Falha no deploy!"
|
||||
exit 1
|
||||
fi
|
||||
log "✅ Deploy realizado"
|
||||
|
||||
echo ""
|
||||
info "4️⃣ TESTANDO FUNCIONAMENTO..."
|
||||
|
||||
# Aguardar servidor inicializar
|
||||
sleep 5
|
||||
|
||||
# Testar conexão
|
||||
if curl -s http://192.168.195.145:5000 > /dev/null; then
|
||||
log "✅ Servidor respondendo"
|
||||
else
|
||||
warn "⚠️ Servidor pode não estar respondendo"
|
||||
fi
|
||||
|
||||
# Testar API
|
||||
if curl -s http://192.168.195.145:5000/api/produtos > /dev/null; then
|
||||
log "✅ API funcionando"
|
||||
else
|
||||
warn "⚠️ API pode não estar funcionando"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log "🎉 REDEPLOY COMPLETO FINALIZADO!"
|
||||
echo ""
|
||||
|
||||
info "📋 RESUMO:"
|
||||
echo " ✅ Backup criado"
|
||||
echo " ✅ Servidor limpo"
|
||||
echo " ✅ Deploy realizado"
|
||||
echo " ✅ Testes executados"
|
||||
echo ""
|
||||
|
||||
info "🌐 ACESSO:"
|
||||
echo " Frontend: http://192.168.195.145:5000"
|
||||
echo " SSH: ssh tiago@192.168.195.145"
|
||||
echo ""
|
||||
|
||||
info "🔧 VERIFICAÇÕES FINAIS:"
|
||||
echo " 1. Acesse o sistema no navegador"
|
||||
echo " 2. Teste login e funcionalidades"
|
||||
echo " 3. Verifique se PIX está funcionando"
|
||||
echo " 4. Confirme se dados estão corretos"
|
||||
echo ""
|
||||
|
||||
warn "💡 LEMBRE-SE:"
|
||||
echo " - Configurar credenciais se necessário"
|
||||
echo " - Aplicar SQL no Supabase se for primeira vez"
|
||||
echo " - Testar todas as funcionalidades importantes"
|
||||
echo ""
|
||||
|
||||
log "✨ SEU PROJETO FOI COMPLETAMENTE REDEPLOYADO!"
|
||||
127
scripts/deploy-local.sh
Executable file
127
scripts/deploy-local.sh
Executable file
@@ -0,0 +1,127 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 🚀 Script de Deploy Local - Liberi Kids
|
||||
# Execute: chmod +x scripts/deploy-local.sh && ./scripts/deploy-local.sh
|
||||
|
||||
echo "🚀 Iniciando deploy local do Liberi Kids..."
|
||||
|
||||
# Cores para output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Função para log colorido
|
||||
log() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Verificar se Node.js está instalado
|
||||
if ! command -v node &> /dev/null; then
|
||||
error "Node.js não encontrado. Instale Node.js 18+ primeiro."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verificar versão do Node.js
|
||||
NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
|
||||
if [ "$NODE_VERSION" -lt 18 ]; then
|
||||
error "Node.js versão 18+ é necessária. Versão atual: $(node -v)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Node.js $(node -v) encontrado ✓"
|
||||
|
||||
# Instalar dependências do servidor
|
||||
log "Instalando dependências do servidor..."
|
||||
npm install
|
||||
if [ $? -ne 0 ]; then
|
||||
error "Falha ao instalar dependências do servidor"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Entrar na pasta do cliente
|
||||
cd client
|
||||
|
||||
# Instalar dependências do cliente
|
||||
log "Instalando dependências do frontend..."
|
||||
npm install
|
||||
if [ $? -ne 0 ]; then
|
||||
error "Falha ao instalar dependências do frontend"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fazer build do frontend
|
||||
log "Fazendo build do frontend..."
|
||||
npm run build
|
||||
if [ $? -ne 0 ]; then
|
||||
error "Falha no build do frontend"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Voltar para pasta raiz
|
||||
cd ..
|
||||
|
||||
# Verificar se arquivo .env existe
|
||||
if [ ! -f ".env" ]; then
|
||||
warn "Arquivo .env não encontrado. Criando exemplo..."
|
||||
cat > .env << EOF
|
||||
# Configurações do Supabase
|
||||
SUPABASE_URL=https://seu-projeto.supabase.co
|
||||
SUPABASE_ANON_KEY=sua_chave_anonima_aqui
|
||||
|
||||
# Configurações do servidor
|
||||
NODE_ENV=production
|
||||
PORT=5000
|
||||
EOF
|
||||
warn "Configure o arquivo .env com suas credenciais do Supabase!"
|
||||
echo "Edite o arquivo .env antes de continuar."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verificar se PM2 está instalado
|
||||
if ! command -v pm2 &> /dev/null; then
|
||||
log "PM2 não encontrado. Instalando PM2..."
|
||||
npm install -g pm2
|
||||
fi
|
||||
|
||||
# Parar processo anterior se existir
|
||||
pm2 delete liberi-kids 2>/dev/null || true
|
||||
|
||||
# Iniciar aplicação com PM2
|
||||
log "Iniciando aplicação com PM2..."
|
||||
pm2 start server-supabase.js --name "liberi-kids" --env production
|
||||
|
||||
# Configurar PM2 para iniciar automaticamente
|
||||
log "Configurando PM2 para inicialização automática..."
|
||||
pm2 startup
|
||||
pm2 save
|
||||
|
||||
# Status final
|
||||
log "Deploy concluído com sucesso! 🎉"
|
||||
echo ""
|
||||
echo -e "${BLUE}📊 Status da aplicação:${NC}"
|
||||
pm2 status
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}🌐 Acesso:${NC}"
|
||||
echo " Local: http://localhost:5000"
|
||||
echo " Rede: http://$(hostname -I | awk '{print $1}'):5000"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}📋 Comandos úteis:${NC}"
|
||||
echo " Ver logs: pm2 logs liberi-kids"
|
||||
echo " Reiniciar: pm2 restart liberi-kids"
|
||||
echo " Parar: pm2 stop liberi-kids"
|
||||
echo " Status: pm2 status"
|
||||
|
||||
echo ""
|
||||
log "Aplicação rodando em produção! ✓"
|
||||
126
scripts/deploy-vercel.sh
Executable file
126
scripts/deploy-vercel.sh
Executable file
@@ -0,0 +1,126 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 🚀 Script de Deploy Vercel - Liberi Kids
|
||||
# Execute: chmod +x scripts/deploy-vercel.sh && ./scripts/deploy-vercel.sh
|
||||
|
||||
echo "☁️ Iniciando deploy no Vercel..."
|
||||
|
||||
# Cores para output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Verificar se Vercel CLI está instalado
|
||||
if ! command -v vercel &> /dev/null; then
|
||||
log "Instalando Vercel CLI..."
|
||||
npm install -g vercel
|
||||
fi
|
||||
|
||||
# Criar arquivo vercel.json se não existir
|
||||
if [ ! -f "vercel.json" ]; then
|
||||
log "Criando configuração do Vercel..."
|
||||
cat > vercel.json << 'EOF'
|
||||
{
|
||||
"version": 2,
|
||||
"name": "liberi-kids-estoque",
|
||||
"builds": [
|
||||
{
|
||||
"src": "server-supabase.js",
|
||||
"use": "@vercel/node"
|
||||
},
|
||||
{
|
||||
"src": "client/package.json",
|
||||
"use": "@vercel/static-build",
|
||||
"config": {
|
||||
"distDir": "build"
|
||||
}
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"src": "/api/(.*)",
|
||||
"dest": "/server-supabase.js"
|
||||
},
|
||||
{
|
||||
"src": "/(.*)",
|
||||
"dest": "/client/build/$1"
|
||||
}
|
||||
],
|
||||
"env": {
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Criar arquivo .vercelignore se não existir
|
||||
if [ ! -f ".vercelignore" ]; then
|
||||
log "Criando .vercelignore..."
|
||||
cat > .vercelignore << 'EOF'
|
||||
node_modules
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.test
|
||||
.env.production
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Verificar se as dependências estão instaladas
|
||||
log "Verificando dependências..."
|
||||
npm install
|
||||
|
||||
cd client
|
||||
npm install
|
||||
cd ..
|
||||
|
||||
# Fazer login no Vercel (se necessário)
|
||||
log "Verificando login no Vercel..."
|
||||
vercel whoami || {
|
||||
log "Faça login no Vercel:"
|
||||
vercel login
|
||||
}
|
||||
|
||||
# Deploy
|
||||
log "Fazendo deploy no Vercel..."
|
||||
vercel --prod
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
log "Deploy concluído com sucesso! 🎉"
|
||||
echo ""
|
||||
echo -e "${BLUE}📋 Próximos passos:${NC}"
|
||||
echo "1. Configure as variáveis de ambiente no dashboard do Vercel:"
|
||||
echo " - SUPABASE_URL"
|
||||
echo " - SUPABASE_ANON_KEY"
|
||||
echo ""
|
||||
echo "2. Acesse: https://vercel.com/dashboard"
|
||||
echo "3. Selecione seu projeto"
|
||||
echo "4. Vá em Settings > Environment Variables"
|
||||
echo "5. Adicione suas variáveis do Supabase"
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ Aplicação disponível na URL fornecida pelo Vercel${NC}"
|
||||
else
|
||||
error "Falha no deploy. Verifique os logs acima."
|
||||
exit 1
|
||||
fi
|
||||
84
scripts/fix-devolucoes.js
Executable file
84
scripts/fix-devolucoes.js
Executable file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
async function limparDevolucoesDuplicadas() {
|
||||
try {
|
||||
console.log('🔧 Iniciando limpeza de devoluções duplicadas...');
|
||||
|
||||
const response = await fetch('http://localhost:5000/api/devolucoes/limpar-duplicadas', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
console.log(`✅ ${result.message}`);
|
||||
console.log(`📊 Total removidas: ${result.removidas}`);
|
||||
} else {
|
||||
console.log('❌ Erro:', result.error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erro ao limpar devoluções:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function verificarEstadoDevolucoes() {
|
||||
try {
|
||||
console.log('\n📋 Verificando estado atual das devoluções...');
|
||||
|
||||
const response = await fetch('http://localhost:5000/api/devolucoes');
|
||||
const devolucoes = await response.json();
|
||||
|
||||
console.log(`📊 Total de devoluções: ${devolucoes.length}`);
|
||||
|
||||
// Agrupar por venda para verificar duplicatas
|
||||
const porVenda = {};
|
||||
devolucoes.forEach(dev => {
|
||||
const key = dev.venda_id;
|
||||
if (!porVenda[key]) {
|
||||
porVenda[key] = [];
|
||||
}
|
||||
porVenda[key].push(dev);
|
||||
});
|
||||
|
||||
console.log(`📊 Vendas com devoluções: ${Object.keys(porVenda).length}`);
|
||||
|
||||
// Verificar vendas com múltiplas devoluções
|
||||
const vendasComMultiplas = Object.entries(porVenda)
|
||||
.filter(([_, devs]) => devs.length > 1);
|
||||
|
||||
if (vendasComMultiplas.length > 0) {
|
||||
console.log(`⚠️ Vendas com múltiplas devoluções: ${vendasComMultiplas.length}`);
|
||||
vendasComMultiplas.forEach(([vendaId, devs]) => {
|
||||
console.log(` - Venda ${vendaId}: ${devs.length} devoluções`);
|
||||
});
|
||||
} else {
|
||||
console.log('✅ Nenhuma venda com múltiplas devoluções encontrada');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erro ao verificar devoluções:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🔧 Sistema de Correção de Devoluções/Trocas - Liberi Kids');
|
||||
console.log('======================================================');
|
||||
|
||||
await verificarEstadoDevolucoes();
|
||||
await limparDevolucoesDuplicadas();
|
||||
await verificarEstadoDevolucoes();
|
||||
|
||||
console.log('\n✅ Processo concluído!');
|
||||
console.log('\n📋 Próximos passos:');
|
||||
console.log('1. Teste uma devolução simples');
|
||||
console.log('2. Teste uma troca de produto');
|
||||
console.log('3. Verifique se o estoque está sendo atualizado corretamente');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
58
scripts/fix-google-drive.sh
Executable file
58
scripts/fix-google-drive.sh
Executable file
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🔧 Script de Correção Google Drive - Liberi Kids"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
|
||||
echo "📋 Checklist de Configuração:"
|
||||
echo ""
|
||||
echo "1. ✅ Verificar se Google Drive API está ativa"
|
||||
echo " - Acesse: https://console.cloud.google.com/apis/library/drive.googleapis.com"
|
||||
echo " - Clique em 'Ativar' se não estiver ativo"
|
||||
echo ""
|
||||
|
||||
echo "2. ✅ Configurar Tela de Consentimento OAuth"
|
||||
echo " - Acesse: https://console.cloud.google.com/apis/credentials/consent"
|
||||
echo " - Tipo: Externo"
|
||||
echo " - Nome do app: Liberi Kids - Sistema de Estoque"
|
||||
echo " - Adicionar escopos: drive.file e drive.readonly"
|
||||
echo ""
|
||||
|
||||
echo "3. 🎯 CRÍTICO: Adicionar Usuários de Teste"
|
||||
echo " - Na tela de consentimento, seção 'Usuários de teste'"
|
||||
echo " - Adicionar SEU EMAIL (o mesmo que vai usar para autorizar)"
|
||||
echo " - Sem isso, você receberá erro de autorização"
|
||||
echo ""
|
||||
|
||||
echo "4. ✅ Verificar Credenciais OAuth"
|
||||
echo " - Acesse: https://console.cloud.google.com/apis/credentials"
|
||||
echo " - URIs de redirecionamento: http://localhost:5000/auth/google-drive/callback"
|
||||
echo ""
|
||||
|
||||
echo "5. 🔄 Testar Configuração"
|
||||
echo " - Recarregar página de Configurações"
|
||||
echo " - Tentar autorizar novamente"
|
||||
echo " - Usar o email que foi adicionado como usuário de teste"
|
||||
echo ""
|
||||
|
||||
echo "📞 URLs Importantes:"
|
||||
echo " - Google Cloud Console: https://console.cloud.google.com/"
|
||||
echo " - APIs & Services: https://console.cloud.google.com/apis/"
|
||||
echo " - OAuth Consent: https://console.cloud.google.com/apis/credentials/consent"
|
||||
echo " - Credentials: https://console.cloud.google.com/apis/credentials"
|
||||
echo ""
|
||||
|
||||
echo "🚨 Lembre-se: Use o MESMO EMAIL em 'Usuários de teste' e na autorização!"
|
||||
echo ""
|
||||
|
||||
# Verificar se o servidor está rodando
|
||||
if curl -s http://localhost:5000/api/google-drive/status > /dev/null; then
|
||||
echo "✅ Servidor está rodando"
|
||||
echo "🔗 Acesse: http://localhost:3000/configuracoes"
|
||||
else
|
||||
echo "❌ Servidor não está rodando"
|
||||
echo "Execute: npm start"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Após configurar no Google Cloud Console, recarregue a página e tente novamente!"
|
||||
99
scripts/init-supabase.js
Normal file
99
scripts/init-supabase.js
Normal file
@@ -0,0 +1,99 @@
|
||||
const supabase = require('../config/supabase');
|
||||
|
||||
async function initializeSupabaseTables() {
|
||||
console.log('🚀 Inicializando tabelas no Supabase...');
|
||||
|
||||
try {
|
||||
// 1. Criar tabela de fornecedores
|
||||
console.log('📦 Criando tabela fornecedores...');
|
||||
const { error: fornecedoresError } = await supabase.rpc('create_fornecedores_table');
|
||||
if (fornecedoresError && !fornecedoresError.message.includes('already exists')) {
|
||||
console.error('Erro ao criar tabela fornecedores:', fornecedoresError);
|
||||
} else {
|
||||
console.log('✅ Tabela fornecedores criada/verificada');
|
||||
}
|
||||
|
||||
// 2. Criar tabela de produtos
|
||||
console.log('📦 Criando tabela produtos...');
|
||||
const { error: produtosError } = await supabase.rpc('create_produtos_table');
|
||||
if (produtosError && !produtosError.message.includes('already exists')) {
|
||||
console.error('Erro ao criar tabela produtos:', produtosError);
|
||||
} else {
|
||||
console.log('✅ Tabela produtos criada/verificada');
|
||||
}
|
||||
|
||||
// 3. Criar tabela de variações de produtos
|
||||
console.log('📦 Criando tabela produto_variacoes...');
|
||||
const { error: variacoesError } = await supabase.rpc('create_produto_variacoes_table');
|
||||
if (variacoesError && !variacoesError.message.includes('already exists')) {
|
||||
console.error('Erro ao criar tabela produto_variacoes:', variacoesError);
|
||||
} else {
|
||||
console.log('✅ Tabela produto_variacoes criada/verificada');
|
||||
}
|
||||
|
||||
// 4. Criar tabela de clientes
|
||||
console.log('📦 Criando tabela clientes...');
|
||||
const { error: clientesError } = await supabase.rpc('create_clientes_table');
|
||||
if (clientesError && !clientesError.message.includes('already exists')) {
|
||||
console.error('Erro ao criar tabela clientes:', clientesError);
|
||||
} else {
|
||||
console.log('✅ Tabela clientes criada/verificada');
|
||||
}
|
||||
|
||||
// 5. Criar tabela de tipos de despesas
|
||||
console.log('📦 Criando tabela tipos_despesas...');
|
||||
const { error: tiposDespesasError } = await supabase.rpc('create_tipos_despesas_table');
|
||||
if (tiposDespesasError && !tiposDespesasError.message.includes('already exists')) {
|
||||
console.error('Erro ao criar tabela tipos_despesas:', tiposDespesasError);
|
||||
} else {
|
||||
console.log('✅ Tabela tipos_despesas criada/verificada');
|
||||
}
|
||||
|
||||
// 6. Criar tabela de despesas
|
||||
console.log('📦 Criando tabela despesas...');
|
||||
const { error: despesasError } = await supabase.rpc('create_despesas_table');
|
||||
if (despesasError && !despesasError.message.includes('already exists')) {
|
||||
console.error('Erro ao criar tabela despesas:', despesasError);
|
||||
} else {
|
||||
console.log('✅ Tabela despesas criada/verificada');
|
||||
}
|
||||
|
||||
// 7. Criar tabela de vendas
|
||||
console.log('📦 Criando tabela vendas...');
|
||||
const { error: vendasError } = await supabase.rpc('create_vendas_table');
|
||||
if (vendasError && !vendasError.message.includes('already exists')) {
|
||||
console.error('Erro ao criar tabela vendas:', vendasError);
|
||||
} else {
|
||||
console.log('✅ Tabela vendas criada/verificada');
|
||||
}
|
||||
|
||||
// 8. Criar tabela de itens de venda
|
||||
console.log('📦 Criando tabela venda_itens...');
|
||||
const { error: vendaItensError } = await supabase.rpc('create_venda_itens_table');
|
||||
if (vendaItensError && !vendaItensError.message.includes('already exists')) {
|
||||
console.error('Erro ao criar tabela venda_itens:', vendaItensError);
|
||||
} else {
|
||||
console.log('✅ Tabela venda_itens criada/verificada');
|
||||
}
|
||||
|
||||
console.log('🎉 Inicialização do Supabase concluída!');
|
||||
|
||||
// Testar conexão
|
||||
const { data, error } = await supabase.from('fornecedores').select('count', { count: 'exact' });
|
||||
if (error) {
|
||||
console.error('❌ Erro ao testar conexão:', error);
|
||||
} else {
|
||||
console.log('✅ Conexão com Supabase funcionando!');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erro geral na inicialização:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Executar se chamado diretamente
|
||||
if (require.main === module) {
|
||||
initializeSupabaseTables();
|
||||
}
|
||||
|
||||
module.exports = initializeSupabaseTables;
|
||||
72
scripts/migrate-despesas-text-fields.js
Normal file
72
scripts/migrate-despesas-text-fields.js
Normal file
@@ -0,0 +1,72 @@
|
||||
const supabase = require('../config/supabase');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
async function migrateDespesasToTextFields() {
|
||||
console.log('🔄 Migrando tabela despesas para campos de texto livre...');
|
||||
|
||||
try {
|
||||
// Ler o script SQL
|
||||
const sqlScript = fs.readFileSync(
|
||||
path.join(__dirname, '../sql/alter-despesas-text-fields.sql'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// Dividir o script em comandos individuais
|
||||
const commands = sqlScript
|
||||
.split(';')
|
||||
.map(cmd => cmd.trim())
|
||||
.filter(cmd => cmd.length > 0 && !cmd.startsWith('--'));
|
||||
|
||||
console.log(`📝 Executando ${commands.length} comandos SQL...`);
|
||||
|
||||
// Executar cada comando
|
||||
for (let i = 0; i < commands.length; i++) {
|
||||
const command = commands[i];
|
||||
console.log(`⚡ Executando comando ${i + 1}/${commands.length}...`);
|
||||
|
||||
try {
|
||||
const { error } = await supabase.rpc('exec_sql', { sql_query: command });
|
||||
if (error) {
|
||||
console.log(`⚠️ Comando ${i + 1} com aviso:`, error.message);
|
||||
} else {
|
||||
console.log(`✅ Comando ${i + 1} executado com sucesso`);
|
||||
}
|
||||
} catch (cmdError) {
|
||||
console.log(`⚠️ Erro no comando ${i + 1}:`, cmdError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar se as colunas foram criadas
|
||||
console.log('🔍 Verificando estrutura da tabela...');
|
||||
const { data: columns, error: columnsError } = await supabase
|
||||
.from('information_schema.columns')
|
||||
.select('column_name')
|
||||
.eq('table_name', 'despesas')
|
||||
.in('column_name', ['tipo_nome', 'fornecedor_nome']);
|
||||
|
||||
if (columnsError) {
|
||||
console.log('⚠️ Não foi possível verificar as colunas:', columnsError.message);
|
||||
} else {
|
||||
const columnNames = columns.map(col => col.column_name);
|
||||
if (columnNames.includes('tipo_nome') && columnNames.includes('fornecedor_nome')) {
|
||||
console.log('✅ Colunas tipo_nome e fornecedor_nome criadas com sucesso!');
|
||||
} else {
|
||||
console.log('⚠️ Algumas colunas podem não ter sido criadas:', columnNames);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎉 Migração concluída!');
|
||||
console.log('📋 Agora você pode usar campos de texto livre para Tipo de Despesa e Fornecedor');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erro durante a migração:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Executar se chamado diretamente
|
||||
if (require.main === module) {
|
||||
migrateDespesasToTextFields();
|
||||
}
|
||||
|
||||
module.exports = migrateDespesasToTextFields;
|
||||
60
scripts/migrate-pix-fields.js
Normal file
60
scripts/migrate-pix-fields.js
Normal file
@@ -0,0 +1,60 @@
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
require('dotenv').config();
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.SUPABASE_URL,
|
||||
process.env.SUPABASE_ANON_KEY
|
||||
);
|
||||
|
||||
async function migratePIXFields() {
|
||||
console.log('🏦 Iniciando migração dos campos PIX...');
|
||||
|
||||
try {
|
||||
// Executar as alterações na tabela vendas
|
||||
const queries = [
|
||||
"ALTER TABLE vendas ADD COLUMN IF NOT EXISTS status_pagamento VARCHAR(20) DEFAULT 'pendente'",
|
||||
"ALTER TABLE vendas ADD COLUMN IF NOT EXISTS data_pagamento TIMESTAMP",
|
||||
"ALTER TABLE vendas ADD COLUMN IF NOT EXISTS pix_payment_id VARCHAR(100)",
|
||||
"ALTER TABLE vendas ADD COLUMN IF NOT EXISTS pix_qr_code TEXT",
|
||||
"ALTER TABLE vendas ADD COLUMN IF NOT EXISTS metodo_pagamento VARCHAR(20) DEFAULT 'dinheiro'"
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
console.log(`Executando: ${query}`);
|
||||
const { error } = await supabase.rpc('exec_sql', { sql: query });
|
||||
|
||||
if (error) {
|
||||
console.error(`Erro ao executar query: ${error.message}`);
|
||||
// Continuar mesmo com erro (campo pode já existir)
|
||||
} else {
|
||||
console.log('✅ Query executada com sucesso');
|
||||
}
|
||||
}
|
||||
|
||||
// Criar índices
|
||||
const indexes = [
|
||||
"CREATE INDEX IF NOT EXISTS idx_vendas_status_pagamento ON vendas(status_pagamento)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_vendas_pix_payment_id ON vendas(pix_payment_id)"
|
||||
];
|
||||
|
||||
for (const index of indexes) {
|
||||
console.log(`Criando índice: ${index}`);
|
||||
const { error } = await supabase.rpc('exec_sql', { sql: index });
|
||||
|
||||
if (error) {
|
||||
console.error(`Erro ao criar índice: ${error.message}`);
|
||||
} else {
|
||||
console.log('✅ Índice criado com sucesso');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎉 Migração PIX concluída com sucesso!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erro na migração:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Executar migração
|
||||
migratePIXFields();
|
||||
4030
server-supabase.js
Normal file
4030
server-supabase.js
Normal file
File diff suppressed because it is too large
Load Diff
635
server.js
Normal file
635
server.js
Normal file
@@ -0,0 +1,635 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
require('dotenv').config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 5000;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use('/uploads', express.static('uploads'));
|
||||
app.use(express.static(path.join(__dirname, 'client/build')));
|
||||
|
||||
// Configuração do multer para upload de imagens
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, 'uploads/');
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueName = `${Date.now()}-${uuidv4()}${path.extname(file.originalname)}`;
|
||||
cb(null, uniqueName);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Apenas imagens são permitidas!'), false);
|
||||
}
|
||||
},
|
||||
limits: { fileSize: 5 * 1024 * 1024 } // 5MB
|
||||
});
|
||||
|
||||
// Inicializar banco de dados
|
||||
const db = new sqlite3.Database('./liberi_kids.db', (err) => {
|
||||
if (err) {
|
||||
console.error('Erro ao conectar com o banco de dados:', err.message);
|
||||
} else {
|
||||
console.log('Conectado ao banco de dados SQLite.');
|
||||
initializeDatabase();
|
||||
}
|
||||
});
|
||||
|
||||
// Função para inicializar as tabelas
|
||||
function initializeDatabase() {
|
||||
// Tabela de produtos
|
||||
db.run(`CREATE TABLE IF NOT EXISTS produtos (
|
||||
id TEXT PRIMARY KEY,
|
||||
id_produto TEXT,
|
||||
marca TEXT NOT NULL,
|
||||
nome TEXT NOT NULL,
|
||||
estacao TEXT NOT NULL,
|
||||
genero TEXT DEFAULT 'Unissex',
|
||||
fornecedor_id TEXT,
|
||||
valor_compra REAL NOT NULL,
|
||||
valor_revenda REAL NOT NULL,
|
||||
foto_principal_url TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (fornecedor_id) REFERENCES fornecedores (id)
|
||||
)`);
|
||||
|
||||
// Tabela de variações de produtos (tamanho, cor, quantidade)
|
||||
db.run(`CREATE TABLE IF NOT EXISTS produto_variacoes (
|
||||
id TEXT PRIMARY KEY,
|
||||
produto_id TEXT NOT NULL,
|
||||
tamanho TEXT NOT NULL,
|
||||
cor TEXT NOT NULL,
|
||||
quantidade INTEGER NOT NULL DEFAULT 0,
|
||||
foto_url TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (produto_id) REFERENCES produtos (id) ON DELETE CASCADE
|
||||
)`);
|
||||
|
||||
// Tabela de clientes
|
||||
db.run(`CREATE TABLE IF NOT EXISTS clientes (
|
||||
id TEXT PRIMARY KEY,
|
||||
nome_completo TEXT NOT NULL,
|
||||
email TEXT,
|
||||
telefone TEXT,
|
||||
endereco TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
|
||||
// Tabela de fornecedores
|
||||
db.run(`CREATE TABLE IF NOT EXISTS fornecedores (
|
||||
id TEXT PRIMARY KEY,
|
||||
razao_social TEXT NOT NULL,
|
||||
telefone TEXT,
|
||||
whatsapp TEXT,
|
||||
endereco TEXT,
|
||||
email TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
|
||||
// Tabela de tipos de despesas
|
||||
db.run(`CREATE TABLE IF NOT EXISTS tipos_despesas (
|
||||
id TEXT PRIMARY KEY,
|
||||
nome TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
|
||||
// Tabela de despesas
|
||||
db.run(`CREATE TABLE IF NOT EXISTS despesas (
|
||||
id TEXT PRIMARY KEY,
|
||||
tipo_despesa_id TEXT NOT NULL,
|
||||
fornecedor_id TEXT,
|
||||
data DATE NOT NULL,
|
||||
valor REAL NOT NULL,
|
||||
descricao TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tipo_despesa_id) REFERENCES tipos_despesas (id),
|
||||
FOREIGN KEY (fornecedor_id) REFERENCES fornecedores (id)
|
||||
)`);
|
||||
|
||||
// Tabela de vendas
|
||||
db.run(`CREATE TABLE IF NOT EXISTS vendas (
|
||||
id TEXT PRIMARY KEY,
|
||||
cliente_id TEXT,
|
||||
tipo_pagamento TEXT NOT NULL, -- 'vista' ou 'parcelado'
|
||||
valor_total REAL NOT NULL,
|
||||
desconto REAL DEFAULT 0,
|
||||
parcelas INTEGER DEFAULT 1,
|
||||
valor_parcela REAL,
|
||||
data_venda DATE NOT NULL,
|
||||
status TEXT DEFAULT 'concluida', -- 'concluida', 'cancelada'
|
||||
observacoes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (cliente_id) REFERENCES clientes (id)
|
||||
)`);
|
||||
|
||||
// Tabela de itens da venda
|
||||
db.run(`CREATE TABLE IF NOT EXISTS venda_itens (
|
||||
id TEXT PRIMARY KEY,
|
||||
venda_id TEXT NOT NULL,
|
||||
produto_variacao_id TEXT NOT NULL,
|
||||
quantidade INTEGER NOT NULL,
|
||||
valor_unitario REAL NOT NULL,
|
||||
valor_total REAL NOT NULL,
|
||||
FOREIGN KEY (venda_id) REFERENCES vendas (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (produto_variacao_id) REFERENCES produto_variacoes (id)
|
||||
)`);
|
||||
|
||||
console.log('Tabelas do banco de dados inicializadas.');
|
||||
}
|
||||
|
||||
// Rotas da API
|
||||
|
||||
// === PRODUTOS ===
|
||||
app.get('/api/produtos', (req, res) => {
|
||||
const query = `
|
||||
SELECT p.*, f.razao_social as fornecedor_nome,
|
||||
COUNT(pv.id) as total_variacoes,
|
||||
SUM(pv.quantidade) as estoque_total
|
||||
FROM produtos p
|
||||
LEFT JOIN fornecedores f ON p.fornecedor_id = f.id
|
||||
LEFT JOIN produto_variacoes pv ON p.id = pv.produto_id
|
||||
GROUP BY p.id
|
||||
ORDER BY p.created_at DESC
|
||||
`;
|
||||
|
||||
db.all(query, [], (err, rows) => {
|
||||
if (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/produtos', upload.any(), (req, res) => {
|
||||
console.log('Recebendo requisição para criar produto:', req.body);
|
||||
console.log('Arquivos recebidos:', req.files ? req.files.length : 0);
|
||||
|
||||
const { id_produto, marca, nome, estacao, genero, fornecedor_id, valor_compra, valor_revenda, variacoes_data } = req.body;
|
||||
const produtoId = uuidv4();
|
||||
|
||||
// Validações básicas
|
||||
if (!marca || !nome || !estacao || !valor_compra || !valor_revenda) {
|
||||
return res.status(400).json({ error: 'Campos obrigatórios não preenchidos' });
|
||||
}
|
||||
|
||||
// Parse das variações
|
||||
let variacoes = [];
|
||||
try {
|
||||
if (variacoes_data) {
|
||||
variacoes = JSON.parse(variacoes_data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao fazer parse das variações:', error);
|
||||
return res.status(400).json({ error: 'Dados de variações inválidos' });
|
||||
}
|
||||
|
||||
if (variacoes.length === 0) {
|
||||
return res.status(400).json({ error: 'Pelo menos uma variação é obrigatória' });
|
||||
}
|
||||
|
||||
// Iniciar transação
|
||||
db.serialize(() => {
|
||||
db.run('BEGIN TRANSACTION');
|
||||
|
||||
// Inserir produto
|
||||
const produtoQuery = `INSERT INTO produtos (id, id_produto, marca, nome, estacao, genero, fornecedor_id, valor_compra, valor_revenda)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
const fornecedorIdFinal = fornecedor_id && fornecedor_id !== '' ? fornecedor_id : null;
|
||||
|
||||
db.run(produtoQuery, [produtoId, id_produto, marca, nome, estacao, genero || 'Unissex', fornecedorIdFinal, valor_compra, valor_revenda], function(err) {
|
||||
if (err) {
|
||||
console.error('Erro ao inserir produto:', err);
|
||||
db.run('ROLLBACK');
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
console.log('Produto inserido com sucesso, processando variações...');
|
||||
|
||||
// Processar variações e fotos
|
||||
let variacoesProcessadas = 0;
|
||||
const totalVariacoes = variacoes.length;
|
||||
|
||||
if (totalVariacoes === 0) {
|
||||
db.run('COMMIT');
|
||||
return res.json({ id: produtoId, message: 'Produto criado com sucesso!' });
|
||||
}
|
||||
|
||||
variacoes.forEach((variacao, varIndex) => {
|
||||
const variacaoId = uuidv4();
|
||||
|
||||
// Inserir variação
|
||||
const variacaoQuery = `INSERT INTO produto_variacoes (id, produto_id, tamanho, cor, quantidade)
|
||||
VALUES (?, ?, ?, ?, ?)`;
|
||||
|
||||
db.run(variacaoQuery, [variacaoId, produtoId, variacao.tamanho, variacao.cor, variacao.quantidade], function(err) {
|
||||
if (err) {
|
||||
console.error('Erro ao inserir variação:', err);
|
||||
db.run('ROLLBACK');
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
// Processar fotos desta variação
|
||||
const fotosVariacao = req.files ? req.files.filter(file =>
|
||||
file.fieldname.startsWith(`variacao_${varIndex}_foto_`)
|
||||
) : [];
|
||||
|
||||
if (fotosVariacao.length > 0) {
|
||||
// Usar a primeira foto como foto_url da variação
|
||||
const primeiraFoto = fotosVariacao[0];
|
||||
const fotoUrl = `/uploads/${primeiraFoto.filename}`;
|
||||
|
||||
// Atualizar variação com a primeira foto
|
||||
db.run('UPDATE produto_variacoes SET foto_url = ? WHERE id = ?', [fotoUrl, variacaoId], function(err) {
|
||||
if (err) {
|
||||
console.error('Erro ao atualizar foto da variação:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
variacoesProcessadas++;
|
||||
console.log(`Variação processada: ${variacoesProcessadas}/${totalVariacoes}`);
|
||||
|
||||
if (variacoesProcessadas === totalVariacoes) {
|
||||
db.run('COMMIT');
|
||||
console.log('Produto criado com sucesso!');
|
||||
res.json({ id: produtoId, message: 'Produto criado com sucesso!' });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/api/produtos/:id', upload.single('foto_principal'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { id_produto, marca, nome, estacao, genero, fornecedor_id, valor_compra, valor_revenda } = req.body;
|
||||
|
||||
let query, params;
|
||||
|
||||
if (req.file) {
|
||||
const foto_principal_url = `/uploads/${req.file.filename}`;
|
||||
query = `UPDATE produtos
|
||||
SET id_produto = ?, marca = ?, nome = ?, estacao = ?, genero = ?, fornecedor_id = ?, valor_compra = ?, valor_revenda = ?, foto_principal_url = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`;
|
||||
params = [id_produto, marca, nome, estacao, genero || 'Unissex', fornecedor_id, valor_compra, valor_revenda, foto_principal_url, id];
|
||||
} else {
|
||||
query = `UPDATE produtos
|
||||
SET id_produto = ?, marca = ?, nome = ?, estacao = ?, genero = ?, fornecedor_id = ?, valor_compra = ?, valor_revenda = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`;
|
||||
params = [id_produto, marca, nome, estacao, genero || 'Unissex', fornecedor_id, valor_compra, valor_revenda, id];
|
||||
}
|
||||
|
||||
db.run(query, params, function(err) {
|
||||
if (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.json({ message: 'Produto atualizado com sucesso!' });
|
||||
});
|
||||
});
|
||||
|
||||
// === VARIAÇÕES DE PRODUTOS ===
|
||||
app.get('/api/produtos/:id/variacoes', (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.all('SELECT * FROM produto_variacoes WHERE produto_id = ? ORDER BY tamanho, cor', [id], (err, rows) => {
|
||||
if (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/produtos/:id/variacoes', upload.single('foto'), (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { tamanho, cor, quantidade } = req.body;
|
||||
const foto_url = req.file ? `/uploads/${req.file.filename}` : null;
|
||||
const variacao_id = uuidv4();
|
||||
|
||||
const query = `INSERT INTO produto_variacoes (id, produto_id, tamanho, cor, quantidade, foto_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
db.run(query, [variacao_id, id, tamanho, cor, quantidade, foto_url], function(err) {
|
||||
if (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.json({ id: variacao_id, message: 'Variação adicionada com sucesso!' });
|
||||
});
|
||||
});
|
||||
|
||||
// === CLIENTES ===
|
||||
app.get('/api/clientes', (req, res) => {
|
||||
db.all('SELECT * FROM clientes ORDER BY nome_completo', [], (err, rows) => {
|
||||
if (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/clientes', (req, res) => {
|
||||
const { nome_completo, email, telefone, endereco } = req.body;
|
||||
const id = uuidv4();
|
||||
|
||||
const query = `INSERT INTO clientes (id, nome_completo, email, telefone, endereco)
|
||||
VALUES (?, ?, ?, ?, ?)`;
|
||||
|
||||
db.run(query, [id, nome_completo, email, telefone, endereco], function(err) {
|
||||
if (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.json({ id, message: 'Cliente cadastrado com sucesso!' });
|
||||
});
|
||||
});
|
||||
|
||||
// === FORNECEDORES ===
|
||||
app.get('/api/fornecedores', (req, res) => {
|
||||
db.all('SELECT * FROM fornecedores ORDER BY razao_social', [], (err, rows) => {
|
||||
if (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/fornecedores', (req, res) => {
|
||||
const { razao_social, telefone, whatsapp, endereco, email } = req.body;
|
||||
const id = uuidv4();
|
||||
|
||||
const query = `INSERT INTO fornecedores (id, razao_social, telefone, whatsapp, endereco, email)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
db.run(query, [id, razao_social, telefone, whatsapp, endereco, email], function(err) {
|
||||
if (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.json({ id, message: 'Fornecedor cadastrado com sucesso!' });
|
||||
});
|
||||
});
|
||||
|
||||
// === TIPOS DE DESPESAS ===
|
||||
app.get('/api/tipos-despesas', (req, res) => {
|
||||
db.all('SELECT * FROM tipos_despesas ORDER BY nome', [], (err, rows) => {
|
||||
if (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/tipos-despesas', (req, res) => {
|
||||
const { nome } = req.body;
|
||||
const id = uuidv4();
|
||||
|
||||
const query = `INSERT INTO tipos_despesas (id, nome) VALUES (?, ?)`;
|
||||
|
||||
db.run(query, [id, nome], function(err) {
|
||||
if (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.json({ id, message: 'Tipo de despesa criado com sucesso!' });
|
||||
});
|
||||
});
|
||||
|
||||
// === DESPESAS ===
|
||||
app.get('/api/despesas', (req, res) => {
|
||||
const query = `
|
||||
SELECT d.*, td.nome as tipo_nome, f.razao_social as fornecedor_nome
|
||||
FROM despesas d
|
||||
LEFT JOIN tipos_despesas td ON d.tipo_despesa_id = td.id
|
||||
LEFT JOIN fornecedores f ON d.fornecedor_id = f.id
|
||||
ORDER BY d.data DESC
|
||||
`;
|
||||
|
||||
db.all(query, [], (err, rows) => {
|
||||
if (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/despesas', (req, res) => {
|
||||
const { tipo_despesa_id, fornecedor_id, data, valor, descricao } = req.body;
|
||||
const id = uuidv4();
|
||||
|
||||
const query = `INSERT INTO despesas (id, tipo_despesa_id, fornecedor_id, data, valor, descricao)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
db.run(query, [id, tipo_despesa_id, fornecedor_id || null, data, valor, descricao], function(err) {
|
||||
if (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.json({ id, message: 'Despesa cadastrada com sucesso!' });
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/api/despesas/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { tipo_despesa_id, fornecedor_id, data, valor, descricao } = req.body;
|
||||
|
||||
const query = `UPDATE despesas
|
||||
SET tipo_despesa_id = ?, fornecedor_id = ?, data = ?, valor = ?, descricao = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`;
|
||||
|
||||
db.run(query, [tipo_despesa_id, fornecedor_id || null, data, valor, descricao, id], function(err) {
|
||||
if (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.json({ message: 'Despesa atualizada com sucesso!' });
|
||||
});
|
||||
});
|
||||
|
||||
app.delete('/api/despesas/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.run('DELETE FROM despesas WHERE id = ?', [id], function(err) {
|
||||
if (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.json({ message: 'Despesa excluída com sucesso!' });
|
||||
});
|
||||
});
|
||||
|
||||
// === VENDAS ===
|
||||
app.get('/api/vendas', (req, res) => {
|
||||
const query = `
|
||||
SELECT v.*, c.nome_completo as cliente_nome
|
||||
FROM vendas v
|
||||
LEFT JOIN clientes c ON v.cliente_id = c.id
|
||||
ORDER BY v.data_venda DESC
|
||||
`;
|
||||
|
||||
db.all(query, [], (err, rows) => {
|
||||
if (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/vendas', (req, res) => {
|
||||
const { cliente_id, tipo_pagamento, valor_total, desconto, parcelas, data_venda, observacoes, itens } = req.body;
|
||||
const vendaId = uuidv4();
|
||||
const valor_parcela = tipo_pagamento === 'parcelado' ? (valor_total - desconto) / parcelas : 0;
|
||||
|
||||
// Iniciar transação
|
||||
db.serialize(() => {
|
||||
db.run('BEGIN TRANSACTION');
|
||||
|
||||
// Inserir venda
|
||||
const vendaQuery = `INSERT INTO vendas (id, cliente_id, tipo_pagamento, valor_total, desconto, parcelas, valor_parcela, data_venda, observacoes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
db.run(vendaQuery, [vendaId, cliente_id || null, tipo_pagamento, valor_total, desconto, parcelas, valor_parcela, data_venda, observacoes], function(err) {
|
||||
if (err) {
|
||||
db.run('ROLLBACK');
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
// Inserir itens da venda
|
||||
let itemsProcessed = 0;
|
||||
const totalItems = itens.length;
|
||||
|
||||
if (totalItems === 0) {
|
||||
db.run('COMMIT');
|
||||
res.json({ id: vendaId, message: 'Venda registrada com sucesso!' });
|
||||
return;
|
||||
}
|
||||
|
||||
itens.forEach((item) => {
|
||||
const itemId = uuidv4();
|
||||
const itemQuery = `INSERT INTO venda_itens (id, venda_id, produto_variacao_id, quantidade, valor_unitario, valor_total)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
db.run(itemQuery, [itemId, vendaId, item.produto_variacao_id, item.quantidade, item.valor_unitario, item.valor_total], function(err) {
|
||||
if (err) {
|
||||
db.run('ROLLBACK');
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
// Atualizar estoque
|
||||
const updateEstoque = `UPDATE produto_variacoes
|
||||
SET quantidade = quantidade - ?
|
||||
WHERE id = ?`;
|
||||
|
||||
db.run(updateEstoque, [item.quantidade, item.produto_variacao_id], function(err) {
|
||||
if (err) {
|
||||
db.run('ROLLBACK');
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
itemsProcessed++;
|
||||
if (itemsProcessed === totalItems) {
|
||||
db.run('COMMIT');
|
||||
res.json({ id: vendaId, message: 'Venda registrada com sucesso!' });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.delete('/api/vendas/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
db.run('DELETE FROM vendas WHERE id = ?', [id], function(err) {
|
||||
if (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.json({ message: 'Venda excluída com sucesso!' });
|
||||
});
|
||||
});
|
||||
|
||||
// === TESTE ===
|
||||
app.get('/api/test', (req, res) => {
|
||||
res.json({ message: 'API funcionando corretamente!', timestamp: new Date() });
|
||||
});
|
||||
|
||||
// === DASHBOARD ===
|
||||
app.get('/api/dashboard', (req, res) => {
|
||||
const queries = {
|
||||
totalProdutos: 'SELECT COUNT(*) as count FROM produtos',
|
||||
totalClientes: 'SELECT COUNT(*) as count FROM clientes',
|
||||
totalFornecedores: 'SELECT COUNT(*) as count FROM fornecedores',
|
||||
vendasMes: `SELECT COUNT(*) as count, SUM(valor_total) as total
|
||||
FROM vendas
|
||||
WHERE strftime('%Y-%m', data_venda) = strftime('%Y-%m', 'now')`,
|
||||
estoqueTotal: 'SELECT SUM(quantidade) as total FROM produto_variacoes',
|
||||
despesasMes: `SELECT SUM(valor) as total
|
||||
FROM despesas
|
||||
WHERE strftime('%Y-%m', data) = strftime('%Y-%m', 'now')`
|
||||
};
|
||||
|
||||
const results = {};
|
||||
let completed = 0;
|
||||
const total = Object.keys(queries).length;
|
||||
|
||||
Object.entries(queries).forEach(([key, query]) => {
|
||||
db.get(query, [], (err, row) => {
|
||||
if (err) {
|
||||
results[key] = { error: err.message };
|
||||
} else {
|
||||
results[key] = row;
|
||||
}
|
||||
|
||||
completed++;
|
||||
if (completed === total) {
|
||||
res.json(results);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Servir arquivos estáticos do React
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'client/build', 'index.html'));
|
||||
});
|
||||
|
||||
// Criar diretório de uploads se não existir
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync('uploads')) {
|
||||
fs.mkdirSync('uploads');
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Servidor rodando na porta ${PORT}`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
163
site/README.md
Normal file
163
site/README.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# 🛍️ Catálogo Web - Liberi Kids
|
||||
|
||||
Catálogo online da **Liberi Kids - Moda Infantil** com carrinho de compras integrado ao WhatsApp.
|
||||
|
||||
## 🎯 Funcionalidades
|
||||
|
||||
### ✅ Catálogo de Produtos
|
||||
- **Carregamento automático** dos produtos cadastrados no sistema
|
||||
- **Filtros inteligentes** por categoria, tamanho e gênero
|
||||
- **Design responsivo** para desktop e mobile
|
||||
- **Imagens otimizadas** com fallback para produtos sem foto
|
||||
|
||||
### 🛒 Carrinho de Compras
|
||||
- **Adicionar/remover produtos** com animações suaves
|
||||
- **Controle de quantidade** individual por item
|
||||
- **Cálculo automático** do total
|
||||
- **Persistência visual** do estado do carrinho
|
||||
|
||||
### 📱 Integração WhatsApp
|
||||
- **Envio automático** do pedido para a vendedora Maiara
|
||||
- **Formatação profissional** da mensagem
|
||||
- **Detalhes completos** do pedido (produtos, quantidades, valores)
|
||||
- **Timestamp** e informações de origem
|
||||
|
||||
### 🎨 Interface Moderna
|
||||
- **Design gradient** com cores atrativas
|
||||
- **Animações CSS** suaves e profissionais
|
||||
- **Tipografia** Google Fonts (Poppins)
|
||||
- **Ícones** Font Awesome
|
||||
- **Layout responsivo** para todos os dispositivos
|
||||
|
||||
## 🚀 Como Usar
|
||||
|
||||
### 1. Acesso ao Catálogo
|
||||
```
|
||||
http://localhost:5000/catalogo
|
||||
```
|
||||
|
||||
### 2. Configuração do WhatsApp
|
||||
Edite o arquivo `script.js` e altere o número da vendedora:
|
||||
|
||||
```javascript
|
||||
const CONFIG = {
|
||||
WHATSAPP_NUMBER: '5511999999999', // Número da Maiara
|
||||
VENDEDORA_NOME: 'Maiara'
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Fluxo de Compra
|
||||
1. **Navegar** pelos produtos no catálogo
|
||||
2. **Filtrar** por categoria, tamanho ou gênero
|
||||
3. **Adicionar** produtos ao carrinho
|
||||
4. **Revisar** itens no carrinho lateral
|
||||
5. **Finalizar** pedido via WhatsApp
|
||||
|
||||
## 📋 Estrutura de Arquivos
|
||||
|
||||
```
|
||||
site/
|
||||
├── index.html # Página principal do catálogo
|
||||
├── styles.css # Estilos CSS responsivos
|
||||
├── script.js # JavaScript com todas as funcionalidades
|
||||
└── README.md # Esta documentação
|
||||
```
|
||||
|
||||
## 🔧 Integração com o Sistema
|
||||
|
||||
### API Utilizada
|
||||
- **Endpoint:** `/api/catalogo/produtos`
|
||||
- **Método:** GET
|
||||
- **Retorna:** Produtos em estoque formatados para o catálogo
|
||||
|
||||
### Dados dos Produtos
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"nome": "Camiseta Infantil",
|
||||
"preco_venda": 29.90,
|
||||
"tamanho": "M",
|
||||
"genero": "unissex",
|
||||
"estacao": "verao",
|
||||
"categoria": "camiseta",
|
||||
"imagem": "/uploads/produto1.jpg",
|
||||
"estoque": 5,
|
||||
"descricao": "Camiseta confortável..."
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 Personalização
|
||||
|
||||
### Cores do Tema
|
||||
- **Primária:** `#667eea` (Azul gradient)
|
||||
- **Secundária:** `#764ba2` (Roxo gradient)
|
||||
- **Sucesso:** `#10b981` (Verde)
|
||||
- **Erro:** `#ef4444` (Vermelho)
|
||||
- **WhatsApp:** `#25d366` (Verde WhatsApp)
|
||||
|
||||
### Responsividade
|
||||
- **Desktop:** Layout completo com 3-4 colunas
|
||||
- **Tablet:** Layout adaptado com 2-3 colunas
|
||||
- **Mobile:** Layout single-column otimizado
|
||||
|
||||
## 📱 Funcionalidades do WhatsApp
|
||||
|
||||
### Formato da Mensagem
|
||||
```
|
||||
🛍️ NOVO PEDIDO - LIBERI KIDS
|
||||
|
||||
👋 Olá Maiara! Gostaria de fazer um pedido:
|
||||
|
||||
📦 ITENS DO PEDIDO:
|
||||
1. Camiseta Infantil
|
||||
• Tamanho: M
|
||||
• Gênero: Unissex
|
||||
• Quantidade: 2x
|
||||
• Preço unitário: R$ 29,90
|
||||
• Subtotal: R$ 59,80
|
||||
|
||||
📊 RESUMO DO PEDIDO:
|
||||
• Total de itens: 2
|
||||
• Valor total: R$ 59,80
|
||||
|
||||
📱 Pedido feito através do catálogo online
|
||||
🕐 07/10/2024 17:30:15
|
||||
|
||||
Aguardo retorno para confirmar o pedido! 😊
|
||||
```
|
||||
|
||||
## 🔄 Sincronização Automática
|
||||
|
||||
### Produtos Novos
|
||||
- **Automático:** Novos produtos aparecem no catálogo imediatamente
|
||||
- **Estoque:** Apenas produtos com estoque > 0 são exibidos
|
||||
- **Ordem:** Produtos mais recentes aparecem primeiro
|
||||
|
||||
### Atualizações em Tempo Real
|
||||
- **Preços:** Atualizados automaticamente
|
||||
- **Estoque:** Produtos sem estoque são ocultados
|
||||
- **Imagens:** Carregamento otimizado com fallback
|
||||
|
||||
## 🛡️ Segurança e Performance
|
||||
|
||||
### Otimizações
|
||||
- **Lazy loading** de imagens
|
||||
- **Debounce** nos filtros
|
||||
- **Cache** de produtos carregados
|
||||
- **Compressão** de imagens
|
||||
|
||||
### Tratamento de Erros
|
||||
- **Fallback** para produtos sem imagem
|
||||
- **Retry** automático em caso de erro de rede
|
||||
- **Mensagens** de erro amigáveis
|
||||
- **Loading states** informativos
|
||||
|
||||
## 📞 Suporte
|
||||
|
||||
Para dúvidas ou problemas com o catálogo:
|
||||
- **WhatsApp:** (11) 99999-9999
|
||||
- **E-mail:** contato@liberikids.com.br
|
||||
|
||||
---
|
||||
|
||||
**Liberi Kids - Moda Infantil** 👶✨
|
||||
BIN
site/assets/Fundo_LiberiKids.jpg
Normal file
BIN
site/assets/Fundo_LiberiKids.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
site/assets/LogoLiberiKids.png
Normal file
BIN
site/assets/LogoLiberiKids.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
129
site/index.html
Normal file
129
site/index.html
Normal file
@@ -0,0 +1,129 @@
|
||||
<!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">
|
||||
<title>Liberi Kids - Moda Infantil | Catálogo Online</title>
|
||||
<meta name="description" content="Descubra as melhores roupas infantis na Liberi Kids. Moda moderna, confortável e estilosa para crianças.">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="assets/LogoLiberiKids.png">
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
|
||||
<!-- Font Awesome para ícones -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-wrapper">
|
||||
<!-- Header Simples -->
|
||||
<header class="header">
|
||||
<div class="container">
|
||||
<div class="header-content">
|
||||
<div class="logo">
|
||||
<img src="assets/LogoLiberiKids.png" alt="Liberi Kids" class="logo-img">
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<button class="cart-btn" onclick="toggleCart()">
|
||||
<i class="fas fa-shopping-cart"></i>
|
||||
<span class="cart-count">0</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filtros Simples -->
|
||||
<section class="filters">
|
||||
<div class="container">
|
||||
<div class="filter-options">
|
||||
<select id="marcaFilter">
|
||||
<option value="">Todas as Marcas</option>
|
||||
</select>
|
||||
|
||||
<select id="tamanhoFilter">
|
||||
<option value="">Todos os Tamanhos</option>
|
||||
<option value="P">P (2-4 anos)</option>
|
||||
<option value="M">M (4-6 anos)</option>
|
||||
<option value="GG">GG (8-10 anos)</option>
|
||||
</select>
|
||||
|
||||
<select id="generoFilter">
|
||||
<option value="">Todos</option>
|
||||
<option value="menino">Menino</option>
|
||||
<option value="menina">Menina</option>
|
||||
<option value="unissex">Unissex</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Produtos -->
|
||||
<section id="produtos" class="produtos">
|
||||
<div class="container">
|
||||
<h2>Nossos Produtos</h2>
|
||||
<div class="loading" id="loading">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<p>Carregando produtos...</p>
|
||||
</div>
|
||||
<div class="produtos-grid" id="produtosGrid">
|
||||
<!-- Produtos serão carregados dinamicamente -->
|
||||
</div>
|
||||
<div class="no-products" id="noProducts" style="display: none;">
|
||||
<i class="fas fa-search"></i>
|
||||
<h3>Nenhum produto encontrado</h3>
|
||||
<p>Tente ajustar os filtros ou volte mais tarde para ver novos produtos.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Carrinho Lateral -->
|
||||
<div class="cart-sidebar" id="cartSidebar">
|
||||
<h3><i class="fas fa-shopping-cart"></i> Seu Carrinho</h3>
|
||||
<button onclick="toggleCart()" class="close-cart">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cart-content" id="cartContent">
|
||||
<div class="empty-cart">
|
||||
<i class="fas fa-shopping-cart"></i>
|
||||
<p>Seu carrinho está vazio</p>
|
||||
<span>Adicione produtos para começar!</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cart-footer" id="cartFooter" style="display: none;">
|
||||
<div class="cart-total">
|
||||
<strong>Total: R$ <span id="cartTotal">0,00</span></strong>
|
||||
</div>
|
||||
<button class="checkout-btn" onclick="finalizarPedido()">
|
||||
<i class="fab fa-whatsapp"></i>
|
||||
Finalizar Pedido
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlay -->
|
||||
<div class="overlay" id="overlay" onclick="toggleCart()"></div>
|
||||
|
||||
|
||||
<!-- WhatsApp Flutuante -->
|
||||
<div class="whatsapp-float" onclick="abrirWhatsApp()">
|
||||
<i class="fab fa-whatsapp"></i>
|
||||
<div class="whatsapp-tooltip">
|
||||
<strong>Maiara</strong><br>
|
||||
Fale conosco!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
17
site/logo.svg
Normal file
17
site/logo.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
|
||||
<!-- Fundo circular -->
|
||||
<circle cx="200" cy="200" r="180" fill="#f8f9fa" stroke="#333" stroke-width="8"/>
|
||||
|
||||
<!-- Lupa -->
|
||||
<circle cx="200" cy="180" r="120" fill="none" stroke="#333" stroke-width="6"/>
|
||||
<line x1="290" y1="270" x2="340" y2="320" stroke="#8B4513" stroke-width="12" stroke-linecap="round"/>
|
||||
|
||||
<!-- Texto Liberi -->
|
||||
<text x="200" y="160" text-anchor="middle" font-family="Arial, sans-serif" font-size="36" font-weight="bold" fill="#FF69B4">Liberi</text>
|
||||
|
||||
<!-- Texto KIDS -->
|
||||
<text x="200" y="200" text-anchor="middle" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="#87CEEB">KIDS</text>
|
||||
|
||||
<!-- Texto "moda infantil" -->
|
||||
<text x="200" y="320" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" font-style="italic" fill="#333">moda infantil</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 895 B |
802
site/script.js
Normal file
802
site/script.js
Normal file
@@ -0,0 +1,802 @@
|
||||
// Configurações
|
||||
const CONFIG = {
|
||||
API_BASE_URL: 'http://localhost:5000/api',
|
||||
WHATSAPP_NUMBER: '5543999762754', // Número da Maiara
|
||||
VENDEDORA_NOME: 'Maiara',
|
||||
EVOLUTION_API: {
|
||||
BASE_URL: 'https://criadordigital-evolution.jesdfs.easypanel.host',
|
||||
INSTANCE_NAME: 'Tiago',
|
||||
API_KEY: 'DBDF609168B1-48A3-8A4A-5E50D0300F2C'
|
||||
}
|
||||
};
|
||||
|
||||
// Estado global
|
||||
let produtos = [];
|
||||
let carrinho = [];
|
||||
let filtros = {
|
||||
marca: '',
|
||||
tamanho: '',
|
||||
genero: ''
|
||||
};
|
||||
|
||||
// Inicialização
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
carregarProdutos();
|
||||
inicializarEventListeners();
|
||||
atualizarContadorCarrinho();
|
||||
});
|
||||
|
||||
// Event Listeners
|
||||
function inicializarEventListeners() {
|
||||
// Filtros
|
||||
document.getElementById('marcaFilter').addEventListener('change', aplicarFiltros);
|
||||
document.getElementById('tamanhoFilter').addEventListener('change', aplicarFiltros);
|
||||
document.getElementById('generoFilter').addEventListener('change', aplicarFiltros);
|
||||
|
||||
// Smooth scroll para links de navegação
|
||||
document.querySelectorAll('a[href^="#"]').forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Função para carregar produtos do banco de dados
|
||||
async function carregarProdutos() {
|
||||
const loading = document.getElementById('loading');
|
||||
const grid = document.getElementById('produtosGrid');
|
||||
const noProducts = document.getElementById('noProducts');
|
||||
|
||||
try {
|
||||
loading.style.display = 'block';
|
||||
|
||||
// Buscar produtos da API
|
||||
const response = await fetch(`${CONFIG.API_BASE_URL}/catalogo/produtos`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erro ao buscar produtos');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Dados recebidos da API:', data); // Debug
|
||||
produtos = data.data || data || [];
|
||||
|
||||
loading.style.display = 'none';
|
||||
|
||||
if (produtos.length === 0) {
|
||||
noProducts.style.display = 'block';
|
||||
grid.innerHTML = '';
|
||||
} else {
|
||||
noProducts.style.display = 'none';
|
||||
popularFiltros();
|
||||
renderizarProdutos();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar produtos:', error);
|
||||
loading.style.display = 'none';
|
||||
noProducts.style.display = 'block';
|
||||
grid.innerHTML = '';
|
||||
} finally {
|
||||
mostrarLoading(false);
|
||||
}
|
||||
}
|
||||
// Mostrar/ocultar loading
|
||||
function mostrarLoading(show) {
|
||||
const loading = document.getElementById('loading');
|
||||
const grid = document.getElementById('produtosGrid');
|
||||
|
||||
if (show) {
|
||||
loading.style.display = 'block';
|
||||
grid.style.display = 'none';
|
||||
} else {
|
||||
loading.style.display = 'none';
|
||||
grid.style.display = 'grid';
|
||||
}
|
||||
}
|
||||
|
||||
// Mostrar erro
|
||||
function mostrarErro(mensagem) {
|
||||
const grid = document.getElementById('produtosGrid');
|
||||
grid.innerHTML = `
|
||||
<div style="grid-column: 1 / -1; text-align: center; padding: 3rem; color: #dc3545;">
|
||||
<i class="fas fa-exclamation-triangle" style="font-size: 3rem; margin-bottom: 1rem;"></i>
|
||||
<h3>Ops! Algo deu errado</h3>
|
||||
<p>${mensagem}</p>
|
||||
<button onclick="carregarProdutos()" style="margin-top: 1rem; padding: 0.5rem 1rem; background: #667eea; color: white; border: none; border-radius: 5px; cursor: pointer;">
|
||||
Tentar Novamente
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Renderizar produtos
|
||||
function renderizarProdutos(produtosList = produtos) {
|
||||
const grid = document.getElementById('produtosGrid');
|
||||
const noProducts = document.getElementById('noProducts');
|
||||
|
||||
console.log('Renderizando produtos:', produtosList); // Debug
|
||||
|
||||
if (!produtosList || produtosList.length === 0) {
|
||||
grid.style.display = 'none';
|
||||
noProducts.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.style.display = 'grid';
|
||||
noProducts.style.display = 'none';
|
||||
|
||||
grid.innerHTML = produtosList.map(produto => {
|
||||
console.log('Renderizando produto individual:', produto); // Debug
|
||||
|
||||
// Pegar a primeira variação disponível para mostrar
|
||||
const variacao = produto.variacoes && produto.variacoes.length > 0 ? produto.variacoes[0] : null;
|
||||
const temEstoque = produto.estoque_total > 0 || (variacao && variacao.quantidade > 0);
|
||||
const preco = produto.valor_revenda || produto.preco_venda || 0;
|
||||
|
||||
return `
|
||||
<div class="produto-card" data-id="${produto.id || ''}">
|
||||
<div class="produto-image">
|
||||
${produto.foto_url ?
|
||||
`<img src="http://localhost:5000${produto.foto_url}" alt="${produto.nome || 'Produto'}" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||
<div class="placeholder-img" style="display: none;"><i class="fas fa-tshirt"></i></div>` :
|
||||
variacao && variacao.foto_url ?
|
||||
`<img src="http://localhost:5000${variacao.foto_url}" alt="${produto.nome || 'Produto'}" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||
<div class="placeholder-img" style="display: none;"><i class="fas fa-tshirt"></i></div>` :
|
||||
`<div class="placeholder-img"><i class="fas fa-tshirt"></i></div>`
|
||||
}
|
||||
${!temEstoque ? '<div class="produto-sem-estoque">Sem Estoque</div>' : ''}
|
||||
</div>
|
||||
<div class="produto-info">
|
||||
<div class="produto-header">
|
||||
<h3 class="produto-nome">${produto.marca ? produto.marca + ' - ' : ''}${produto.nome || 'Produto sem nome'}</h3>
|
||||
${produto.id_produto ? `<span class="produto-codigo">Cód: ${produto.id_produto}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="produto-badges">
|
||||
${variacao && variacao.tamanho ? `<span class="produto-badge badge-tamanho">${variacao.tamanho}</span>` : ''}
|
||||
${variacao && variacao.cor ? `<span class="produto-badge badge-cor" style="background-color: ${getCorBadge(variacao.cor)}">${variacao.cor}</span>` : ''}
|
||||
${produto.genero ? `<span class="produto-badge badge-genero">${formatarGenero(produto.genero)}</span>` : ''}
|
||||
${produto.estacao ? `<span class="produto-badge badge-estacao">${formatarEstacao(produto.estacao)}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="produto-detalhes">
|
||||
${produto.fornecedor ? `<small>Fornecedor: ${produto.fornecedor}</small>` : ''}
|
||||
${produto.estoque_total ? `<small>Estoque: ${produto.estoque_total} unidades</small>` : ''}
|
||||
${variacao && !produto.estoque_total ? `<small>Estoque: ${variacao.quantidade || 0} unidades</small>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="produto-preco">R$ ${formatarPreco(preco)}</div>
|
||||
|
||||
<button class="add-to-cart" onclick="adicionarAoCarrinho(${produto.id || 0})" ${!temEstoque ? 'disabled' : ''}>
|
||||
<i class="fas fa-cart-plus"></i> ${temEstoque ? 'Adicionar ao Carrinho' : 'Sem Estoque'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Formatadores
|
||||
function formatarPreco(preco) {
|
||||
return parseFloat(preco || 0).toLocaleString('pt-BR', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
}
|
||||
|
||||
function formatarGenero(genero) {
|
||||
const generos = {
|
||||
'masculino': 'Menino',
|
||||
'feminino': 'Menina',
|
||||
'unissex': 'Unissex'
|
||||
};
|
||||
return generos[genero] || genero || 'Unissex';
|
||||
}
|
||||
|
||||
function formatarEstacao(estacao) {
|
||||
const estacoes = {
|
||||
'verao': 'Verão',
|
||||
'inverno': 'Inverno',
|
||||
'outono': 'Outono',
|
||||
'primavera': 'Primavera'
|
||||
};
|
||||
return estacoes[estacao] || estacao || 'Meia Estação';
|
||||
}
|
||||
|
||||
function getCorBadge(cor) {
|
||||
const cores = {
|
||||
'azul': '#c0daf3',
|
||||
'rosa': '#ecbad7',
|
||||
'amarelo': '#f9f295',
|
||||
'verde': '#90ee90',
|
||||
'vermelho': '#ffb3ba',
|
||||
'branco': '#f6f4e6',
|
||||
'preto': '#464444'
|
||||
};
|
||||
return cores[cor?.toLowerCase()] || '#ddd9c7';
|
||||
}
|
||||
|
||||
function formatarGenero(genero) {
|
||||
const generos = {
|
||||
'menino': 'Menino',
|
||||
'menina': 'Menina',
|
||||
'unissex': 'Unissex'
|
||||
};
|
||||
return generos[genero] || 'Unissex';
|
||||
}
|
||||
|
||||
function formatarEstacao(estacao) {
|
||||
const estacoes = {
|
||||
'verao': 'Verão',
|
||||
'inverno': 'Inverno',
|
||||
'meia-estacao': 'Meia Estação'
|
||||
};
|
||||
return estacoes[estacao] || 'Meia Estação';
|
||||
}
|
||||
|
||||
// Filtros
|
||||
function aplicarFiltros() {
|
||||
filtros.categoria = document.getElementById('categoriaFilter').value;
|
||||
filtros.tamanho = document.getElementById('tamanhoFilter').value;
|
||||
filtros.genero = document.getElementById('generoFilter').value;
|
||||
|
||||
let produtosFiltrados = produtos.filter(produto => {
|
||||
const matchCategoria = !filtros.categoria ||
|
||||
produto.nome.toLowerCase().includes(filtros.categoria.toLowerCase()) ||
|
||||
(produto.categoria && produto.categoria.toLowerCase().includes(filtros.categoria.toLowerCase()));
|
||||
|
||||
const matchTamanho = !filtros.tamanho || produto.tamanho === filtros.tamanho;
|
||||
const matchGenero = !filtros.genero || produto.genero === filtros.genero;
|
||||
|
||||
return matchCategoria && matchTamanho && matchGenero;
|
||||
});
|
||||
|
||||
renderizarProdutos(produtosFiltrados);
|
||||
}
|
||||
|
||||
function limparFiltros() {
|
||||
document.getElementById('categoriaFilter').value = '';
|
||||
document.getElementById('tamanhoFilter').value = '';
|
||||
document.getElementById('generoFilter').value = '';
|
||||
|
||||
filtros = { categoria: '', tamanho: '', genero: '' };
|
||||
renderizarProdutos(produtos);
|
||||
}
|
||||
|
||||
// Carrinho de Compras
|
||||
function adicionarAoCarrinho(produtoId) {
|
||||
const produto = produtos.find(p => p.id === produtoId);
|
||||
if (!produto) return;
|
||||
|
||||
const itemExistente = carrinho.find(item => item.id === produtoId);
|
||||
|
||||
if (itemExistente) {
|
||||
itemExistente.quantidade += 1;
|
||||
} else {
|
||||
carrinho.push({
|
||||
id: produto.id,
|
||||
nome: produto.nome,
|
||||
preco: parseFloat(produto.preco_venda),
|
||||
tamanho: produto.tamanho,
|
||||
genero: produto.genero,
|
||||
imagem: produto.imagem,
|
||||
quantidade: 1
|
||||
});
|
||||
}
|
||||
|
||||
atualizarCarrinho();
|
||||
mostrarNotificacao('Produto adicionado ao carrinho!', 'success');
|
||||
|
||||
// Animação do botão
|
||||
const btn = event.target.closest('.add-to-cart');
|
||||
btn.style.transform = 'scale(0.95)';
|
||||
setTimeout(() => {
|
||||
btn.style.transform = 'scale(1)';
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function removerDoCarrinho(produtoId) {
|
||||
carrinho = carrinho.filter(item => item.id !== produtoId);
|
||||
atualizarCarrinho();
|
||||
mostrarNotificacao('Produto removido do carrinho', 'info');
|
||||
}
|
||||
|
||||
function alterarQuantidade(produtoId, novaQuantidade) {
|
||||
if (novaQuantidade <= 0) {
|
||||
removerDoCarrinho(produtoId);
|
||||
return;
|
||||
}
|
||||
|
||||
const item = carrinho.find(item => item.id === produtoId);
|
||||
if (item) {
|
||||
item.quantidade = novaQuantidade;
|
||||
atualizarCarrinho();
|
||||
}
|
||||
}
|
||||
|
||||
function atualizarCarrinho() {
|
||||
atualizarContadorCarrinho();
|
||||
renderizarCarrinho();
|
||||
atualizarTotalCarrinho();
|
||||
}
|
||||
|
||||
function atualizarContadorCarrinho() {
|
||||
const contador = document.querySelector('.cart-count');
|
||||
const totalItens = carrinho.reduce((total, item) => total + item.quantidade, 0);
|
||||
contador.textContent = totalItens;
|
||||
|
||||
// Animação do contador
|
||||
if (totalItens > 0) {
|
||||
contador.style.display = 'flex';
|
||||
contador.style.animation = 'pulse 0.3s ease';
|
||||
} else {
|
||||
contador.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderizarCarrinho() {
|
||||
const cartContent = document.getElementById('cartContent');
|
||||
const cartFooter = document.getElementById('cartFooter');
|
||||
|
||||
if (carrinho.length === 0) {
|
||||
cartContent.innerHTML = `
|
||||
<div class="empty-cart">
|
||||
<i class="fas fa-shopping-cart"></i>
|
||||
<p>Seu carrinho está vazio</p>
|
||||
<span>Adicione produtos para começar!</span>
|
||||
</div>
|
||||
`;
|
||||
cartFooter.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
cartContent.innerHTML = carrinho.map(item => `
|
||||
<div class="cart-item">
|
||||
<div class="cart-item-image">
|
||||
${item.imagem ?
|
||||
`<img src="${item.imagem}" alt="${item.nome}" style="width: 100%; height: 100%; object-fit: cover; border-radius: 8px;">` :
|
||||
`<i class="fas fa-tshirt"></i>`
|
||||
}
|
||||
</div>
|
||||
<div class="cart-item-info">
|
||||
<div class="cart-item-name">${item.nome}</div>
|
||||
<div class="cart-item-details">${item.tamanho} • ${formatarGenero(item.genero)}</div>
|
||||
<div class="cart-item-price">R$ ${formatarPreco(item.preco)}</div>
|
||||
</div>
|
||||
<div class="cart-item-actions">
|
||||
<button class="qty-btn" onclick="alterarQuantidade(${item.id}, ${item.quantidade - 1})">
|
||||
<i class="fas fa-minus"></i>
|
||||
</button>
|
||||
<span style="margin: 0 0.5rem; font-weight: 600;">${item.quantidade}</span>
|
||||
<button class="qty-btn" onclick="alterarQuantidade(${item.id}, ${item.quantidade + 1})">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
<button class="remove-item" onclick="removerDoCarrinho(${item.id})">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
cartFooter.style.display = 'block';
|
||||
}
|
||||
|
||||
function atualizarTotalCarrinho() {
|
||||
const total = carrinho.reduce((sum, item) => sum + (item.preco * item.quantidade), 0);
|
||||
const totalElement = document.getElementById('cartTotal');
|
||||
if (totalElement) {
|
||||
totalElement.textContent = formatarPreco(total);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCart() {
|
||||
const sidebar = document.getElementById('cartSidebar');
|
||||
const overlay = document.getElementById('overlay');
|
||||
|
||||
sidebar.classList.toggle('active');
|
||||
overlay.classList.toggle('active');
|
||||
|
||||
if (sidebar.classList.contains('active')) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
// Finalizar Pedido - WhatsApp
|
||||
function finalizarPedido() {
|
||||
if (carrinho.length === 0) {
|
||||
mostrarNotificacao('Seu carrinho está vazio!', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const total = carrinho.reduce((sum, item) => sum + (item.preco * item.quantidade), 0);
|
||||
const totalItens = carrinho.reduce((sum, item) => sum + item.quantidade, 0);
|
||||
|
||||
let mensagem = `🛍️ *NOVO PEDIDO - LIBERI KIDS*\n\n`;
|
||||
mensagem += `👋 Olá ${CONFIG.VENDEDORA_NOME}! Gostaria de fazer um pedido:\n\n`;
|
||||
|
||||
mensagem += `📦 *ITENS DO PEDIDO:*\n`;
|
||||
carrinho.forEach((item, index) => {
|
||||
mensagem += `${index + 1}. *${item.nome}*\n`;
|
||||
mensagem += ` • Tamanho: ${item.tamanho}\n`;
|
||||
mensagem += ` • Gênero: ${formatarGenero(item.genero)}\n`;
|
||||
mensagem += ` • Quantidade: ${item.quantidade}x\n`;
|
||||
mensagem += ` • Preço unitário: R$ ${formatarPreco(item.preco)}\n`;
|
||||
mensagem += ` • Subtotal: R$ ${formatarPreco(item.preco * item.quantidade)}\n\n`;
|
||||
});
|
||||
|
||||
mensagem += `📊 *RESUMO DO PEDIDO:*\n`;
|
||||
mensagem += `• Total de itens: ${totalItens}\n`;
|
||||
mensagem += `• *Valor total: R$ ${formatarPreco(total)}*\n\n`;
|
||||
|
||||
mensagem += `📱 Pedido feito através do catálogo online da Liberi Kids\n`;
|
||||
mensagem += `🕐 ${new Date().toLocaleString('pt-BR')}\n\n`;
|
||||
|
||||
mensagem += `Aguardo retorno para confirmar o pedido e combinar a entrega! 😊`;
|
||||
|
||||
// Codificar mensagem para URL
|
||||
const mensagemCodificada = encodeURIComponent(mensagem);
|
||||
const urlWhatsApp = `https://wa.me/${CONFIG.WHATSAPP_NUMBER}?text=${mensagemCodificada}`;
|
||||
|
||||
// Abrir WhatsApp
|
||||
window.open(urlWhatsApp, '_blank');
|
||||
|
||||
// Limpar carrinho após envio
|
||||
setTimeout(() => {
|
||||
if (confirm('Pedido enviado! Deseja limpar o carrinho?')) {
|
||||
carrinho = [];
|
||||
atualizarCarrinho();
|
||||
toggleCart();
|
||||
mostrarNotificacao('Pedido enviado com sucesso!', 'success');
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Notificações
|
||||
function mostrarNotificacao(mensagem, tipo = 'info') {
|
||||
// Remover notificação existente
|
||||
const existente = document.querySelector('.notification');
|
||||
if (existente) {
|
||||
existente.remove();
|
||||
}
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${tipo}`;
|
||||
notification.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<i class="fas fa-${getIconeNotificacao(tipo)}"></i>
|
||||
<span>${mensagem}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Estilos da notificação
|
||||
Object.assign(notification.style, {
|
||||
position: 'fixed',
|
||||
top: '100px',
|
||||
right: '20px',
|
||||
background: getCorNotificacao(tipo),
|
||||
color: 'white',
|
||||
padding: '1rem 1.5rem',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
zIndex: '3000',
|
||||
animation: 'slideInRight 0.3s ease',
|
||||
maxWidth: '300px'
|
||||
});
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Remover após 3 segundos
|
||||
setTimeout(() => {
|
||||
notification.style.animation = 'slideOutRight 0.3s ease';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function getIconeNotificacao(tipo) {
|
||||
const icones = {
|
||||
'success': 'check-circle',
|
||||
'error': 'exclamation-circle',
|
||||
'warning': 'exclamation-triangle',
|
||||
'info': 'info-circle'
|
||||
};
|
||||
return icones[tipo] || 'info-circle';
|
||||
}
|
||||
|
||||
function getCorNotificacao(tipo) {
|
||||
const cores = {
|
||||
'success': '#10b981',
|
||||
'error': '#ef4444',
|
||||
'warning': '#f59e0b',
|
||||
'info': '#3b82f6'
|
||||
};
|
||||
return cores[tipo] || '#3b82f6';
|
||||
}
|
||||
|
||||
// Adicionar animações CSS dinamicamente
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideOutRight {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Fechar carrinho ao clicar fora
|
||||
document.addEventListener('click', function(e) {
|
||||
const sidebar = document.getElementById('cartSidebar');
|
||||
const cartBtn = document.querySelector('.cart-btn');
|
||||
|
||||
if (sidebar.classList.contains('active') &&
|
||||
!sidebar.contains(e.target) &&
|
||||
!cartBtn.contains(e.target)) {
|
||||
toggleCart();
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll suave para seções
|
||||
function scrollToSection(sectionId) {
|
||||
const section = document.getElementById(sectionId);
|
||||
if (section) {
|
||||
section.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
// Função para abrir WhatsApp
|
||||
async function abrirWhatsApp() {
|
||||
const numero = CONFIG.WHATSAPP_NUMBER;
|
||||
const nome = CONFIG.VENDEDORA_NOME;
|
||||
const mensagem = `Olá ${nome}! Vim através do catálogo online da Liberi Kids e gostaria de saber mais sobre os produtos. Pode me ajudar?`;
|
||||
|
||||
try {
|
||||
// Tentar enviar via Evolution API primeiro
|
||||
await enviarMensagemEvolution(mensagem);
|
||||
mostrarNotificacao('Mensagem enviada via WhatsApp!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao enviar via Evolution API:', error);
|
||||
|
||||
// Fallback para WhatsApp Web
|
||||
const mensagemCodificada = encodeURIComponent(mensagem);
|
||||
const urlWhatsApp = `https://wa.me/${numero}?text=${mensagemCodificada}`;
|
||||
window.open(urlWhatsApp, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
// Popular filtros dinamicamente
|
||||
function popularFiltros() {
|
||||
// Marcas únicas
|
||||
const marcas = [...new Set(produtos.map(p => p.marca).filter(Boolean))];
|
||||
const marcaSelect = document.getElementById('marcaFilter');
|
||||
marcaSelect.innerHTML = '<option value="">Todas as Marcas</option>';
|
||||
marcas.forEach(marca => {
|
||||
marcaSelect.innerHTML += `<option value="${marca}">${marca}</option>`;
|
||||
});
|
||||
|
||||
// Tamanhos únicos das variações
|
||||
const tamanhos = [...new Set(produtos.flatMap(p =>
|
||||
p.variacoes?.map(v => v.tamanho) || []
|
||||
).filter(Boolean))];
|
||||
const tamanhoSelect = document.getElementById('tamanhoFilter');
|
||||
tamanhoSelect.innerHTML = '<option value="">Todos os Tamanhos</option>';
|
||||
tamanhos.forEach(tamanho => {
|
||||
tamanhoSelect.innerHTML += `<option value="${tamanho}">${tamanho}</option>`;
|
||||
});
|
||||
|
||||
// Gêneros únicos
|
||||
const generos = [...new Set(produtos.map(p => p.genero).filter(Boolean))];
|
||||
const generoSelect = document.getElementById('generoFilter');
|
||||
generoSelect.innerHTML = '<option value="">Todos</option>';
|
||||
generos.forEach(genero => {
|
||||
generoSelect.innerHTML += `<option value="${genero}">${formatarGenero(genero)}</option>`;
|
||||
});
|
||||
}
|
||||
|
||||
// Aplicar filtros
|
||||
function aplicarFiltros() {
|
||||
filtros.marca = document.getElementById('marcaFilter').value;
|
||||
filtros.tamanho = document.getElementById('tamanhoFilter').value;
|
||||
filtros.genero = document.getElementById('generoFilter').value;
|
||||
|
||||
const produtosFiltrados = produtos.filter(produto => {
|
||||
// Filtro por marca
|
||||
if (filtros.marca && produto.marca !== filtros.marca) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filtro por gênero
|
||||
if (filtros.genero && produto.genero !== filtros.genero) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filtro por tamanho (verifica nas variações)
|
||||
if (filtros.tamanho) {
|
||||
const temTamanho = produto.variacoes?.some(v => v.tamanho === filtros.tamanho);
|
||||
if (!temTamanho) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
renderizarProdutos(produtosFiltrados);
|
||||
}
|
||||
|
||||
// Funções do carrinho
|
||||
function adicionarAoCarrinho(produtoId) {
|
||||
const produto = produtos.find(p => p.id === produtoId);
|
||||
if (!produto) return;
|
||||
|
||||
const itemExistente = carrinho.find(item => item.id === produtoId);
|
||||
|
||||
if (itemExistente) {
|
||||
itemExistente.quantidade++;
|
||||
} else {
|
||||
carrinho.push({
|
||||
id: produto.id,
|
||||
nome: `${produto.marca} - ${produto.nome}`,
|
||||
preco: produto.valor_revenda,
|
||||
quantidade: 1,
|
||||
foto: produto.foto_url || (produto.variacoes?.[0]?.foto_url)
|
||||
});
|
||||
}
|
||||
|
||||
atualizarContadorCarrinho();
|
||||
mostrarNotificacao('Produto adicionado ao carrinho!');
|
||||
}
|
||||
|
||||
function atualizarContadorCarrinho() {
|
||||
const contador = document.querySelector('.cart-count');
|
||||
const totalItens = carrinho.reduce((total, item) => total + item.quantidade, 0);
|
||||
contador.textContent = totalItens;
|
||||
}
|
||||
|
||||
function mostrarNotificacao(mensagem) {
|
||||
const notificacao = document.createElement('div');
|
||||
notificacao.className = 'notificacao';
|
||||
notificacao.textContent = mensagem;
|
||||
notificacao.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #25d366;
|
||||
color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 10px;
|
||||
z-index: 9999;
|
||||
animation: slideIn 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(notificacao);
|
||||
|
||||
setTimeout(() => {
|
||||
notificacao.remove();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function toggleCart() {
|
||||
const cartSidebar = document.getElementById('cartSidebar');
|
||||
const overlay = document.getElementById('overlay');
|
||||
|
||||
cartSidebar.classList.toggle('active');
|
||||
overlay.classList.toggle('active');
|
||||
|
||||
if (cartSidebar.classList.contains('active')) {
|
||||
renderizarCarrinho();
|
||||
}
|
||||
}
|
||||
|
||||
function renderizarCarrinho() {
|
||||
const cartContent = document.querySelector('.cart-content');
|
||||
const cartTotal = document.querySelector('.cart-total');
|
||||
|
||||
if (carrinho.length === 0) {
|
||||
cartContent.innerHTML = `
|
||||
<div style="text-align: center; padding: 2rem; color: #666;">
|
||||
<i class="fas fa-shopping-cart" style="font-size: 3rem; margin-bottom: 1rem; opacity: 0.3;"></i>
|
||||
<p>Seu carrinho está vazio</p>
|
||||
</div>
|
||||
`;
|
||||
cartTotal.innerHTML = '<strong>Total: R$ 0,00</strong>';
|
||||
return;
|
||||
}
|
||||
|
||||
const total = carrinho.reduce((sum, item) => sum + (item.preco * item.quantidade), 0);
|
||||
|
||||
cartContent.innerHTML = carrinho.map(item => `
|
||||
<div class="cart-item">
|
||||
<div class="item-info">
|
||||
<h4>${item.nome}</h4>
|
||||
<p>R$ ${formatarPreco(item.preco)} x ${item.quantidade}</p>
|
||||
</div>
|
||||
<button class="remove-item" onclick="removerDoCarrinho('${item.id}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
cartTotal.innerHTML = `<strong>Total: R$ ${formatarPreco(total)}</strong>`;
|
||||
}
|
||||
|
||||
function removerDoCarrinho(produtoId) {
|
||||
const index = carrinho.findIndex(item => item.id === produtoId);
|
||||
if (index > -1) {
|
||||
carrinho.splice(index, 1);
|
||||
atualizarContadorCarrinho();
|
||||
renderizarCarrinho();
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizarPedido() {
|
||||
if (carrinho.length === 0) {
|
||||
mostrarNotificacao('Adicione produtos ao carrinho primeiro!');
|
||||
return;
|
||||
}
|
||||
|
||||
const total = carrinho.reduce((sum, item) => sum + (item.preco * item.quantidade), 0);
|
||||
const itens = carrinho.map(item => `${item.quantidade}x ${item.nome} - R$ ${formatarPreco(item.preco)}`).join('\n');
|
||||
|
||||
const mensagem = `🛍️ *Pedido Liberi Kids*\n\n${itens}\n\n💰 *Total: R$ ${formatarPreco(total)}*\n\nGostaria de finalizar este pedido!`;
|
||||
|
||||
try {
|
||||
// Tentar enviar via Evolution API primeiro
|
||||
await enviarMensagemEvolution(mensagem);
|
||||
mostrarNotificacao('Pedido enviado via WhatsApp!');
|
||||
|
||||
// Limpar carrinho após envio
|
||||
carrinho = [];
|
||||
atualizarContadorCarrinho();
|
||||
renderizarCarrinho();
|
||||
toggleCart();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao enviar via Evolution API:', error);
|
||||
|
||||
// Fallback para WhatsApp Web
|
||||
const mensagemCodificada = encodeURIComponent(mensagem);
|
||||
const urlWhatsApp = `https://wa.me/${CONFIG.WHATSAPP_NUMBER}?text=${mensagemCodificada}`;
|
||||
window.open(urlWhatsApp, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
async function enviarMensagemEvolution(mensagem) {
|
||||
const response = await fetch(`${CONFIG.EVOLUTION_API.BASE_URL}/message/sendText/${CONFIG.EVOLUTION_API.INSTANCE_NAME}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'apikey': CONFIG.EVOLUTION_API.API_KEY
|
||||
},
|
||||
body: JSON.stringify({
|
||||
number: CONFIG.WHATSAPP_NUMBER,
|
||||
text: mensagem
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Falha ao enviar mensagem via Evolution API');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
1033
site/styles.css
Normal file
1033
site/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
15
sql/add-description-field.sql
Normal file
15
sql/add-description-field.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- =====================================================
|
||||
-- ADICIONAR CAMPO DESCRIÇÃO NA TABELA PRODUTOS
|
||||
-- =====================================================
|
||||
|
||||
-- Adicionar campo descrição na tabela produtos
|
||||
ALTER TABLE produtos ADD COLUMN IF NOT EXISTS descricao TEXT;
|
||||
|
||||
-- Comentário para documentar o campo
|
||||
COMMENT ON COLUMN produtos.descricao IS 'Descrição detalhada do produto, pode ser gerada automaticamente via ChatGPT';
|
||||
|
||||
-- Verificar se o campo foi adicionado
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'produtos'
|
||||
AND column_name = 'descricao';
|
||||
22
sql/add-id-cliente-column.sql
Normal file
22
sql/add-id-cliente-column.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- Adicionar coluna id_cliente na tabela clientes (numérico)
|
||||
ALTER TABLE clientes ADD COLUMN IF NOT EXISTS id_cliente VARCHAR(10);
|
||||
|
||||
-- Criar índice para melhor performance
|
||||
CREATE INDEX IF NOT EXISTS idx_clientes_id_cliente ON clientes(id_cliente);
|
||||
|
||||
-- Gerar IDs numéricos sequenciais para clientes existentes (se houver)
|
||||
WITH numbered_clients AS (
|
||||
SELECT id, ROW_NUMBER() OVER (ORDER BY created_at) as row_num
|
||||
FROM clientes
|
||||
WHERE id_cliente IS NULL
|
||||
)
|
||||
UPDATE clientes
|
||||
SET id_cliente = numbered_clients.row_num::TEXT
|
||||
FROM numbered_clients
|
||||
WHERE clientes.id = numbered_clients.id;
|
||||
|
||||
-- Tornar a coluna NOT NULL após popular os dados existentes
|
||||
ALTER TABLE clientes ALTER COLUMN id_cliente SET NOT NULL;
|
||||
|
||||
-- Adicionar constraint de unicidade
|
||||
ALTER TABLE clientes ADD CONSTRAINT unique_id_cliente UNIQUE (id_cliente);
|
||||
13
sql/add-id-venda-column.sql
Normal file
13
sql/add-id-venda-column.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Adicionar coluna id_venda na tabela vendas
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS id_venda VARCHAR(20);
|
||||
|
||||
-- Adicionar coluna data_primeiro_vencimento na tabela vendas
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS data_primeiro_vencimento DATE;
|
||||
|
||||
-- Criar índice para busca rápida por ID da venda
|
||||
CREATE INDEX IF NOT EXISTS idx_vendas_id_venda ON vendas(id_venda);
|
||||
|
||||
-- Atualizar vendas existentes com ID gerado (usando created_at se existir)
|
||||
UPDATE vendas
|
||||
SET id_venda = 'VD' || TO_CHAR(COALESCE(created_at, NOW()), 'YYYYMMDDHH24MISS')
|
||||
WHERE id_venda IS NULL;
|
||||
17
sql/add-pix-fields.sql
Normal file
17
sql/add-pix-fields.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Adicionar campos PIX na tabela vendas
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS status_pagamento VARCHAR(20) DEFAULT 'pendente';
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS data_pagamento TIMESTAMP;
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS pix_payment_id VARCHAR(100);
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS pix_qr_code TEXT;
|
||||
ALTER TABLE vendas ADD COLUMN IF NOT EXISTS metodo_pagamento VARCHAR(20) DEFAULT 'dinheiro';
|
||||
|
||||
-- Criar índices para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_vendas_status_pagamento ON vendas(status_pagamento);
|
||||
CREATE INDEX IF NOT EXISTS idx_vendas_pix_payment_id ON vendas(pix_payment_id);
|
||||
|
||||
-- Comentários para documentação
|
||||
COMMENT ON COLUMN vendas.status_pagamento IS 'Status do pagamento: pendente, pago, cancelado, expirado';
|
||||
COMMENT ON COLUMN vendas.data_pagamento IS 'Data e hora da confirmação do pagamento';
|
||||
COMMENT ON COLUMN vendas.pix_payment_id IS 'ID do pagamento no Mercado Pago';
|
||||
COMMENT ON COLUMN vendas.pix_qr_code IS 'Código PIX para copiar e colar';
|
||||
COMMENT ON COLUMN vendas.metodo_pagamento IS 'Método: dinheiro, cartao, pix, transferencia';
|
||||
11
sql/add-valor-custo-produtos.sql
Normal file
11
sql/add-valor-custo-produtos.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Adicionar coluna valor_custo na tabela produtos para controle de custos
|
||||
ALTER TABLE produtos ADD COLUMN IF NOT EXISTS valor_custo DECIMAL(10,2) DEFAULT 0;
|
||||
|
||||
-- Comentário para documentação
|
||||
COMMENT ON COLUMN produtos.valor_custo IS 'Valor de custo do produto para cálculo de lucro real';
|
||||
|
||||
-- Atualizar produtos existentes com valor de custo baseado no valor de compra (se existir)
|
||||
-- Se não existir valor_compra, usar 60% do valor_revenda como estimativa
|
||||
UPDATE produtos
|
||||
SET valor_custo = COALESCE(valor_compra, valor_revenda * 0.6)
|
||||
WHERE valor_custo = 0 OR valor_custo IS NULL;
|
||||
37
sql/alter-despesas-text-fields.sql
Normal file
37
sql/alter-despesas-text-fields.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- =====================================================
|
||||
-- ALTERAÇÃO DA TABELA DESPESAS PARA CAMPOS LIVRES
|
||||
-- =====================================================
|
||||
|
||||
-- Adicionar novos campos de texto livre
|
||||
ALTER TABLE despesas ADD COLUMN IF NOT EXISTS tipo_nome TEXT;
|
||||
ALTER TABLE despesas ADD COLUMN IF NOT EXISTS fornecedor_nome TEXT;
|
||||
|
||||
-- Migrar dados existentes (se houver)
|
||||
UPDATE despesas
|
||||
SET tipo_nome = (
|
||||
SELECT nome FROM tipos_despesas
|
||||
WHERE tipos_despesas.id = despesas.tipo_despesa_id
|
||||
)
|
||||
WHERE tipo_despesa_id IS NOT NULL AND tipo_nome IS NULL;
|
||||
|
||||
UPDATE despesas
|
||||
SET fornecedor_nome = (
|
||||
SELECT razao_social FROM fornecedores
|
||||
WHERE fornecedores.id = despesas.fornecedor_id
|
||||
)
|
||||
WHERE fornecedor_id IS NOT NULL AND fornecedor_nome IS NULL;
|
||||
|
||||
-- Remover as constraints das chaves estrangeiras (se existirem)
|
||||
ALTER TABLE despesas DROP CONSTRAINT IF EXISTS despesas_tipo_despesa_id_fkey;
|
||||
ALTER TABLE despesas DROP CONSTRAINT IF EXISTS despesas_fornecedor_id_fkey;
|
||||
|
||||
-- Tornar os campos antigos opcionais (para compatibilidade)
|
||||
ALTER TABLE despesas ALTER COLUMN tipo_despesa_id DROP NOT NULL;
|
||||
|
||||
-- Criar índice nos novos campos para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_despesas_tipo_nome ON despesas(tipo_nome);
|
||||
CREATE INDEX IF NOT EXISTS idx_despesas_fornecedor_nome ON despesas(fornecedor_nome);
|
||||
|
||||
-- Comentários para documentação
|
||||
COMMENT ON COLUMN despesas.tipo_nome IS 'Nome do tipo de despesa (campo livre)';
|
||||
COMMENT ON COLUMN despesas.fornecedor_nome IS 'Nome do fornecedor (campo livre)';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user