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

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;