补齐报销待组批财务入口 [09-04-reimbursement-state-gap-finance-view][R1]
这个提交包含在:
@@ -147,12 +147,10 @@ export function PaymentBatchesPage() {
|
||||
const canRevealPayeeBank =
|
||||
hasPermission(permissions, "employee.bank.read") &&
|
||||
Boolean(me?.roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode)));
|
||||
const [candidateSearch, setCandidateSearch] = useState("");
|
||||
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
|
||||
const [selectedPaymentChannel, setSelectedPaymentChannel] = useState<PaymentChannel>("bank");
|
||||
const receiptInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [showCreatePanel, setShowCreatePanel] = useState(false);
|
||||
const [confirmPaymentAction, setConfirmPaymentAction] = useState<{
|
||||
kind: "financeReview" | "financePayment";
|
||||
batchId: string;
|
||||
@@ -181,18 +179,50 @@ export function PaymentBatchesPage() {
|
||||
};
|
||||
}, [receiptPreview?.objectUrl]);
|
||||
|
||||
const selectedBatchId = searchParams.get("id") ?? "";
|
||||
const requestedView = searchParams.get("view");
|
||||
const activeView = selectedBatchId
|
||||
? "detail"
|
||||
: canManageBatch && requestedView !== "batches"
|
||||
? "pending"
|
||||
: "batches";
|
||||
const candidateSearch = activeView === "pending" ? (searchParams.get("q") ?? "") : "";
|
||||
const parsedCandidatePage = Number(searchParams.get("page") ?? "1");
|
||||
const candidatePage = activeView === "pending" && Number.isInteger(parsedCandidatePage) && parsedCandidatePage > 0
|
||||
? parsedCandidatePage
|
||||
: 1;
|
||||
const candidatePageSize = 20;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedBatchId) return;
|
||||
const normalizedView = canManageBatch && requestedView !== "batches" ? "pending" : "batches";
|
||||
if (requestedView === normalizedView) return;
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("view", normalizedView);
|
||||
if (normalizedView === "batches") {
|
||||
next.delete("q");
|
||||
next.delete("page");
|
||||
}
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [canManageBatch, requestedView, searchParams, selectedBatchId, setSearchParams]);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["payment-batches"],
|
||||
queryFn: () => fetchPaymentBatches(),
|
||||
});
|
||||
const candidatesQuery = useQuery({
|
||||
queryKey: ["payment-batch-candidates", candidateSearch],
|
||||
enabled: showCreatePanel && canManageBatch,
|
||||
queryFn: () => fetchPaymentBatchCandidates({ q: candidateSearch, page: 1, pageSize: 50 }),
|
||||
queryKey: ["payment-batch-candidates", candidateSearch, candidatePage, candidatePageSize],
|
||||
enabled: canManageBatch,
|
||||
queryFn: () => fetchPaymentBatchCandidates({
|
||||
q: candidateSearch,
|
||||
page: candidatePage,
|
||||
pageSize: candidatePageSize,
|
||||
}),
|
||||
});
|
||||
|
||||
const selectedBatchId = searchParams.get("id") ?? "";
|
||||
const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]);
|
||||
const candidateTotal = candidatesQuery.data?.total ?? 0;
|
||||
const candidateTotalPages = Math.max(1, Math.ceil(candidateTotal / candidatePageSize));
|
||||
const selectedBatchQuery = useQuery({
|
||||
queryKey: ["payment-batches", "detail", selectedBatchId],
|
||||
enabled: Boolean(selectedBatchId),
|
||||
@@ -279,9 +309,40 @@ export function PaymentBatchesPage() {
|
||||
const returnToList = () => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("id");
|
||||
next.set("view", "batches");
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
|
||||
const openView = (view: "pending" | "batches") => {
|
||||
if (view === "batches") {
|
||||
setSelectedCandidateIds([]);
|
||||
setSelectedPaymentChannel("bank");
|
||||
}
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("id");
|
||||
next.set("view", view);
|
||||
next.delete("page");
|
||||
if (view === "batches") next.delete("q");
|
||||
setSearchParams(next);
|
||||
};
|
||||
|
||||
const updateCandidateSearch = (value: string) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("view", "pending");
|
||||
if (value) next.set("q", value);
|
||||
else next.delete("q");
|
||||
next.delete("page");
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
|
||||
const updateCandidatePage = (page: number) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("view", "pending");
|
||||
if (page > 1) next.set("page", String(page));
|
||||
else next.delete("page");
|
||||
setSearchParams(next);
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () =>
|
||||
createPaymentBatch({
|
||||
@@ -292,10 +353,12 @@ export function PaymentBatchesPage() {
|
||||
onSuccess: async ({ item }) => {
|
||||
setError("");
|
||||
setSelectedCandidateIds([]);
|
||||
setCandidateSearch("");
|
||||
setSelectedPaymentChannel("bank");
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("id", item.id);
|
||||
next.set("view", "batches");
|
||||
next.delete("q");
|
||||
next.delete("page");
|
||||
setSearchParams(next, { replace: true });
|
||||
await refresh();
|
||||
toast.success("已提交付款复核,等待负责人处理");
|
||||
@@ -571,39 +634,67 @@ export function PaymentBatchesPage() {
|
||||
返回列表
|
||||
</Button>
|
||||
) : null}
|
||||
{!selectedBatchId && canManageBatch ? (
|
||||
<Button type="button" onClick={() => setShowCreatePanel(true)}>
|
||||
新建批次
|
||||
</Button>
|
||||
) : null}
|
||||
</HeaderActionGroup>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null}
|
||||
|
||||
{!selectedBatchId && showCreatePanel ? (
|
||||
<div className="finance-command-panel space-y-4" data-testid="payment-batch-create-panel">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">新建批次</h3>
|
||||
</div>
|
||||
{!canManageBatch ? (
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
|
||||
当前账号仅可查看批次并执行复核,创建与编辑入口仅对付款批次管理员开放。
|
||||
</div>
|
||||
{!selectedBatchId ? (
|
||||
<nav className="flex min-h-11 flex-wrap items-center gap-2 border-b border-zinc-200" aria-label="付款工作台视图">
|
||||
{canManageBatch ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`min-h-11 border-b-2 px-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 ${activeView === "pending" ? "border-primary text-primary" : "border-transparent text-zinc-600 hover:text-zinc-900"}`}
|
||||
aria-current={activeView === "pending" ? "page" : undefined}
|
||||
onClick={() => openView("pending")}
|
||||
>
|
||||
待组批 {candidateTotal}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`min-h-11 border-b-2 px-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 ${activeView === "batches" ? "border-primary text-primary" : "border-transparent text-zinc-600 hover:text-zinc-900"}`}
|
||||
aria-current={activeView === "batches" ? "page" : undefined}
|
||||
onClick={() => openView("batches")}
|
||||
>
|
||||
付款批次 {batches.length}
|
||||
</button>
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
{!selectedBatchId && activeView === "pending" ? (
|
||||
<div className="finance-command-panel space-y-4" data-testid="payment-batch-create-panel">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">待组批报销</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">选择报销后创建付款批次并提交复核。</p>
|
||||
</div>
|
||||
{selectedCandidateIds.length ? (
|
||||
<span className="rounded-full bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700">
|
||||
已选择 {selectedCandidateIds.length} 项
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>选择待付款报销</Label>
|
||||
<Label htmlFor="payment-batch-candidate-search">搜索待组批报销</Label>
|
||||
<Input
|
||||
id="payment-batch-candidate-search"
|
||||
data-testid="payment-batch-candidate-search"
|
||||
value={candidateSearch}
|
||||
disabled={!canManageBatch}
|
||||
onChange={(event) => setCandidateSearch(event.target.value)}
|
||||
onChange={(event) => updateCandidateSearch(event.target.value)}
|
||||
placeholder="按报销编号、人员或摘要搜索"
|
||||
/>
|
||||
<div className="max-h-72 space-y-2 overflow-y-auto rounded-lg border border-zinc-200 p-2" data-testid="payment-batch-candidates">
|
||||
{candidatesQuery.isLoading ? <div className="px-2 py-3 text-sm text-zinc-500">正在加载待付款报销...</div> : null}
|
||||
{candidatesQuery.isError ? <div className="px-2 py-3 text-sm text-red-600">{resolveError(candidatesQuery.error)}</div> : null}
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-2" data-testid="payment-batch-candidates">
|
||||
{candidatesQuery.isLoading ? <div className="px-2 py-6 text-center text-sm text-zinc-500">正在加载待组批报销...</div> : null}
|
||||
{candidatesQuery.isError ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-2 py-3 text-sm text-red-600" role="alert">
|
||||
<span>{resolveError(candidatesQuery.error)}</span>
|
||||
<Button type="button" size="sm" variant="secondary" disabled={candidatesQuery.isFetching} onClick={() => void candidatesQuery.refetch()}>
|
||||
{candidatesQuery.isFetching ? "重新加载中..." : "重新加载"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{(candidatesQuery.data?.items ?? []).map((candidate) => {
|
||||
const selected = selectedCandidateIds.includes(candidate.entityId);
|
||||
return (
|
||||
@@ -614,7 +705,7 @@ export function PaymentBatchesPage() {
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4"
|
||||
className="mt-1 h-5 w-5"
|
||||
checked={selected}
|
||||
onChange={(event) => setSelectedCandidateIds((current) =>
|
||||
event.target.checked
|
||||
@@ -636,9 +727,20 @@ export function PaymentBatchesPage() {
|
||||
);
|
||||
})}
|
||||
{!candidatesQuery.isLoading && !candidatesQuery.isError && !(candidatesQuery.data?.items ?? []).length ? (
|
||||
<div className="px-2 py-3 text-sm text-zinc-500">没有可加入批次的已审批报销。</div>
|
||||
<div className="px-2 py-6 text-center text-sm text-zinc-500">当前没有待组批报销</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!candidatesQuery.isError && candidateTotal > candidatePageSize ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-zinc-600" data-testid="payment-batch-candidate-pagination">
|
||||
<span>第 {candidatePage} / {candidateTotalPages} 页,共 {candidateTotal} 项</span>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" size="sm" variant="secondary" disabled={candidatePage <= 1 || candidatesQuery.isFetching} onClick={() => updateCandidatePage(candidatePage - 1)}>上一页</Button>
|
||||
<Button type="button" size="sm" variant="secondary" disabled={candidatePage >= candidateTotalPages || candidatesQuery.isFetching} onClick={() => updateCandidatePage(candidatePage + 1)}>下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedCandidateIds.length ? (
|
||||
<>
|
||||
<div className="max-w-sm space-y-2">
|
||||
<Label htmlFor="payment-batch-default-channel">付款渠道</Label>
|
||||
<select
|
||||
@@ -646,7 +748,6 @@ export function PaymentBatchesPage() {
|
||||
data-testid="payment-batch-default-channel"
|
||||
className="select-std w-full"
|
||||
value={selectedPaymentChannel}
|
||||
disabled={!canManageBatch}
|
||||
onChange={(event) => setSelectedPaymentChannel(event.target.value as PaymentChannel)}
|
||||
>
|
||||
{PAYMENT_CHANNEL_OPTIONS.map((option) => (
|
||||
@@ -657,10 +758,14 @@ export function PaymentBatchesPage() {
|
||||
<div className="text-xs text-zinc-500">
|
||||
已选择 {selectedCandidateIds.length} 项;系统自动生成编号和标题,按{paymentChannelLabel(selectedPaymentChannel)}提交负责人复核。
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="button" disabled={createMutation.isPending || !canManageBatch || selectedCandidateIds.length === 0} onClick={() => createMutation.mutate()}>
|
||||
{createMutation.isPending ? "提交中..." : "创建并提交复核"}
|
||||
</Button>
|
||||
{selectedCandidateIds.length ? (
|
||||
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>
|
||||
{createMutation.isPending ? "提交中..." : "创建并提交复核"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -995,7 +1100,7 @@ export function PaymentBatchesPage() {
|
||||
) : (
|
||||
<div className="finance-command-panel text-sm text-zinc-500">未找到付款批次</div>
|
||||
)
|
||||
) : (
|
||||
) : activeView === "batches" ? (
|
||||
<>
|
||||
<BusinessListToolbar
|
||||
actions={
|
||||
@@ -1090,7 +1195,7 @@ export function PaymentBatchesPage() {
|
||||
</tbody>
|
||||
</BusinessTable>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
<ActionConfirmDialog
|
||||
open={Boolean(confirmPaymentAction)}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -160,6 +160,7 @@ export function ReimbursementDetailPage() {
|
||||
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
|
||||
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
|
||||
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||||
const canManagePaymentBatch = hasPermission(permissions, "payment.batch.manage");
|
||||
|
||||
const reimbursementPrecheckQuery = useQuery({
|
||||
enabled: Boolean(
|
||||
@@ -956,7 +957,7 @@ export function ReimbursementDetailPage() {
|
||||
return currentProcessingStage?.detail ?? "查看处理进度";
|
||||
}
|
||||
if (detail.status === "approved") {
|
||||
return "等待财务处理";
|
||||
return "等待财务组建付款批次";
|
||||
}
|
||||
if (detail.status === "paid") {
|
||||
return "查看付款结果";
|
||||
@@ -1001,6 +1002,16 @@ export function ReimbursementDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (detail.status === "approved" && canManagePaymentBatch) {
|
||||
return (
|
||||
<Button asChild data-testid="reimbursement-open-pending-payment-batches">
|
||||
<Link to={`/payment-batches?view=pending&q=${encodeURIComponent(resolveReimbursementDisplayCode(detail))}`}>
|
||||
去组建付款批次
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})();
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户