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

@@ -31,19 +31,31 @@ type CellProps = { row: AdminAgentFields; className?: string };
export function AdminAgentHead({ className }: HeadProps): React.ReactElement {
const { t } = useTranslation("common");
return (
<TableHead className={cn("whitespace-nowrap", className)}>
<TableHead className={cn("min-w-[7.5rem] whitespace-nowrap", className)}>
{t("agentColumns.agent")}
</TableHead>
);
}
export function AdminAgentCell({ row, className }: CellProps): React.ReactElement {
const name = cellText(row.agent_name);
const code = row.agent_code?.trim() ?? "";
return (
<TableCell className={cn("text-xs", className)}>
<span className="font-medium">{cellText(row.agent_name)}</span>
{row.agent_code ? (
<span className="mt-0.5 block font-mono text-[11px] text-muted-foreground">{row.agent_code}</span>
) : null}
<TableCell className={cn("min-w-[7.5rem] max-w-[10rem] align-top text-xs", className)}>
<div className="min-w-0 space-y-0.5">
<span className="block truncate font-medium" title={name !== "—" ? name : undefined}>
{name}
</span>
{code !== "" ? (
<span
className="block truncate font-mono text-[11px] text-muted-foreground"
title={code}
>
{code}
</span>
) : null}
</div>
</TableCell>
);
}

View File

@@ -14,6 +14,15 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
export type AdminDateRangeCalendarMarkers = {
/** 本地日历 `yyyy-MM-dd` — 已有账期,不可选 */
occupiedPeriod?: string[];
/** 本地日历 `yyyy-MM-dd` */
pendingActivity?: string[];
/** 本地日历 `yyyy-MM-dd` — 已有账期内的未结清日 */
unpaidBill?: string[];
};
function parseYmd(value: string): Date | undefined {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return undefined;
@@ -45,6 +54,9 @@ export function AdminDateRangeField({
to: toProp,
onRangeChange,
placeholder,
rangeHint,
calendarMarkers,
disabled,
}: {
id: string;
label?: string;
@@ -52,6 +64,9 @@ export function AdminDateRangeField({
to: string;
onRangeChange: (next: { from: string; to: string }) => void;
placeholder?: string;
rangeHint?: string;
calendarMarkers?: AdminDateRangeCalendarMarkers;
disabled?: boolean;
}) {
const { t } = useTranslation(["common"]);
const [open, setOpen] = React.useState(false);
@@ -77,6 +92,28 @@ export function AdminDateRangeField({
const hasSelection = Boolean(parseYmd(fromProp) || parseYmd(toProp));
const defaultMonth = selected?.from ?? selected?.to ?? new Date();
const pendingActivityDates = React.useMemo(
() =>
(calendarMarkers?.pendingActivity ?? [])
.map((ymd) => parseYmd(ymd))
.filter((d): d is Date => d instanceof Date),
[calendarMarkers?.pendingActivity],
);
const occupiedPeriodDates = React.useMemo(
() =>
(calendarMarkers?.occupiedPeriod ?? [])
.map((ymd) => parseYmd(ymd))
.filter((d): d is Date => d instanceof Date),
[calendarMarkers?.occupiedPeriod],
);
const unpaidBillDates = React.useMemo(
() =>
(calendarMarkers?.unpaidBill ?? [])
.map((ymd) => parseYmd(ymd))
.filter((d): d is Date => d instanceof Date),
[calendarMarkers?.unpaidBill],
);
return (
<div className="grid gap-1.5">
{label ? (
@@ -94,6 +131,7 @@ export function AdminDateRangeField({
<PopoverTrigger
type="button"
id={id}
disabled={disabled}
className={cn(
buttonVariants({ variant: "outline", size: "default" }),
"h-8 min-h-8 w-full justify-start gap-2 px-2.5 font-normal md:text-sm",
@@ -106,13 +144,17 @@ export function AdminDateRangeField({
</span>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-auto max-w-[calc(100vw-2rem)] min-w-fit p-0">
<p className="text-muted-foreground border-b px-3 py-2 text-xs leading-relaxed">
{t("date.rangeHint", {
ns: "common",
defaultValue:
"Select a start date, then an end date. For a single day, click the same date twice. Click Done to close.",
})}
</p>
{rangeHint ? (
<p className="text-muted-foreground border-b px-3 py-2 text-xs leading-relaxed">{rangeHint}</p>
) : (
<p className="text-muted-foreground border-b px-3 py-2 text-xs leading-relaxed">
{t("date.rangeHint", {
ns: "common",
defaultValue:
"Select a start date, then an end date. For a single day, click the same date twice. Click Done to close.",
})}
</p>
)}
<Calendar
mode="range"
locale={enUS}
@@ -120,6 +162,20 @@ export function AdminDateRangeField({
selected={selected}
defaultMonth={defaultMonth}
numberOfMonths={isMobile ? 1 : 2}
disabled={occupiedPeriodDates}
modifiers={{
occupiedPeriod: occupiedPeriodDates,
pendingActivity: pendingActivityDates,
unpaidBill: unpaidBillDates,
}}
modifiersClassNames={{
occupiedPeriod:
"[&>button]:cursor-not-allowed [&>button]:bg-muted/80 [&>button]:text-muted-foreground [&>button]:line-through [&>button]:opacity-70",
pendingActivity:
"[&:not([data-disabled=true])>button]:relative [&:not([data-disabled=true])>button]:after:absolute [&:not([data-disabled=true])>button]:after:bottom-0.5 [&:not([data-disabled=true])>button]:after:left-1/2 [&:not([data-disabled=true])>button]:after:size-1 [&:not([data-disabled=true])>button]:after:-translate-x-1/2 [&:not([data-disabled=true])>button]:after:rounded-full [&:not([data-disabled=true])>button]:after:bg-amber-500 [&:not([data-disabled=true])>button]:after:content-['']",
unpaidBill:
"[&>button]:relative [&>button]:before:absolute [&>button]:before:top-0.5 [&>button]:before:right-0.5 [&>button]:before:size-1.5 [&>button]:before:rounded-full [&>button]:before:bg-rose-500 [&>button]:before:content-['']",
}}
onSelect={(range) => {
if (!range?.from && !range?.to) {
onRangeChange({ from: "", to: "" });

View File

@@ -36,7 +36,7 @@ type CellProps = { row: AdminPlayerIdentityFields; className?: string };
export function AdminPlayerSiteHead({ className }: HeadProps): React.ReactElement {
const { t } = useTranslation("common");
return (
<TableHead className={cn("whitespace-nowrap", className)}>
<TableHead className={cn("min-w-[5.5rem] whitespace-nowrap", className)}>
{t("playerColumns.site")}
</TableHead>
);
@@ -45,7 +45,7 @@ export function AdminPlayerSiteHead({ className }: HeadProps): React.ReactElemen
export function AdminPlayerDisplayHead({ className }: HeadProps): React.ReactElement {
const { t } = useTranslation("common");
return (
<TableHead className={cn("whitespace-nowrap", className)}>
<TableHead className={cn("min-w-[4.5rem] whitespace-nowrap", className)}>
{t("playerColumns.display")}
</TableHead>
);
@@ -54,7 +54,7 @@ export function AdminPlayerDisplayHead({ className }: HeadProps): React.ReactEle
export function AdminPlayerSiteIdHead({ className }: HeadProps): React.ReactElement {
const { t } = useTranslation("common");
return (
<TableHead className={cn("whitespace-nowrap", className)}>
<TableHead className={cn("min-w-[6.5rem] whitespace-nowrap", className)}>
{t("playerColumns.sitePlayerId")}
</TableHead>
);
@@ -71,25 +71,40 @@ export function AdminPlayerIdentityHeads({ className }: { className?: string }):
}
export function AdminPlayerSiteCell({ row, className }: CellProps): React.ReactElement {
const site = cellText(row.site_code);
return (
<TableCell className={cn("text-xs", className)}>
<span className="font-mono text-xs">{cellText(row.site_code)}</span>
<TableCell className={cn("min-w-[5.5rem] max-w-[8rem] align-top text-xs", className)}>
<span className="block truncate font-mono text-xs" title={site !== "—" ? site : undefined}>
{site}
</span>
</TableCell>
);
}
export function AdminPlayerDisplayCell({ row, className }: CellProps): React.ReactElement {
const label = adminPlayerDisplayName(row);
return (
<TableCell className={cn("text-xs", className)}>
{adminPlayerDisplayName(row)}
<TableCell className={cn("min-w-[4.5rem] max-w-[8rem] align-top text-xs", className)}>
<span className="block truncate" title={label !== "—" ? label : undefined}>
{label}
</span>
</TableCell>
);
}
export function AdminPlayerSiteIdCell({ row, className }: CellProps): React.ReactElement {
const sitePlayerId = cellText(row.site_player_id);
return (
<TableCell className={cn("text-xs", className)}>
<span className="font-mono text-xs">{cellText(row.site_player_id)}</span>
<TableCell className={cn("min-w-[6.5rem] max-w-[9rem] align-top text-xs", className)}>
<span
className="block truncate font-mono text-xs"
title={sitePlayerId !== "—" ? sitePlayerId : undefined}
>
{sitePlayerId}
</span>
</TableCell>
);
}