Files
mercadodevida/project/apps/admin/src/app/(dashboard)/customers/page.tsx
2026-08-17 22:23:10 +02:00

299 lines
14 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback } from 'react';
import type { Customer } from '@/types';
import { customersApi } from '@/lib/api-client';
const PAGE_SIZE = 20;
// ── Modal genérico ────────────────────────────────────────────────────────────
function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
}
// ── Formulario crear cliente ─────────────────────────────────────────────────
function CreateForm({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [phone, setPhone] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const handle = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true); setError('');
try {
await customersApi.create({ email, password, displayName: displayName || undefined, phone: phone || undefined });
onCreated();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al crear cliente');
} finally {
setSaving(false);
}
};
return (
<form onSubmit={handle} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Contraseña *</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre / Razón social</label>
<input type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Opcional"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Teléfono</label>
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+34 600 000 000"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{error}</p>}
<div className="flex gap-3 pt-2">
<button type="submit" disabled={saving}
className="flex-1 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
{saving ? 'Creando...' : 'Crear cliente'}
</button>
<button type="button" onClick={onClose}
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50 transition-colors">
Cancelar
</button>
</div>
</form>
);
}
// ── Formulario editar cliente ────────────────────────────────────────────────
function EditForm({ customer, onClose, onSaved }: { customer: Customer; onClose: () => void; onSaved: () => void }) {
const [displayName, setDisplayName] = useState('');
const [phone, setPhone] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setDisplayName(customer.displayName || '');
setPhone(customer.phone || '');
}, [customer]);
const handle = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true); setError('');
try {
await customersApi.update(customer.id, {
displayName: displayName || undefined,
phone: phone || undefined,
});
onSaved();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al guardar');
} finally {
setSaving(false);
}
};
return (
<form onSubmit={handle} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" value={customer.email} disabled
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50 text-gray-400" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre / Razón social</label>
<input type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Teléfono</label>
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+34 600 000 000"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{error}</p>}
<div className="flex gap-3 pt-2">
<button type="submit" disabled={saving}
className="flex-1 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
{saving ? 'Guardando...' : 'Guardar cambios'}
</button>
<button type="button" onClick={onClose}
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50 transition-colors">
Cancelar
</button>
</div>
</form>
);
}
// ── Página principal ───────────────────────────────────────────────────────────
export default function CustomersPage() {
const [customers, setCustomers] = useState<Customer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [debounced, setDebounced] = useState('');
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
const [showCreate, setShowCreate] = useState(false);
const [editing, setEditing] = useState<Customer | null>(null);
const [msg, setMsg] = useState('');
useEffect(() => {
const t = setTimeout(() => setDebounced(search), 400);
return () => clearTimeout(t);
}, [search]);
useEffect(() => { setPage(0); }, [debounced]);
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const data = await customersApi.list({ limit: PAGE_SIZE, offset: page * PAGE_SIZE, q: debounced || undefined });
setCustomers(data.items ?? []);
setTotal(data.total ?? 0);
} catch (e) {
setError(e instanceof Error ? e.message : 'Error');
} finally {
setLoading(false);
}
}, [page, debounced]);
useEffect(() => { load(); }, [load]);
const handleCreated = () => { setMsg('Cliente creado correctamente'); setTimeout(() => setMsg(''), 3000); load(); };
const handleSaved = () => { setMsg('Cliente actualizado'); setTimeout(() => setMsg(''), 3000); load(); };
return (
<div className="p-8 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Clientes</h1>
<p className="text-sm text-gray-500 mt-0.5">{total > 0 ? `${total} cliente${total !== 1 ? 's' : ''}` : ''}</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors"
>
+ Nuevo cliente
</button>
</div>
{msg && (
<div className="p-4 rounded-xl text-sm bg-green-50 text-green-700">{msg}</div>
)}
{/* Buscador */}
<div className="relative max-w-sm">
<input type="search" placeholder="Buscar por email..." value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none" />
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
</svg>
</div>
{/* Tabla */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? (
<div className="p-12 flex items-center justify-center gap-3 text-gray-400">
<div className="h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full animate-spin" />
<span className="text-sm">Cargando...</span>
</div>
) : error ? (
<div className="p-8 text-center">
<p className="text-red-600 text-sm mb-3">{error}</p>
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline">Reintentar</button>
</div>
) : customers.length === 0 ? (
<div className="p-12 text-center">
<p className="text-4xl mb-3">👥</p>
<p className="text-gray-500 text-sm">No hay clientes</p>
</div>
) : (
<>
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{['Email', 'Nombre', 'Teléfono', 'Rol', 'Alta', ''].map((h) => (
<th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{customers.map((c) => (
<tr key={c.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{c.email}</td>
<td className="px-4 py-3.5 text-sm text-gray-600">{c.displayName || '—'}</td>
<td className="px-4 py-3.5 text-sm text-gray-600">{c.phone || '—'}</td>
<td className="px-4 py-3.5">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium capitalize ${
c.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'
}`}>{c.role}</span>
</td>
<td className="px-4 py-3.5 text-sm text-gray-500">
{c.createdAt ? new Date(c.createdAt).toLocaleDateString('es-ES') : '—'}
</td>
<td className="px-4 py-3.5">
<button onClick={() => setEditing(c)}
className="text-xs text-[#2D6A4F] hover:underline">Editar</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Paginación */}
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 bg-gray-50">
<p className="text-sm text-gray-500">Página {page + 1}</p>
<div className="flex gap-2">
<button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white">
Anterior
</button>
<button onClick={() => setPage((p) => p + 1)} disabled={customers.length < PAGE_SIZE}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white">
Siguiente
</button>
</div>
</div>
</>
)}
</div>
{/* Modal crear */}
{showCreate && (
<Modal title="Nuevo cliente" onClose={() => setShowCreate(false)}>
<CreateForm onClose={() => setShowCreate(false)} onCreated={handleCreated} />
</Modal>
)}
{/* Modal editar */}
{editing && (
<Modal title={`Editar: ${editing.email}`} onClose={() => setEditing(null)}>
<EditForm customer={editing} onClose={() => setEditing(null)} onSaved={handleSaved} />
</Modal>
)}
</div>
);
}