Added new types for downline share breakdown and settlement period open hints to enhance the agent settlement API. Updated the admin console components to support these new features, improving the user experience with better data presentation and interaction. Additionally, refined the date range field to accommodate new calendar markers and hints, ensuring a more intuitive interface for managing settlement periods.
590 lines
20 KiB
TypeScript
590 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { Plus } from "lucide-react";
|
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
|
|
import {
|
|
getSettlementPeriodOpenHints,
|
|
postSettlementPeriod,
|
|
postSettlementPeriodClose,
|
|
type SettlementPeriodCloseResult,
|
|
type SettlementPeriodOpenHints,
|
|
type SettlementPeriodRow,
|
|
} from "@/api/admin-agent-settlement";
|
|
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
|
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 { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
formatSettlementPeriodSpan,
|
|
isSettlementLocalDateRangeValid,
|
|
localDateRangeToUtcPeriodBounds,
|
|
settlementRangeOverlapsOccupiedDates,
|
|
utcStorageDateToLocalFormYmd,
|
|
utcStorageDatesToLocalMarks,
|
|
} from "@/lib/agent-settlement-period-range";
|
|
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
|
|
import { LotteryApiBizError } from "@/types/api/errors";
|
|
|
|
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[];
|
|
headerActions?: ReactNode;
|
|
onViewDetail: (periodId: number) => void;
|
|
onReloadPeriods: () => Promise<SettlementPeriodRow[]>;
|
|
onPeriodOpened?: (periodId: number) => void;
|
|
onPeriodClosed?: (result: SettlementPeriodCloseResult) => void;
|
|
};
|
|
|
|
export function SettlementPeriodWorkbench({
|
|
adminSiteId,
|
|
currencyCode,
|
|
canManage,
|
|
periods,
|
|
headerActions,
|
|
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 [openHints, setOpenHints] = useState<SettlementPeriodOpenHints | null>(null);
|
|
const [hintsLoading, setHintsLoading] = useState(false);
|
|
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 calendarMarkers = useMemo(() => {
|
|
if (openHints === null) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
occupiedPeriod: utcStorageDatesToLocalMarks(openHints.occupied_period_dates),
|
|
pendingActivity: utcStorageDatesToLocalMarks(openHints.pending_activity_dates),
|
|
unpaidBill: utcStorageDatesToLocalMarks(openHints.unpaid_bill_dates),
|
|
};
|
|
}, [openHints]);
|
|
|
|
const occupiedLocalDates = useMemo(
|
|
() => utcStorageDatesToLocalMarks(openHints?.occupied_period_dates ?? []),
|
|
[openHints],
|
|
);
|
|
|
|
const selectedRangeOverlapsOccupied = useMemo(() => {
|
|
if (!customStart.trim() || !customEnd.trim()) {
|
|
return false;
|
|
}
|
|
return settlementRangeOverlapsOccupiedDates(
|
|
customStart.trim(),
|
|
customEnd.trim(),
|
|
occupiedLocalDates,
|
|
);
|
|
}, [customEnd, customStart, occupiedLocalDates]);
|
|
|
|
const statusFilterLabel = (value: PeriodStatusFilter): string => {
|
|
if (value === "all") {
|
|
return t("filters.statusAll", { defaultValue: "全部" });
|
|
}
|
|
return settlementPeriodStatusLabel(value, t);
|
|
};
|
|
|
|
async function openWithRange(startYmd: string, endYmd: string): Promise<void> {
|
|
if (!canManage) {
|
|
return;
|
|
}
|
|
if (!isSettlementLocalDateRangeValid(startYmd, endYmd)) {
|
|
toast.error(
|
|
t("agents:settlementPeriods.invalidRange", {
|
|
defaultValue: "结束日期不能早于开始日期",
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (settlementRangeOverlapsOccupiedDates(startYmd, endYmd, occupiedLocalDates)) {
|
|
toast.error(
|
|
t("agents:settlementPeriods.overlapsOccupied", {
|
|
defaultValue: "所选范围与已有账期重叠,请避开灰色删除线的日期。",
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
const bounds = localDateRangeToUtcPeriodBounds(startYmd, endYmd);
|
|
setBusy(true);
|
|
try {
|
|
const row = await postSettlementPeriod({
|
|
admin_site_id: adminSiteId,
|
|
period_start: bounds.period_start,
|
|
period_end: bounds.period_end,
|
|
});
|
|
await onReloadPeriods();
|
|
onPeriodOpened?.(row.id);
|
|
setOpenDialogOpen(false);
|
|
setCustomStart("");
|
|
setCustomEnd("");
|
|
setOpenHints(null);
|
|
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 openCustom(): Promise<void> {
|
|
if (!customStart.trim() || !customEnd.trim()) {
|
|
toast.error(t("agents:settlementPeriods.datesRequired", { defaultValue: "请填写账期起止" }));
|
|
return;
|
|
}
|
|
await openWithRange(customStart.trim(), customEnd.trim());
|
|
}
|
|
|
|
async function loadOpenHints(): Promise<void> {
|
|
setHintsLoading(true);
|
|
try {
|
|
const hints = await getSettlementPeriodOpenHints({ admin_site_id: adminSiteId });
|
|
setOpenHints(hints);
|
|
setCustomStart("");
|
|
setCustomEnd("");
|
|
if (hints.suggested_start && hints.suggested_end) {
|
|
const from = utcStorageDateToLocalFormYmd(hints.suggested_start);
|
|
const to = utcStorageDateToLocalFormYmd(hints.suggested_end);
|
|
const occupied = utcStorageDatesToLocalMarks(hints.occupied_period_dates);
|
|
if (!settlementRangeOverlapsOccupiedDates(from, to, occupied)) {
|
|
setCustomStart(from);
|
|
setCustomEnd(to);
|
|
}
|
|
}
|
|
} catch (err: unknown) {
|
|
setOpenHints(null);
|
|
toast.error(
|
|
err instanceof LotteryApiBizError
|
|
? err.message
|
|
: t("agents:settlementPeriods.hintsFailed", { defaultValue: "无法加载开账建议" }),
|
|
);
|
|
} finally {
|
|
setHintsLoading(false);
|
|
}
|
|
}
|
|
|
|
function handleOpenDialog(open: boolean): void {
|
|
setOpenDialogOpen(open);
|
|
if (open) {
|
|
void loadOpenHints();
|
|
return;
|
|
}
|
|
setCustomStart("");
|
|
setCustomEnd("");
|
|
setOpenHints(null);
|
|
}
|
|
|
|
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}
|
|
actions={headerActions}
|
|
>
|
|
<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={() => handleOpenDialog(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={handleOpenDialog}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t("period.openTitle", { defaultValue: "开账" })}</DialogTitle>
|
|
<DialogDescription>
|
|
{t("agents:settlementPeriods.openDesc", {
|
|
defaultValue:
|
|
"选择账期起止日期;灰色删除线为已有账期不可再开,琥珀点为待入账,右上角红点为未结清。",
|
|
})}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<AdminDateRangeField
|
|
id="sp-dialog-range"
|
|
label={t("agents:settlementPeriods.rangeLabel", { defaultValue: "账期范围" })}
|
|
from={customStart}
|
|
to={customEnd}
|
|
disabled={hintsLoading || busy}
|
|
calendarMarkers={calendarMarkers}
|
|
rangeHint={t("agents:settlementPeriods.rangeHint", {
|
|
defaultValue: "先选开始日期,再选结束日期;单日账期可点同一天两次。",
|
|
})}
|
|
onRangeChange={({ from, to }) => {
|
|
setCustomStart(from);
|
|
setCustomEnd(to);
|
|
}}
|
|
/>
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<span
|
|
className="inline-block size-3 rounded bg-muted ring-1 ring-border line-through"
|
|
aria-hidden
|
|
/>
|
|
{t("agents:settlementPeriods.markerOccupied", { defaultValue: "已有账期" })}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<span
|
|
className="inline-block size-1.5 rounded-full bg-amber-500"
|
|
aria-hidden
|
|
/>
|
|
{t("agents:settlementPeriods.markerPending", { defaultValue: "待入账流水" })}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<span
|
|
className="relative inline-block size-3 rounded ring-1 ring-border"
|
|
aria-hidden
|
|
>
|
|
<span className="absolute top-0 right-0 size-1.5 rounded-full bg-rose-500" />
|
|
</span>
|
|
{t("agents:settlementPeriods.markerUnpaid", { defaultValue: "未结清账期" })}
|
|
</span>
|
|
</div>
|
|
{selectedRangeOverlapsOccupied ? (
|
|
<p className="text-destructive text-xs">
|
|
{t("agents:settlementPeriods.overlapsOccupied", {
|
|
defaultValue: "所选范围与已有账期重叠,请避开灰色删除线的日期。",
|
|
})}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={busy || hintsLoading}
|
|
onClick={() => handleOpenDialog(false)}
|
|
>
|
|
{t("common:cancel", { defaultValue: "取消" })}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
disabled={busy || hintsLoading || selectedRangeOverlapsOccupied}
|
|
onClick={() => void openCustom()}
|
|
>
|
|
{t("period.openBtn", { 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>
|
|
</>
|
|
);
|
|
}
|