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

- Replace mock data with React Query hooks
- Add CRUD operations (create/update customers)
- Add loading, error, and empty states
- Add modal for customer form
- 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:12 -06:00
parent 2c4db175c2
commit 969f8acb9a

View File

@ -1,61 +1,162 @@
import { useState } from 'react';
import { Search, Plus, Phone, Mail, MapPin, CreditCard } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Search, Plus, Phone, Mail, MapPin, CreditCard, X, Loader2, AlertCircle, Edit2 } from 'lucide-react';
import { customersApi } from '../lib/api';
const mockCustomers = [
{
id: '1',
name: 'Maria Lopez',
phone: '5551234567',
email: 'maria@email.com',
address: 'Calle 1 #123',
totalPurchases: 45,
totalSpent: 3450.00,
fiadoBalance: 150.00,
fiadoLimit: 500.00,
lastVisit: '2024-01-15',
},
{
id: '2',
name: 'Juan Perez',
phone: '5559876543',
email: null,
address: null,
totalPurchases: 23,
totalSpent: 1890.00,
fiadoBalance: 0,
fiadoLimit: 300.00,
lastVisit: '2024-01-14',
},
{
id: '3',
name: 'Ana Garcia',
phone: '5555555555',
email: 'ana@email.com',
address: 'Av. Principal #456',
totalPurchases: 67,
totalSpent: 5670.00,
fiadoBalance: 320.00,
fiadoLimit: 1000.00,
lastVisit: '2024-01-15',
},
];
interface Customer {
id: string;
name: string;
phone: string;
email?: string | null;
address?: string | null;
totalPurchases?: number;
totalSpent?: number;
fiadoBalance?: number;
fiadoLimit?: number;
lastVisit?: string;
createdAt?: string;
}
interface CustomerFormData {
name: string;
phone: string;
email?: string;
address?: string;
fiadoLimit?: number;
}
export function Customers() {
const [search, setSearch] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [formData, setFormData] = useState<CustomerFormData>({
name: '',
phone: '',
email: '',
address: '',
fiadoLimit: 0,
});
const [formError, setFormError] = useState<string | null>(null);
const filteredCustomers = mockCustomers.filter((customer) =>
customer.name.toLowerCase().includes(search.toLowerCase()) ||
customer.phone.includes(search)
);
const queryClient = useQueryClient();
// Fetch customers with search
const { data: customersResponse, isLoading, error } = useQuery({
queryKey: ['customers', { search }],
queryFn: () => customersApi.getAll({ search: search || undefined }),
});
const customers: Customer[] = customersResponse?.data || [];
// Create customer mutation
const createMutation = useMutation({
mutationFn: (data: CustomerFormData) => customersApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
closeModal();
},
onError: (err: unknown) => {
const error = err as { response?: { data?: { message?: string } } };
setFormError(error.response?.data?.message || 'Error al crear cliente');
},
});
// Update customer mutation
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: CustomerFormData }) =>
customersApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
closeModal();
},
onError: (err: unknown) => {
const error = err as { response?: { data?: { message?: string } } };
setFormError(error.response?.data?.message || 'Error al actualizar cliente');
},
});
const openCreateModal = () => {
setEditingCustomer(null);
setFormData({
name: '',
phone: '',
email: '',
address: '',
fiadoLimit: 0,
});
setFormError(null);
setIsModalOpen(true);
};
const openEditModal = (customer: Customer) => {
setEditingCustomer(customer);
setFormData({
name: customer.name,
phone: customer.phone,
email: customer.email || '',
address: customer.address || '',
fiadoLimit: customer.fiadoLimit || 0,
});
setFormError(null);
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingCustomer(null);
setFormError(null);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
if (!formData.name.trim()) {
setFormError('El nombre es requerido');
return;
}
if (!formData.phone.trim()) {
setFormError('El telefono es requerido');
return;
}
const dataToSend = {
name: formData.name.trim(),
phone: formData.phone.trim(),
email: formData.email?.trim() || undefined,
address: formData.address?.trim() || undefined,
fiadoLimit: formData.fiadoLimit || 0,
};
if (editingCustomer) {
updateMutation.mutate({ id: editingCustomer.id, data: dataToSend });
} else {
createMutation.mutate(dataToSend);
}
};
const isSubmitting = createMutation.isPending || updateMutation.isPending;
// Format date for display
const formatDate = (dateString?: string) => {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleDateString('es-MX');
} catch {
return dateString;
}
};
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">Clientes</h1>
<p className="text-gray-500">{mockCustomers.length} clientes registrados</p>
<p className="text-gray-500">
{isLoading ? 'Cargando...' : `${customers.length} clientes registrados`}
</p>
</div>
<button className="btn-primary flex items-center gap-2">
<button onClick={openCreateModal} className="btn-primary flex items-center gap-2">
<Plus className="h-5 w-5" />
Nuevo Cliente
</button>
@ -73,62 +174,216 @@ export function Customers() {
/>
</div>
{/* Loading State */}
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary-600" />
<span className="ml-2 text-gray-600">Cargando clientes...</span>
</div>
)}
{/* Error State */}
{error && (
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
<AlertCircle className="h-5 w-5 text-red-500" />
<p className="text-red-700">Error al cargar clientes. Intenta de nuevo.</p>
</div>
)}
{/* Empty State */}
{!isLoading && !error && customers.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 mb-4">
{search ? 'No se encontraron clientes con esa busqueda' : 'No hay clientes registrados'}
</p>
{!search && (
<button onClick={openCreateModal} className="btn-primary">
Agregar primer cliente
</button>
)}
</div>
)}
{/* Customers List */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredCustomers.map((customer) => (
<div key={customer.id} className="card hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-bold text-gray-900">{customer.name}</h3>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Phone className="h-4 w-4" />
{customer.phone}
{!isLoading && !error && customers.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{customers.map((customer) => (
<div key={customer.id} className="card hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-bold text-gray-900">{customer.name}</h3>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Phone className="h-4 w-4" />
{customer.phone}
</div>
{customer.email && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Mail className="h-4 w-4" />
{customer.email}
</div>
)}
{customer.address && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<MapPin className="h-4 w-4" />
{customer.address}
</div>
)}
</div>
{customer.email && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Mail className="h-4 w-4" />
{customer.email}
<div className="flex items-start gap-2">
<button
onClick={() => openEditModal(customer)}
className="p-1 text-gray-400 hover:text-primary-600 transition-colors"
title="Editar cliente"
>
<Edit2 className="h-4 w-4" />
</button>
<div className="text-right">
<p className="text-xs text-gray-500">Ultima visita</p>
<p className="text-sm font-medium">{formatDate(customer.lastVisit || customer.createdAt)}</p>
</div>
)}
{customer.address && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<MapPin className="h-4 w-4" />
{customer.address}
</div>
</div>
<div className="grid grid-cols-3 gap-4 pt-4 border-t">
<div>
<p className="text-sm text-gray-500">Compras</p>
<p className="text-lg font-bold">{customer.totalPurchases || 0}</p>
</div>
<div>
<p className="text-sm text-gray-500">Total gastado</p>
<p className="text-lg font-bold">${(customer.totalSpent || 0).toFixed(0)}</p>
</div>
<div>
<p className="text-sm text-gray-500">Fiado</p>
<div className="flex items-center gap-1">
<CreditCard className={`h-4 w-4 ${
(customer.fiadoBalance || 0) > 0 ? 'text-orange-500' : 'text-green-500'
}`} />
<p className={`text-lg font-bold ${
(customer.fiadoBalance || 0) > 0 ? 'text-orange-600' : 'text-green-600'
}`}>
${(customer.fiadoBalance || 0).toFixed(0)}
</p>
</div>
)}
</div>
<div className="text-right">
<p className="text-xs text-gray-500">Ultima visita</p>
<p className="text-sm font-medium">{customer.lastVisit}</p>
</div>
</div>
</div>
))}
</div>
)}
{/* Create/Edit Modal */}
{isModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full">
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-lg font-bold">
{editingCustomer ? 'Editar Cliente' : 'Nuevo Cliente'}
</h2>
<button onClick={closeModal} className="text-gray-400 hover:text-gray-600">
<X className="h-5 w-5" />
</button>
</div>
<div className="grid grid-cols-3 gap-4 pt-4 border-t">
<div>
<p className="text-sm text-gray-500">Compras</p>
<p className="text-lg font-bold">{customer.totalPurchases}</p>
</div>
<div>
<p className="text-sm text-gray-500">Total gastado</p>
<p className="text-lg font-bold">${customer.totalSpent.toFixed(0)}</p>
</div>
<div>
<p className="text-sm text-gray-500">Fiado</p>
<div className="flex items-center gap-1">
<CreditCard className={`h-4 w-4 ${
customer.fiadoBalance > 0 ? 'text-orange-500' : 'text-green-500'
}`} />
<p className={`text-lg font-bold ${
customer.fiadoBalance > 0 ? 'text-orange-600' : 'text-green-600'
}`}>
${customer.fiadoBalance.toFixed(0)}
</p>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{formError && (
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-lg">
<AlertCircle className="h-4 w-4 text-red-500" />
<p className="text-sm text-red-700">{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="Nombre del cliente"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Telefono *
</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className="input"
placeholder="5551234567"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="input"
placeholder="cliente@email.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Direccion
</label>
<input
type="text"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
className="input"
placeholder="Calle y numero"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Limite de Fiado
</label>
<input
type="number"
value={formData.fiadoLimit}
onChange={(e) => setFormData({ ...formData, fiadoLimit: Number(e.target.value) })}
className="input"
placeholder="0"
min="0"
step="50"
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={closeModal}
className="btn-secondary flex-1"
disabled={isSubmitting}
>
Cancelar
</button>
<button
type="submit"
className="btn-primary flex-1 flex items-center justify-center gap-2"
disabled={isSubmitting}
>
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
{editingCustomer ? 'Guardar' : 'Crear'}
</button>
</div>
</form>
</div>
))}
</div>
</div>
)}
</div>
);
}