[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:
parent
0385695d27
commit
8199d622b1
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user