refactor: update agent API schemas, standardize UI text styling, and enhance settlement credit ledger components
This commit is contained in:
@@ -262,7 +262,7 @@ export function AgentBillDetail({
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog />
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(340px,0.95fr)]">
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(0,1.35fr)_minmax(340px,0.95fr)]">
|
||||
<div className="space-y-5 text-sm">
|
||||
<SettlementBillSummaryHeader bill={bill} currencyCode={currencyCode} />
|
||||
<SettlementBillPartiesRow bill={bill} />
|
||||
@@ -294,22 +294,22 @@ export function AgentBillDetail({
|
||||
|
||||
<div className="space-y-5 text-sm">
|
||||
{rebateAllocations.length > 0 ? (
|
||||
<div className="space-y-2 rounded-xl border border-border/70 p-4">
|
||||
<p className="font-medium">
|
||||
<div className="space-y-2 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
|
||||
<p className="font-semibold tracking-tight">
|
||||
{t("settlementBills.rebateAllocations", { defaultValue: "回水分摊" })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("settlementCenter:billDisplay.rebateAllocationsHint", {
|
||||
defaultValue: "各层级代理对回水的承担明细。",
|
||||
})}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="mt-3 space-y-3">
|
||||
<ul className="space-y-1.5 text-muted-foreground">
|
||||
{rebateAllocationSummary.map((row) => (
|
||||
<li key={row.key} className="flex justify-between gap-2">
|
||||
<span className="min-w-0">
|
||||
{row.label}
|
||||
<span className="ml-2 text-xs text-muted-foreground/75">
|
||||
<span className="ml-2 text-xs text-muted-foreground/60">
|
||||
{t("common:count", { defaultValue: "{{count}} 条", count: row.rows })}
|
||||
</span>
|
||||
</span>
|
||||
@@ -354,12 +354,12 @@ export function AgentBillDetail({
|
||||
) : null}
|
||||
|
||||
{canManage && bill.status === "pending_confirm" ? (
|
||||
<div className="space-y-3 rounded-xl border border-border/70 bg-muted/15 p-4">
|
||||
<div className="space-y-4 rounded-xl border border-border/70 bg-primary/5 p-5 shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">
|
||||
<p className="font-semibold tracking-tight text-primary">
|
||||
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("settlementCenter:billDisplay.confirmHint", {
|
||||
defaultValue: "确认后才可以登记收款或付款。",
|
||||
})}
|
||||
@@ -377,54 +377,57 @@ export function AgentBillDetail({
|
||||
) : null}
|
||||
|
||||
{canManage && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? (
|
||||
<div className="space-y-3 rounded-xl border border-border/70 p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-medium">{paymentTitle}</p>
|
||||
<span className="rounded-full bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
<p className="font-semibold tracking-tight">{paymentTitle}</p>
|
||||
<span className="rounded-full bg-amber-500/10 px-2.5 py-0.5 text-xs font-medium text-amber-600 dark:bg-amber-500/20 dark:text-amber-400">
|
||||
{t("settlementCenter:columns.unpaid", { defaultValue: "未结" })}{" "}
|
||||
{formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>{direction.payer}</span>
|
||||
<div className="flex flex-wrap items-center gap-1 text-[13px] text-muted-foreground">
|
||||
<span className="font-medium text-foreground/80">{direction.payer}</span>
|
||||
<ArrowRight className="size-3.5 shrink-0" aria-hidden />
|
||||
<span>{direction.payee}</span>
|
||||
<span className="font-medium text-foreground/80">{direction.payee}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
|
||||
<div className="grid gap-4 mt-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground">{t("settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
|
||||
<Input
|
||||
value={payAmount}
|
||||
onChange={(e) => setPayAmount(e.target.value)}
|
||||
placeholder={String(bill.unpaid_amount)}
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementBills.paymentMethod", { defaultValue: "收付方式" })}</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground">{t("settlementBills.paymentMethod", { defaultValue: "收付方式" })}</Label>
|
||||
<Input
|
||||
value={payMethod}
|
||||
onChange={(e) => setPayMethod(e.target.value)}
|
||||
placeholder={t("settlementBills.paymentMethodPlaceholder", {
|
||||
defaultValue: "例如:现金 / 银行转账",
|
||||
})}
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementBills.paymentProof", { defaultValue: "凭证/备注" })}</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground">{t("settlementBills.paymentProof", { defaultValue: "凭证/备注" })}</Label>
|
||||
<Input
|
||||
value={payProof}
|
||||
onChange={(e) => setPayProof(e.target.value)}
|
||||
placeholder={t("settlementBills.paymentProofPlaceholder", {
|
||||
defaultValue: "可填写流水号、截图说明或备注",
|
||||
})}
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
className="w-full mt-2"
|
||||
disabled={confirmBusy}
|
||||
onClick={requestPayment}
|
||||
>
|
||||
@@ -434,31 +437,32 @@ export function AgentBillDetail({
|
||||
) : null}
|
||||
|
||||
{canWriteOff ? (
|
||||
<div className="space-y-3 rounded-xl border border-border/70 p-4">
|
||||
<div className="space-y-4 rounded-xl border border-destructive/20 bg-destructive/5 p-5 shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">
|
||||
<p className="font-semibold tracking-tight text-destructive">
|
||||
{t("settlementBills.badDebtWriteOff", { defaultValue: "坏账核销" })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-destructive/80">
|
||||
{t("settlementBills.badDebtHint", {
|
||||
defaultValue: "仅在确认无法收回时使用,核销后会生成坏账记录。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
|
||||
<div className="space-y-1.5 mt-2">
|
||||
<Label className="text-destructive/90">{t("settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
|
||||
<Input
|
||||
value={badDebtReason}
|
||||
onChange={(e) => setBadDebtReason(e.target.value)}
|
||||
placeholder={t("settlementBills.badDebtReasonPlaceholder", {
|
||||
defaultValue: "例如:客户失联、确认坏账",
|
||||
})}
|
||||
className="bg-background/50 transition-colors focus:bg-background border-destructive/30"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
className="w-full mt-2"
|
||||
disabled={confirmBusy}
|
||||
onClick={requestBadDebtWriteOff}
|
||||
>
|
||||
@@ -468,19 +472,19 @@ export function AgentBillDetail({
|
||||
) : null}
|
||||
|
||||
{canManage && locked ? (
|
||||
<div className="space-y-3 rounded-xl border border-dashed border-border/70 p-4">
|
||||
<div className="space-y-4 rounded-xl border border-dashed border-border/70 bg-card p-5 shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">
|
||||
<p className="font-semibold tracking-tight">
|
||||
{t("settlementBills.adjustment", { defaultValue: "补差/冲正单" })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("settlementBills.adjustmentHint", {
|
||||
defaultValue: "正数表示补收,负数表示冲减;提交后会生成一张独立调账单。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementBills.adjustmentAmount", { defaultValue: "调整金额(可负)" })}</Label>
|
||||
<div className="space-y-1.5 mt-2">
|
||||
<Label className="text-muted-foreground">{t("settlementBills.adjustmentAmount", { defaultValue: "调整金额(可负)" })}</Label>
|
||||
<Input
|
||||
value={adjustAmount}
|
||||
onChange={(e) => setAdjustAmount(e.target.value)}
|
||||
@@ -488,22 +492,24 @@ export function AgentBillDetail({
|
||||
placeholder={t("settlementBills.adjustmentAmountPlaceholder", {
|
||||
defaultValue: "输入正数或负数",
|
||||
})}
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementBills.adjustmentReason", { defaultValue: "调整原因" })}</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground">{t("settlementBills.adjustmentReason", { defaultValue: "调整原因" })}</Label>
|
||||
<Input
|
||||
value={adjustReason}
|
||||
onChange={(e) => setAdjustReason(e.target.value)}
|
||||
placeholder={t("settlementBills.adjustmentReasonPlaceholder", {
|
||||
defaultValue: "例如:人工复核补差、冲正错账",
|
||||
})}
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
className="w-full mt-2"
|
||||
disabled={confirmBusy}
|
||||
onClick={requestAdjustment}
|
||||
>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function SettlementBillSummaryHeader({
|
||||
const unpaid = bill.unpaid_amount > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-border/70 bg-muted/15 p-4">
|
||||
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AdminStatusBadge status={bill.status}>
|
||||
{settlementBillStatusLabel(bill.status, t)}
|
||||
@@ -60,7 +60,7 @@ export function SettlementBillSummaryHeader({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-lg border border-border/50 bg-background/80 px-3 py-2">
|
||||
<div className="rounded-xl border border-border/50 bg-background/50 px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settlementCenter:columns.paid", { defaultValue: "已收付" })}
|
||||
</p>
|
||||
@@ -70,10 +70,10 @@ export function SettlementBillSummaryHeader({
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2",
|
||||
"rounded-xl border px-4 py-3",
|
||||
unpaid
|
||||
? "border-amber-200/80 bg-amber-50/80 dark:border-amber-900/50 dark:bg-amber-950/20"
|
||||
: "border-border/50 bg-background/80",
|
||||
: "border-border/50 bg-background/50",
|
||||
)}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -125,8 +125,8 @@ export function SettlementBillAmountBreakdown({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-border/70 p-4">
|
||||
<p className="font-medium text-foreground">
|
||||
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
|
||||
<p className="font-semibold tracking-tight text-foreground">
|
||||
{t("settlementCenter:billDisplay.howAmountWorks", { defaultValue: "金额怎么来的" })}
|
||||
</p>
|
||||
|
||||
@@ -178,18 +178,18 @@ export function SettlementBillPartiesRow({ bill }: SettlementBillPartiesRowProps
|
||||
const counterparty = resolveBillPartyName(bill, "counterparty", t);
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div className="rounded-lg border border-border/60 bg-muted/15 px-3 py-2">
|
||||
<div className="grid gap-4 text-sm sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-border/60 bg-card px-4 py-3.5 shadow-sm">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settlementCenter:billDisplay.billOwner", { defaultValue: "账单主体" })}
|
||||
</p>
|
||||
<p className="mt-1 font-medium text-foreground">{owner}</p>
|
||||
<p className="mt-1 font-semibold text-foreground">{owner}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/15 px-3 py-2">
|
||||
<div className="rounded-xl border border-border/60 bg-card px-4 py-3.5 shadow-sm">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settlementCenter:billDisplay.billCounterparty", { defaultValue: "结算对手" })}
|
||||
</p>
|
||||
<p className="mt-1 font-medium text-foreground">{counterparty}</p>
|
||||
<p className="mt-1 font-semibold text-foreground">{counterparty}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -107,6 +107,17 @@ function unpaidMoneyClass(row: SettlementBillRow): string {
|
||||
return "font-medium text-amber-800 dark:text-amber-300";
|
||||
}
|
||||
|
||||
function paidMoneyClass(row: SettlementBillRow): string {
|
||||
if ((row.paid_amount ?? 0) <= 0) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
if (row.unpaid_amount > 0) {
|
||||
return "font-medium text-amber-800 dark:text-amber-300";
|
||||
}
|
||||
|
||||
return "font-medium text-emerald-700";
|
||||
}
|
||||
|
||||
function ownerPartyLabel(row: SettlementBillRow): string | null {
|
||||
if (row.bill_type === "player") {
|
||||
return row.player_username ?? row.owner_label ?? null;
|
||||
@@ -118,18 +129,6 @@ function ownerPartyLabel(row: SettlementBillRow): string | null {
|
||||
return row.owner_label ?? null;
|
||||
}
|
||||
|
||||
function fundingModeHint(row: SettlementBillRow, t: (key: string, options?: Record<string, unknown>) => string) {
|
||||
if (row.owner_funding_mode !== "credit") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="rounded-full border border-border/70 bg-muted/30 px-1.5 py-0.5 text-[11px] font-normal leading-none text-muted-foreground">
|
||||
{t("columns.creditMode", { defaultValue: "信用盘" })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettlementBillsTable({
|
||||
rows,
|
||||
loading,
|
||||
@@ -211,10 +210,7 @@ export function SettlementBillsTable({
|
||||
{playerView ? (
|
||||
<>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<SettlementDashCell value={row.player_username ?? row.owner_label} />
|
||||
{fundingModeHint(row, t)}
|
||||
</div>
|
||||
<SettlementDashCell value={row.player_username ?? row.owner_label} />
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<SettlementDashCell
|
||||
@@ -234,14 +230,7 @@ export function SettlementBillsTable({
|
||||
) : null}
|
||||
{mixedView ? (
|
||||
<TableCell className="text-sm">
|
||||
{isPlayerBill ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<SettlementDashCell value={ownerPartyLabel(row)} />
|
||||
{fundingModeHint(row, t)}
|
||||
</div>
|
||||
) : (
|
||||
<SettlementDashCell value={ownerPartyLabel(row)} />
|
||||
)}
|
||||
<SettlementDashCell value={ownerPartyLabel(row)} />
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="min-w-[10rem] text-sm">
|
||||
@@ -275,7 +264,7 @@ export function SettlementBillsTable({
|
||||
{formatDashboardMoneyMinor(direction.amount, currencyCode)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
<TableCell className={cn("text-right tabular-nums", paidMoneyClass(row))}>
|
||||
{formatDashboardMoneyMinor(row.paid_amount ?? 0, currencyCode)}
|
||||
</TableCell>
|
||||
<TableCell className={cn("text-right tabular-nums", unpaidMoneyClass(row))}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -24,16 +25,14 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
type SiteOption = { id: number; label: string; currency_code: string };
|
||||
type SiteOption = { id: number; label: string; code: string; currency_code: string };
|
||||
|
||||
export function SettlementCenterShell(): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "common"]);
|
||||
@@ -54,6 +53,8 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
|
||||
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
|
||||
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
|
||||
const [sitePickerOpen, setSitePickerOpen] = useState(false);
|
||||
const [siteKeyword, setSiteKeyword] = useState("");
|
||||
const [periods, setPeriods] = useState<SettlementPeriodRow[]>([]);
|
||||
const [periodsReady, setPeriodsReady] = useState(false);
|
||||
const [detailBillId, setDetailBillId] = useState<number | null>(null);
|
||||
@@ -62,7 +63,12 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
useEffect(() => {
|
||||
if (boundAgent?.admin_site_id) {
|
||||
const label = formatAdminSiteLabel(boundAgent.name, boundAgent.site_code ?? boundAgent.code);
|
||||
setSiteOptions([{ id: boundAgent.admin_site_id, label, currency_code: "NPR" }]);
|
||||
setSiteOptions([{
|
||||
id: boundAgent.admin_site_id,
|
||||
label,
|
||||
code: boundAgent.site_code ?? boundAgent.code ?? "",
|
||||
currency_code: "NPR",
|
||||
}]);
|
||||
setAdminSiteId(boundAgent.admin_site_id);
|
||||
return;
|
||||
}
|
||||
@@ -71,6 +77,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
const options = (sites.items ?? []).map((site) => ({
|
||||
id: site.id,
|
||||
label: formatAdminSiteLabel(site.name, site.code),
|
||||
code: site.code,
|
||||
currency_code: site.currency_code ?? "NPR",
|
||||
}));
|
||||
setSiteOptions(options);
|
||||
@@ -81,8 +88,81 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
}, [adminSiteId, boundAgent]);
|
||||
|
||||
const siteId = adminSiteId ?? siteOptions[0]?.id ?? null;
|
||||
const siteLabel = siteOptions.find((s) => s.id === siteId)?.label ?? null;
|
||||
const selectedSite = siteOptions.find((s) => s.id === siteId) ?? null;
|
||||
const siteLabel = selectedSite?.label ?? null;
|
||||
const currency = siteOptions.find((s) => s.id === siteId)?.currency_code ?? "NPR";
|
||||
const filteredSites = siteKeyword.trim().toLowerCase()
|
||||
? siteOptions.filter((site) => site.label.toLowerCase().includes(siteKeyword.trim().toLowerCase()))
|
||||
: siteOptions;
|
||||
|
||||
const siteSelector =
|
||||
siteOptions.length > 0 && siteId !== null ? (
|
||||
<Popover open={sitePickerOpen} onOpenChange={setSitePickerOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-[240px] justify-between gap-2 bg-background px-3 font-normal"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-left">{siteLabel ?? "—"}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{selectedSite?.code ?? ""}
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[320px] p-0">
|
||||
<div className="border-b border-border/60 p-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={siteKeyword}
|
||||
onChange={(event) => setSiteKeyword(event.target.value)}
|
||||
placeholder={t("siteSearch", { defaultValue: "搜索站点名称" })}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="max-h-72">
|
||||
<div className="p-2">
|
||||
{filteredSites.map((site) => {
|
||||
const active = site.id === siteId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={site.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors",
|
||||
active ? "bg-primary/10 text-primary" : "hover:bg-muted/70",
|
||||
)}
|
||||
onClick={() => {
|
||||
setAdminSiteId(site.id);
|
||||
setSitePickerOpen(false);
|
||||
setSiteKeyword("");
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{site.label}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{site.code}</div>
|
||||
</div>
|
||||
{active ? <Check className="size-4 shrink-0" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredSites.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{t("common:states.empty", { defaultValue: "暂无数据" })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null;
|
||||
|
||||
const loadPeriods = useCallback(async (): Promise<SettlementPeriodRow[]> => {
|
||||
if (siteId === null) {
|
||||
@@ -118,41 +198,6 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
{t("title", { defaultValue: "结算中心" })}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{isListMode
|
||||
? t("subtitleList", { defaultValue: "账期列表:开账、关账,从行操作进入账单与报表。" })
|
||||
: t("subtitle", { defaultValue: "账期关账、账单确认与收付登记" })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{siteOptions.length >= 1 && siteId !== null ? (
|
||||
<Select
|
||||
value={String(siteId)}
|
||||
onValueChange={(v) => {
|
||||
setAdminSiteId(Number(v));
|
||||
setPeriodsReady(false);
|
||||
router.push("/admin/settlement-center");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[220px]">
|
||||
<SelectValue>{siteLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.id} value={String(site.id)}>
|
||||
{site.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{siteId === null || !periodsReady ? (
|
||||
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择站点。" })}</p>
|
||||
) : isListMode ? (
|
||||
@@ -161,6 +206,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
currencyCode={currency}
|
||||
canManage={canManagePeriods}
|
||||
periods={periods}
|
||||
headerActions={siteSelector}
|
||||
onViewDetail={(id) => openPeriodView(id, "bills")}
|
||||
onReloadPeriods={loadPeriods}
|
||||
onPeriodOpened={() => {
|
||||
|
||||
@@ -91,6 +91,31 @@ function reasonLabel(
|
||||
return creditLedgerReasonLabel(value, t);
|
||||
}
|
||||
|
||||
function CreditLedgerReasonBadge({ reason }: { reason: string }): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter"]);
|
||||
const label = creditLedgerReasonLabel(reason, t);
|
||||
|
||||
let colorClass = "border-border bg-muted/30 text-foreground/80";
|
||||
|
||||
if (reason.includes("payment") || reason.includes("settlement_payout")) {
|
||||
colorClass = "border-emerald-200/60 bg-emerald-50 text-emerald-700 dark:border-emerald-800/60 dark:bg-emerald-950/30 dark:text-emerald-400";
|
||||
} else if (reason.includes("game_settlement") || reason === "share_ledger") {
|
||||
colorClass = "border-blue-200/60 bg-blue-50 text-blue-700 dark:border-blue-800/60 dark:bg-blue-950/30 dark:text-blue-400";
|
||||
} else if (reason === "bet_hold" || reason === "bet_hold_release") {
|
||||
colorClass = "border-orange-200/60 bg-orange-50 text-orange-700 dark:border-orange-800/60 dark:bg-orange-950/30 dark:text-orange-400";
|
||||
} else if (reason === "adjustment" || reason === "reversal") {
|
||||
colorClass = "border-amber-200/60 bg-amber-50 text-amber-700 dark:border-amber-800/60 dark:bg-amber-950/30 dark:text-amber-400";
|
||||
} else if (reason === "bad_debt") {
|
||||
colorClass = "border-rose-200/60 bg-rose-50 text-rose-700 dark:border-rose-800/60 dark:bg-rose-950/30 dark:text-rose-400";
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium", colorClass)}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type SettlementCreditLedgerPanelProps = {
|
||||
adminSiteId: number;
|
||||
settlementPeriodId: number;
|
||||
@@ -209,17 +234,7 @@ export function SettlementCreditLedgerPanel({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-border/70 bg-muted/20 p-4 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">
|
||||
{t("panels.ledger.title", { defaultValue: "账务流水" })}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
{t("ledger.groupIntro", {
|
||||
defaultValue:
|
||||
"账期内资金变动明细:信用占用、账单收付、调账与坏账。关账后生成的占成账单在「账单管理」。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
@@ -346,7 +361,9 @@ export function SettlementCreditLedgerPanel({
|
||||
<TableCell>
|
||||
<PlayerLedgerSourceBadge ledgerSource={row.ledger_source} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{creditLedgerReasonLabel(row.biz_type, t)}</TableCell>
|
||||
<TableCell>
|
||||
<CreditLedgerReasonBadge reason={row.biz_type} />
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums text-xs">
|
||||
{row.settlement_bill_id ? `#${row.settlement_bill_id}` : "—"}
|
||||
</TableCell>
|
||||
|
||||
@@ -209,105 +209,109 @@ export function SettlementMainPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sb-bill-id">{t("billsPanel.billId", { defaultValue: "账单 ID" })}</Label>
|
||||
<Input
|
||||
id="sb-bill-id"
|
||||
inputMode="numeric"
|
||||
placeholder={t("billsPanel.optional", { defaultValue: "可选" })}
|
||||
value={draft.billId}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, billId: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
runSearch();
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border border-border/70 bg-card p-5 shadow-sm">
|
||||
<div className="grid gap-x-5 gap-y-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sb-bill-id" className="text-muted-foreground">{t("billsPanel.billId", { defaultValue: "账单 ID" })}</Label>
|
||||
<Input
|
||||
id="sb-bill-id"
|
||||
inputMode="numeric"
|
||||
placeholder={t("billsPanel.optional", { defaultValue: "可选" })}
|
||||
value={draft.billId}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, billId: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
runSearch();
|
||||
}
|
||||
}}
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sb-owner" className="text-muted-foreground">{t("billsPanel.ownerKeyword", { defaultValue: "本方 / 对方" })}</Label>
|
||||
<Input
|
||||
id="sb-owner"
|
||||
placeholder={t("billsPanel.ownerKeywordPh", { defaultValue: "玩家账号、代理名称" })}
|
||||
value={draft.ownerKeyword}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, ownerKeyword: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
runSearch();
|
||||
}
|
||||
}}
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sb-status" className="text-muted-foreground">{t("billsPanel.status", { defaultValue: "账单状态" })}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draft.statusScope}
|
||||
onValueChange={(v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
statusScope: (v ?? "all") as BillStatusFilter,
|
||||
}))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sb-owner">{t("billsPanel.ownerKeyword", { defaultValue: "本方 / 对方" })}</Label>
|
||||
<Input
|
||||
id="sb-owner"
|
||||
placeholder={t("billsPanel.ownerKeywordPh", { defaultValue: "玩家账号、代理名称" })}
|
||||
value={draft.ownerKeyword}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, ownerKeyword: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
runSearch();
|
||||
>
|
||||
<SelectTrigger id="sb-status" className="w-full bg-background/50 transition-colors focus:bg-background">
|
||||
<SelectValue>{() => statusOptionLabel(draft.statusScope)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(
|
||||
[
|
||||
"all",
|
||||
"pending_confirm",
|
||||
"awaiting_payment",
|
||||
"settled",
|
||||
"adjustment",
|
||||
] as BillStatusFilter[]
|
||||
).map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{statusOptionLabel(value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sb-type" className="text-muted-foreground">{t("billsPanel.billType", { defaultValue: "账单类型" })}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draft.billType}
|
||||
onValueChange={(v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
billType: (v ?? "all") as BillTypeFilter,
|
||||
}))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger id="sb-type" className="w-full bg-background/50 transition-colors focus:bg-background">
|
||||
<SelectValue>{() => billTypeLabel(draft.billType)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["all", "player", "agent"] as BillTypeFilter[]).map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{billTypeLabel(value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sb-status">{t("billsPanel.status", { defaultValue: "账单状态" })}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draft.statusScope}
|
||||
onValueChange={(v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
statusScope: (v ?? "all") as BillStatusFilter,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="sb-status" className="h-9 w-full">
|
||||
<SelectValue>{() => statusOptionLabel(draft.statusScope)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(
|
||||
[
|
||||
"all",
|
||||
"pending_confirm",
|
||||
"awaiting_payment",
|
||||
"settled",
|
||||
"adjustment",
|
||||
] as BillStatusFilter[]
|
||||
).map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{statusOptionLabel(value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sb-type">{t("billsPanel.billType", { defaultValue: "账单类型" })}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draft.billType}
|
||||
onValueChange={(v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
billType: (v ?? "all") as BillTypeFilter,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="sb-type" className="h-9 w-full">
|
||||
<SelectValue>{() => billTypeLabel(draft.billType)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["all", "player", "agent"] as BillTypeFilter[]).map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{billTypeLabel(value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("billsPanel.searchBtn", { defaultValue: "搜索" })}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
|
||||
{t("billsPanel.reset", { defaultValue: "重置" })}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
{t("billsPanel.refresh", { defaultValue: "刷新" })}
|
||||
</Button>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<Button type="button" onClick={() => runSearch()}>
|
||||
{t("billsPanel.searchBtn", { defaultValue: "搜索" })}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => resetFilters()}>
|
||||
{t("billsPanel.reset", { defaultValue: "重置" })}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => void load()}>
|
||||
{t("billsPanel.refresh", { defaultValue: "刷新" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && rows.length === 0 ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -51,6 +51,7 @@ type SettlementPeriodWorkbenchProps = {
|
||||
currencyCode: string;
|
||||
canManage: boolean;
|
||||
periods: SettlementPeriodRow[];
|
||||
headerActions?: ReactNode;
|
||||
onViewDetail: (periodId: number) => void;
|
||||
onReloadPeriods: () => Promise<SettlementPeriodRow[]>;
|
||||
onPeriodOpened?: (periodId: number) => void;
|
||||
@@ -62,6 +63,7 @@ export function SettlementPeriodWorkbench({
|
||||
currencyCode,
|
||||
canManage,
|
||||
periods,
|
||||
headerActions,
|
||||
onViewDetail,
|
||||
onReloadPeriods,
|
||||
onPeriodOpened,
|
||||
@@ -257,6 +259,7 @@ export function SettlementPeriodWorkbench({
|
||||
<AdminPageCard
|
||||
title={t("periodTable.title", { defaultValue: "账期管理" })}
|
||||
description={cardDescription}
|
||||
actions={headerActions}
|
||||
>
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
|
||||
Reference in New Issue
Block a user