[MCH-FE] feat: Connect Products to real API

Replace mock data with real API calls using React Query:
- useQuery for fetching products with category/search filters
- useMutation for create, update, and delete operations
- Add loading, error, and empty states
- Add create/edit modal with form validation
- Add delete confirmation modal
- Maintain existing UI structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-20 02:15:31 -06:00
parent 0385695d27
commit 8199d622b1

View File

@ -1,14 +1,31 @@
import { useState } from 'react';
import { Search, Plus, Edit, Trash2, Package } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Search, Plus, Edit, Trash2, Package, X, AlertCircle, Loader2 } from 'lucide-react';
import { productsApi } from '../lib/api';
const mockProducts = [
{ id: '1', name: 'Coca-Cola 600ml', category: 'bebidas', price: 18.00, stock: 24, barcode: '7501055300051' },
{ id: '2', name: 'Sabritas Original', category: 'botanas', price: 15.00, stock: 12, barcode: '7501000111111' },
{ id: '3', name: 'Leche Lala 1L', category: 'lacteos', price: 28.00, stock: 8, barcode: '7501020500001' },
{ id: '4', name: 'Pan Bimbo Grande', category: 'panaderia', price: 45.00, stock: 5, barcode: '7501030400001' },
{ id: '5', name: 'Fabuloso 1L', category: 'limpieza', price: 32.00, stock: 6, barcode: '7501040300001' },
{ id: '6', name: 'Pepsi 600ml', category: 'bebidas', price: 17.00, stock: 20, barcode: '7501055300052' },
];
// Type definitions
interface Product {
id: string;
name: string;
category: string;
price: number;
stock: number;
barcode?: string;
description?: string;
minStock?: number;
sku?: string;
}
interface ProductFormData {
name: string;
category: string;
price: number;
stock: number;
barcode?: string;
description?: string;
minStock?: number;
sku?: string;
}
const categories = [
{ id: 'all', name: 'Todos' },
@ -17,26 +34,162 @@ const categories = [
{ id: 'lacteos', name: 'Lacteos' },
{ id: 'panaderia', name: 'Panaderia' },
{ id: 'limpieza', name: 'Limpieza' },
{ id: 'abarrotes', name: 'Abarrotes' },
{ id: 'otros', name: 'Otros' },
];
const initialFormData: ProductFormData = {
name: '',
category: 'bebidas',
price: 0,
stock: 0,
barcode: '',
description: '',
minStock: 5,
sku: '',
};
export function Products() {
const [search, setSearch] = useState('');
const [category, setCategory] = useState('all');
const [showModal, setShowModal] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [deletingProduct, setDeletingProduct] = useState<Product | null>(null);
const [formData, setFormData] = useState<ProductFormData>(initialFormData);
const [formError, setFormError] = useState<string | null>(null);
const filteredProducts = mockProducts.filter((product) => {
const matchesSearch = product.name.toLowerCase().includes(search.toLowerCase());
const matchesCategory = category === 'all' || product.category === category;
return matchesSearch && matchesCategory;
const queryClient = useQueryClient();
// Fetch products from API
const { data: productsResponse, isLoading, isError, error } = useQuery({
queryKey: ['products', { category: category === 'all' ? undefined : category, search: search || undefined }],
queryFn: async () => {
const response = await productsApi.getAll({
category: category === 'all' ? undefined : category,
search: search || undefined,
});
return response.data;
},
});
// Extract products array from response
const products: Product[] = Array.isArray(productsResponse)
? productsResponse
: productsResponse?.data || productsResponse?.products || [];
// Create product mutation
const createMutation = useMutation({
mutationFn: (data: ProductFormData) => productsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
handleCloseModal();
},
onError: (err: any) => {
setFormError(err.response?.data?.message || 'Error al crear el producto');
},
});
// Update product mutation
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: ProductFormData }) =>
productsApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
handleCloseModal();
},
onError: (err: any) => {
setFormError(err.response?.data?.message || 'Error al actualizar el producto');
},
});
// Delete product mutation
const deleteMutation = useMutation({
mutationFn: (id: string) => productsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
setDeletingProduct(null);
},
onError: (err: any) => {
alert(err.response?.data?.message || 'Error al eliminar el producto');
},
});
const handleOpenCreate = () => {
setEditingProduct(null);
setFormData(initialFormData);
setFormError(null);
setShowModal(true);
};
const handleOpenEdit = (product: Product) => {
setEditingProduct(product);
setFormData({
name: product.name,
category: product.category,
price: product.price,
stock: product.stock,
barcode: product.barcode || '',
description: product.description || '',
minStock: product.minStock || 5,
sku: product.sku || '',
});
setFormError(null);
setShowModal(true);
};
const handleCloseModal = () => {
setShowModal(false);
setEditingProduct(null);
setFormData(initialFormData);
setFormError(null);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
// Validation
if (!formData.name.trim()) {
setFormError('El nombre es requerido');
return;
}
if (formData.price <= 0) {
setFormError('El precio debe ser mayor a 0');
return;
}
if (formData.stock < 0) {
setFormError('El stock no puede ser negativo');
return;
}
if (editingProduct) {
updateMutation.mutate({ id: editingProduct.id, data: formData });
} else {
createMutation.mutate(formData);
}
};
const handleDelete = () => {
if (deletingProduct) {
deleteMutation.mutate(deletingProduct.id);
}
};
const isSubmitting = createMutation.isPending || updateMutation.isPending;
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Productos</h1>
<p className="text-gray-500">{mockProducts.length} productos en catalogo</p>
<p className="text-gray-500">
{isLoading ? 'Cargando...' : `${products.length} productos en catalogo`}
</p>
</div>
<button className="btn-primary flex items-center gap-2">
<button
onClick={handleOpenCreate}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-5 w-5" />
Agregar Producto
</button>
@ -65,39 +218,294 @@ export function Products() {
</select>
</div>
{/* Products Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredProducts.map((product) => (
<div key={product.id} className="card hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="p-2 bg-gray-100 rounded-lg">
<Package className="h-8 w-8 text-gray-600" />
</div>
<div className="flex gap-1">
<button className="p-1 hover:bg-gray-100 rounded">
<Edit className="h-4 w-4 text-gray-500" />
</button>
<button className="p-1 hover:bg-red-100 rounded">
<Trash2 className="h-4 w-4 text-red-500" />
</button>
</div>
</div>
<h3 className="font-semibold text-gray-900">{product.name}</h3>
<p className="text-sm text-gray-500 capitalize">{product.category}</p>
<div className="mt-3 flex items-center justify-between">
<span className="text-xl font-bold text-primary-600">
${product.price.toFixed(2)}
</span>
<span className={`text-sm font-medium ${
product.stock > 10 ? 'text-green-600' : product.stock > 5 ? 'text-yellow-600' : 'text-red-600'
}`}>
{product.stock} en stock
</span>
</div>
<p className="text-xs text-gray-400 mt-2">{product.barcode}</p>
{/* Error State */}
{isError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-red-800">Error al cargar productos</h3>
<p className="text-red-600 text-sm">
{(error as any)?.response?.data?.message || 'No se pudieron cargar los productos. Intenta de nuevo.'}
</p>
</div>
))}
</div>
</div>
)}
{/* Loading State */}
{isLoading && (
<div className="text-center py-12">
<Loader2 className="h-8 w-8 text-primary-600 animate-spin mx-auto" />
<p className="text-gray-500 mt-2">Cargando productos...</p>
</div>
)}
{/* Empty State */}
{!isLoading && !isError && products.length === 0 && (
<div className="text-center py-12 card">
<Package className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900">No hay productos</h3>
<p className="text-gray-500 mb-4">
{search || category !== 'all'
? 'No se encontraron productos con esos filtros'
: 'Agrega tu primer producto para empezar'}
</p>
{!search && category === 'all' && (
<button onClick={handleOpenCreate} className="btn-primary">
<Plus className="h-4 w-4 inline mr-2" />
Agregar Producto
</button>
)}
</div>
)}
{/* Products Grid */}
{!isLoading && !isError && products.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{products.map((product) => (
<div key={product.id} className="card hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="p-2 bg-gray-100 rounded-lg">
<Package className="h-8 w-8 text-gray-600" />
</div>
<div className="flex gap-1">
<button
onClick={() => handleOpenEdit(product)}
className="p-1 hover:bg-gray-100 rounded"
title="Editar producto"
>
<Edit className="h-4 w-4 text-gray-500" />
</button>
<button
onClick={() => setDeletingProduct(product)}
className="p-1 hover:bg-red-100 rounded"
title="Eliminar producto"
>
<Trash2 className="h-4 w-4 text-red-500" />
</button>
</div>
</div>
<h3 className="font-semibold text-gray-900">{product.name}</h3>
<p className="text-sm text-gray-500 capitalize">{product.category}</p>
<div className="mt-3 flex items-center justify-between">
<span className="text-xl font-bold text-primary-600">
${Number(product.price).toFixed(2)}
</span>
<span className={`text-sm font-medium ${
product.stock > 10 ? 'text-green-600' : product.stock > 5 ? 'text-yellow-600' : 'text-red-600'
}`}>
{product.stock} en stock
</span>
</div>
{product.barcode && (
<p className="text-xs text-gray-400 mt-2">{product.barcode}</p>
)}
</div>
))}
</div>
)}
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b flex items-center justify-between">
<h2 className="text-xl font-bold">
{editingProduct ? 'Editar Producto' : 'Nuevo Producto'}
</h2>
<button
onClick={handleCloseModal}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{formError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0" />
<p className="text-red-600 text-sm">{formError}</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nombre *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="input"
placeholder="Coca-Cola 600ml"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Categoria *
</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="input"
required
>
{categories.filter(c => c.id !== 'all').map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Precio *
</label>
<input
type="number"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })}
className="input"
placeholder="0.00"
step="0.01"
min="0"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Stock *
</label>
<input
type="number"
value={formData.stock}
onChange={(e) => setFormData({ ...formData, stock: parseInt(e.target.value) || 0 })}
className="input"
placeholder="0"
min="0"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Codigo de Barras
</label>
<input
type="text"
value={formData.barcode}
onChange={(e) => setFormData({ ...formData, barcode: e.target.value })}
className="input"
placeholder="7501055300051"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
SKU
</label>
<input
type="text"
value={formData.sku}
onChange={(e) => setFormData({ ...formData, sku: e.target.value })}
className="input"
placeholder="COCA-600ML"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Stock Minimo (alerta)
</label>
<input
type="number"
value={formData.minStock}
onChange={(e) => setFormData({ ...formData, minStock: parseInt(e.target.value) || 0 })}
className="input"
placeholder="5"
min="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Descripcion
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="input"
rows={2}
placeholder="Descripcion opcional del producto..."
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={handleCloseModal}
className="btn-secondary flex-1"
disabled={isSubmitting}
>
Cancelar
</button>
<button
type="submit"
disabled={isSubmitting}
className="btn-primary flex-1 flex items-center justify-center gap-2"
>
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
{isSubmitting
? 'Guardando...'
: editingProduct
? 'Guardar Cambios'
: 'Crear Producto'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{deletingProduct && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-xl max-w-sm w-full p-6">
<div className="text-center">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
<Trash2 className="h-6 w-6 text-red-600" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
Eliminar Producto
</h3>
<p className="text-gray-500 mb-6">
Estas seguro de eliminar <strong>{deletingProduct.name}</strong>?
Esta accion no se puede deshacer.
</p>
<div className="flex gap-3">
<button
onClick={() => setDeletingProduct(null)}
className="btn-secondary flex-1"
disabled={deleteMutation.isPending}
>
Cancelar
</button>
<button
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center justify-center gap-2"
>
{deleteMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}