chore: sincroniza projeto para gitea

This commit is contained in:
Tiago
2025-11-29 21:31:52 -03:00
parent 33d8645eb4
commit 7e7a0f8867
129 changed files with 24999 additions and 6757 deletions

124
client/package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "liberi-kids-client",
"version": "1.0.0",
"dependencies": {
"@supabase/supabase-js": "^2.75.0",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.5.2",
@@ -3991,6 +3992,123 @@
"@sinonjs/commons": "^1.7.0"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.75.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.75.0.tgz",
"integrity": "sha512-J8TkeqCOMCV4KwGKVoxmEBuDdHRwoInML2vJilthOo7awVCro2SM+tOcpljORwuBQ1vHUtV62Leit+5wlxrNtw==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "2.6.15"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.75.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.75.0.tgz",
"integrity": "sha512-18yk07Moj/xtQ28zkqswxDavXC3vbOwt1hDuYM3/7xPnwwpKnsmPyZ7bQ5th4uqiJzQ135t74La9tuaxBR6e7w==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "2.6.15"
}
},
"node_modules/@supabase/node-fetch": {
"version": "2.6.15",
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
}
},
"node_modules/@supabase/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/@supabase/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/@supabase/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "2.75.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.75.0.tgz",
"integrity": "sha512-YfBz4W/z7eYCFyuvHhfjOTTzRrQIvsMG2bVwJAKEVVUqGdzqfvyidXssLBG0Fqlql1zJFgtsPpK1n4meHrI7tg==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "2.6.15"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.75.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.75.0.tgz",
"integrity": "sha512-B4Xxsf2NHd5cEnM6MGswOSPSsZKljkYXpvzKKmNxoUmNQOfB7D8HOa6NwHcUBSlxcjV+vIrYKcYXtavGJqeGrw==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "2.6.15",
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"ws": "^8.18.2"
}
},
"node_modules/@supabase/realtime-js/node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@supabase/storage-js": {
"version": "2.75.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.75.0.tgz",
"integrity": "sha512-wpJMYdfFDckDiHQaTpK+Ib14N/O2o0AAWWhguKvmmMurB6Unx17GGmYp5rrrqCTf8S1qq4IfIxTXxS4hzrUySg==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "2.6.15"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.75.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.75.0.tgz",
"integrity": "sha512-8UN/vATSgS2JFuJlMVr51L3eUDz+j1m7Ww63wlvHLKULzCDaVWYzvacCjBTLW/lX/vedI2LBI4Vg+01G9ufsJQ==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.75.0",
"@supabase/functions-js": "2.75.0",
"@supabase/node-fetch": "2.6.15",
"@supabase/postgrest-js": "2.75.0",
"@supabase/realtime-js": "2.75.0",
"@supabase/storage-js": "2.75.0"
}
},
"node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@@ -4707,6 +4825,12 @@
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT"
},
"node_modules/@types/phoenix": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
"license": "MIT"
},
"node_modules/@types/prettier": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz",

View File

@@ -3,18 +3,19 @@
"version": "1.0.0",
"private": true,
"dependencies": {
"@supabase/supabase-js": "^2.75.0",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.5.2",
"axios": "^1.5.0",
"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",
"react-icons": "^4.11.0",
"react-router-dom": "^6.15.0",
"react-scripts": "5.0.1",
"recharts": "^2.8.0",
"web-vitals": "^2.1.4"
},
"scripts": {

View File

@@ -994,9 +994,11 @@
}
.config-section {
background: white;
background-color: #ffffff;
background-image: none;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
margin-bottom: 24px;
overflow: hidden;
}
@@ -1005,14 +1007,17 @@
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s ease;
padding: 24px;
background-color: #ffffff;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
}
.config-header:hover {
background-color: #f8fafc;
border-radius: 8px;
padding: 8px;
margin: -8px;
border-radius: 0;
padding: 24px;
margin: 0;
box-shadow: inset 0 -1px 0 rgba(226, 232, 240, 0.8);
}
.config-controls {
@@ -1120,6 +1125,15 @@
.config-form {
padding: 24px;
background-color: #ffffff;
border-top: 1px solid #f1f5f9;
}
.config-info-text {
font-size: 14px;
color: #475569;
margin-bottom: 16px;
line-height: 1.5;
}
.form-grid {
@@ -2080,7 +2094,7 @@
}
/* =====================================================
GOOGLE SHEETS STYLES
ESTILOS DA PÁGINA DE CONFIGURAÇÕES
===================================================== */
.config-status {
@@ -2224,7 +2238,7 @@
left: 0;
}
/* Responsivo para Google Sheets */
/* Responsivo para seções de exportação */
@media (max-width: 768px) {
.export-buttons {
flex-direction: column;
@@ -2491,9 +2505,10 @@
.image-modal {
background: white;
border-radius: 12px;
max-width: 90vw;
max-height: 90vh;
width: 800px;
max-width: 95vw;
max-height: 95vh;
width: 900px;
height: 700px;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -2534,8 +2549,8 @@
.image-modal-content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.image-container {
@@ -2545,7 +2560,8 @@
align-items: center;
justify-content: center;
background: #f3f4f6;
min-height: 400px;
min-height: 0;
overflow: hidden;
}
.modal-image {
@@ -2587,9 +2603,14 @@
}
.image-details {
width: 280px;
padding: 20px;
background: #f9fafb;
border-top: 1px solid #e5e7eb;
border-left: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
}
.image-details h4 {
@@ -2654,6 +2675,161 @@
text-align: center;
}
/* Operações agrupadas nas vendas */
.operacoes-badge {
margin-top: 4px;
}
.operacoes-badge .badge {
font-size: 10px;
padding: 2px 6px;
}
.agrupamento-info {
background: #e3f2fd;
border: 1px solid #90caf9;
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
}
.agrupamento-info .info-text {
margin: 0;
color: #1565c0;
font-size: 14px;
}
.operacoes-agrupadas-view {
display: flex;
flex-direction: column;
gap: 16px;
}
.operacao-agrupada-item {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 16px;
border-left: 4px solid #007bff;
}
.operacao-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e9ecef;
}
.operacao-tipo {
display: flex;
align-items: center;
gap: 8px;
}
.operacao-id {
font-size: 12px;
color: #6c757d;
background: #e9ecef;
padding: 2px 6px;
border-radius: 4px;
}
.operacao-data {
font-size: 12px;
color: #6c757d;
}
.operacao-detalhes {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.operacao-detalhes > div {
font-size: 14px;
}
.itens-operacao h5 {
margin: 0 0 8px 0;
font-size: 14px;
color: #495057;
}
.itens-lista {
display: flex;
flex-direction: column;
gap: 8px;
}
.item-operacao {
background: white;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 8px;
}
.item-nome {
font-weight: 500;
margin-bottom: 4px;
}
.item-detalhes {
display: flex;
gap: 12px;
font-size: 12px;
color: #6c757d;
}
.item-detalhes span {
background: #f8f9fa;
padding: 2px 6px;
border-radius: 3px;
}
/* Seções de configuração de alertas */
.alert-config-section {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.alert-config-section h4 {
margin: 0 0 16px 0;
color: #1f2937;
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.form-help-section {
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
padding: 16px;
margin-top: 20px;
}
.form-help-section .form-help {
margin: 0;
color: #0369a1;
}
.form-help-section code {
background: #dbeafe;
color: #1e40af;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
@media (max-width: 768px) {
.description-input-group {
flex-direction: column;

View File

@@ -9,8 +9,10 @@ import Clientes from './pages/Clientes';
import Fornecedores from './pages/Fornecedores';
import Despesas from './pages/Despesas';
import Vendas from './pages/Vendas';
import PedidosCatalogo from './pages/PedidosCatalogo';
import Devolucoes from './pages/Devolucoes';
import Emprestimos from './pages/Emprestimos';
import SiteCatalogo from './pages/SiteCatalogo';
import Configuracoes from './pages/Configuracoes';
import './App.css';
@@ -37,8 +39,10 @@ function App() {
<Route path="/fornecedores" element={<Fornecedores />} />
<Route path="/despesas" element={<Despesas />} />
<Route path="/vendas" element={<Vendas />} />
<Route path="/pedidos" element={<PedidosCatalogo />} />
<Route path="/devolucoes" element={<Devolucoes />} />
<Route path="/emprestimos" element={<Emprestimos />} />
<Route path="/site/catalogo" element={<SiteCatalogo />} />
<Route path="/configuracoes" element={<Configuracoes />} />
</Routes>
</Layout>

View File

@@ -2,6 +2,61 @@ import React, { useState, useEffect, useRef } from 'react';
import { FiX, FiSend, FiPhone, FiMessageCircle } from 'react-icons/fi';
import './ChatWhatsApp.css';
const ZONA_HORARIA_BRASIL = 'America/Sao_Paulo';
const parseDate = (valor) => {
if (!valor) return null;
if (valor instanceof Date && !isNaN(valor)) {
return valor;
}
if (typeof valor === 'number') {
const date = new Date(valor);
return isNaN(date) ? null : date;
}
if (typeof valor === 'string') {
const normalizada = valor.replace(' ', 'T');
let date = new Date(normalizada);
if (!isNaN(date)) {
return date;
}
const partes = normalizada.split('-');
if (partes.length === 3) {
const [ano, mes, dia] = partes.map(Number);
if (ano && mes && dia) {
date = new Date(ano, mes - 1, dia);
if (!isNaN(date)) {
return date;
}
}
}
}
return null;
};
const formatarDataBrasil = (valor) => {
const data = parseDate(valor);
if (!data) return 'Data inválida';
return new Intl.DateTimeFormat('pt-BR', {
timeZone: ZONA_HORARIA_BRASIL
}).format(data);
};
const formatarHoraBrasil = (valor) => {
const data = parseDate(valor);
if (!data) return '--:--';
return new Intl.DateTimeFormat('pt-BR', {
timeZone: ZONA_HORARIA_BRASIL,
hour: '2-digit',
minute: '2-digit'
}).format(data);
};
const ChatWhatsApp = ({ isOpen, onClose, cliente }) => {
const [mensagens, setMensagens] = useState([]);
const [novaMensagem, setNovaMensagem] = useState('');
@@ -102,16 +157,6 @@ const ChatWhatsApp = ({ isOpen, onClose, cliente }) => {
}
};
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;
@@ -154,13 +199,13 @@ const ChatWhatsApp = ({ isOpen, onClose, cliente }) => {
<>
{mensagens.map((mensagem, index) => {
const showDate = index === 0 ||
formatarData(mensagem.created_at) !== formatarData(mensagens[index - 1].created_at);
formatarDataBrasil(mensagem.created_at) !== formatarDataBrasil(mensagens[index - 1].created_at);
return (
<React.Fragment key={mensagem.id || index}>
{showDate && (
<div className="chat-date-divider">
{formatarData(mensagem.created_at)}
{formatarDataBrasil(mensagem.created_at)}
</div>
)}
<div className={`chat-message ${mensagem.tipo}`}>
@@ -168,7 +213,7 @@ const ChatWhatsApp = ({ isOpen, onClose, cliente }) => {
<p>{mensagem.mensagem}</p>
<div className="message-info">
<span className="message-time">
{formatarHora(mensagem.created_at)}
{formatarHoraBrasil(mensagem.created_at)}
</span>
{mensagem.tipo === 'enviada' && (
<span className={`message-status ${mensagem.status}`}>

View File

@@ -183,13 +183,15 @@
/* Desktop */
@media (min-width: 1024px) {
.sidebar {
position: static;
transform: translateX(0);
width: 280px;
}
.main-content {
margin-left: 0;
margin-left: 280px;
}
.sidebar-overlay {
display: none !important;
}
.menu-toggle {

View File

@@ -13,7 +13,9 @@ import {
FiX,
FiWifi,
FiWifiOff,
FiCreditCard
FiCreditCard,
FiGlobe,
FiList
} from 'react-icons/fi';
import './Layout.css';
@@ -28,8 +30,10 @@ const Layout = ({ children }) => {
{ path: '/fornecedores', icon: FiTruck, label: 'Fornecedores' },
{ path: '/despesas', icon: FiDollarSign, label: 'Despesas' },
{ path: '/vendas', icon: FiShoppingCart, label: 'Vendas' },
{ path: '/pedidos', icon: FiList, label: 'Pedidos' },
{ path: '/devolucoes', icon: FiRotateCcw, label: 'Devolução/Troca' },
{ path: '/emprestimos', icon: FiCreditCard, label: 'Empréstimos' },
{ path: '/site/catalogo', icon: FiGlobe, label: 'Site / Catalogo' },
{ path: '/configuracoes', icon: FiSettings, label: 'Configurações' },
];
@@ -107,6 +111,7 @@ const Layout = ({ children }) => {
<div className="header-title">
<h1>Sistema de Controle de Estoque</h1>
<p>Liberi Kids - Moda Infantil</p>
<p style={{ fontSize: '0.85em', opacity: 0.7, marginTop: '-8px' }}>v1.0.0</p>
</div>
<div className="header-actions">

View File

@@ -0,0 +1,200 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = 'https://ydhzylfnpqlxnzfcclla.supabase.co'
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkaHp5bGZucHFseG56ZmNjbGxhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjA1NDA1NjIsImV4cCI6MjA3NjExNjU2Mn0.gIHxyAYngqkJ8z2Gt5ESYmG605vhY_LGTQB7Cjp4ZTA'
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
// Configurações dos buckets
export const STORAGE_BUCKETS = {
PRODUTOS: 'produtos',
CATALOGO: 'catalogo'
}
// Função para fazer upload de imagem
export const uploadImage = async (file, bucket, path) => {
try {
const fileExt = file.name.split('.').pop()
const fileName = `${Date.now()}_${Math.random().toString(36).substring(2)}.${fileExt}`
const filePath = path ? `${path}/${fileName}` : fileName
const { data, error } = await supabase.storage
.from(bucket)
.upload(filePath, file)
if (error) throw error
// Retornar URL pública
const { data: publicData } = supabase.storage
.from(bucket)
.getPublicUrl(filePath)
return {
success: true,
path: filePath,
url: publicData.publicUrl
}
} catch (error) {
console.error('Erro ao fazer upload:', error)
return {
success: false,
error: error.message
}
}
}
// Função para deletar imagem
export const deleteImage = async (bucket, path) => {
try {
const { error } = await supabase.storage
.from(bucket)
.remove([path])
if (error) throw error
return { success: true }
} catch (error) {
console.error('Erro ao deletar imagem:', error)
return {
success: false,
error: error.message
}
}
}
// Função para obter URL pública
export const getPublicUrl = (bucket, path) => {
const { data } = supabase.storage
.from(bucket)
.getPublicUrl(path)
return data.publicUrl
}
// Função para login por telefone (WhatsApp)
export const loginWithPhone = async (phone, password) => {
try {
// Primeiro, verificar se o cliente existe
const { data: cliente, error: clienteError } = await supabase
.from('clientes')
.select('*')
.eq('whatsapp', phone)
.single()
if (clienteError || !cliente) {
throw new Error('Cliente não encontrado')
}
// Para simplificar, vamos usar o ID do cliente como "senha"
// Em produção, você deve implementar um sistema de autenticação mais robusto
const { data, error } = await supabase.auth.signInWithPassword({
email: `${phone}@catalogo.local`, // Email fictício baseado no telefone
password: password || cliente.id // Usar ID como senha temporária
})
if (error) throw error
return {
success: true,
user: data.user,
cliente: cliente
}
} catch (error) {
console.error('Erro no login:', error)
return {
success: false,
error: error.message
}
}
}
// Função para registrar cliente
export const registerClient = async (clienteData) => {
try {
// Inserir cliente na tabela
const { data: cliente, error: clienteError } = await supabase
.from('clientes')
.insert([clienteData])
.select()
.single()
if (clienteError) throw clienteError
// Criar usuário de autenticação
const { data: authData, error: authError } = await supabase.auth.signUp({
email: `${clienteData.whatsapp}@catalogo.local`,
password: cliente.id, // Usar ID como senha temporária
options: {
data: {
cliente_id: cliente.id,
nome: clienteData.nome_completo,
whatsapp: clienteData.whatsapp
}
}
})
if (authError) throw authError
return {
success: true,
cliente: cliente,
user: authData.user
}
} catch (error) {
console.error('Erro no registro:', error)
return {
success: false,
error: error.message
}
}
}
// Função para logout
export const logout = async () => {
try {
const { error } = await supabase.auth.signOut()
if (error) throw error
return { success: true }
} catch (error) {
console.error('Erro no logout:', error)
return {
success: false,
error: error.message
}
}
}
// Função para obter usuário atual
export const getCurrentUser = async () => {
try {
const { data: { user }, error } = await supabase.auth.getUser()
if (error) throw error
if (user) {
// Buscar dados completos do cliente
const { data: cliente, error: clienteError } = await supabase
.from('clientes')
.select('*')
.eq('id', user.user_metadata.cliente_id)
.single()
if (clienteError) throw clienteError
return {
success: true,
user: user,
cliente: cliente
}
}
return { success: true, user: null, cliente: null }
} catch (error) {
console.error('Erro ao obter usuário:', error)
return {
success: false,
error: error.message
}
}
}
export default supabase

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,26 @@ import { dashboardAPI, clientesAPI, despesasAPI, fornecedoresAPI } from '../serv
import toast from 'react-hot-toast';
const Dashboard = () => {
const enviarWhatsAppAutomatico = async ({ telefone, mensagem }) => {
if (!telefone || !mensagem) return false;
try {
const response = await fetch('/api/chat/enviar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ telefone, mensagem, clienteNome: 'Cliente' })
});
const data = await response.json();
if (!response.ok) {
console.error('Erro ao enviar WhatsApp automático:', data.error);
return false;
}
return true;
} catch (error) {
console.error('Erro ao enviar WhatsApp automático:', error);
return false;
}
};
const [dashboardData, setDashboardData] = useState({
contabilidade: {
receitaBruta: 0,
@@ -78,6 +98,8 @@ const Dashboard = () => {
const [fornecedores, setFornecedores] = useState([]);
const [tiposDespesas, setTiposDespesas] = useState([]);
const [clientes, setClientes] = useState([]);
const [vendasPrazo, setVendasPrazo] = useState([]);
const [loadingVendasPrazo, setLoadingVendasPrazo] = useState(false);
useEffect(() => {
carregarDashboard();
@@ -123,23 +145,21 @@ const Dashboard = () => {
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
})
});
const telefone = (venda?.cliente_whatsapp || venda?.cliente_telefone || '').replace(/\D/g, '');
if (!telefone) {
toast.error('Cliente sem telefone/whatsapp cadastrado');
return;
}
if (response.ok) {
const valorFinal = parseFloat(venda.valor_total) - parseFloat(venda.desconto || 0);
const valorFormatado = valorFinal.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
const mensagem = `Olá ${venda.cliente_nome || ''}! Lembramos que sua parcela de ${valorFormatado} está próxima do vencimento. Entre em contato conosco se precisar de ajuda.`;
const enviado = await enviarWhatsAppAutomatico({ telefone, mensagem });
if (enviado) {
toast.success('Mensagem enviada com sucesso!');
} else {
const error = await response.json();
toast.error(error.message || 'Erro ao enviar mensagem');
toast.error('Não foi possível enviar a mensagem.');
}
} catch (error) {
console.error('Erro ao enviar WhatsApp:', error);
@@ -182,6 +202,31 @@ const Dashboard = () => {
{ name: 'Meia Estação', value: 20, color: '#f093fb' },
];
const contabilidade = dashboardData?.contabilidade || {};
const resumoFinanceiro = dashboardData?.resumoFinanceiro || {};
const emprestimosResumo = dashboardData?.emprestimos || {};
const vendasPrazoResumo = dashboardData?.vendasPrazo || {};
const parcelasResumo = dashboardData?.parcelasPendentes || {};
const estatisticasResumo = dashboardData?.estatisticas || {};
const formatCurrency = (valor) =>
Number(valor || 0).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL'
});
const stats = {
totalProdutos: { count: estatisticasResumo.totalProdutos || 0 },
totalClientes: { count: estatisticasResumo.totalClientes || 0 },
totalFornecedores: { count: estatisticasResumo.totalFornecedores || 0 },
vendasMes: {
total: resumoFinanceiro.receitasMes || 0,
count: resumoFinanceiro.totalVendas || 0
},
estoqueTotal: { total: estatisticasResumo.estoqueTotal || 0 },
despesasMes: { total: contabilidade.totalDespesas || 0 }
};
const estatisticas = [
{
title: 'Total de Produtos',
@@ -230,7 +275,7 @@ const Dashboard = () => {
},
{
title: 'Faturamento Mensal',
value: `R$ ${(stats.vendasMes?.total || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}`,
value: formatCurrency(resumoFinanceiro.receitasMes),
icon: FiDollarSign,
color: '#06b6d4',
bgColor: '#ecfeff',
@@ -262,19 +307,19 @@ const Dashboard = () => {
<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 })}
{formatCurrency(resumoFinanceiro.receitasMes)}
</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 })}
{formatCurrency(resumoFinanceiro.despesasMes || contabilidade.totalDespesas)}
</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 })}
{formatCurrency(resumoFinanceiro.lucroEstimado ?? contabilidade.lucroReal)}
</span>
</div>
</div>
@@ -297,6 +342,11 @@ const Dashboard = () => {
</div>
<div className="vendas-prazo-content">
<div className="vendas-prazo-stats">
<span>Total pendente: {formatCurrency(vendasPrazoResumo.total)}</span>
<span>Vendas: {vendasPrazoResumo.quantidade || 0}</span>
<span>Parcelas pendentes: {parcelasResumo.quantidade || 0}</span>
</div>
{loadingVendasPrazo ? (
<div className="loading-vendas">Carregando vendas...</div>
) : vendasPrazo.length === 0 ? (
@@ -349,6 +399,48 @@ const Dashboard = () => {
)}
</div>
</div>
<div className="summary-card">
<h3>Empréstimos</h3>
<div className="summary-items">
<div className="summary-item">
<span className="summary-label">Em aberto:</span>
<span className="summary-value negative">
{formatCurrency(emprestimosResumo.totalAberto)}
</span>
</div>
<div className="summary-item">
<span className="summary-label">Quitado:</span>
<span className="summary-value positive">
{formatCurrency(emprestimosResumo.totalQuitado)}
</span>
</div>
<div className="summary-item">
<span className="summary-label">Contratos:</span>
<span className="summary-value">
{emprestimosResumo.quantidade || 0}
</span>
</div>
</div>
</div>
</div>
<div className="stats-grid">
{estatisticas.map((item) => (
<div key={item.title} className="stat-card" style={{ backgroundColor: item.bgColor }}>
<div className="stat-icon" style={{ color: item.color }}>
<item.icon size={22} />
</div>
<div className="stat-content">
<span className="stat-title">{item.title}</span>
<span className="stat-value">{item.value}</span>
<span className={`stat-trend ${item.trendUp ? 'up' : 'down'}`}>
{item.trend}
</span>
</div>
</div>
))}
</div>

View File

@@ -22,8 +22,35 @@ const Fornecedores = () => {
const [showModal, setShowModal] = useState(false);
const [editingSupplier, setEditingSupplier] = useState(null);
const formatFornecedor = (fornecedor) => {
if (!fornecedor) return {
id: undefined,
nome: 'Fornecedor sem nome',
telefone: '',
whatsapp: '',
endereco: '',
email: ''
};
const nome = fornecedor.nome || fornecedor.razao_social || 'Fornecedor sem nome';
const telefone = fornecedor.telefone || fornecedor.whatsapp || '';
const whatsapp = fornecedor.whatsapp || fornecedor.telefone || '';
return {
...fornecedor,
nome,
telefone,
whatsapp,
endereco: fornecedor.endereco || '',
email: fornecedor.email || ''
};
};
const normalizeSearch = (value) => (value || '').toString().toLowerCase();
const [formData, setFormData] = useState({
razao_social: '',
nome: '',
telefone: '',
whatsapp: '',
endereco: '',
@@ -38,7 +65,8 @@ const Fornecedores = () => {
try {
setLoading(true);
const response = await fornecedoresAPI.listar();
setFornecedores(response.data);
const dados = (response.data || []).map(formatFornecedor);
setFornecedores(dados);
} catch (error) {
console.error('Erro ao carregar fornecedores:', error);
toast.error('Erro ao carregar fornecedores');
@@ -61,7 +89,7 @@ const Fornecedores = () => {
setShowModal(false);
setEditingSupplier(null);
setFormData({
razao_social: '',
nome: '',
telefone: '',
whatsapp: '',
endereco: '',
@@ -75,13 +103,14 @@ const Fornecedores = () => {
};
const handleEdit = (fornecedor) => {
setEditingSupplier(fornecedor);
const fornecedorFormatado = formatFornecedor(fornecedor);
setEditingSupplier(fornecedorFormatado);
setFormData({
razao_social: fornecedor.razao_social,
telefone: fornecedor.telefone || '',
whatsapp: fornecedor.whatsapp || '',
endereco: fornecedor.endereco || '',
email: fornecedor.email || ''
nome: fornecedorFormatado.nome,
telefone: fornecedorFormatado.telefone || '',
whatsapp: fornecedorFormatado.whatsapp || '',
endereco: fornecedorFormatado.endereco || '',
email: fornecedorFormatado.email || ''
});
setShowModal(true);
};
@@ -99,11 +128,18 @@ const Fornecedores = () => {
}
};
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))
);
const searchTermNormalized = normalizeSearch(searchTerm);
const filteredFornecedores = fornecedores.filter((fornecedor) => {
if (!searchTermNormalized) return true;
return (
normalizeSearch(fornecedor.nome).includes(searchTermNormalized) ||
normalizeSearch(fornecedor.email).includes(searchTermNormalized) ||
normalizeSearch(fornecedor.telefone).includes(searchTermNormalized) ||
normalizeSearch(fornecedor.whatsapp).includes(searchTermNormalized)
);
});
if (loading) {
return (
@@ -188,7 +224,7 @@ const Fornecedores = () => {
</div>
<div className="supplier-info">
<h3 className="supplier-name">{fornecedor.razao_social}</h3>
<h3 className="supplier-name">{fornecedor.nome}</h3>
{fornecedor.email && (
<div className="supplier-detail">
@@ -243,7 +279,7 @@ const Fornecedores = () => {
setShowModal(false);
setEditingSupplier(null);
setFormData({
razao_social: '',
nome: '',
telefone: '',
whatsapp: '',
endereco: '',
@@ -257,12 +293,12 @@ const Fornecedores = () => {
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Razão Social *</label>
<label className="form-label">Nome do Fornecedor *</label>
<input
type="text"
className="form-input"
value={formData.razao_social}
onChange={(e) => setFormData({...formData, razao_social: e.target.value})}
value={formData.nome}
onChange={(e) => setFormData({...formData, nome: e.target.value})}
required
/>
</div>

View File

@@ -0,0 +1,155 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
FiRefreshCw,
FiClipboard,
FiClock,
FiPhone,
FiMapPin,
FiPackage
} from 'react-icons/fi';
import toast from 'react-hot-toast';
import '../styles/pedidos-catalogo.css';
const formatarMoeda = (valor) => {
return (Number(valor) || 0).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL'
});
};
const formatarData = (dataISO) => {
if (!dataISO) return '-';
try {
return new Date(dataISO).toLocaleString('pt-BR', {
dateStyle: 'short',
timeStyle: 'short'
});
} catch (error) {
return dataISO;
}
};
const PedidosCatalogo = () => {
const [pedidos, setPedidos] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const carregarPedidos = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/catalogo/pedidos');
if (!response.ok) {
throw new Error('Erro ao carregar pedidos');
}
const data = await response.json();
setPedidos(Array.isArray(data) ? data : []);
} catch (err) {
console.error('Erro ao carregar pedidos:', err);
setError('Não foi possível carregar os pedidos.');
toast.error('Erro ao carregar pedidos do catálogo.');
} finally {
setLoading(false);
}
};
useEffect(() => {
carregarPedidos();
}, []);
const pedidosOrdenados = useMemo(() => {
return [...pedidos].sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
}, [pedidos]);
return (
<div className="pedidos-catalogo-page fade-in">
<div className="page-header">
<div>
<h1>Pedidos do Catálogo</h1>
<p>Pedidos registrados automaticamente através do catálogo online</p>
</div>
<button
type="button"
className="btn btn-secondary"
onClick={carregarPedidos}
disabled={loading}
>
<FiRefreshCw />
{loading ? 'Atualizando...' : 'Atualizar'}
</button>
</div>
{error && (
<div className="alert alert-error">
{error}
</div>
)}
{pedidosOrdenados.length === 0 && !loading ? (
<div className="empty-state">
<FiClipboard size={48} />
<p>Nenhum pedido registrado ainda</p>
<span>Assim que um cliente finalizar uma compra no catálogo, o pedido aparecerá aqui.</span>
</div>
) : (
<div className="pedidos-lista">
{pedidosOrdenados.map((pedido) => (
<div key={pedido.id} className="pedido-card">
<header className="pedido-card-header">
<div className="pedido-id">
<FiClipboard />
<span>{pedido.codigo || pedido.id || 'Pedido sem ID'}</span>
</div>
<div className="pedido-data">
<FiClock />
<span>{formatarData(pedido.createdAt)}</span>
</div>
</header>
<section className="pedido-cliente">
<div className="cliente-nome">{pedido.cliente?.nome || 'Cliente não informado'}</div>
<div className="cliente-info">
<span><FiPhone /> {pedido.cliente?.whatsapp || 'Sem contato'}</span>
{pedido.cliente?.endereco && (
<span><FiMapPin /> {pedido.cliente.endereco}</span>
)}
</div>
</section>
<section className="pedido-itens">
<h4>Itens</h4>
<div className="pedido-itens-lista">
{(pedido.itens || []).map((item, index) => (
<div key={`${pedido.id}-item-${index}`} className="pedido-item">
<div className="pedido-item-info">
<strong>{item.nome}</strong>
<div className="pedido-item-meta">
<span><FiPackage /> {item.codigo || 'Sem ID'}</span>
<span>Tam: {item.tamanho || '-'}</span>
<span>Cor: {item.cor || '-'}</span>
</div>
</div>
<div className="pedido-item-valores">
<span>{item.quantidade || 0} x {formatarMoeda(item.preco)}</span>
<strong>{formatarMoeda(item.subtotal)}</strong>
</div>
</div>
))}
</div>
</section>
<footer className="pedido-footer">
<div className="pedido-total">
<span>Total do pedido</span>
<strong>{formatarMoeda(pedido.total)}</strong>
</div>
</footer>
</div>
))}
</div>
)}
</div>
);
};
export default PedidosCatalogo;

View File

@@ -14,6 +14,13 @@ import { produtosAPI, fornecedoresAPI } from '../services/api';
import ViewToggle from '../components/ViewToggle';
import toast from 'react-hot-toast';
const BASE_MEDIA_URL = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:5000';
const resolveImageUrl = (url) => {
if (!url) return null;
return url.startsWith('http') ? url : `${BASE_MEDIA_URL}${url}`;
};
const Produtos = () => {
const [produtos, setProdutos] = useState([]);
const [fornecedores, setFornecedores] = useState([]);
@@ -237,15 +244,7 @@ const Produtos = () => {
try {
console.log('Iniciando envio do produto...', formData);
// Verificar se Google Drive está configurado
const googleDriveStatus = await fetch('/api/google-drive/status');
const driveStatusData = await googleDriveStatus.json();
const useGoogleDrive = driveStatusData.status === 'connected';
if (useGoogleDrive) {
console.log('🔄 Usando Google Drive para armazenar fotos');
toast.loading('Enviando fotos para Google Drive...', { id: 'upload-drive' });
}
// Usar armazenamento local
const formDataToSend = new FormData();
formDataToSend.append('id_produto', formData.id_produto || '');
@@ -257,7 +256,6 @@ const Produtos = () => {
formDataToSend.append('fornecedor_id', formData.fornecedor_id || '');
formDataToSend.append('valor_compra', formData.valor_compra);
formDataToSend.append('valor_revenda', formData.valor_revenda);
formDataToSend.append('use_google_drive', useGoogleDrive.toString());
// Adicionar dados das variações apenas para criação
if (!editingProduct) {
@@ -289,11 +287,7 @@ const Produtos = () => {
const response = await produtosAPI.criarComFoto(formDataToSend);
console.log('Resposta do servidor:', response);
if (useGoogleDrive) {
toast.success('Produto criado e fotos salvas no Google Drive!', { id: 'upload-drive' });
} else {
toast.success('Produto criado com sucesso!');
}
toast.success('Produto criado com sucesso!');
}
setShowModal(false);
@@ -310,7 +304,7 @@ const Produtos = () => {
errorMessage = error.message;
}
toast.error(errorMessage, { id: 'upload-drive' });
toast.error(errorMessage);
}
};
@@ -376,7 +370,7 @@ const Produtos = () => {
// Adicionar foto principal se existir
if (produto.foto_principal_url) {
images.push({
url: produto.foto_principal_url,
url: resolveImageUrl(produto.foto_principal_url),
title: `${produto.marca} - ${produto.nome}`,
type: 'principal'
});
@@ -384,10 +378,21 @@ const Produtos = () => {
// Adicionar fotos das variações
if (produto.produto_variacoes && produto.produto_variacoes.length > 0) {
produto.produto_variacoes.forEach((variacao, index) => {
if (variacao.foto_url) {
produto.produto_variacoes.forEach((variacao) => {
const fotosVariacao = Array.isArray(variacao.fotos) ? variacao.fotos : [];
if (fotosVariacao.length > 0) {
fotosVariacao.forEach((fotoUrl, index) => {
images.push({
url: resolveImageUrl(fotoUrl),
title: `${produto.marca} - ${produto.nome} (${variacao.tamanho} - ${variacao.cor})` + (fotosVariacao.length > 1 ? ` - Foto ${index + 1}` : ''),
type: 'variacao',
variacao: variacao
});
});
} else if (variacao.foto_url) {
images.push({
url: variacao.foto_url,
url: resolveImageUrl(variacao.foto_url),
title: `${produto.marca} - ${produto.nome} (${variacao.tamanho} - ${variacao.cor})`,
type: 'variacao',
variacao: variacao
@@ -658,7 +663,7 @@ const Produtos = () => {
>
{produto.foto_principal_url || (produto.produto_variacoes && produto.produto_variacoes.find(v => v.foto_url)) ? (
<img
src={produto.foto_principal_url || produto.produto_variacoes.find(v => v.foto_url)?.foto_url}
src={resolveImageUrl(produto.foto_principal_url || produto.produto_variacoes.find(v => v.foto_url)?.foto_url)}
alt={produto.nome}
className="product-thumbnail"
onError={(e) => {
@@ -845,7 +850,7 @@ const Produtos = () => {
<option value="">Selecione um fornecedor</option>
{fornecedores.map((fornecedor) => (
<option key={fornecedor.id} value={fornecedor.id}>
{fornecedor.razao_social}
{fornecedor.nome}
</option>
))}
</select>
@@ -1142,7 +1147,7 @@ const Produtos = () => {
{variacao.foto_url && (
<div className="variacao-image">
<img
src={`http://localhost:5000${variacao.foto_url}`}
src={resolveImageUrl(variacao.foto_url)}
alt={`${variacao.cor} - ${variacao.tamanho}`}
onError={(e) => {
e.target.style.display = 'none';
@@ -1226,34 +1231,43 @@ const Produtos = () => {
</div>
)}
{selectedImages[currentImageIndex]?.type === 'variacao' && (
<div className="image-details">
<h4>Detalhes da Variação:</h4>
<p><strong>Tamanho:</strong> {selectedImages[currentImageIndex].variacao.tamanho}</p>
<p><strong>Cor:</strong> {selectedImages[currentImageIndex].variacao.cor}</p>
<p><strong>Quantidade:</strong> {selectedImages[currentImageIndex].variacao.quantidade}</p>
</div>
)}
{selectedImages[currentImageIndex]?.type === 'placeholder' && (
<div className="image-details">
<h4>📷 Sem Imagens</h4>
<p>Este produto ainda não possui imagens cadastradas.</p>
<p>Para adicionar imagens, edite o produto e faça upload das fotos nas variações.</p>
<div style={{ marginTop: '16px' }}>
<button
className="btn btn-primary"
onClick={() => {
setShowImageModal(false);
handleEdit(selectedProduct);
}}
>
<FiEdit />
Editar Produto e Adicionar Fotos
</button>
</div>
</div>
)}
<div className="image-details">
{selectedImages[currentImageIndex]?.type === 'variacao' && (
<>
<h4>Detalhes da Variação:</h4>
<p><strong>Tamanho:</strong> {selectedImages[currentImageIndex].variacao.tamanho}</p>
<p><strong>Cor:</strong> {selectedImages[currentImageIndex].variacao.cor}</p>
<p><strong>Quantidade:</strong> {selectedImages[currentImageIndex].variacao.quantidade}</p>
</>
)}
{selectedImages[currentImageIndex]?.type === 'placeholder' && (
<>
<h4>📷 Sem Imagens</h4>
<p>Este produto ainda não possui imagens cadastradas.</p>
<p>Para adicionar imagens, edite o produto e faça upload das fotos nas variações.</p>
<div style={{ marginTop: '16px' }}>
<button
className="btn btn-primary"
onClick={() => {
setShowImageModal(false);
handleEdit(selectedProduct);
}}
>
<FiEdit />
Editar Produto e Adicionar Fotos
</button>
</div>
</>
)}
{selectedImages[currentImageIndex]?.type === 'principal' && (
<>
<h4>Foto Principal</h4>
<p>Esta é a imagem principal do produto.</p>
</>
)}
</div>
</div>
{selectedImages.length > 1 && (

View File

@@ -0,0 +1,671 @@
import React, { useState, useEffect } from 'react';
import {
FiGlobe,
FiPackage,
FiImage,
FiEye,
FiEyeOff,
FiRefreshCw,
FiSave,
FiSettings
} from 'react-icons/fi';
import toast from 'react-hot-toast';
import '../styles/site-catalogo.css';
import '../styles/site-catalogo-table.css';
const SiteCatalogo = () => {
const [loading, setLoading] = useState(false);
const [produtos, setProdutos] = useState([]);
const [precosPromocionais, setPrecosPromocionais] = useState({});
const [precosOriginais, setPrecosOriginais] = useState({});
const [precosAlterados, setPrecosAlterados] = useState({});
const [precosSalvando, setPrecosSalvando] = useState({});
const [catalogoConfig, setCatalogoConfig] = useState({
catalogoAtivo: false,
exibirPrecos: true,
exibirEstoque: false,
exibirNovidades: true,
exibirPromocoes: true
});
const [modalFotosOpen, setModalFotosOpen] = useState(false);
const [produtoSelecionado, setProdutoSelecionado] = useState(null);
const [fotosAdicionais, setFotosAdicionais] = useState([]);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
useEffect(() => {
carregarProdutos();
carregarConfiguracoes();
}, []);
const carregarProdutos = async () => {
try {
setLoading(true);
const response = await fetch('/api/produtos');
if (response.ok) {
const data = await response.json();
setProdutos(data);
const precos = {};
const originais = {};
data.forEach((produto) => {
const valor = produto.preco_promocional;
const valorFormatado =
valor === null || valor === undefined || valor === ''
? ''
: Number(valor).toFixed(2);
precos[produto.id] = valorFormatado;
originais[produto.id] = valorFormatado;
});
setPrecosPromocionais(precos);
setPrecosOriginais(originais);
setPrecosAlterados({});
setPrecosSalvando({});
}
} catch (error) {
console.error('Erro ao carregar produtos:', error);
toast.error('Erro ao carregar produtos');
} finally {
setLoading(false);
}
};
const carregarConfiguracoes = async () => {
try {
const response = await fetch('/api/configuracoes/catalogo');
if (response.ok) {
const data = await response.json();
setCatalogoConfig(data);
}
} catch (error) {
console.error('Erro ao carregar configurações:', error);
}
};
const toggleProdutoPromocao = async (produtoId, emPromocao) => {
try {
const response = await fetch(`/api/produtos/${produtoId}/promocao`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emPromocao: !emPromocao }),
});
if (response.ok) {
toast.success('Promoção atualizada!');
carregarProdutos();
}
} catch (error) {
console.error('Erro ao atualizar promoção:', error);
toast.error('Erro ao atualizar promoção');
}
};
const toggleProdutoNovidade = async (produtoId, novidade) => {
try {
const response = await fetch(`/api/produtos/${produtoId}/novidade`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ novidade: !novidade }),
});
if (response.ok) {
toast.success('Novidade atualizada!');
carregarProdutos();
}
} catch (error) {
console.error('Erro ao atualizar novidade:', error);
toast.error('Erro ao atualizar novidade');
}
};
const handlePrecoPromocionalChange = (produtoId, valor) => {
setPrecosPromocionais(prev => ({
...prev,
[produtoId]: valor
}));
setPrecosAlterados(prev => ({
...prev,
[produtoId]: valor !== (precosOriginais[produtoId] ?? '')
}));
};
const handlePrecoPromocionalBlur = (produtoId) => {
const valorAtual = precosPromocionais[produtoId];
if (valorAtual === '' || valorAtual === null || valorAtual === undefined) {
setPrecosAlterados(prev => ({
...prev,
[produtoId]: (precosOriginais[produtoId] ?? '') !== ''
}));
return;
}
const numero = Number(valorAtual);
if (Number.isNaN(numero)) {
toast.error('Informe um valor numérico válido');
return;
}
const formatado = numero.toFixed(2);
setPrecosPromocionais(prev => ({
...prev,
[produtoId]: formatado
}));
setPrecosAlterados(prev => ({
...prev,
[produtoId]: formatado !== (precosOriginais[produtoId] ?? '')
}));
};
const salvarPrecoPromocional = async (produtoId) => {
const valorAtual = precosPromocionais[produtoId];
const precoFormatado = valorAtual === '' || valorAtual === null || valorAtual === undefined
? ''
: Number(valorAtual).toFixed(2);
const precoPayload = valorAtual === '' || valorAtual === null || valorAtual === undefined
? null
: Number(valorAtual);
if (precoPayload !== null && (Number.isNaN(precoPayload) || precoPayload < 0)) {
toast.error('Informe um valor válido para o desconto');
return;
}
setPrecosSalvando(prev => ({
...prev,
[produtoId]: true
}));
try {
const response = await fetch(`/api/produtos/${produtoId}/preco-promocional`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ precoPromocional: precoPayload }),
});
if (response.ok) {
toast.success('Preço promocional atualizado!');
setPrecosOriginais(prev => ({
...prev,
[produtoId]: precoFormatado
}));
setPrecosPromocionais(prev => ({
...prev,
[produtoId]: precoFormatado
}));
setPrecosAlterados(prev => ({
...prev,
[produtoId]: false
}));
setProdutos(prev =>
prev.map(produto =>
produto.id === produtoId
? { ...produto, preco_promocional: precoPayload }
: produto
)
);
} else {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || 'Erro ao atualizar preço');
}
} catch (error) {
console.error('Erro ao atualizar preço:', error);
toast.error('Erro ao atualizar preço');
} finally {
setPrecosSalvando(prev => ({
...prev,
[produtoId]: false
}));
}
};
const salvarConfiguracoes = async () => {
try {
setLoading(true);
const response = await fetch('/api/configuracoes/catalogo', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(catalogoConfig),
});
if (response.ok) {
toast.success('Configurações salvas com sucesso!');
} else {
throw new Error('Erro ao salvar configurações');
}
} catch (error) {
console.error('Erro ao salvar configurações:', error);
toast.error('Erro ao salvar configurações');
} finally {
setLoading(false);
}
};
const toggleProdutoVisivel = async (produtoId, visivelAtual) => {
try {
const response = await fetch(`/api/produtos/${produtoId}/visibilidade`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ visivelCatalogo: !visivelAtual }),
});
if (response.ok) {
toast.success('Visibilidade atualizada!');
carregarProdutos();
}
} catch (error) {
console.error('Erro ao atualizar visibilidade:', error);
toast.error('Erro ao atualizar visibilidade');
}
};
const abrirGerenciarFotos = async (produto) => {
setProdutoSelecionado(produto);
setModalFotosOpen(true);
await carregarFotosAdicionais(produto.id);
};
const carregarFotosAdicionais = async (produtoId) => {
try {
const response = await fetch(`/api/produtos/${produtoId}/fotos-catalogo`);
if (response.ok) {
const data = await response.json();
setFotosAdicionais(data.fotos || []);
}
} catch (error) {
console.error('Erro ao carregar fotos adicionais:', error);
}
};
const handleUploadFoto = async (event) => {
const file = event.target.files?.[0];
if (!file || !produtoSelecionado) return;
// Validar tipo de arquivo
const tiposPermitidos = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
if (!tiposPermitidos.includes(file.type)) {
toast.error('Tipo de arquivo não permitido. Use JPEG, PNG, WebP ou GIF');
return;
}
// Validar tamanho (5MB)
if (file.size > 5 * 1024 * 1024) {
toast.error('Arquivo muito grande. Máximo 5MB');
return;
}
const formData = new FormData();
formData.append('foto', file);
try {
setUploadingPhoto(true);
const response = await fetch(`/api/produtos/${produtoSelecionado.id}/fotos-catalogo`, {
method: 'POST',
body: formData,
});
const data = await response.json();
if (response.ok) {
toast.success('Foto adicionada com sucesso!');
await carregarFotosAdicionais(produtoSelecionado.id);
await carregarProdutos();
// Limpar input
event.target.value = '';
} else {
const errorMsg = data.error || 'Erro ao fazer upload';
console.error('Erro do servidor:', data);
toast.error(errorMsg);
}
} catch (error) {
console.error('Erro ao fazer upload:', error);
toast.error('Erro ao adicionar foto: ' + error.message);
} finally {
setUploadingPhoto(false);
}
};
const deletarFoto = async (fileName) => {
if (!produtoSelecionado) return;
if (!window.confirm('Tem certeza que deseja remover esta foto?')) return;
try {
const response = await fetch(
`/api/produtos/${produtoSelecionado.id}/fotos-catalogo/${fileName}`,
{ method: 'DELETE' }
);
if (response.ok) {
toast.success('Foto removida!');
await carregarFotosAdicionais(produtoSelecionado.id);
await carregarProdutos();
}
} catch (error) {
console.error('Erro ao deletar foto:', error);
toast.error('Erro ao remover foto');
}
};
if (loading && produtos.length === 0) {
return (
<div className="loading">
<div>Carregando catálogo...</div>
</div>
);
}
const produtosVisiveis = produtos.filter(p => p.visivel_catalogo);
const produtosPromocao = produtos.filter(p => p.em_promocao && p.visivel_catalogo);
const produtosNovidade = produtos.filter(p => p.novidade && p.visivel_catalogo);
return (
<div className="site-catalogo fade-in">
<div className="page-header">
<div>
<h1>
<FiGlobe style={{ marginRight: '10px' }} />
Site / Catálogo
</h1>
<p>Gerencie os produtos visíveis no catálogo online</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className="btn btn-secondary"
onClick={carregarProdutos}
>
<FiRefreshCw />
Atualizar
</button>
<a
href="/catalogo"
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
style={{ textDecoration: 'none' }}
>
<FiGlobe />
Ver Catálogo
</a>
</div>
</div>
{/* Configurações do Catálogo */}
<div className="config-section">
<div className="config-header">
<div className="config-title">
<FiSettings className="config-icon" />
<div>
<h2>Configurações do Catálogo</h2>
<p>Configure as opções de exibição do catálogo online</p>
</div>
</div>
</div>
<div className="config-form">
<p className="config-info-text">
As opções de exibição do catálogo são gerenciadas automaticamente. Utilize o botão abaixo caso precise sincronizar as configurações.
</p>
<div className="config-actions">
<button
type="button"
className={`btn btn-primary ${loading ? 'loading' : ''}`}
onClick={salvarConfiguracoes}
disabled={loading}
>
<FiSettings />
{loading ? 'Salvando...' : 'Salvar Configurações'}
</button>
</div>
</div>
</div>
{/* Estatísticas */}
<div className="catalogo-stats">
<div className="stat-card">
<FiPackage className="stat-icon" />
<div className="stat-info">
<span className="stat-value">{produtos.length}</span>
<span className="stat-label">Total de Produtos</span>
</div>
</div>
<div className="stat-card success">
<FiEye className="stat-icon" />
<div className="stat-info">
<span className="stat-value">{produtosVisiveis.length}</span>
<span className="stat-label">Visíveis</span>
</div>
</div>
<div className="stat-card warning">
<FiEyeOff className="stat-icon" />
<div className="stat-info">
<span className="stat-value">{produtos.length - produtosVisiveis.length}</span>
<span className="stat-label">Ocultos</span>
</div>
</div>
<div className="stat-card" style={{ background: 'linear-gradient(135deg, #ffd89b 0%, #f59e0b 100%)', color: 'white' }}>
<span className="stat-icon" style={{ fontSize: '2rem' }}>🏷</span>
<div className="stat-info">
<span className="stat-value">{produtosPromocao.length}</span>
<span className="stat-label">Em Promoção</span>
</div>
</div>
<div className="stat-card" style={{ background: 'linear-gradient(135deg, #a8edea 0%, #3b82f6 100%)', color: 'white' }}>
<span className="stat-icon" style={{ fontSize: '2rem' }}></span>
<div className="stat-info">
<span className="stat-value">{produtosNovidade.length}</span>
<span className="stat-label">Novidades</span>
</div>
</div>
</div>
{/* Lista de Produtos */}
<div className="products-section">
<h2>
<FiPackage style={{ marginRight: '10px' }} />
Produtos do Catálogo
</h2>
{produtos.length === 0 ? (
<div className="empty-state">
<FiPackage size={48} />
<p>Nenhum produto cadastrado</p>
</div>
) : (
<div className="products-table">
<table>
<thead>
<tr>
<th>Foto</th>
<th>Produto</th>
<th>Preço Normal</th>
<th>Preço Promocional</th>
<th>Estoque</th>
<th>Status</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{produtos.map((produto) => (
<tr
key={produto.id}
className={!produto.visivel_catalogo ? 'row-hidden' : ''}
>
<td>
<div className="table-product-image">
{produto.foto_principal_url || produto.imagem ? (
<img src={produto.foto_principal_url || produto.imagem} alt={produto.nome} />
) : (
<div className="no-image">
<FiImage size={20} />
</div>
)}
</div>
</td>
<td>
<div className="table-product-info">
<strong>{produto.nome}</strong>
<small>{produto.marca || 'Sem marca'}</small>
</div>
</td>
<td>
<span className="price-normal">
R$ {parseFloat(produto.valor_revenda || 0).toFixed(2)}
</span>
</td>
<td>
<div className="promo-input-wrapper">
<input
type="number"
step="0.01"
min="0"
className={`input-preco-promo ${precosAlterados[produto.id] ? 'changed' : ''}`}
placeholder="0.00"
value={precosPromocionais[produto.id] ?? ''}
onChange={(e) => handlePrecoPromocionalChange(produto.id, e.target.value)}
onBlur={() => handlePrecoPromocionalBlur(produto.id)}
/>
<button
type="button"
className={`btn-icon promo-save-btn ${precosAlterados[produto.id] ? 'active' : ''}`}
title={precosAlterados[produto.id] ? 'Salvar preço promocional' : 'Nenhuma alteração pendente'}
onClick={() => salvarPrecoPromocional(produto.id)}
disabled={!precosAlterados[produto.id] || precosSalvando[produto.id]}
>
{precosSalvando[produto.id] ? (
<FiRefreshCw className="icon-spin" />
) : (
<FiSave />
)}
</button>
</div>
</td>
<td>
<span className={`stock-badge ${produto.estoque_total > 0 ? 'in-stock' : 'out-stock'}`}>
{produto.estoque_total || 0}
</span>
</td>
<td>
<div className="status-badges">
<span
className={`badge ${produto.visivel_catalogo ? 'badge-success' : 'badge-secondary'}`}
title={produto.visivel_catalogo ? 'Visível' : 'Oculto'}
>
{produto.visivel_catalogo ? <FiEye /> : <FiEyeOff />}
</span>
<span
className={`badge ${produto.novidade ? 'badge-info' : 'badge-light'}`}
title="Novidade"
style={{ cursor: 'pointer' }}
onClick={() => toggleProdutoNovidade(produto.id, produto.novidade)}
>
{produto.novidade ? 'NOVO' : ''}
</span>
<span
className={`badge ${produto.em_promocao ? 'badge-warning' : 'badge-light'}`}
title="Promoção"
style={{ cursor: 'pointer' }}
onClick={() => toggleProdutoPromocao(produto.id, produto.em_promocao)}
>
🏷 {produto.em_promocao ? 'PROMO' : ''}
</span>
</div>
</td>
<td>
<div className="table-actions">
<button
className="btn-icon"
onClick={() => toggleProdutoVisivel(produto.id, produto.visivel_catalogo)}
title={produto.visivel_catalogo ? 'Ocultar' : 'Mostrar'}
>
{produto.visivel_catalogo ? <FiEyeOff /> : <FiEye />}
</button>
<button
className="btn-icon"
onClick={() => abrirGerenciarFotos(produto)}
title="Gerenciar fotos"
>
<FiImage />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Modal de Gerenciamento de Fotos */}
{modalFotosOpen && produtoSelecionado && (
<div className="modal-overlay" onClick={() => setModalFotosOpen(false)}>
<div className="modal-content-fotos" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>
<FiImage style={{ marginRight: '10px' }} />
Gerenciar Fotos - {produtoSelecionado.nome}
</h2>
<button className="modal-close" onClick={() => setModalFotosOpen(false)}>
×
</button>
</div>
<div className="modal-body">
<div className="upload-section">
<label className="upload-button">
<input
type="file"
accept="image/*"
onChange={handleUploadFoto}
disabled={uploadingPhoto}
style={{ display: 'none' }}
/>
<FiImage />
{uploadingPhoto ? 'Enviando...' : 'Adicionar Nova Foto'}
</label>
<p className="upload-help">
Adicione fotos extras que aparecerão no catálogo online
</p>
</div>
<div className="fotos-grid">
{fotosAdicionais.length === 0 ? (
<div className="empty-fotos">
<FiImage size={48} />
<p>Nenhuma foto adicional</p>
<span>Adicione fotos para exibir no catálogo</span>
</div>
) : (
fotosAdicionais.map((foto) => (
<div key={foto.name} className="foto-item">
<img src={foto.url} alt={foto.name} />
<button
className="btn-delete-foto"
onClick={() => deletarFoto(foto.name)}
title="Remover foto"
>
×
</button>
</div>
))
)}
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default SiteCatalogo;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,179 @@
.pedidos-catalogo-page {
display: flex;
flex-direction: column;
gap: 24px;
}
.pedidos-lista {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
}
.pedido-card {
background: white;
border-radius: 16px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
}
.pedido-card-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #f1f5f9;
padding-bottom: 12px;
}
.pedido-id, .pedido-data {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #334155;
}
.pedido-cliente {
display: flex;
flex-direction: column;
gap: 8px;
}
.pedido-cliente .cliente-nome {
font-size: 16px;
font-weight: 700;
color: #1f2937;
}
.pedido-cliente .cliente-info {
display: flex;
flex-wrap: wrap;
gap: 12px;
color: #475569;
font-size: 14px;
}
.pedido-cliente .cliente-info span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.pedido-itens {
background: #f8fafc;
border-radius: 12px;
border: 1px solid #e2e8f0;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.pedido-itens h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.pedido-itens-lista {
display: flex;
flex-direction: column;
gap: 12px;
}
.pedido-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.pedido-item-info {
display: flex;
flex-direction: column;
gap: 6px;
}
.pedido-item-info strong {
font-size: 15px;
color: #1f2937;
}
.pedido-item-meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
font-size: 12px;
color: #64748b;
}
.pedido-item-meta span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.pedido-item-valores {
text-align: right;
font-size: 13px;
color: #475569;
}
.pedido-item-valores strong {
display: block;
margin-top: 4px;
font-size: 14px;
color: #0f172a;
}
.pedido-footer {
display: flex;
justify-content: flex-end;
}
.pedido-total {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.pedido-total span {
font-size: 13px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.pedido-total strong {
font-size: 18px;
color: #0f172a;
}
.alert {
padding: 16px;
border-radius: 12px;
border: 1px solid transparent;
font-size: 14px;
}
.alert-error {
background: #fef2f2;
border-color: #fecaca;
color: #b91c1c;
}
@media (max-width: 768px) {
.pedidos-lista {
grid-template-columns: 1fr;
}
.pedido-card {
padding: 16px;
}
}

View File

@@ -154,3 +154,40 @@
width: 100%;
}
}
.btn-whatsapp-pix {
margin-left: 8px;
padding: 8px 12px;
background-color: #22c55e;
color: #fff;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: background-color 0.2s ease;
}
.btn-whatsapp-pix:hover {
background-color: #16a34a;
}
.pix-code-actions {
display: flex;
gap: 8px;
}
.pix-actions {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: center;
gap: 10px;
}
.pix-actions .btn {
min-width: 200px;
}

View File

@@ -0,0 +1,324 @@
/* ====================
TABELA DE PRODUTOS
==================== */
.products-table {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.products-table table {
width: 100%;
border-collapse: collapse;
}
.products-table thead {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.products-table th {
padding: 1rem;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.products-table tbody tr {
border-bottom: 1px solid #e2e8f0;
transition: all 0.3s ease;
}
.products-table tbody tr:hover {
background: #f7fafc;
}
.products-table tbody tr.row-hidden {
opacity: 0.5;
background: #f1f5f9;
}
.products-table td {
padding: 1rem;
vertical-align: middle;
}
/* Imagem do Produto na Tabela */
.table-product-image {
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
border: 2px solid #e2e8f0;
}
.table-product-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.table-product-image .no-image {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f7fafc;
color: #a0aec0;
}
/* Info do Produto */
.table-product-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.table-product-info strong {
font-size: 1rem;
color: #2d3748;
}
.table-product-info small {
font-size: 0.85rem;
color: #718096;
}
/* Preços */
.price-normal {
font-size: 1.1rem;
font-weight: 600;
color: #2d3748;
}
.input-preco-promo {
width: 120px;
padding: 0.5rem;
border: 2px solid #e2e8f0;
border-radius: 6px;
font-size: 0.95rem;
font-weight: 600;
color: #e53e3e;
transition: all 0.3s ease;
}
.input-preco-promo:focus {
outline: none;
border-color: #f56565;
box-shadow: 0 0 0 3px rgba(245, 101, 101, 0.1);
}
.input-preco-promo::placeholder {
color: #cbd5e0;
}
.input-preco-promo.changed {
border-color: #f59e0b;
background: #fff7ed;
}
.promo-input-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
}
.promo-save-btn {
width: 38px;
height: 38px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
color: #4b5563;
transition: all 0.2s ease;
}
.promo-save-btn.active {
background: #4f46e5;
border-color: #4338ca;
color: #ffffff;
}
.promo-save-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
background: #f1f5f9;
color: #94a3b8;
}
.promo-save-btn:not(:disabled):hover {
background: #4338ca;
color: #ffffff;
}
.icon-spin {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Badge de Estoque */
.stock-badge {
display: inline-block;
padding: 0.4rem 0.8rem;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 600;
}
.stock-badge.in-stock {
background: #c6f6d5;
color: #22543d;
}
.stock-badge.out-stock {
background: #fed7d7;
color: #742a2a;
}
/* Status Badges */
.status-badges {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.4rem 0.8rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.badge-success {
background: #c6f6d5;
color: #22543d;
border-color: #9ae6b4;
}
.badge-secondary {
background: #e2e8f0;
color: #4a5568;
border-color: #cbd5e0;
}
.badge-info {
background: #bee3f8;
color: #2c5282;
border-color: #90cdf4;
}
.badge-warning {
background: #feebc8;
color: #7c2d12;
border-color: #fbd38d;
}
.badge-light {
background: #f7fafc;
color: #a0aec0;
border-color: #e2e8f0;
cursor: pointer;
}
.badge-light:hover {
background: #e2e8f0;
border-color: #cbd5e0;
}
/* Ações da Tabela */
.table-actions {
display: flex;
gap: 0.5rem;
}
.btn-icon {
padding: 0.6rem;
border: none;
background: #f7fafc;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
color: #4a5568;
}
.btn-icon:hover {
background: #667eea;
color: white;
transform: translateY(-2px);
}
/* Responsividade */
@media (max-width: 1024px) {
.products-table {
overflow-x: auto;
}
.products-table table {
min-width: 900px;
}
}
@media (max-width: 768px) {
.products-table th,
.products-table td {
padding: 0.75rem 0.5rem;
font-size: 0.85rem;
}
.table-product-image {
width: 50px;
height: 50px;
}
.input-preco-promo {
width: 100px;
}
.status-badges {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
}
/* Animações */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.products-table tbody tr {
animation: fadeIn 0.3s ease;
}
.products-table tbody tr:nth-child(even) {
animation-delay: 0.05s;
}

View File

@@ -0,0 +1,666 @@
.site-catalogo {
padding: 1rem;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid #e2e8f0;
}
.page-header h1 {
display: flex;
align-items: center;
font-size: 2rem;
color: #2d3748;
margin: 0;
}
.page-header p {
color: #718096;
margin: 0.5rem 0 0 0;
}
/* Config Section */
.config-section {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
overflow: hidden;
}
.config-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.config-title {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.config-icon {
font-size: 1.5rem;
margin-top: 0.25rem;
}
.config-title h2 {
margin: 0;
font-size: 1.25rem;
}
.config-title p {
margin: 0.5rem 0 0 0;
font-size: 0.9rem;
opacity: 0.9;
}
.config-form {
padding: 2rem;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-weight: 600;
color: #2d3748;
font-size: 0.9rem;
}
.form-input {
padding: 0.75rem;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
transition: all 0.3s ease;
}
.form-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.config-toggle {
display: flex;
align-items: center;
gap: 1rem;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 50px;
height: 28px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #cbd5e0;
transition: 0.4s;
border-radius: 34px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background-color: white;
transition: 0.4s;
border-radius: 50%;
}
.toggle-switch input:checked + .toggle-slider {
background-color: #667eea;
}
.toggle-switch input:checked + .toggle-slider:before {
transform: translateX(22px);
}
.toggle-label {
font-weight: 500;
color: #2d3748;
}
.config-actions {
display: flex;
gap: 1rem;
}
/* Estatísticas */
.catalogo-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 1rem;
border-left: 4px solid #667eea;
}
.stat-card.success {
border-left-color: #48bb78;
}
.stat-card.warning {
border-left-color: #ed8936;
}
.stat-icon {
font-size: 2rem;
color: #667eea;
}
.stat-card.success .stat-icon {
color: #48bb78;
}
.stat-card.warning .stat-icon {
color: #ed8936;
}
.stat-info {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
color: #2d3748;
}
.stat-label {
font-size: 0.9rem;
color: #718096;
}
/* Products Section */
.products-section {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.products-section h2 {
display: flex;
align-items: center;
font-size: 1.5rem;
color: #2d3748;
margin: 0 0 1.5rem 0;
}
.products-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.product-card {
background: white;
border: 2px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
transition: all 0.3s ease;
}
.product-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.product-card.product-hidden {
opacity: 0.5;
}
.product-image {
width: 100%;
height: 200px;
background: #f7fafc;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.product-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.product-no-image {
color: #cbd5e0;
}
.product-info {
padding: 1rem;
}
.product-info h3 {
font-size: 1.1rem;
color: #2d3748;
margin: 0 0 0.5rem 0;
}
.product-description {
font-size: 0.9rem;
color: #718096;
margin: 0 0 1rem 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.product-details {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 1rem;
border-top: 1px solid #e2e8f0;
}
.product-price {
font-size: 1.25rem;
font-weight: 700;
color: #48bb78;
}
.product-stock {
font-size: 0.9rem;
color: #718096;
}
.product-actions {
padding: 1rem;
background: #f7fafc;
border-top: 1px solid #e2e8f0;
display: flex;
gap: 0.5rem;
flex-direction: column;
}
.btn-visibility,
.btn-manage-photos {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.3s ease;
}
.btn-visibility.visible {
background: #48bb78;
color: white;
}
.btn-visibility.visible:hover {
background: #38a169;
}
.btn-visibility.hidden {
background: #cbd5e0;
color: #4a5568;
}
.btn-visibility.hidden:hover {
background: #a0aec0;
}
.btn-manage-photos {
background: #667eea;
color: white;
}
.btn-manage-photos:hover {
background: #5a67d8;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #a0aec0;
text-align: center;
}
.empty-state p {
margin-top: 1rem;
font-size: 1.1rem;
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
transition: all 0.3s ease;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5a67d8;
}
.btn-secondary {
background: #e2e8f0;
color: #4a5568;
}
.btn-secondary:hover {
background: #cbd5e0;
}
.btn.loading {
opacity: 0.6;
cursor: not-allowed;
}
/* Loading */
.loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
font-size: 1.2rem;
color: #718096;
}
/* Fade In Animation */
.fade-in {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Modal de Fotos */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-content-fotos {
background: white;
border-radius: 16px;
max-width: 800px;
width: 100%;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 2px solid #e2e8f0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.modal-header h2 {
display: flex;
align-items: center;
font-size: 1.5rem;
margin: 0;
}
.modal-close {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
line-height: 1;
}
.modal-close:hover {
background: rgba(255, 255, 255, 0.3);
transform: rotate(90deg);
}
.modal-body {
padding: 2rem;
overflow-y: auto;
}
.upload-section {
text-align: center;
margin-bottom: 2rem;
}
.upload-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 2rem;
background: #667eea;
color: white;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
border: none;
}
.upload-button:hover {
background: #5a67d8;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.upload-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.upload-help {
margin-top: 0.5rem;
color: #718096;
font-size: 0.9rem;
}
.fotos-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
}
.foto-item {
position: relative;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
border: 2px solid #e2e8f0;
transition: all 0.3s ease;
}
.foto-item:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
border-color: #667eea;
}
.foto-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.btn-delete-foto {
position: absolute;
top: 8px;
right: 8px;
background: rgba(220, 53, 69, 0.9);
color: white;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
font-size: 1.5rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
opacity: 0;
}
.foto-item:hover .btn-delete-foto {
opacity: 1;
}
.btn-delete-foto:hover {
background: #dc3545;
transform: scale(1.1);
}
.empty-fotos {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #a0aec0;
text-align: center;
}
.empty-fotos p {
margin: 1rem 0 0.5rem 0;
font-size: 1.1rem;
font-weight: 600;
}
.empty-fotos span {
font-size: 0.9rem;
}
/* Responsive */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.form-grid {
grid-template-columns: 1fr;
}
.catalogo-stats {
grid-template-columns: 1fr;
}
.products-grid {
grid-template-columns: 1fr;
}
.fotos-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.modal-content-fotos {
max-height: 95vh;
}
.modal-header h2 {
font-size: 1.2rem;
}
}

View File

@@ -94,6 +94,163 @@
}
}
/* Parcelas individuais na visualização */
.parcelas-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
margin-top: 16px;
}
.parcela-card {
background: white;
border: 2px solid #e9ecef;
border-radius: 12px;
padding: 16px;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.parcela-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.parcela-card.pago {
border-color: #28a745;
background: #f0f9f4;
}
.parcela-card.vencida {
border-color: #dc3545;
background: #fff5f5;
}
.parcela-card.pendente {
border-color: #ffc107;
background: #fffdf0;
}
.parcela-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #e9ecef;
}
.parcela-numero {
font-size: 16px;
font-weight: 700;
color: #2c3e50;
}
.parcela-status {
display: flex;
align-items: center;
}
.parcela-info {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.parcela-info > div {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #495057;
}
.parcela-info strong {
color: #2c3e50;
font-weight: 600;
}
.parcela-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 12px;
}
.btn-sm {
padding: 6px 12px;
font-size: 13px;
border-radius: 6px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-pix {
background: linear-gradient(135deg, #00c4cc 0%, #00a8b5 100%);
color: white;
border: none;
transition: all 0.3s ease;
}
.btn-pix:hover:not(:disabled) {
background: linear-gradient(135deg, #00a8b5 0%, #008c99 100%);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 196, 204, 0.3);
}
.btn-pix:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 768px) {
.parcelas-list {
grid-template-columns: 1fr;
}
.parcela-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.parcela-actions {
width: 100%;
}
.parcela-actions button {
flex: 1;
}
}
/* Estilos para linhas de parcelas na tabela */
.parcela-row {
background-color: #f8f9fa;
border-left: 3px solid #ffc107;
}
.parcela-row:hover {
background-color: #e9ecef;
}
.total-row {
background-color: #e7f3ff;
font-weight: bold;
border-top: 2px solid #007bff;
border-bottom: 2px solid #007bff;
}
.total-row td {
padding: 12px 8px !important;
}
.parcela-row td[rowspan] {
vertical-align: top;
border-right: 1px solid #dee2e6;
}
/* Estilos para coluna de produtos na tabela de vendas */
.sale-products {
max-width: 250px;