Files
ofertaweb.cl/administracion/src/pages/Products.jsx
cesar 2a88b4a71b Initial commit: Estructura backend y frontend con estándar VPS
- Backend migrado a estructura VPS (src/ subfolder)
- Frontend con estructura Vite + React 19 + Tailwind
- Configuración PostgreSQL con Pool
- API service con interceptores JWT
- Ambos servidores funcionando (backend:3001, frontend:5173)
2025-12-09 00:35:46 -03:00

271 lines
10 KiB
JavaScript

import { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, Image as ImageIcon, Printer } from 'lucide-react';
import toast from 'react-hot-toast';
import { productService, categoryService } from '../services';
import ProductModal from '../components/ProductModal';
import PrintLabel from '../components/PrintLabel';
export default function Products() {
const [products, setProducts] = useState([]);
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [printModalOpen, setPrintModalOpen] = useState(false);
const [selectedProduct, setSelectedProduct] = useState(null);
const [search, setSearch] = useState('');
const [pagination, setPagination] = useState({});
useEffect(() => {
loadProducts();
loadCategories();
}, []);
const loadProducts = async (searchTerm = '') => {
setLoading(true);
try {
const response = await productService.getAll({ search: searchTerm, limit: 50 });
setProducts(response.data);
setPagination(response.pagination);
} catch (err) {
console.error('Error cargando productos:', err);
toast.error('Error cargando productos');
} finally {
setLoading(false);
}
};
const loadCategories = async () => {
try {
const response = await categoryService.getAll();
setCategories(response.data);
} catch (err) {
console.error('Error cargando categorías:', err);
}
};
const handleSearch = (e) => {
const value = e.target.value;
setSearch(value);
if (value.length > 2 || value.length === 0) {
loadProducts(value);
}
};
const handleEdit = (product) => {
setSelectedProduct(product);
setModalOpen(true);
};
const handlePrint = (product) => {
setSelectedProduct(product);
setPrintModalOpen(true);
};
const handleDelete = async (product) => {
if (!confirm(`¿Eliminar producto "${product.nombre}"?`)) return;
try {
await productService.delete(product.id);
toast.success('Producto eliminado');
loadProducts();
} catch (err) {
console.error('Error eliminando producto:', err);
toast.error('Error al eliminar producto');
}
};
const handleModalClose = () => {
setModalOpen(false);
setSelectedProduct(null);
loadProducts();
};
const getCategoryName = (categoryId) => {
if (!categories || categories.length === 0) return 'Sin categoría';
const category = categories.find(c => c.id === categoryId);
return category?.nombre || 'Sin categoría';
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold text-gray-800">Productos</h1>
<button
onClick={() => setModalOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus size={20} />
Nuevo Producto
</button>
</div>
{/* Search Bar */}
<div className="bg-white rounded-lg shadow p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
value={search}
onChange={handleSearch}
placeholder="Buscar productos por nombre, SKU o descripción..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
{/* Products Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
) : products.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">No hay productos registrados</p>
<button
onClick={() => setModalOpen(true)}
className="mt-4 text-blue-600 hover:text-blue-700 font-medium"
>
Crear primer producto
</button>
</div>
) : (
<>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Producto
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
SKU
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Categoría
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Precio
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Stock
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Estado
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Acciones
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{products.map((product) => (
<tr key={product.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="h-10 w-10 flex-shrink-0 bg-gray-200 rounded flex items-center justify-center">
{product.images?.length > 0 ? (
<img
src={`http://localhost:3001${product.images[0].url}`}
alt={product.nombre}
className="h-10 w-10 rounded object-cover"
/>
) : (
<ImageIcon size={20} className="text-gray-400" />
)}
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{product.nombre}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-900 font-mono">{product.sku}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-600">{getCategoryName(product.category_id)}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-semibold text-gray-900">
${product.precio_base.toLocaleString('es-CL')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`text-sm font-medium ${
product.stock_disponible === 0 ? 'text-red-600' :
product.stock_disponible < 10 ? 'text-yellow-600' :
'text-green-600'
}`}>
{product.stock_disponible || 0}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
product.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{product.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handlePrint(product)}
className="text-gray-600 hover:text-gray-900 mr-3"
title="Imprimir etiqueta"
>
<Printer size={18} />
</button>
<button
onClick={() => handleEdit(product)}
className="text-blue-600 hover:text-blue-900 mr-3"
title="Editar"
>
<Edit size={18} />
</button>
<button
onClick={() => handleDelete(product)}
className="text-red-600 hover:text-red-900"
title="Eliminar"
>
<Trash2 size={18} />
</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination Info */}
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200">
<p className="text-sm text-gray-700">
Mostrando {products.length} de {pagination.totalItems} productos
</p>
</div>
</>
)}
</div>
{/* Modal */}
{modalOpen && (
<ProductModal
product={selectedProduct}
categories={categories}
onClose={handleModalClose}
/>
)}
{/* Modal de impresión */}
{printModalOpen && selectedProduct && (
<PrintLabel
product={selectedProduct}
onClose={() => {
setPrintModalOpen(false);
setSelectedProduct(null);
}}
/>
)}
</div>
);
}