it0/it0-web-admin/src/app/(admin)/referral/page.tsx

299 lines
12 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
getAdminReferralStats,
listAdminRelationships,
listAdminRewards,
} from '@/infrastructure/repositories/api-referral.repository';
import { ReferralStatus, RewardStatus } from '@/domain/entities/referral';
// ── Status badge helper ────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: string }) {
const colorMap: Record<string, string> = {
PENDING: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
ACTIVE: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
REWARDED: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
EXPIRED: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
APPLIED: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
};
const labelMap: Record<string, string> = {
PENDING: '待激活', ACTIVE: '已激活', REWARDED: '已奖励',
EXPIRED: '已过期', APPLIED: '已抵扣',
};
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${colorMap[status] ?? 'bg-gray-100 text-gray-600'}`}>
{labelMap[status] ?? status}
</span>
);
}
function formatCents(cents: number) {
return `$${(cents / 100).toFixed(2)}`;
}
function formatDate(iso: string | null) {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('zh-CN');
}
// ── Stats Overview ─────────────────────────────────────────────────────────────
function StatsOverview() {
const { data, isLoading } = useQuery({
queryKey: ['referral-admin-stats'],
queryFn: getAdminReferralStats,
retry: 1,
});
if (isLoading) return <div className="text-sm text-muted-foreground"></div>;
if (!data) return null;
const cards = [
{ label: '总推荐数', value: data.totalReferrals, color: 'text-indigo-600' },
{ label: '已激活推荐', value: data.activeReferrals, color: 'text-green-600' },
{ label: '待领积分记录', value: data.pendingRewards, color: 'text-amber-600' },
];
return (
<div className="grid grid-cols-3 gap-4">
{cards.map((c) => (
<div key={c.label} className="rounded-xl border bg-card p-5">
<p className="text-sm text-muted-foreground">{c.label}</p>
<p className={`mt-1 text-3xl font-bold ${c.color}`}>{c.value}</p>
</div>
))}
</div>
);
}
// ── Relationships Table ────────────────────────────────────────────────────────
function RelationshipsTable() {
const [status, setStatus] = useState<ReferralStatus | ''>('');
const [page, setPage] = useState(0);
const limit = 20;
const { data, isLoading } = useQuery({
queryKey: ['referral-relationships', status, page],
queryFn: () =>
listAdminRelationships({
status: (status as ReferralStatus) || undefined,
limit,
offset: page * limit,
}),
retry: 1,
});
return (
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold"></h2>
<select
value={status}
onChange={(e) => { setStatus(e.target.value as any); setPage(0); }}
className="text-sm border rounded-lg px-3 py-1.5 bg-background"
>
<option value=""></option>
<option value="PENDING"></option>
<option value="ACTIVE"></option>
<option value="REWARDED"></option>
<option value="EXPIRED"></option>
</select>
</div>
{isLoading ? (
<div className="text-sm text-muted-foreground py-8 text-center"></div>
) : (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
{['推荐人租户', '被推荐租户', '推荐码', '层级', '状态', '注册时间', '激活时间'].map((h) => (
<th key={h} className="text-left px-4 py-3 text-muted-foreground font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{(data?.items ?? []).map((r) => (
<tr key={r.id} className="border-t hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-mono text-xs">{r.referrerTenantId.slice(0, 12)}</td>
<td className="px-4 py-3 font-mono text-xs">{r.referredTenantId.slice(0, 12)}</td>
<td className="px-4 py-3 font-mono font-semibold text-indigo-600">{r.referralCode}</td>
<td className="px-4 py-3">L{r.level}</td>
<td className="px-4 py-3"><StatusBadge status={r.status} /></td>
<td className="px-4 py-3">{formatDate(r.registeredAt)}</td>
<td className="px-4 py-3">{formatDate(r.activatedAt)}</td>
</tr>
))}
{data?.items.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground"></td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{data && data.total > limit && (
<div className="flex justify-end gap-2 mt-3">
<button
className="px-3 py-1 text-sm border rounded-lg disabled:opacity-40"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
></button>
<span className="px-3 py-1 text-sm">
{page + 1} / {Math.ceil(data.total / limit)}
</span>
<button
className="px-3 py-1 text-sm border rounded-lg disabled:opacity-40"
disabled={(page + 1) * limit >= data.total}
onClick={() => setPage((p) => p + 1)}
></button>
</div>
)}
</div>
);
}
// ── Rewards Table ─────────────────────────────────────────────────────────────
function RewardsTable() {
const [status, setStatus] = useState<RewardStatus | ''>('');
const [page, setPage] = useState(0);
const limit = 20;
const { data, isLoading } = useQuery({
queryKey: ['referral-rewards', status, page],
queryFn: () =>
listAdminRewards({
status: (status as RewardStatus) || undefined,
limit,
offset: page * limit,
}),
retry: 1,
});
const triggerLabel = (t: string, m: number | null) =>
t === 'FIRST_PAYMENT' ? '首次付款' : `续订第 ${m ?? 1}`;
return (
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold"></h2>
<select
value={status}
onChange={(e) => { setStatus(e.target.value as any); setPage(0); }}
className="text-sm border rounded-lg px-3 py-1.5 bg-background"
>
<option value=""></option>
<option value="PENDING"></option>
<option value="APPLIED"></option>
<option value="EXPIRED"></option>
</select>
</div>
{isLoading ? (
<div className="text-sm text-muted-foreground py-8 text-center"></div>
) : (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
{['受益租户', '金额', '触发类型', '状态', '来源账单', '创建时间', '抵扣时间'].map((h) => (
<th key={h} className="text-left px-4 py-3 text-muted-foreground font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{(data?.items ?? []).map((r) => (
<tr key={r.id} className="border-t hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-mono text-xs">{r.beneficiaryTenantId.slice(0, 12)}</td>
<td className="px-4 py-3 font-semibold text-green-600">{formatCents(r.amountCents)}</td>
<td className="px-4 py-3">{triggerLabel(r.triggerType, r.recurringMonth)}</td>
<td className="px-4 py-3"><StatusBadge status={r.status} /></td>
<td className="px-4 py-3 font-mono text-xs">{r.sourceInvoiceId?.slice(0, 8) ?? '—'}</td>
<td className="px-4 py-3">{formatDate(r.createdAt)}</td>
<td className="px-4 py-3">{formatDate(r.appliedAt)}</td>
</tr>
))}
{data?.items.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground"></td>
</tr>
)}
</tbody>
</table>
</div>
)}
{data && data.total > limit && (
<div className="flex justify-end gap-2 mt-3">
<button
className="px-3 py-1 text-sm border rounded-lg disabled:opacity-40"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
></button>
<span className="px-3 py-1 text-sm">
{page + 1} / {Math.ceil(data.total / limit)}
</span>
<button
className="px-3 py-1 text-sm border rounded-lg disabled:opacity-40"
disabled={(page + 1) * limit >= data.total}
onClick={() => setPage((p) => p + 1)}
></button>
</div>
)}
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
type Tab = 'overview' | 'relationships' | 'rewards';
export default function ReferralPage() {
const [tab, setTab] = useState<Tab>('overview');
const tabs: { key: Tab; label: string }[] = [
{ key: 'overview', label: '概览' },
{ key: 'relationships', label: '推荐关系' },
{ key: 'rewards', label: '积分奖励' },
];
return (
<div className="flex flex-col gap-6 p-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1"></p>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${
tab === t.key
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{t.label}
</button>
))}
</div>
{/* Tab content */}
{tab === 'overview' && <StatsOverview />}
{tab === 'relationships' && <RelationshipsTable />}
{tab === 'rewards' && <RewardsTable />}
</div>
);
}