- 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)
271 lines
10 KiB
JavaScript
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>
|
|
);
|
|
}
|