[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:
parent
2c4db175c2
commit
969f8acb9a
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user