feat(settlement, admin): introduce new types and functions for downline share and settlement period hints

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.
This commit is contained in:
2026-06-12 16:01:42 +08:00
parent 1eb6702c51
commit 24fd7c10bd
50 changed files with 1821 additions and 618 deletions

View File

@@ -6,11 +6,14 @@ 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";
@@ -23,7 +26,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
@@ -34,14 +36,15 @@ import {
} from "@/components/ui/select";
import {
formatSettlementPeriodSpan,
settlementPeriodPresetRange,
type SettlementPeriodPresetKey,
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";
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"];
@@ -77,6 +80,8 @@ export function SettlementPeriodWorkbench({
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);
@@ -112,16 +117,32 @@ export function SettlementPeriodWorkbench({
}
}, [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 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") {
@@ -130,45 +151,97 @@ export function SettlementPeriodWorkbench({
return settlementPeriodStatusLabel(value, t);
};
async function openWithRange(periodStart: string, periodEnd: string): Promise<void> {
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: periodStart,
period_end: periodEnd,
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: "开失败" }),
: 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);
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 {
@@ -299,7 +372,7 @@ export function SettlementPeriodWorkbench({
type="button"
size="sm"
disabled={busy}
onClick={() => setOpenDialogOpen(true)}
onClick={() => handleOpenDialog(true)}
>
<Plus className="size-4" aria-hidden />
{t("period.openBtn", { defaultValue: "开账" })}
@@ -370,76 +443,81 @@ export function SettlementPeriodWorkbench({
/>
</AdminPageCard>
<Dialog
open={openDialogOpen}
onOpenChange={(open) => {
setOpenDialogOpen(open);
if (!open) {
setCustomStart("");
setCustomEnd("");
}
}}
>
<Dialog open={openDialogOpen} onOpenChange={handleOpenDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("period.openTitle", { defaultValue: "开账" })}</DialogTitle>
<DialogDescription>
{t("agents:settlementPeriods.openHint", {
defaultValue: "选择快捷账期或自定义起止时间。",
{t("agents:settlementPeriods.openDesc", {
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)}
<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
>
{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>
<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}
onClick={() => setOpenDialogOpen(false)}
disabled={busy || hintsLoading}
onClick={() => handleOpenDialog(false)}
>
{t("common:cancel", { defaultValue: "取消" })}
</Button>
<Button type="button" disabled={busy} onClick={() => void openCustom()}>
{t("agents:settlementPeriods.open", { defaultValue: "开期" })}
<Button
type="button"
disabled={busy || hintsLoading || selectedRangeOverlapsOccupied}
onClick={() => void openCustom()}
>
{t("period.openBtn", { defaultValue: "开账" })}
</Button>
</DialogFooter>
</DialogContent>