feat(club-006): club admin: dashboard, members, movements and settings
This commit is contained in:
@@ -8110,14 +8110,16 @@
|
||||
"description": "Phase 6. Add admin Club de Clientes section with dashboard, member list, movements and configuration screens.",
|
||||
"priority": "med",
|
||||
"risk": "med",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"created_at": "2026-08-26",
|
||||
"gates": {
|
||||
"reviewer": false,
|
||||
"security": false,
|
||||
"qa": false
|
||||
"reviewer": true,
|
||||
"security": true,
|
||||
"qa": true,
|
||||
"close": true
|
||||
},
|
||||
"phase": "admin"
|
||||
"phase": "admin",
|
||||
"completed_at": "2026-08-26T18:55:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
175
project/apps/admin/src/app/(dashboard)/club/[id]/page.tsx
Normal file
175
project/apps/admin/src/app/(dashboard)/club/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
project/apps/admin/src/app/(dashboard)/club/page.tsx
Normal file
204
project/apps/admin/src/app/(dashboard)/club/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AppError } from '../../../shared/errors.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { ClubService } from '../application/club-service.js';
|
||||
import type { ClubMember, ClubRecoveryCode, ClubSettings, ClubTransaction } from '../domain/club.js';
|
||||
import type { ClubMember, ClubMemberWithUser, ClubRecoveryCode, ClubSettings, ClubTransaction } from '../domain/club.js';
|
||||
import {
|
||||
ClubAnonymousJoinDisabledError,
|
||||
ClubDeviceTokenRequiredError,
|
||||
@@ -411,6 +411,107 @@ export async function registerClubRoutes(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Admin member management ──────────────────────────────────────────────────
|
||||
app.get(
|
||||
'/admin/club/members',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Club Admin'],
|
||||
summary: 'List Club members (admin)',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string' },
|
||||
status: { type: 'string' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
||||
offset: { type: 'integer', minimum: 0, default: 0 },
|
||||
},
|
||||
},
|
||||
response: { 200: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
} as FastifySchema,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const query = request.query as { q?: string; status?: string; limit?: number; offset?: number };
|
||||
const result = await clubs.listMembers({
|
||||
q: query.q,
|
||||
status: query.status,
|
||||
limit: query.limit ?? 20,
|
||||
offset: query.offset ?? 0,
|
||||
});
|
||||
return reply.send({
|
||||
items: result.items.map((m: ClubMemberWithUser) => serializeMemberWithUser(m)),
|
||||
total: result.total,
|
||||
limit: result.limit,
|
||||
offset: result.offset,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/club/members/:memberId',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Club Admin'],
|
||||
summary: 'Get Club member detail (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['memberId'],
|
||||
properties: { memberId: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 200: { type: 'object' }, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
} as FastifySchema,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const params = request.params as { memberId?: string };
|
||||
try {
|
||||
const member = await clubs.getMemberById(params.memberId!);
|
||||
return reply.send({ member: serializeMemberWithUser(member) });
|
||||
} catch (error) {
|
||||
throw mapClubError(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/club/members/:memberId/movements',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Club Admin'],
|
||||
summary: 'List Club member movements (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['memberId'],
|
||||
properties: { memberId: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: { limit: { type: 'integer', minimum: 1, maximum: 200, default: 50 } },
|
||||
},
|
||||
response: { 200: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
} as FastifySchema,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const params = request.params as { memberId?: string };
|
||||
const query = request.query as { limit?: number };
|
||||
try {
|
||||
const member = await clubs.getMemberById(params.memberId!);
|
||||
const { items } = await clubs.listMemberTransactions(member.id, query.limit ?? 50);
|
||||
return reply.send({
|
||||
member: serializeMemberWithUser(member),
|
||||
items: items.map(serializeTransaction),
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapClubError(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function authenticateOptional(
|
||||
@@ -509,6 +610,13 @@ function serializeMember(member: ClubMember) {
|
||||
};
|
||||
}
|
||||
|
||||
function serializeMemberWithUser(member: ClubMemberWithUser) {
|
||||
return {
|
||||
...serializeMember(member),
|
||||
userEmail: member.userEmail,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeTransaction(transaction: ClubTransaction) {
|
||||
return {
|
||||
id: transaction.id,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type {
|
||||
ClubMember,
|
||||
ClubMemberWithUser,
|
||||
ClubResolveInput,
|
||||
ClubSettings,
|
||||
ClubTransaction,
|
||||
GenerateRecoveryCodesResult,
|
||||
JoinClubResult,
|
||||
LinkMemberResult,
|
||||
MergeMembersResult,
|
||||
PaginatedClubMembers,
|
||||
RecoverByCodeResult,
|
||||
RecordClubTransactionCommand,
|
||||
RecordClubTransactionResult,
|
||||
@@ -143,6 +146,27 @@ export class ClubService {
|
||||
return this.clubs.linkMemberToUser(member.id, input.userId);
|
||||
}
|
||||
|
||||
async listMembers(opts: { q?: string; status?: string; limit: number; offset: number }): Promise<PaginatedClubMembers> {
|
||||
return this.clubs.listMembers({
|
||||
q: opts.q?.trim() || undefined,
|
||||
status: opts.status || undefined,
|
||||
limit: Math.min(Math.max(opts.limit, 1), 100),
|
||||
offset: Math.max(opts.offset, 0),
|
||||
});
|
||||
}
|
||||
|
||||
async getMemberById(id: string): Promise<ClubMemberWithUser> {
|
||||
const member = await this.clubs.findMemberById(id);
|
||||
if (!member) throw new ClubMemberNotFoundError();
|
||||
return member;
|
||||
}
|
||||
|
||||
async listMemberTransactions(memberId: string, limit = 50): Promise<{ items: ClubTransaction[] }> {
|
||||
const safeLimit = Math.min(Math.max(limit, 1), 200);
|
||||
const items = await this.clubs.listTransactions(memberId, safeLimit);
|
||||
return { items };
|
||||
}
|
||||
|
||||
async mergeMembers(primaryMemberId: string, mergedMemberId: string): Promise<MergeMembersResult> {
|
||||
if (primaryMemberId === mergedMemberId) {
|
||||
throw new InvalidClubTransactionError('No se puede fusionar un socio consigo mismo');
|
||||
|
||||
@@ -106,3 +106,14 @@ export interface MergeMembersResult {
|
||||
primary: ClubMember; // the member kept (registered one)
|
||||
merged: ClubMember; // the member archived (anonymous one)
|
||||
}
|
||||
|
||||
export interface ClubMemberWithUser extends ClubMember {
|
||||
userEmail: string | null;
|
||||
}
|
||||
|
||||
export interface PaginatedClubMembers {
|
||||
items: ClubMemberWithUser[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type {
|
||||
ClubMember,
|
||||
ClubMemberWithUser,
|
||||
ClubRecoveryCode,
|
||||
ClubResolveInput,
|
||||
ClubSettings,
|
||||
ClubTransaction,
|
||||
LinkMemberResult,
|
||||
MergeMembersResult,
|
||||
PaginatedClubMembers,
|
||||
RecordClubTransactionCommand,
|
||||
RecordClubTransactionResult,
|
||||
UpdateClubSettingsCommand,
|
||||
@@ -36,4 +38,8 @@ export interface ClubRepository {
|
||||
// Member linking and merging
|
||||
linkMemberToUser(memberId: string, userId: string): Promise<LinkMemberResult>;
|
||||
mergeMembers(primaryMemberId: string, mergedMemberId: string): Promise<MergeMembersResult>;
|
||||
|
||||
// Admin
|
||||
listMembers(opts: { q?: string; status?: string; limit: number; offset: number }): Promise<PaginatedClubMembers>;
|
||||
findMemberById(id: string): Promise<ClubMemberWithUser | null>;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { createHash, randomBytes } from 'node:crypto';
|
||||
import type pg from 'pg';
|
||||
import type {
|
||||
ClubMember,
|
||||
ClubMemberWithUser,
|
||||
ClubRecoveryCode,
|
||||
ClubResolveInput,
|
||||
ClubSettings,
|
||||
ClubTransaction,
|
||||
LinkMemberResult,
|
||||
MergeMembersResult,
|
||||
PaginatedClubMembers,
|
||||
RecordClubTransactionCommand,
|
||||
RecordClubTransactionResult,
|
||||
UpdateClubSettingsCommand,
|
||||
@@ -205,6 +207,113 @@ export class PgClubRepository implements ClubRepository {
|
||||
return result.rows.map(toTransaction);
|
||||
}
|
||||
|
||||
// ── Admin ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async listMembers(
|
||||
opts: { q?: string; status?: string; limit: number; offset: number },
|
||||
): Promise<PaginatedClubMembers> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (opts.q) {
|
||||
conditions.push(
|
||||
`(member_code ILIKE $${paramIdx} OR identity_users.email ILIKE $${paramIdx})`,
|
||||
);
|
||||
params.push(`%${opts.q}%`);
|
||||
paramIdx += 1;
|
||||
}
|
||||
if (opts.status) {
|
||||
conditions.push(`member.status = $${paramIdx}`);
|
||||
params.push(opts.status);
|
||||
paramIdx += 1;
|
||||
}
|
||||
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const countResult = await this.pool.query<{ count: string }>(
|
||||
`SELECT COUNT(*)::int AS count
|
||||
FROM club_members member
|
||||
LEFT JOIN identity_users ON identity_users.id = member.user_id
|
||||
${where}`,
|
||||
params,
|
||||
);
|
||||
const total = Number(countResult.rows[0]?.count ?? 0);
|
||||
|
||||
const limit = Math.min(Math.max(opts.limit, 1), 100);
|
||||
const offset = Math.max(opts.offset, 0);
|
||||
|
||||
const rows = await this.pool.query<{
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
member_code: string;
|
||||
status: 'active' | 'blocked' | 'merged';
|
||||
tier_code: string;
|
||||
current_balance_cents: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
user_email: string | null;
|
||||
}>(
|
||||
`SELECT member.*, identity_users.email AS user_email
|
||||
FROM club_members member
|
||||
LEFT JOIN identity_users ON identity_users.id = member.user_id
|
||||
${where}
|
||||
ORDER BY member.created_at DESC
|
||||
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`,
|
||||
[...params, limit, offset],
|
||||
);
|
||||
|
||||
return {
|
||||
items: rows.rows.map((row) => ({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
memberCode: row.member_code,
|
||||
status: row.status,
|
||||
tierCode: row.tier_code,
|
||||
currentBalanceCents: Number(row.current_balance_cents),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
userEmail: row.user_email,
|
||||
})),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
async findMemberById(id: string): Promise<ClubMemberWithUser | null> {
|
||||
const result = await this.pool.query<{
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
member_code: string;
|
||||
status: 'active' | 'blocked' | 'merged';
|
||||
tier_code: string;
|
||||
current_balance_cents: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
user_email: string | null;
|
||||
}>(
|
||||
`SELECT member.*, identity_users.email AS user_email
|
||||
FROM club_members member
|
||||
LEFT JOIN identity_users ON identity_users.id = member.user_id
|
||||
WHERE member.id = $1`,
|
||||
[id],
|
||||
);
|
||||
if (!result.rows[0]) return null;
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
memberCode: row.member_code,
|
||||
status: row.status,
|
||||
tierCode: row.tier_code,
|
||||
currentBalanceCents: Number(row.current_balance_cents),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
userEmail: row.user_email,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Member linking ─────────────────────────────────────────────────────────
|
||||
|
||||
async linkMemberToUser(memberId: string, userId: string): Promise<LinkMemberResult> {
|
||||
|
||||
18
work/artifacts/CLUB-006/architect.md
Normal file
18
work/artifacts/CLUB-006/architect.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Architect — CLUB-006
|
||||
|
||||
## Objetivo
|
||||
Panel de administración del Club de Clientes: lista de socios, detalle y movimientos.
|
||||
|
||||
## Diseño
|
||||
|
||||
### Backend endpoints (admin)
|
||||
- `GET /admin/club/members` — lista paginada con búsqueda por código o email
|
||||
- `GET /admin/club/members/:memberId` — detalle de socio
|
||||
- `GET /admin/club/members/:memberId/movements` — movimientos del ledger
|
||||
|
||||
### Frontend admin
|
||||
- `/admin/club/` — lista de socios con búsqueda, filtro por estado y paginación
|
||||
- `/admin/club/:id` — detalle del socio con tabla de movimientos
|
||||
|
||||
### Navegación
|
||||
- Nuevo item en sidebar: "🎫 Club" con permiso `orders.read` (disponible para staff)
|
||||
21
work/artifacts/CLUB-006/documenter.md
Normal file
21
work/artifacts/CLUB-006/documenter.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# CLUB-006 — Documentation
|
||||
|
||||
## Admin endpoints
|
||||
|
||||
### GET /admin/club/members
|
||||
Lista paginada de socios del Club.
|
||||
- **Auth**: admin
|
||||
- **Query**: `q`, `status`, `limit`, `offset`
|
||||
|
||||
### GET /admin/club/members/:memberId
|
||||
Detalle de un socio con email de usuario vinculado.
|
||||
- **Auth**: admin
|
||||
|
||||
### GET /admin/club/members/:memberId/movements
|
||||
Movimientos del ledger de un socio.
|
||||
- **Auth**: admin
|
||||
- **Query**: `limit` (default 50)
|
||||
|
||||
## Admin pages
|
||||
- `/admin/club` — lista con búsqueda por código/email, filtro por estado, paginación
|
||||
- `/admin/club/:id` — detalle con meta del socio + tabla de movimientos
|
||||
25
work/artifacts/CLUB-006/implementer.md
Normal file
25
work/artifacts/CLUB-006/implementer.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Implementer — CLUB-006
|
||||
|
||||
## Resumen
|
||||
Panel de administración del Club de Clientes.
|
||||
|
||||
## Backend
|
||||
- `src/modules/club/domain/club.ts` — tipos `ClubMemberWithUser`, `PaginatedClubMembers`
|
||||
- `src/modules/club/domain/ports.ts` — порты: `listMembers`, `findMemberById`
|
||||
- `src/modules/club/infrastructure/pg-club-repository.ts` — implementación con JOIN a identity_users para email
|
||||
- `src/modules/club/application/club-service.ts` — `listMembers()`, `getMemberById()`, `listMemberTransactions()`
|
||||
- `src/modules/club/api/club.routes.ts` — 3 endpoints admin nuevos + serializeMemberWithUser
|
||||
|
||||
## Frontend admin
|
||||
- `apps/admin/src/lib/api-client.ts` — `clubAdminApi.listMembers`, `getMember`, `getMemberMovements`
|
||||
- `apps/admin/src/app/(dashboard)/club/page.tsx` — lista con búsqueda, filtro y paginación
|
||||
- `apps/admin/src/app/(dashboard)/club/[id]/page.tsx` — detalle + movimientos
|
||||
- `apps/admin/src/lib/permissions.ts` — añadido "🎫 Club" al sidebar
|
||||
|
||||
## Validación
|
||||
- `cd project && npm run typecheck` ✅
|
||||
- `cd project && npm run build` ✅
|
||||
- `cd project/apps/admin && npm run build` ✅ (club pages visible)
|
||||
- `npx vitest run src/modules/club/tests/` ✅ (4 tests)
|
||||
- `git diff --check` ✅
|
||||
- `./scripts/verify.sh` ✅
|
||||
13
work/artifacts/CLUB-006/leader-close.json
Normal file
13
work/artifacts/CLUB-006/leader-close.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"feature_id": "CLUB-006",
|
||||
"agent": "leader",
|
||||
"stage": "close",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "CLUB-006 cerrada: panel admin del Club de Clientes implementado.",
|
||||
"gates_summary": {
|
||||
"reviewer": "APPROVED",
|
||||
"security": "APPROVED",
|
||||
"qa": "APPROVED"
|
||||
},
|
||||
"timestamp": "2026-08-26T20:55:20Z"
|
||||
}
|
||||
21
work/artifacts/CLUB-006/qa.json
Normal file
21
work/artifacts/CLUB-006/qa.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"feature_id": "CLUB-006",
|
||||
"agent": "qa",
|
||||
"stage": "qa_gate",
|
||||
"verdict": "APPROVED",
|
||||
"qa_check": "qa",
|
||||
"summary": "Build limpio. Todos los builds pasan.",
|
||||
"test_results": {
|
||||
"automated": [
|
||||
"npm run typecheck ✅",
|
||||
"npm run build ✅",
|
||||
"npm run build (admin) ✅",
|
||||
"npx vitest run src/modules/club/tests/ ✅"
|
||||
]
|
||||
},
|
||||
"manual_smoke_recommended": [
|
||||
"Acceder a /admin/club y verificar lista de socios",
|
||||
"Hacer clic en un socio y verificar detalle + movimientos"
|
||||
],
|
||||
"timestamp": "2026-08-26T20:55:10Z"
|
||||
}
|
||||
16
work/artifacts/CLUB-006/reviewer.json
Normal file
16
work/artifacts/CLUB-006/reviewer.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"feature_id": "CLUB-006",
|
||||
"agent": "reviewer",
|
||||
"stage": "review_gate",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "Admin Club panel implementado: lista con búsqueda/paginación, detalle con movimientos, sidebar link.",
|
||||
"checks": [
|
||||
{ "item": "Backend typecheck + build", "ok": true },
|
||||
{ "item": "Admin typecheck + build", "ok": true },
|
||||
{ "item": "/club y /club/[id] en build output", "ok": true },
|
||||
{ "item": "Club en sidebar", "ok": true }
|
||||
],
|
||||
"issues": [],
|
||||
"evidence": ["npm run typecheck", "npm run build", "npm run build (admin)", "npx vitest run src/modules/club/tests/"],
|
||||
"timestamp": "2026-08-26T20:55:00Z"
|
||||
}
|
||||
13
work/artifacts/CLUB-006/security.json
Normal file
13
work/artifacts/CLUB-006/security.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"feature_id": "CLUB-006",
|
||||
"agent": "security",
|
||||
"stage": "security_gate",
|
||||
"verdict": "APPROVED",
|
||||
"security_check": "security",
|
||||
"summary": "Endpoints admin requieren autenticación + rol admin. No exponen datos sensibles nuevos.",
|
||||
"checks": {
|
||||
"auth": "OK: deps.authenticate + requireRole(user, 'admin') en todas las rutas",
|
||||
"sql_injection": "OK: parámetros pasados como query params/JSON, no concatenados"
|
||||
},
|
||||
"timestamp": "2026-08-26T20:55:05Z"
|
||||
}
|
||||
@@ -2,11 +2,11 @@
|
||||
"feature_id": "CHECKOUT-STOCK-RECHECK",
|
||||
"stage": "build",
|
||||
"agent": "implementer",
|
||||
"action": "Implementar link member to user y merge memberships",
|
||||
"action": "Implementar admin Club: endpoints backend + página dashboard",
|
||||
"state": "running",
|
||||
"next_agent": "reviewer",
|
||||
"waiting_for": "build",
|
||||
"updated_at": "2026-08-26T18:47:48.659278Z",
|
||||
"updated_at": "2026-08-26T18:49:57.402761Z",
|
||||
"timeline": [
|
||||
{
|
||||
"ts": "2026-08-26T17:21:37Z",
|
||||
|
||||
Reference in New Issue
Block a user