feat(F-048): completed feature

This commit is contained in:
chattie
2026-08-19 07:17:14 +02:00
parent 8ee1938af9
commit 835ab66eda
187 changed files with 12361 additions and 1065 deletions

View File

@@ -1,8 +1,114 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { customersApi } from '@/lib/api-client';
import type { Customer } from '@/types';
import type { Customer, Address } from '@/types';
// ── FIX-18: edición de datos personales (no credenciales) + CRUD de direcciones ──
interface AddressFormState {
label: string;
recipientName: string;
street: string;
city: string;
postalCode: string;
country: string;
isDefault: boolean;
}
const EMPTY_ADDRESS: AddressFormState = {
label: '',
recipientName: '',
street: '',
city: '',
postalCode: '',
country: '',
isDefault: false,
};
function AddressForm({
initial,
onSave,
onCancel,
saving,
}: {
initial?: Address;
onSave: (data: AddressFormState) => void;
onCancel: () => void;
saving: boolean;
}) {
const [f, setF] = useState<AddressFormState>(
initial
? {
label: initial.label ?? '',
recipientName: initial.recipientName,
street: initial.street,
city: initial.city,
postalCode: initial.postalCode,
country: initial.country,
isDefault: initial.isDefault ?? false,
}
: EMPTY_ADDRESS,
);
const set = (k: keyof AddressFormState) => (e: React.ChangeEvent<HTMLInputElement>) =>
setF((prev) => ({ ...prev, [k]: e.target.value }));
const valid = f.recipientName && f.street && f.city && f.postalCode && f.country;
return (
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Etiqueta</label>
<input value={f.label} onChange={set('label')} placeholder="Casa, Oficina…"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Destinatario *</label>
<input value={f.recipientName} onChange={set('recipientName')}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div className="col-span-2">
<label className="block text-xs font-medium text-gray-600 mb-1">Dirección *</label>
<input value={f.street} onChange={set('street')} placeholder="Calle, número, piso"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Ciudad *</label>
<input value={f.city} onChange={set('city')}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Código postal *</label>
<input value={f.postalCode} onChange={set('postalCode')}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">País *</label>
<input value={f.country} onChange={set('country')} placeholder="España"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input type="checkbox" checked={f.isDefault} onChange={(e) => setF((p) => ({ ...p, isDefault: e.target.checked }))}
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
Predeterminada
</label>
</div>
</div>
<div className="flex gap-2">
<button onClick={() => onSave(f)} disabled={!valid || saving}
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-lg">
{saving ? 'Guardando…' : 'Guardar dirección'}
</button>
<button onClick={onCancel} className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-lg hover:bg-white">
Cancelar
</button>
</div>
</div>
);
}
export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -10,29 +116,98 @@ export default function CustomerDetailPage() {
const [customer, setCustomer] = useState<Customer | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
useEffect(() => {
customersApi.get(id).then(setCustomer).catch(() => setError('No se encontró el cliente')).finally(() => setLoading(false));
// Personal data (no credenciales)
const [displayName, setDisplayName] = useState('');
const [phone, setPhone] = useState('');
const [savingProfile, setSavingProfile] = useState(false);
// Addresses
const [addresses, setAddresses] = useState<Address[]>([]);
const [showAddressForm, setShowAddressForm] = useState(false);
const [editingAddress, setEditingAddress] = useState<Address | null>(null);
const [savingAddress, setSavingAddress] = useState(false);
const loadAddresses = useCallback(async () => {
try {
const d = await customersApi.listAddresses(id);
setAddresses(d.items ?? []);
} catch {
/* silencioso */
}
}, [id]);
const handleSave = async () => {
if (!customer) return;
setSaving(true);
setMsg('');
useEffect(() => {
customersApi
.get(id)
.then((c) => {
setCustomer(c);
setDisplayName(c.displayName ?? '');
setPhone(c.phone ?? '');
})
.catch(() => setError('No se encontró el cliente'))
.finally(() => setLoading(false));
loadAddresses();
}, [id, loadAddresses]);
const flash = (text: string) => {
setMsg(text);
setTimeout(() => setMsg(''), 3000);
};
const saveProfile = async () => {
setSavingProfile(true);
try {
// PATCH /users/:id for profile fields (displayName, phone — role changes require separate process)
await fetch(`/api/customers/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: customer.role }),
const updated = await customersApi.update(id, {
displayName: displayName || undefined,
phone: phone || undefined,
});
setMsg('Cliente actualizado');
setCustomer(updated);
flash('Datos personales guardados');
} catch {
setMsg('Error al guardar');
flash('Error al guardar');
} finally {
setSaving(false);
setSavingProfile(false);
}
};
const handleSaveAddress = async (data: AddressFormState) => {
setSavingAddress(true);
try {
const payload = {
label: data.label || null,
recipientName: data.recipientName,
street: data.street,
city: data.city,
postalCode: data.postalCode,
country: data.country,
isDefault: data.isDefault,
};
if (editingAddress) {
await customersApi.updateAddress(id, editingAddress.id, payload);
flash('Dirección actualizada');
} else {
await customersApi.createAddress(id, payload);
flash('Dirección creada');
}
setShowAddressForm(false);
setEditingAddress(null);
loadAddresses();
} catch {
flash('Error al guardar dirección');
} finally {
setSavingAddress(false);
}
};
const handleDeleteAddress = async (addressId: string) => {
if (!confirm('¿Eliminar esta dirección?')) return;
try {
await customersApi.deleteAddress(id, addressId);
loadAddresses();
} catch {
flash('Error al eliminar dirección');
}
};
@@ -40,49 +215,108 @@ export default function CustomerDetailPage() {
if (error || !customer) return <div className="p-8 text-red-600">{error || 'No encontrado'}</div>;
return (
<div className="p-8 max-w-2xl space-y-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-8 space-y-6">
<div className="flex items-center gap-4">
<button onClick={() => router.push('/customers')} className="text-sm text-gray-500 hover:text-gray-700"> Clientes</button>
<h1 className="text-2xl font-bold text-gray-900">{customer.email}</h1>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
<div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Email</p>
<p className="text-sm text-gray-900">{customer.email}</p>
{msg && (
<div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>{msg}</div>
)}
{/* Datos personales */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
<h2 className="text-base font-semibold text-gray-800">Datos personales</h2>
<p className="text-xs text-gray-400 mt-0.5">El email y las credenciales de acceso no se pueden modificar desde aquí.</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Rol</p>
<select
value={customer.role}
onChange={(e) => setCustomer({ ...customer, role: e.target.value as 'customer' | 'admin' })}
className="px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white"
>
<option value="customer">Cliente</option>
<option value="admin">Administrador</option>
</select>
</div>
<div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Registrado</p>
<p className="text-sm text-gray-900">
{customer.createdAt ? new Date(customer.createdAt).toLocaleString('es-ES') : '—'}
</p>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email (solo lectura)</label>
<input 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 className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre / Razón social</label>
<input 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 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>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-gray-400">
Registrado: {customer.createdAt ? new Date(customer.createdAt).toLocaleString('es-ES') : '—'}
</p>
<button onClick={saveProfile} disabled={savingProfile}
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl">
{savingProfile ? 'Guardando…' : 'Guardar datos personales'}
</button>
</div>
</div>
</div>
{msg && (
<div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
{msg}
{/* Direcciones */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-800">Direcciones ({addresses.length})</h2>
{!showAddressForm && (
<button onClick={() => { setEditingAddress(null); setShowAddressForm(true); }}
className="px-3 py-1.5 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-lg">
+ Añadir dirección
</button>
)}
</div>
)}
<div className="p-6 space-y-4">
{showAddressForm && (
<AddressForm
initial={editingAddress ?? undefined}
onSave={handleSaveAddress}
onCancel={() => { setShowAddressForm(false); setEditingAddress(null); }}
saving={savingAddress}
/>
)}
<button
onClick={handleSave}
disabled={saving}
className="px-6 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>
{addresses.length === 0 && !showAddressForm ? (
<p className="text-sm text-gray-400 text-center py-6">Este cliente no tiene direcciones.</p>
) : (
<div className="space-y-3">
{addresses.map((a) => (
<div key={a.id} className="border border-gray-200 rounded-xl p-4 flex items-start justify-between gap-4">
<div className="text-sm">
<div className="flex items-center gap-2">
<p className="font-semibold text-gray-900">{a.recipientName}</p>
{a.label && <span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded-full">{a.label}</span>}
{a.isDefault && <span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">Predeterminada</span>}
</div>
<p className="text-gray-600 mt-1">{a.street}</p>
<p className="text-gray-500">{a.postalCode} {a.city}, {a.country}</p>
</div>
<div className="flex gap-2 shrink-0">
<button onClick={() => { setEditingAddress(a); setShowAddressForm(true); }} title="Editar" aria-label="Editar"
className="p-1.5 text-gray-400 hover:text-[#2D6A4F] hover:bg-green-50 rounded-lg transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button onClick={() => handleDeleteAddress(a.id)} title="Eliminar" aria-label="Eliminar"
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,9 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import type { Customer } from '@/types';
import { customersApi } from '@/lib/api-client';
import { RowActions } from '@/components/ui/RowActions';
const PAGE_SIZE = 20;
@@ -84,69 +86,9 @@ function CreateForm({ onClose, onCreated }: { onClose: () => void; onCreated: ()
);
}
// ── 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 router = useRouter();
const [customers, setCustomers] = useState<Customer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -155,7 +97,6 @@ export default function CustomersPage() {
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(() => {
@@ -181,7 +122,6 @@ export default function CustomersPage() {
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">
@@ -255,8 +195,7 @@ export default function CustomersPage() {
{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>
<RowActions onEdit={() => router.push(`/customers/${c.id}`)} />
</td>
</tr>
))}
@@ -287,12 +226,6 @@ export default function CustomersPage() {
</Modal>
)}
{/* Modal editar */}
{editing && (
<Modal title={`Editar: ${editing.email}`} onClose={() => setEditing(null)}>
<EditForm customer={editing} onClose={() => setEditing(null)} onSaved={handleSaved} />
</Modal>
)}
</div>
);
}