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