299 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|