Primeiro commit
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user