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:
508
src/modules/settlement/settlement-period-workbench.tsx
Normal file
508
src/modules/settlement/settlement-period-workbench.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user