fix: 统一报销付款详情布局与票据展示(09-04-reimbursement-state-gap-finance-view)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-11 22:28:06 +08:00
父节点 2b4e3faa0e
当前提交 98eabdde7f
共修改 11 个文件,包含 205 行新增和 31 行删除
@@ -87,6 +87,7 @@ export function BusinessDetailActionPanel({
primaryAction,
secondaryActions,
moreActions,
inlineActions = false,
summary,
children,
className,
@@ -98,6 +99,7 @@ export function BusinessDetailActionPanel({
primaryAction?: ReactNode;
secondaryActions?: ReactNode;
moreActions?: ReactNode;
inlineActions?: boolean;
summary?: ReactNode;
children?: ReactNode;
className?: string;
@@ -121,9 +123,10 @@ export function BusinessDetailActionPanel({
<SectionPanel title={title} actions={status} className={cn("detail-action-panel lg:sticky lg:top-24", className)}>
<div className="detail-action-stack" data-testid={testId}>
{hasBeforeActions ? <div>{beforeActions}</div> : null}
{hasPrimaryAction ? <div className="grid gap-2">{primaryAction}</div> : null}
{inlineActions && (hasPrimaryAction || hasMoreActions) ? <div className="flex flex-wrap items-center gap-2" data-testid="detail-inline-actions">{primaryAction}{moreActions}</div> : null}
{!inlineActions && hasPrimaryAction ? <div className="grid gap-2">{primaryAction}</div> : null}
{hasSecondaryActions ? <div className="workflow-action-bar">{secondaryActions}</div> : null}
{hasMoreActions ? <div className="border-t border-zinc-200 pt-3">{moreActions}</div> : null}
{!inlineActions && hasMoreActions ? <div className="border-t border-zinc-200 pt-3">{moreActions}</div> : null}
{hasChildren ? (
<div className={cn(hasActionContent && "border-t border-zinc-200 pt-3")}>{children}</div>
) : null}
@@ -30,7 +30,7 @@ export function ReimbursementDesktopWorkspaceShell({ model, capabilities, slots
)}
/>
{slots.transientFeedback}
<BusinessDetailWorkspace className="mx-auto w-full max-w-7xl">
<BusinessDetailWorkspace className="detail-workspace-grid--payment mx-auto w-full max-w-7xl">
<BusinessDetailMain>
{slots.headerDetails}
{slots.beforeEvidence}
@@ -167,6 +167,43 @@ function InvoiceMaterialRow({ invoice, capabilities, linkTargets }: { invoice: R
);
}
function InvoiceMaterialsTable({ invoices, capabilities }: { invoices: ReimbursementInvoiceEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
return (
<div className="overflow-hidden rounded-lg border border-zinc-200" data-testid="reimbursement-invoices-table">
<table aria-label="报销发票" className="block w-full table-fixed text-left text-sm lg:table">
<thead className="hidden bg-zinc-50 text-xs text-zinc-600 lg:table-header-group">
<tr>
<th className="w-[30%] px-3 py-2.5 font-medium" scope="col">发票号码</th>
<th className="px-3 py-2.5 font-medium" scope="col">开票方</th>
<th className="w-24 px-3 py-2.5 font-medium" scope="col">日期</th>
<th className="w-28 px-3 py-2.5 text-right font-medium" scope="col">金额</th>
{capabilities.onUnlinkInvoice ? <th className="w-28 px-3 py-2.5 font-medium" scope="col">操作</th> : null}
</tr>
</thead>
<tbody className="block divide-y divide-zinc-200 bg-white lg:table-row-group">
{invoices.map((invoice) => {
const title = invoice.invoiceNumber || invoice.originalName || "未命名发票";
const statusCopy = evidenceStatusCopy(invoice.amountEvidenceStatus);
const hasBlockingIssue = invoice.amountEvidenceStatus === "ocr_failed";
return (
<tr className="block p-3 lg:table-row lg:p-0" data-testid="approval-invoice-evidence" data-reimbursement-guard-issue={hasBlockingIssue ? "true" : undefined} id={`reimbursement-invoice-${invoice.id}`} key={invoice.id} tabIndex={hasBlockingIssue ? -1 : undefined}>
<td className="block break-all align-top lg:table-cell lg:px-3 lg:py-2">
{capabilities.onOpenInvoice ? <Button aria-label={`查看发票:${title}`} className="h-auto min-h-11 max-w-full justify-start whitespace-normal break-all px-0 text-left" onClick={() => capabilities.onOpenInvoice?.(invoice)} type="button" variant="link">{title}</Button> : <span className="font-medium text-zinc-900">{title}</span>}
{statusCopy ? <p className={hasBlockingIssue ? "text-xs text-red-700" : "text-xs text-zinc-600"}>{statusCopy}</p> : null}
</td>
<td className="block break-words text-zinc-600 [overflow-wrap:anywhere] lg:table-cell lg:px-3 lg:py-3">{invoice.sellerName || invoice.documentSubtype || invoice.documentCategory || "票据"}</td>
<td className="block text-zinc-600 lg:table-cell lg:px-3 lg:py-3">{formatDisplayDate(invoice.invoiceDate)}</td>
<td className="block text-right font-medium tabular-nums lg:table-cell lg:px-3 lg:py-3">{formatMoney(invoice.totalAmount)}</td>
{capabilities.onUnlinkInvoice ? <td className="block lg:table-cell lg:px-3 lg:py-2"><Button className="min-h-11 whitespace-normal" onClick={() => capabilities.onUnlinkInvoice?.(invoice)} size="sm" type="button" variant="secondary">移除对应关系</Button></td> : null}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function AttachmentMaterialRow({ attachment, capabilities }: { attachment: ReimbursementAttachmentEvidence; capabilities: ReimbursementEvidenceCapabilities }) {
return <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-zinc-200 px-3 py-2">
<div className="min-w-0 flex-1">
@@ -197,7 +234,7 @@ export function ReimbursementItemEvidenceList({ amountSummary, evidence, capabil
<div className="flex flex-wrap items-center justify-between gap-2"><h3 className="font-medium text-zinc-900">票据与附件</h3>{capabilities.onOpenMaterialBundle ? <Button className="min-h-11" onClick={capabilities.onOpenMaterialBundle} size="sm" type="button" variant="secondary">查看完整材料</Button> : null}</div>
<div className="space-y-2" data-testid="approval-invoices">
<h4 className="text-sm font-medium text-zinc-800">发票({linkedInvoices.length + evidence.unlinkedInvoices.length})</h4>
{linkedInvoices.map((invoice) => <InvoiceMaterialRow capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />)}
{linkedInvoices.length ? (mobile ? linkedInvoices.map((invoice) => <InvoiceMaterialRow capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />) : <InvoiceMaterialsTable capabilities={capabilities} invoices={linkedInvoices} />) : null}
{evidence.unlinkedInvoices.length ? <div className="space-y-2 rounded-lg border border-red-200 bg-red-50 p-3" data-testid="approval-unlinked-invoices" id="reimbursement-workspace-unlinked"><p className="text-sm font-medium text-red-900">尚未对应明细({evidence.unlinkedInvoices.length})</p><p className="text-sm text-red-800">这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。</p>{evidence.unlinkedInvoices.map((invoice) => <InvoiceMaterialRow capabilities={capabilities} invoice={invoice} key={invoice.id} linkTargets={evidence.items} />)}</div> : null}
{!linkedInvoices.length && !evidence.unlinkedInvoices.length ? <p className="text-sm text-zinc-500">暂无发票</p> : null}
</div>
+4
查看文件
@@ -588,6 +588,10 @@
@apply grid gap-5 lg:grid-cols-[minmax(0,1.45fr),minmax(280px,0.72fr)];
}
.detail-workspace-grid--payment {
@apply lg:grid-cols-[minmax(0,1fr),320px];
}
.detail-command-panel {
border-color: rgba(203, 213, 225, 0.92);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.9));
+14 -10
查看文件
@@ -3,6 +3,7 @@ import { resolveErrorReasonMessage, UNKNOWN_VALIDATION_MESSAGE } from "../api/er
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
import { Eye } from "lucide-react";
import {
cancelPaymentBatch,
createPaymentBatch,
@@ -827,7 +828,7 @@ export function PaymentBatchesPage() {
<div
className={paymentActionModel.primary || paymentActionModel.more.length ? "pb-[calc(var(--mobile-sticky-action-height,78px)+env(safe-area-inset-bottom,0px)+1.25rem)] lg:pb-0" : ""}
>
<BusinessDetailWorkspace testId="payment-batch-detail-workspace">
<BusinessDetailWorkspace className="detail-workspace-grid--payment" testId="payment-batch-detail-workspace">
<BusinessDetailMain>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-detail-panel">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -914,7 +915,7 @@ export function PaymentBatchesPage() {
<div className="mt-2 detail-fact-grid text-sm md:grid-cols-4">
<div className="detail-fact-card"><div className="detail-fact-card__label">收款人</div><div className="detail-fact-card__value">{item.payeeName || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">开户银行</div><div className="detail-fact-card__value">{item.bankName || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value">{item.bankAccountMasked || "-"}</div>{canRevealPayeeBank && ["reviewed", "failed"].includes(selectedBatch.status) && selectedBatch.paymentAccount ? <Button type="button" size="sm" variant="secondary" className="mt-2" onClick={() => { setActionReason(""); setRevealedPayeeAccount(""); setReasonAction({ kind: "payeeBank", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, itemId: item.id, entityCode: item.entityCode }); }}>查看完整账号</Button> : null}</div>
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value flex items-center gap-1"><span className="min-w-0 break-all">{item.bankAccountMasked || "-"}</span>{canRevealPayeeBank && ["reviewed", "failed"].includes(selectedBatch.status) && selectedBatch.paymentAccount ? <Button type="button" size="sm" variant="ghost" className="h-11 w-11 shrink-0 p-0" aria-label="查看完整账号" title="查看完整账号" onClick={() => { setActionReason(""); setRevealedPayeeAccount(""); setReasonAction({ kind: "payeeBank", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, itemId: item.id, entityCode: item.entityCode }); }}><Eye aria-hidden="true" className="size-4" /></Button> : null}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">付款渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel)}</div></div>
</div>
{["reviewed", "failed"].includes(selectedBatch.status) ? (
@@ -1073,18 +1074,21 @@ export function PaymentBatchesPage() {
<BusinessDetailAside>
<BusinessDetailActionPanel
inlineActions
className="hidden lg:block lg:static"
status={<StatusBadge status={selectedBatch.status} label={paymentBatchStatusLabel(selectedBatch.status)} />}
primaryAction={paymentActionModel.primary ? renderPaymentActionButton(paymentActionModel.primary) : undefined}
moreActions={paymentActionModel.more.length ? (
<details className="detail-disclosure">
<summary>更多</summary>
<div className="mt-3 grid gap-2">
{paymentActionModel.more.map((kind) =>
renderPaymentActionButton(kind, kind === "cancel" ? "destructive" : "secondary"),
)}
</div>
</details>
<DropdownMenu>
<DropdownMenuTrigger asChild><Button type="button" variant="secondary">更多</Button></DropdownMenuTrigger>
<DropdownMenuContent align="end">
{paymentActionModel.more.map((kind) => (
<DropdownMenuItem key={kind} data-testid={`payment-batch-action-${kind}`} disabled={paymentActionDisabled(kind)} className={kind === "cancel" ? "min-h-11 text-red-600 focus:text-red-600" : "min-h-11"} onSelect={() => activatePaymentAction(kind)}>
{paymentActionLabel(kind)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : undefined}
summary={(
<>
+4 -13
查看文件
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
import { fetchExpenseTypes } from "../api/expenseTypes";
import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl";
import {
@@ -114,7 +115,6 @@ export function ReimbursementDetailPage() {
const [itemError, setItemError] = useState("");
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
const [showSecondaryDetail, setShowSecondaryDetail] = useState(false);
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
const [invoiceSearchInput, setInvoiceSearchInput] = useState("");
@@ -1041,17 +1041,6 @@ export function ReimbursementDetailPage() {
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
) : null,
hasMoreActions ? (
<Button
key="more"
type="button"
variant="secondary"
data-testid="reimbursement-more-toggle"
onClick={() => setShowSecondaryDetail((value) => !value)}
>
{showSecondaryDetail ? "收起更多" : "更多"}
</Button>
) : null,
];
const workspaceEvidence = buildReimbursementApprovalEvidence(detail, {
@@ -1112,8 +1101,10 @@ export function ReimbursementDetailPage() {
</>
);
const workspaceActionPanel = (
<BusinessDetailActionPanel className="lg:static" status={<StatusBadge status={detail.deletedAt ? "deleted" : detail.status} />} primaryAction={primaryAction} secondaryActions={secondaryActions} moreActions={showSecondaryDetail ? <div className="grid gap-2" data-testid="reimbursement-secondary-detail">{detail.status !== "paid" ? <Button asChild variant="secondary"><Link to={"/reimbursements/" + detail.id + "/print"}>打印 / PDF</Link></Button> : null}{canDeleteReimbursement ? <Button data-testid="reimbursement-delete-draft" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除报销单</Button> : null}</div> : null} summary={<><div>下一步:{nextStepText}</div>{workspaceModel.issueAnchors.map((issue) => <a className="block hover:underline" href={"#" + issue.id} key={issue.label}>{issue.label}</a>)}</>} testId="reimbursement-action-panel">
<BusinessDetailActionPanel inlineActions className="lg:static" status={<StatusBadge status={detail.deletedAt ? "deleted" : detail.status} />} primaryAction={primaryAction} secondaryActions={secondaryActions} moreActions={hasMoreActions ? <DropdownMenu><DropdownMenuTrigger asChild><Button data-testid="reimbursement-more-toggle" type="button" variant="secondary">更多</Button></DropdownMenuTrigger><DropdownMenuContent align="end" data-testid="reimbursement-secondary-detail">{detail.status !== "paid" ? <DropdownMenuItem asChild className="min-h-11"><Link to={"/reimbursements/" + detail.id + "/print"}>打印 / PDF</Link></DropdownMenuItem> : null}{canDeleteReimbursement ? <DropdownMenuItem className="min-h-11 text-red-600 focus:text-red-600" data-testid="reimbursement-delete-draft" disabled={deleteReimbursementMutation.isPending} onSelect={() => setConfirmDeleteReimbursement(true)}>删除报销单</DropdownMenuItem> : null}</DropdownMenuContent></DropdownMenu> : null} summary={<><div>下一步:{nextStepText}</div>{workspaceModel.issueAnchors.map((issue) => <a className="block hover:underline" href={"#" + issue.id} key={issue.label}>{issue.label}</a>)}</>} testId="reimbursement-action-panel">
{(detail.status === "reviewing" && isReimbursementParticipant) || (["reviewing", "approved"].includes(detail.status) && actionError) ? (
<ReimbursementActions status={detail.status} showSubmitAction={false} showWithdrawAction={isReimbursementParticipant} actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""} submitDisabled={blockStandardChecks.length > 0} submitHint="" submitNotice="" isPending={mutation.isPending} onSubmit={() => void handleSubmitAction()} onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })} />
) : null}
</BusinessDetailActionPanel>
);