Files
App-Estoque-LiberiKids/client/src/pages/SiteCatalogo.js
2025-11-29 21:31:52 -03:00

672 lines
22 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;