feat(club-006): club admin: dashboard, members, movements and settings

This commit is contained in:
Deploy
2026-08-26 20:55:00 +02:00
parent db33706d2d
commit 665fc2a60d
18 changed files with 830 additions and 8 deletions

View File

@@ -0,0 +1,175 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { clubAdminApi } from '@/lib/api-client';
const TX_LABELS: Record<string, string> = {
earn: 'Cashback',
redeem: 'Canje',
refund: 'Reembolso',
bonus: 'Bono',
adjustment: 'Ajuste',
};
const TX_COLORS: Record<string, string> = {
earn: 'text-green-700',
redeem: 'text-blue-700',
refund: 'text-orange-700',
bonus: 'text-purple-700',
adjustment: 'text-gray-700',
};
function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('es-ES', {
day: 'numeric', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
interface MemberDetail {
id: string;
userId: string | null;
userEmail: string | null;
memberCode: string;
status: 'active' | 'blocked' | 'merged';
tierCode: string;
currentBalanceCents: number;
isAnonymous: boolean;
createdAt: string;
updatedAt: string;
}
interface Transaction {
id: string;
memberId: string;
saleId: string | null;
storeId: string | null;
type: string;
amountCents: number;
balanceDeltaCents: number;
createdAt: string;
}
export default function ClubMemberPage() {
const { id } = useParams<{ id: string }>();
const [member, setMember] = useState<MemberDetail | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await clubAdminApi.getMemberMovements(id);
setMember(data.member as MemberDetail);
setTransactions(data.items as Transaction[]);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { void load(); }, [load]);
if (loading) {
return (
<div className="p-8 text-center text-gray-400">
<div className="inline-block animate-spin h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full" />
<p className="mt-2 text-sm">Cargando...</p>
</div>
);
}
if (error || !member) {
return (
<div className="p-8 text-center">
<p className="text-red-600 text-sm mb-3">{error || 'Socio no encontrado'}</p>
<Link href="/club" className="text-sm text-[#2D6A4F] hover:underline"> Volver al Club</Link>
</div>
);
}
return (
<div className="p-8">
<div className="mb-6">
<Link href="/club" className="text-sm text-[#2D6A4F] hover:underline mb-4 inline-block"> Club de Clientes</Link>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">{member.memberCode}</h1>
<p className="text-sm text-gray-500 mt-1">
{member.userEmail ?? 'Socio anónimo'}
{!member.userId && <span className="ml-2 text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded">Sin cuenta</span>}
</p>
</div>
<div className="text-right">
<p className="text-3xl font-bold text-gray-900">{formatPrice(member.currentBalanceCents)}</p>
<p className="text-xs text-gray-500">Saldo actual</p>
<span className={`inline-block mt-1 px-2 py-0.5 rounded-full text-xs font-medium ${
member.status === 'active' ? 'bg-green-100 text-green-800' :
member.status === 'blocked' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-600'
}`}>
{member.status}
</span>
</div>
</div>
</div>
{/* Meta */}
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6">
<h2 className="mb-4 text-lg font-semibold text-gray-900">Datos del socio</h2>
<div className="grid grid-cols-2 gap-4 text-sm">
<div><span className="text-gray-500">ID:</span> <span className="font-mono text-gray-700">{member.id}</span></div>
<div><span className="text-gray-500">Código:</span> <span className="font-mono text-gray-700">{member.memberCode}</span></div>
<div><span className="text-gray-500">Email:</span> <span className="text-gray-700">{member.userEmail ?? '—'}</span></div>
<div><span className="text-gray-500">User ID:</span> <span className="font-mono text-gray-700">{member.userId ?? '—'}</span></div>
<div><span className="text-gray-500">Alta:</span> <span className="text-gray-700">{formatDate(member.createdAt)}</span></div>
<div><span className="text-gray-500">Última actualización:</span> <span className="text-gray-700">{formatDate(member.updatedAt)}</span></div>
</div>
</div>
{/* Movements */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="px-5 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Movimientos</h2>
</div>
{transactions.length === 0 ? (
<div className="p-12 text-center text-gray-400 text-sm">Sin movimientos</div>
) : (
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{['Fecha', 'Tipo', 'Importe', 'Delta'].map((h) => (
<th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-5 py-3">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{transactions.map((tx) => (
<tr key={tx.id} className="hover:bg-gray-50">
<td className="px-5 py-3 text-sm text-gray-600">{formatDate(tx.createdAt)}</td>
<td className="px-5 py-3">
<span className={`text-sm font-medium ${TX_COLORS[tx.type] ?? 'text-gray-700'}`}>
{TX_LABELS[tx.type] ?? tx.type}
</span>
</td>
<td className="px-5 py-3 text-sm text-gray-700">{formatPrice(tx.amountCents)}</td>
<td className={`px-5 py-3 text-sm font-bold ${tx.balanceDeltaCents >= 0 ? 'text-green-700' : 'text-red-700'}`}>
{tx.balanceDeltaCents >= 0 ? '+' : ''}{formatPrice(tx.balanceDeltaCents)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,204 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { clubAdminApi } from '@/lib/api-client';
type MemberStatus = 'active' | 'blocked' | 'merged';
const STATUS_LABELS: Record<MemberStatus, string> = {
active: 'Activo',
blocked: 'Bloqueado',
merged: 'Fusionado',
};
const STATUS_COLORS: Record<MemberStatus, string> = {
active: 'bg-green-100 text-green-800',
blocked: 'bg-red-100 text-red-800',
merged: 'bg-gray-100 text-gray-600',
};
function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
function timeAgo(dateStr: string) {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Hoy';
if (diffDays === 1) return 'Ayer';
if (diffDays < 30) return `Hace ${diffDays} días`;
return date.toLocaleDateString('es-ES', { day: 'numeric', month: 'short' });
}
export default function ClubPage() {
const [members, setMembers] = useState<Array<{
id: string; userId: string | null; userEmail: string | null;
memberCode: string; status: MemberStatus; tierCode: string;
currentBalanceCents: number; createdAt: string;
}>>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const limit = 20;
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 400);
return () => clearTimeout(t);
}, [search]);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await clubAdminApi.listMembers({
q: debouncedSearch || undefined,
status: filterStatus || undefined,
limit,
offset,
});
setMembers(data.items as typeof members);
setTotal(data.total);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar');
} finally {
setLoading(false);
}
}, [debouncedSearch, filterStatus, offset]);
useEffect(() => { void load(); }, [load]);
const totalPages = Math.ceil(total / limit);
return (
<div className="p-8">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Club de Clientes</h1>
<p className="text-sm text-gray-500 mt-0.5">{total} socios</p>
</div>
</div>
{/* Filters */}
<div className="flex gap-3 mb-6 flex-wrap">
<div className="relative flex-1 max-w-xs">
<input
type="search"
placeholder="Buscar por código o email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 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>
<select
value={filterStatus}
onChange={(e) => { setFilterStatus(e.target.value); setOffset(0); }}
className="px-3 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none bg-white"
>
<option value="">Todos los estados</option>
<option value="active">Activo</option>
<option value="blocked">Bloqueado</option>
<option value="merged">Fusionado</option>
</select>
</div>
{/* Table */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? (
<div className="text-center text-gray-400 py-12">
<div className="inline-block animate-spin h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full" />
<p className="mt-2 text-sm">Cargando...</p>
</div>
) : error ? (
<div className="text-center py-12">
<p className="text-red-600 text-sm mb-3">{error}</p>
<button onClick={() => void load()} className="text-sm text-[#2D6A4F] hover:underline">
Reintentar
</button>
</div>
) : members.length === 0 ? (
<div className="p-12 text-center">
<p className="text-4xl mb-3">🎫</p>
<p className="text-gray-500 text-sm">No hay socios del Club</p>
</div>
) : (
<>
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{['Código', 'Email', 'Estado', 'Saldo', '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">
{members.map((m) => (
<tr key={m.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3">
<Link href={`/club/${m.id}`} className="text-sm font-mono text-[#2D6A4F] hover:underline">
{m.memberCode}
</Link>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-600">{m.userEmail ?? '—'}</p>
{m.userEmail && <p className="text-xs text-gray-400">Anónimo vinculado</p>}
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[m.status]}`}>
<span className="w-1.5 h-1.5 rounded-full bg-current" />
{STATUS_LABELS[m.status]}
</span>
</td>
<td className="px-4 py-3">
<p className="text-sm font-bold text-gray-900">{formatPrice(m.currentBalanceCents)}</p>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-600">{timeAgo(m.createdAt)}</p>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200">
<p className="text-xs text-gray-500">
{offset + 1}{Math.min(offset + limit, total)} de {total}
</p>
<div className="flex gap-1">
<button
onClick={() => setOffset(Math.max(0, offset - limit))}
disabled={offset === 0}
className="px-3 py-1 text-xs border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-gray-50"
>
Anterior
</button>
<button
onClick={() => setOffset(offset + limit)}
disabled={offset + limit >= total}
className="px-3 py-1 text-xs border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-gray-50"
>
Siguiente
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -441,3 +441,58 @@ export const settingsApi = {
update: (data: Partial<StoreSettings>) => api.patch<StoreSettings>('/api/admin/settings', data),
about: () => api.get<AboutInfo>('/api/admin/about'),
};
// ── Club Admin API ────────────────────────────────────────────────────────────
interface ClubAdminMember {
id: string;
userId: string | null;
userEmail: string | null;
memberCode: string;
status: 'active' | 'blocked' | 'merged';
tierCode: string;
currentBalanceCents: number;
isAnonymous: boolean;
createdAt: string;
updatedAt: string;
}
interface ClubAdminTransaction {
id: string;
memberId: string;
saleId: string | null;
storeId: string | null;
type: 'earn' | 'redeem' | 'refund' | 'bonus' | 'adjustment';
amountCents: number;
balanceDeltaCents: number;
idempotencyKey: string | null;
metadata: Record<string, unknown>;
createdAt: string;
}
interface ClubAdminMembersResponse {
items: ClubAdminMember[];
total: number;
limit: number;
offset: number;
}
export const clubAdminApi = {
listMembers: (opts?: { q?: string; status?: string; limit?: number; offset?: number }) => {
const params = new URLSearchParams();
if (opts?.q) params.set('q', opts.q);
if (opts?.status) params.set('status', opts.status);
if (opts?.limit) params.set('limit', String(opts.limit));
if (opts?.offset) params.set('offset', String(opts.offset));
const qs = params.toString();
return api.get<ClubAdminMembersResponse>(`/api/admin/club/members${qs ? `?${qs}` : ''}`);
},
getMember: (memberId: string) =>
api.get<{ member: ClubAdminMember }>(`/api/admin/club/members/${memberId}`),
getMemberMovements: (memberId: string, limit = 50) => {
const params = new URLSearchParams({ limit: String(limit) });
return api.get<{ member: ClubAdminMember; items: ClubAdminTransaction[] }>(
`/api/admin/club/members/${memberId}/movements?${params}`,
);
},
};

View File

@@ -45,6 +45,7 @@ export const NAV_ITEMS: NavItem[] = [
{ href: '/pos', label: 'TPV', icon: '🛒', permission: 'dashboard' },
{ href: '/products', label: 'Productos', icon: '📦', permission: 'products.read' },
{ href: '/orders', label: 'Pedidos', icon: '🧾', permission: 'orders.read' },
{ href: '/club', label: 'Club', icon: '🎫', permission: 'orders.read' },
{ href: '/payments', label: 'Pagos', icon: '💳', permission: 'orders.read' },
{ href: '/inventory', label: 'Inventario', icon: '📊', permission: 'inventory.read' },
{ href: '/customers', label: 'Clientes', icon: '👥', permission: 'customers.read' },