feat(api, agents, i18n): enhance settlement features and multi-language support

Added new types and API functions for settlement period summaries and credit ledgers, improving the management of agent settlements. Updated the admin console to reflect these changes, enhancing user experience with better navigation and data presentation. Additionally, expanded multi-language support by incorporating new translations in English, Nepali, and Chinese for settlement-related terms, ensuring consistency across the platform.
This commit is contained in:
2026-06-05 18:00:59 +08:00
parent 65eaeecf8c
commit af982bb9f7
73 changed files with 4307 additions and 2494 deletions

View File

@@ -0,0 +1,508 @@
"use client";
import { Plus } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
postSettlementPeriod,
postSettlementPeriodClose,
type SettlementPeriodCloseResult,
type SettlementPeriodRow,
} from "@/api/admin-agent-settlement";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { SettlementPeriodsTable } from "@/modules/settlement/settlement-periods-table";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
formatSettlementPeriodSpan,
settlementPeriodPresetRange,
type SettlementPeriodPresetKey,
} from "@/lib/agent-settlement-period-range";
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
import { LotteryApiBizError } from "@/types/api/errors";
const PRESET_KEYS: SettlementPeriodPresetKey[] = ["this_week", "last_week", "this_month"];
type PeriodStatusFilter = "all" | "open" | "closed" | "completed";
const STATUS_FILTER_OPTIONS: PeriodStatusFilter[] = ["all", "open", "closed", "completed"];
type SettlementPeriodWorkbenchProps = {
adminSiteId: number;
currencyCode: string;
canManage: boolean;
periods: SettlementPeriodRow[];
onViewDetail: (periodId: number) => void;
onReloadPeriods: () => Promise<SettlementPeriodRow[]>;
onPeriodOpened?: (periodId: number) => void;
onPeriodClosed?: (result: SettlementPeriodCloseResult) => void;
};
export function SettlementPeriodWorkbench({
adminSiteId,
currencyCode,
canManage,
periods,
onViewDetail,
onReloadPeriods,
onPeriodOpened,
onPeriodClosed,
}: SettlementPeriodWorkbenchProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
const [draftStatus, setDraftStatus] = useState<PeriodStatusFilter>("all");
const [appliedStatus, setAppliedStatus] = useState<PeriodStatusFilter>("all");
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [openDialogOpen, setOpenDialogOpen] = useState(false);
const [customStart, setCustomStart] = useState("");
const [customEnd, setCustomEnd] = useState("");
const [busy, setBusy] = useState(false);
const [reloading, setReloading] = useState(false);
const [closeDialogOpen, setCloseDialogOpen] = useState(false);
const [closeTarget, setCloseTarget] = useState<SettlementPeriodRow | null>(null);
const openPeriod = useMemo(
() => periods.find((row) => row.status === "open") ?? null,
[periods],
);
const filteredPeriods = useMemo(() => {
let list = [...periods];
if (appliedStatus !== "all") {
list = list.filter((row) => row.status === appliedStatus);
}
return list.sort((a, b) => b.id - a.id);
}, [periods, appliedStatus]);
const lastPage = Math.max(1, Math.ceil(filteredPeriods.length / perPage));
const pagedPeriods = useMemo(() => {
const start = (page - 1) * perPage;
return filteredPeriods.slice(start, start + perPage);
}, [filteredPeriods, page, perPage]);
useEffect(() => {
setPage(1);
}, [appliedStatus, perPage]);
useEffect(() => {
if (page > lastPage) {
setPage(lastPage);
}
}, [page, lastPage]);
const presetLabel = (key: SettlementPeriodPresetKey): string => {
switch (key) {
case "this_week":
return t("agents:settlementPeriods.presetThisWeek", { defaultValue: "本周" });
case "last_week":
return t("agents:settlementPeriods.presetLastWeek", { defaultValue: "上周" });
case "this_month":
return t("agents:settlementPeriods.presetThisMonth", { defaultValue: "本月" });
}
};
const statusFilterLabel = (value: PeriodStatusFilter): string => {
if (value === "all") {
return t("filters.statusAll", { defaultValue: "全部" });
}
return settlementPeriodStatusLabel(value, t);
};
async function openWithRange(periodStart: string, periodEnd: string): Promise<void> {
if (!canManage) {
return;
}
setBusy(true);
try {
const row = await postSettlementPeriod({
admin_site_id: adminSiteId,
period_start: periodStart,
period_end: periodEnd,
});
await onReloadPeriods();
onPeriodOpened?.(row.id);
setOpenDialogOpen(false);
setCustomStart("");
setCustomEnd("");
toast.success(t("agents:settlementPeriods.opened", { defaultValue: "账期已开启" }));
} catch (err: unknown) {
toast.error(
err instanceof LotteryApiBizError
? err.message
: t("agents:settlementPeriods.openFailed", { defaultValue: "开期失败" }),
);
} finally {
setBusy(false);
}
}
async function openWithPreset(key: SettlementPeriodPresetKey): Promise<void> {
const range = settlementPeriodPresetRange(key);
await openWithRange(range.period_start, range.period_end);
}
async function openCustom(): Promise<void> {
if (!customStart.trim() || !customEnd.trim()) {
toast.error(t("agents:settlementPeriods.datesRequired", { defaultValue: "请填写账期起止" }));
return;
}
await openWithRange(customStart, customEnd);
}
function requestClose(row: SettlementPeriodRow): void {
setCloseTarget(row);
setCloseDialogOpen(true);
}
async function confirmClose(): Promise<void> {
if (!closeTarget) {
return;
}
setBusy(true);
try {
const result = await postSettlementPeriodClose(closeTarget.id);
const items = await onReloadPeriods();
setCloseDialogOpen(false);
setCloseTarget(null);
onPeriodClosed?.(result);
const stillThere = items.find((row) => row.id === closeTarget.id);
if (stillThere?.status === "closed") {
toast.success(t("agents:settlementPeriods.closed", { defaultValue: "账期已关账,账单已生成" }));
}
} catch (err: unknown) {
toast.error(
err instanceof LotteryApiBizError
? err.message
: t("agents:settlementPeriods.closeFailed", { defaultValue: "关账失败" }),
);
} finally {
setBusy(false);
}
}
async function handleRefresh(): Promise<void> {
setReloading(true);
try {
await onReloadPeriods();
} finally {
setReloading(false);
}
}
function applyFilters(): void {
setAppliedStatus(draftStatus);
setPage(1);
}
function resetFilters(): void {
setDraftStatus("all");
setAppliedStatus("all");
setPage(1);
}
const shareCount = closeTarget?.pipeline?.share_ledger_count ?? 0;
const unsettledCount = closeTarget?.pipeline?.unsettled_ticket_count ?? 0;
const cardDescription = canManage
? t("subtitle", { defaultValue: "账期关账、账单确认与收付登记" })
: t("periodTable.readOnlyHint", {
defaultValue: "绑定代理账号不可开/关账期,仅可查看与收付。",
});
const openPeriodHiddenByFilter =
openPeriod !== null &&
appliedStatus !== "all" &&
appliedStatus !== "open" &&
!filteredPeriods.some((row) => row.id === openPeriod.id);
const tableEmptyMessage = useMemo(() => {
if (periods.length === 0) {
if (!canManage) {
return t("periodTable.emptyReadOnly", { defaultValue: "暂无账期记录。" });
}
return t("periodTable.emptyOpenHint", {
defaultValue: "暂无账期,请点击工具栏「开账」创建。",
});
}
if (openPeriodHiddenByFilter) {
return t("periodTable.emptyFilteredOpen", {
defaultValue: "当前筛选未包含进行中的账期,请选「全部」或「进行中」。",
});
}
return t("periodTable.emptyFiltered", { defaultValue: "筛选结果为空,请重置筛选。" });
}, [canManage, openPeriodHiddenByFilter, periods.length, t]);
return (
<>
<AdminPageCard
title={t("periodTable.title", { defaultValue: "账期管理" })}
description={cardDescription}
>
<div className="admin-list-toolbar">
<div className="admin-list-field">
<Label htmlFor="sp-status-filter" className="sm:shrink-0">
{t("periodTable.statusFilter", { defaultValue: "状态" })}
</Label>
<Select
modal={false}
value={draftStatus}
onValueChange={(v) => setDraftStatus((v ?? "all") as PeriodStatusFilter)}
>
<SelectTrigger id="sp-status-filter" className="h-9 w-full sm:w-40">
<SelectValue>{() => statusFilterLabel(draftStatus)}</SelectValue>
</SelectTrigger>
<SelectContent>
{STATUS_FILTER_OPTIONS.map((value) => (
<SelectItem key={value} value={value}>
{statusFilterLabel(value)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="admin-list-actions">
{canManage && openPeriod ? (
<Button
type="button"
size="sm"
disabled={busy}
onClick={() => requestClose(openPeriod)}
>
{t("periodTable.close", { defaultValue: "关账" })}
</Button>
) : null}
{canManage && !openPeriod ? (
<Button
type="button"
size="sm"
disabled={busy}
onClick={() => setOpenDialogOpen(true)}
>
<Plus className="size-4" aria-hidden />
{t("period.openBtn", { defaultValue: "开账" })}
</Button>
) : null}
<Button type="button" size="sm" onClick={() => applyFilters()}>
{t("ledgerPanel.searchBtn", { defaultValue: "搜索" })}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => resetFilters()}>
{t("ledgerPanel.reset", { defaultValue: "重置筛选" })}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
disabled={reloading}
onClick={() => void handleRefresh()}
>
{t("ledgerPanel.refresh", { defaultValue: "刷新当前页" })}
</Button>
</div>
</div>
{canManage && openPeriod ? (
<div className="flex flex-col gap-2 rounded-md border border-amber-200/80 bg-amber-50/60 px-3 py-2 text-sm text-amber-950 sm:flex-row sm:items-center sm:justify-between dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-100">
<p>
{t("periodTable.hasOpen", {
defaultValue: "已有进行中账期 {{range}},须先关账才能开新期。",
range: formatSettlementPeriodSpan(openPeriod.period_start, openPeriod.period_end),
})}
</p>
<Button
type="button"
size="sm"
variant="secondary"
className="shrink-0"
disabled={busy}
onClick={() => requestClose(openPeriod)}
>
{t("periodTable.closeNow", { defaultValue: "立即关账" })}
</Button>
</div>
) : null}
<SettlementPeriodsTable
periods={pagedPeriods}
loading={reloading}
canManage={canManage}
busy={busy}
currencyCode={currencyCode}
emptyMessage={tableEmptyMessage}
onViewDetail={onViewDetail}
onRequestClose={requestClose}
/>
<AdminListPaginationFooter
selectId="settlement-periods-per-page"
total={filteredPeriods.length}
page={page}
lastPage={lastPage}
perPage={perPage}
loading={reloading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
</AdminPageCard>
<Dialog
open={openDialogOpen}
onOpenChange={(open) => {
setOpenDialogOpen(open);
if (!open) {
setCustomStart("");
setCustomEnd("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("period.openTitle", { defaultValue: "开账" })}</DialogTitle>
<DialogDescription>
{t("agents:settlementPeriods.openHint", {
defaultValue: "选择快捷账期或自定义起止时间。",
})}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{PRESET_KEYS.map((key) => (
<Button
key={key}
type="button"
size="sm"
variant="secondary"
disabled={busy}
onClick={() => void openWithPreset(key)}
>
{presetLabel(key)}
</Button>
))}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="sp-dialog-start">
{t("agents:settlementPeriods.start", { defaultValue: "开始" })}
</Label>
<Input
id="sp-dialog-start"
type="datetime-local"
value={customStart}
onChange={(e) => setCustomStart(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sp-dialog-end">
{t("agents:settlementPeriods.end", { defaultValue: "结束" })}
</Label>
<Input
id="sp-dialog-end"
type="datetime-local"
value={customEnd}
onChange={(e) => setCustomEnd(e.target.value)}
/>
</div>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => setOpenDialogOpen(false)}
>
{t("common:cancel", { defaultValue: "取消" })}
</Button>
<Button type="button" disabled={busy} onClick={() => void openCustom()}>
{t("agents:settlementPeriods.open", { defaultValue: "开期" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={closeDialogOpen}
onOpenChange={(open) => {
setCloseDialogOpen(open);
if (!open) {
setCloseTarget(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("period.closeDialogTitle", { defaultValue: "确认关账" })}</DialogTitle>
<DialogDescription>
{closeTarget
? t("period.closeDialogDesc", {
defaultValue: "将汇总 {{range}} 内的流水并生成账单。",
range: formatSettlementPeriodSpan(
closeTarget.period_start,
closeTarget.period_end,
),
})
: null}
</DialogDescription>
</DialogHeader>
{closeTarget ? (
<ul className="list-inside list-disc space-y-1 text-sm text-muted-foreground">
<li>
{shareCount > 0
? t("period.closeDialogShare", {
defaultValue: "流水 {{count}} 笔",
count: shareCount,
})
: t("period.closeDialogEmpty", {
defaultValue: "本期暂无占成流水,关账后不会生成账单。",
})}
</li>
{unsettledCount > 0 ? (
<li className="text-amber-800">
{t("period.closeDialogUnsettled", {
defaultValue: "仍有 {{count}} 笔注单未结算",
count: unsettledCount,
})}
</li>
) : null}
<li>
{t("period.closeDialogIrreversible", {
defaultValue: "关账后不可撤销,差错请通过调账或冲正处理。",
})}
</li>
</ul>
) : null}
<DialogFooter>
<Button type="button" variant="outline" disabled={busy} onClick={() => setCloseDialogOpen(false)}>
{t("common:cancel", { defaultValue: "取消" })}
</Button>
<Button type="button" disabled={busy} onClick={() => void confirmClose()}>
{t("period.closeDialogConfirm", { defaultValue: "确认关账" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}