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.
683 lines
29 KiB
TypeScript
683 lines
29 KiB
TypeScript
"use client";
|
|
|
|
import { CalendarRange, Eye, ShieldAlert, UserRound } from "lucide-react";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
|
import { toast } from "sonner";
|
|
|
|
import {
|
|
getAdminReconcileJobItems,
|
|
getAdminReconcileJobs,
|
|
postAdminReconcileJob,
|
|
} from "@/api/admin-reconcile";
|
|
import { getAdminPlayers } from "@/api/admin-player";
|
|
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
|
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
|
import { cn } from "@/lib/utils";
|
|
import { useAdminProfile } from "@/stores/admin-session";
|
|
import { LotteryApiBizError } from "@/types/api/errors";
|
|
import type { AdminPlayerRow } from "@/types/api/admin-player";
|
|
import type {
|
|
AdminReconcileJobRow,
|
|
AdminReconcileItemsData,
|
|
AdminReconcileJobListData,
|
|
} from "@/types/api/admin-reconcile";
|
|
|
|
const MANAGE = ["prd.wallet_reconcile.manage"] as const;
|
|
|
|
/** 与后端 reconcile_type 对齐;扩展时在 API 与下拉同步增加 */
|
|
const RECONCILE_TYPE = "wallet_transfer" as const;
|
|
|
|
function jobStatusLabel(status: string, t: (key: string) => string): string {
|
|
switch (status) {
|
|
case "completed":
|
|
return t("statusCompleted");
|
|
case "running":
|
|
return t("statusRunning");
|
|
case "failed":
|
|
return t("statusFailed");
|
|
default:
|
|
return status;
|
|
}
|
|
}
|
|
|
|
function itemStatusLabel(status: string, t: (key: string) => string): string {
|
|
switch (status) {
|
|
case "mismatch":
|
|
return t("itemMismatch");
|
|
case "matched":
|
|
return t("itemMatched");
|
|
case "pending_check":
|
|
return t("itemPendingCheck");
|
|
default:
|
|
return status;
|
|
}
|
|
}
|
|
|
|
function reconcileTypeLabel(type: string, t: (key: string) => string): string {
|
|
switch (type) {
|
|
case "wallet_transfer":
|
|
return t("reconcileTypeFixed");
|
|
default:
|
|
return type;
|
|
}
|
|
}
|
|
|
|
function getJobSummaryValue(summary: Record<string, unknown> | null | undefined, key: string): number {
|
|
const raw = summary?.[key];
|
|
return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
|
|
}
|
|
|
|
function renderPeriodRange(
|
|
row: Pick<AdminReconcileJobRow, "period_start" | "period_end">,
|
|
formatTs: (value: string | null | undefined) => string,
|
|
): string {
|
|
const from = row.period_start ? formatTs(row.period_start) : "—";
|
|
const to = row.period_end ? formatTs(row.period_end) : "—";
|
|
return `${from} ~ ${to}`;
|
|
}
|
|
|
|
export function ReconcileConsole(): React.ReactElement {
|
|
const { t } = useTranslation(["reconcile", "common"]);
|
|
const tRef = useTranslationRef(["reconcile", "common"]);
|
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
|
const profile = useAdminProfile();
|
|
const canCreate = adminHasAnyPermission(profile?.permissions, [...MANAGE]);
|
|
const formatTs = useAdminDateTimeFormatter();
|
|
|
|
const [jobs, setJobs] = useState<AdminReconcileJobListData | null>(null);
|
|
const [jobsLoading, setJobsLoading] = useState(true);
|
|
const [jobsErr, setJobsErr] = useState<string | null>(null);
|
|
const [page, setPage] = useState(1);
|
|
const [perPage, setPerPage] = useState(10);
|
|
|
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
|
const [items, setItems] = useState<AdminReconcileItemsData | null>(null);
|
|
const [itemsPage, setItemsPage] = useState(1);
|
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
|
const [itemsLoading, setItemsLoading] = useState(false);
|
|
|
|
const [dateFrom, setDateFrom] = useState("");
|
|
const [dateTo, setDateTo] = useState("");
|
|
const [playerSearch, setPlayerSearch] = useState("");
|
|
const [playerResults, setPlayerResults] = useState<AdminPlayerRow[]>([]);
|
|
const [playerLoading, setPlayerLoading] = useState(false);
|
|
const [selectedPlayer, setSelectedPlayer] = useState<AdminPlayerRow | null>(null);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const loadJobs = useCallback(async () => {
|
|
setJobsLoading(true);
|
|
setJobsErr(null);
|
|
try {
|
|
const d = await getAdminReconcileJobs({ page, per_page: perPage });
|
|
setJobs(d);
|
|
} catch (e) {
|
|
setJobsErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
|
setJobs(null);
|
|
} finally {
|
|
setJobsLoading(false);
|
|
}
|
|
}, [page, perPage]);
|
|
|
|
useAsyncEffect(() => {
|
|
void loadJobs();
|
|
}, [page, perPage]);
|
|
|
|
const loadItems = useCallback(async () => {
|
|
if (selectedId == null) {
|
|
setItems(null);
|
|
return;
|
|
}
|
|
setItemsLoading(true);
|
|
try {
|
|
const d = await getAdminReconcileJobItems(selectedId, {
|
|
page: itemsPage,
|
|
per_page: itemsPerPage,
|
|
});
|
|
setItems(d);
|
|
} catch (e) {
|
|
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("loadItemsFailed"));
|
|
setItems(null);
|
|
} finally {
|
|
setItemsLoading(false);
|
|
}
|
|
}, [selectedId, itemsPage, itemsPerPage]);
|
|
|
|
useAsyncEffect(() => {
|
|
void loadItems();
|
|
}, [selectedId, itemsPage, itemsPerPage]);
|
|
|
|
const loadPlayers = useCallback(async (keyword: string) => {
|
|
const q = keyword.trim();
|
|
if (q === "") {
|
|
setPlayerResults([]);
|
|
return;
|
|
}
|
|
setPlayerLoading(true);
|
|
try {
|
|
const data = await getAdminPlayers({ page: 1, per_page: 8, keyword: q });
|
|
setPlayerResults(data.items);
|
|
} catch {
|
|
setPlayerResults([]);
|
|
} finally {
|
|
setPlayerLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const q = playerSearch.trim();
|
|
if (q === "") {
|
|
return;
|
|
}
|
|
const timer = window.setTimeout(() => {
|
|
void loadPlayers(q);
|
|
}, 250);
|
|
return () => window.clearTimeout(timer);
|
|
}, [loadPlayers, playerSearch]);
|
|
|
|
async function onCreate(): Promise<void> {
|
|
if (!dateFrom.trim() || !dateTo.trim()) {
|
|
toast.error(t("periodRequired"));
|
|
return;
|
|
}
|
|
if (dateFrom > dateTo) {
|
|
toast.error(t("periodOrderInvalid"));
|
|
return;
|
|
}
|
|
|
|
setSubmitting(true);
|
|
try {
|
|
await postAdminReconcileJob({
|
|
reconcile_type: RECONCILE_TYPE,
|
|
date_from: dateFrom,
|
|
date_to: dateTo,
|
|
player_id: selectedPlayer ? selectedPlayer.id : null,
|
|
});
|
|
toast.success(t("createSuccess"));
|
|
setPage(1);
|
|
setDateFrom("");
|
|
setDateTo("");
|
|
setPlayerSearch("");
|
|
setSelectedPlayer(null);
|
|
setPlayerResults([]);
|
|
await loadJobs();
|
|
} catch (e) {
|
|
toast.error(e instanceof LotteryApiBizError ? e.message : t("createFailed"));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
const jm = jobs?.meta;
|
|
const im = items?.meta;
|
|
const selectedJob = jobs?.items.find((job) => job.id === selectedId) ?? null;
|
|
const selectedJobItemCount = getJobSummaryValue(selectedJob?.summary_json, "item_count");
|
|
const selectedJobMismatchCount = getJobSummaryValue(selectedJob?.summary_json, "mismatch_count");
|
|
const selectedJobMatchedCount = Math.max(0, selectedJobItemCount - selectedJobMismatchCount);
|
|
const hasSelectedRange = dateFrom.trim() !== "" && dateTo.trim() !== "";
|
|
|
|
return (
|
|
<div className="flex w-full max-w-none flex-col gap-6">
|
|
{canCreate ? (
|
|
<Card className="admin-list-card">
|
|
<CardHeader className="admin-list-header">
|
|
<CardTitle className="admin-list-title">{t("createTitle")}</CardTitle>
|
|
<CardDescription>{t("createDesc")}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="admin-list-content pt-4">
|
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
|
<div className="rounded-xl border bg-muted/15 p-4">
|
|
<div className="mb-4 flex items-start gap-3">
|
|
<div className="rounded-lg bg-background p-2 text-muted-foreground">
|
|
<CalendarRange className="size-4" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium">{t("scopeTitle")}</div>
|
|
<p className="text-sm text-muted-foreground">{t("scopeDescription")}</p>
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-4">
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="rc-type">{t("reconcileType")}</Label>
|
|
<Input id="rc-type" value={t("reconcileTypeFixed")} readOnly className="bg-muted/30" />
|
|
<p className="text-xs text-muted-foreground">{t("reconcileTypeHint")}</p>
|
|
</div>
|
|
<div className="grid gap-1.5">
|
|
<AdminDateRangeField
|
|
id="rc-date-range"
|
|
label={t("dateRange")}
|
|
from={dateFrom}
|
|
to={dateTo}
|
|
onRangeChange={({ from, to }) => {
|
|
setDateFrom(from);
|
|
setDateTo(to);
|
|
}}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">{t("dateRangeHint")}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-xl border bg-background p-4">
|
|
<div className="mb-4 flex items-start gap-3">
|
|
<div className="rounded-lg bg-muted/20 p-2 text-muted-foreground">
|
|
<UserRound className="size-4" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium">{t("playerScopeTitle")}</div>
|
|
<p className="text-sm text-muted-foreground">{t("playerSearchHint")}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="rc-player-search">{t("playerSearch")}</Label>
|
|
<Input
|
|
id="rc-player-search"
|
|
value={playerSearch}
|
|
onChange={(e) => setPlayerSearch(e.target.value)}
|
|
placeholder={t("playerSearchPlaceholder")}
|
|
/>
|
|
</div>
|
|
|
|
{selectedPlayer ? (
|
|
<div className="mt-4 flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2 text-sm">
|
|
<div className="min-w-0">
|
|
<div className="truncate font-medium text-foreground">
|
|
{selectedPlayer.site_player_id}
|
|
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
|
|
{selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""}
|
|
</div>
|
|
<div className="truncate text-xs text-muted-foreground">
|
|
{t("playerSelected")} · {selectedPlayer.site_code}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setSelectedPlayer(null);
|
|
setPlayerSearch("");
|
|
setPlayerResults([]);
|
|
}}
|
|
>
|
|
{t("playerClear")}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
{playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? (
|
|
<div className="mt-4 rounded-lg border bg-background">
|
|
<div className="max-h-56 overflow-y-auto">
|
|
{playerLoading ? (
|
|
<AdminLoadingInline className="py-2" label={t("loadingPlayers")} />
|
|
) : playerResults.length === 0 ? (
|
|
<AdminNoResourceState compact className="px-3 py-4" />
|
|
) : (
|
|
<div className="divide-y">
|
|
{playerResults.map((player) => {
|
|
const active = selectedPlayer?.id === player.id;
|
|
return (
|
|
<button
|
|
key={player.id}
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-start justify-between gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/25",
|
|
active && "bg-muted/30",
|
|
)}
|
|
onClick={() => {
|
|
setSelectedPlayer(player);
|
|
setPlayerSearch(player.site_player_id);
|
|
}}
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="truncate font-medium text-foreground">
|
|
{player.site_player_id}
|
|
{player.nickname ? ` · ${player.nickname}` : ""}
|
|
</div>
|
|
<div className="truncate text-xs text-muted-foreground">
|
|
{player.username ?? "—"} · {player.site_code}
|
|
</div>
|
|
</div>
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|
{active ? t("playerSelectedShort") : t("playerChoose")}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="mt-4 rounded-lg border border-dashed bg-muted/10 px-3 py-3 text-sm text-muted-foreground">
|
|
{t("playerAllPlayersHint")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-muted/10 px-4 py-3">
|
|
<div className="min-w-0 text-sm text-muted-foreground">
|
|
{hasSelectedRange
|
|
? selectedPlayer
|
|
? t("createSummaryPlayer", {
|
|
player: selectedPlayer.site_player_id,
|
|
from: dateFrom,
|
|
to: dateTo,
|
|
})
|
|
: t("createSummaryAll", {
|
|
from: dateFrom,
|
|
to: dateTo,
|
|
})
|
|
: t("createSummaryPending", {
|
|
defaultValue: "请选择完整的对账日期范围后,再创建任务。",
|
|
})}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
className="w-full sm:w-auto"
|
|
disabled={submitting}
|
|
onClick={() =>
|
|
requestConfirm({
|
|
title: t("confirmCreateTitle"),
|
|
description: t("confirmCreateDescription", {
|
|
playerHint: selectedPlayer
|
|
? t("confirmCreatePlayer")
|
|
: t("confirmCreateAllPlayers"),
|
|
}),
|
|
onConfirm: () => onCreate(),
|
|
})
|
|
}
|
|
>
|
|
{submitting ? t("submitting") : t("createTask")}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<p className="text-muted-foreground text-sm">{t("noCreatePermission")}</p>
|
|
)}
|
|
|
|
<Card className="admin-list-card">
|
|
<CardHeader className="admin-list-header flex flex-row flex-wrap items-end justify-between gap-4">
|
|
<div>
|
|
<CardTitle className="admin-list-title">{t("jobsTitle")}</CardTitle>
|
|
<CardDescription>{t("jobsDesc")}</CardDescription>
|
|
</div>
|
|
<Button type="button" variant="secondary" size="sm" onClick={() => void loadJobs()}>
|
|
{t("refresh")}
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent className="admin-list-content pt-4">
|
|
{jobsErr ? <p className="text-sm text-red-600 dark:text-red-400">{jobsErr}</p> : null}
|
|
{jobs ? (
|
|
<>
|
|
<div className="admin-table-shell">
|
|
<Table id="reconcile-jobs-table">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="sticky left-0 z-20 w-24 bg-muted shadow-[1px_0_0_rgba(203,213,225,0.7)]">
|
|
{t("table.id", { ns: "common" })}
|
|
</TableHead>
|
|
<TableHead className="sticky left-24 z-20 min-w-[14rem] bg-muted shadow-[1px_0_0_rgba(203,213,225,0.7)]">
|
|
{t("jobNo")}
|
|
</TableHead>
|
|
<TableHead>{t("type")}</TableHead>
|
|
<TableHead>{t("status")}</TableHead>
|
|
<TableHead className="text-center">{t("itemCount")}</TableHead>
|
|
<TableHead className="text-center">{t("mismatchCount")}</TableHead>
|
|
<TableHead>{t("period")}</TableHead>
|
|
<TableHead>{t("finishedAt")}</TableHead>
|
|
<TableHead>{t("createdAt")}</TableHead>
|
|
<TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
|
{t("operate")}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{jobsLoading && !jobs ? (
|
|
<AdminTableLoadingRow colSpan={10} />
|
|
) : jobs.items.length === 0 ? (
|
|
<AdminTableNoResourceRow colSpan={10} className="text-muted-foreground" />
|
|
) : (
|
|
jobs.items.map((row) => (
|
|
<TableRow key={row.id}>
|
|
<TableCell className="sticky left-0 z-10 bg-card tabular-nums shadow-[1px_0_0_rgba(226,232,240,0.9)]">
|
|
{row.id}
|
|
</TableCell>
|
|
<TableCell className="sticky left-24 z-10 min-w-[14rem] bg-card font-mono text-xs shadow-[1px_0_0_rgba(226,232,240,0.9)]">
|
|
{row.job_no}
|
|
</TableCell>
|
|
<TableCell className="text-sm">{reconcileTypeLabel(row.reconcile_type, t)}</TableCell>
|
|
<TableCell>
|
|
<AdminStatusBadge status={row.status}>
|
|
{jobStatusLabel(row.status, t)}
|
|
</AdminStatusBadge>
|
|
</TableCell>
|
|
<TableCell className="text-center tabular-nums">
|
|
{getJobSummaryValue(row.summary_json, "item_count")}
|
|
</TableCell>
|
|
<TableCell className="text-center tabular-nums">
|
|
<span
|
|
className={cn(
|
|
getJobSummaryValue(row.summary_json, "mismatch_count") > 0
|
|
? "font-medium text-amber-700"
|
|
: "text-muted-foreground",
|
|
)}
|
|
>
|
|
{getJobSummaryValue(row.summary_json, "mismatch_count")}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="max-w-[16rem] text-xs text-muted-foreground">
|
|
<span className="line-clamp-2">
|
|
{renderPeriodRange(row, formatTs)}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
|
{formatTs(row.finished_at)}
|
|
</TableCell>
|
|
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
|
{formatTs(row.created_at)}
|
|
</TableCell>
|
|
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(226,232,240,0.9)]">
|
|
<AdminRowActionsMenu
|
|
actions={[
|
|
{
|
|
key: "view",
|
|
label: t("view"),
|
|
icon: Eye,
|
|
onClick: () => {
|
|
setSelectedId(row.id);
|
|
setItemsPage(1);
|
|
setDetailOpen(true);
|
|
},
|
|
},
|
|
]}
|
|
/>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
{jm ? (
|
|
<AdminListPaginationFooter
|
|
selectId="reconcile-jobs-per-page"
|
|
total={jm.total}
|
|
page={jm.current_page}
|
|
lastPage={Math.max(1, jm.last_page)}
|
|
perPage={jm.per_page}
|
|
loading={jobsLoading}
|
|
onPerPageChange={(n) => {
|
|
setPerPage(n);
|
|
setPage(1);
|
|
}}
|
|
onPageChange={setPage}
|
|
/>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog
|
|
open={detailOpen}
|
|
onOpenChange={(open) => {
|
|
setDetailOpen(open);
|
|
if (!open) {
|
|
setSelectedId(null);
|
|
setItems(null);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent
|
|
showCloseButton
|
|
className="flex h-[min(86vh,780px)] !max-w-[min(920px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden p-0"
|
|
>
|
|
<DialogHeader className="shrink-0 space-y-1 border-b bg-background px-5 py-4 pr-12">
|
|
<DialogTitle className="text-base">{t("detailsTitle")}</DialogTitle>
|
|
<DialogDescription className="font-mono text-xs">
|
|
{selectedJob ? `${selectedJob.job_no} · #${selectedJob.id}` : selectedId != null ? `#${selectedId}` : ""}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
|
|
{itemsLoading && !items ? (
|
|
<AdminLoadingState minHeight="6rem" className="py-6" />
|
|
) : null}
|
|
{items ? (
|
|
<>
|
|
<div className="mb-4 grid gap-3 md:grid-cols-3">
|
|
<div className="rounded-lg border bg-background px-4 py-3">
|
|
<div className="text-xs text-muted-foreground">{t("itemCount")}</div>
|
|
<div className="mt-1 text-xl font-semibold tabular-nums">{selectedJobItemCount}</div>
|
|
</div>
|
|
<div className="rounded-lg border bg-background px-4 py-3">
|
|
<div className="text-xs text-muted-foreground">{t("mismatchCount")}</div>
|
|
<div className="mt-1 flex items-center gap-2 text-xl font-semibold tabular-nums text-amber-700">
|
|
<ShieldAlert className="size-4" />
|
|
{selectedJobMismatchCount}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg border bg-background px-4 py-3">
|
|
<div className="text-xs text-muted-foreground">{t("matchedCount")}</div>
|
|
<div className="mt-1 text-xl font-semibold tabular-nums">{selectedJobMatchedCount}</div>
|
|
</div>
|
|
</div>
|
|
<div className="mb-3 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
|
<span>{t("jobNo")} {items.job_no}</span>
|
|
<span>·</span>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
{t("status")}
|
|
{selectedJob ? (
|
|
<AdminStatusBadge status={selectedJob.status}>
|
|
{jobStatusLabel(selectedJob.status, t)}
|
|
</AdminStatusBadge>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</span>
|
|
<span>·</span>
|
|
<span>{t("period")} {selectedJob ? renderPeriodRange(selectedJob, formatTs) : "—"}</span>
|
|
</div>
|
|
<div className="rounded-lg border bg-background">
|
|
<Table id={`reconcile-items-table-${selectedId ?? "none"}`}>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
|
<TableHead>{t("sideARef")}</TableHead>
|
|
<TableHead>{t("sideBRef")}</TableHead>
|
|
<TableHead className="text-right">{t("differenceAmount")}</TableHead>
|
|
<TableHead>{t("status")}</TableHead>
|
|
<TableHead>{t("detectedAt")}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{items.items.length === 0 ? (
|
|
<AdminTableNoResourceRow colSpan={6} />
|
|
) : (
|
|
items.items.map((r) => (
|
|
<TableRow
|
|
key={r.id}
|
|
className={cn(
|
|
r.status === "mismatch" && "bg-amber-500/5",
|
|
r.status === "matched" && "bg-emerald-500/5",
|
|
)}
|
|
>
|
|
<TableCell>{r.id}</TableCell>
|
|
<TableCell className="font-mono text-xs">{r.side_a_ref ?? "—"}</TableCell>
|
|
<TableCell className="font-mono text-xs">{r.side_b_ref ?? "—"}</TableCell>
|
|
<TableCell className="text-right tabular-nums">
|
|
<span
|
|
className={cn(
|
|
r.difference_amount !== 0 ? "font-medium text-amber-700" : "text-muted-foreground",
|
|
)}
|
|
>
|
|
{r.difference_amount}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<AdminStatusBadge status={r.status}>
|
|
{itemStatusLabel(r.status, t)}
|
|
</AdminStatusBadge>
|
|
</TableCell>
|
|
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
|
{formatTs(r.created_at)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
{im ? (
|
|
<div className="pt-4">
|
|
<AdminListPaginationFooter
|
|
selectId="reconcile-items-per-page"
|
|
total={im.total}
|
|
page={im.current_page}
|
|
lastPage={Math.max(1, im.last_page)}
|
|
perPage={im.per_page}
|
|
loading={itemsLoading}
|
|
onPerPageChange={(n) => {
|
|
setItemsPerPage(n);
|
|
setItemsPage(1);
|
|
}}
|
|
onPageChange={setItemsPage}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<ConfirmDialog />
|
|
</div>
|
|
);
|
|
}
|