重构报销详情证据与动作区(reimbursement-detail-evidence-actions)
这个提交包含在:
@@ -3,7 +3,7 @@
|
|||||||
"name": "reimbursement-detail-evidence-actions",
|
"name": "reimbursement-detail-evidence-actions",
|
||||||
"title": "报销详情证据与动作区重构",
|
"title": "报销详情证据与动作区重构",
|
||||||
"description": "按全页面设计规则重构报销详情:状态与下一步先行,证据材料居中,付款动作集中,右侧动作区降噪。",
|
"description": "按全页面设计规则重构报销详情:状态与下一步先行,证据材料居中,付款动作集中,右侧动作区降噪。",
|
||||||
"status": "planning",
|
"status": "in_progress",
|
||||||
"dev_type": null,
|
"dev_type": null,
|
||||||
"scope": "frontend-page-redesign",
|
"scope": "frontend-page-redesign",
|
||||||
"package": null,
|
"package": null,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
deleteReimbursementItem,
|
deleteReimbursementItem,
|
||||||
downloadReimbursementAttachment,
|
downloadReimbursementAttachment,
|
||||||
fetchReimbursementDetail,
|
fetchReimbursementDetail,
|
||||||
payReimbursement,
|
|
||||||
precheckReimbursement,
|
precheckReimbursement,
|
||||||
submitReimbursement,
|
submitReimbursement,
|
||||||
updateReimbursementItem,
|
updateReimbursementItem,
|
||||||
@@ -53,8 +52,6 @@ import { Input } from "../components/ui/input";
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
||||||
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||||
import { StatStrip } from "../components/patterns/ListWorkspace";
|
|
||||||
import { MetricCard } from "../components/patterns/MetricCard";
|
|
||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||||
import {
|
import {
|
||||||
@@ -69,7 +66,7 @@ import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
|||||||
import { formatDisplayDate } from "../lib/displayFormat";
|
import { formatDisplayDate } from "../lib/displayFormat";
|
||||||
import { TEST_IDS } from "../lib/testIds";
|
import { TEST_IDS } from "../lib/testIds";
|
||||||
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
|
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
|
||||||
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
|
import { ReimbursementActions } from "./reimbursement-detail/ReimbursementActions";
|
||||||
import {
|
import {
|
||||||
ReimbursementItemEditor,
|
ReimbursementItemEditor,
|
||||||
type ItemEditorInitialValues,
|
type ItemEditorInitialValues,
|
||||||
@@ -162,6 +159,15 @@ function materialCompletenessLabel(count: number, noun: string) {
|
|||||||
return count > 0 ? `${count} ${noun}` : `缺${noun}`;
|
return count > 0 ? `${count} ${noun}` : `缺${noun}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function paymentChannelLabel(value: string | null | undefined) {
|
||||||
|
if (value === "bank") return "银行转账";
|
||||||
|
if (value === "alipay") return "支付宝";
|
||||||
|
if (value === "wechat") return "微信";
|
||||||
|
if (value === "cash") return "现金";
|
||||||
|
if (value === "other") return "其他";
|
||||||
|
return value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
export function ReimbursementDetailPage() {
|
export function ReimbursementDetailPage() {
|
||||||
const { reimbursementId = "" } = useParams();
|
const { reimbursementId = "" } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -265,7 +271,7 @@ export function ReimbursementDetailPage() {
|
|||||||
}, [bindableInvoicesQuery.data?.items]);
|
}, [bindableInvoicesQuery.data?.items]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (params: { action: "submit" } | { action: "withdraw"; comment: string } | { action: "pay"; payload: PayPayload }) => {
|
mutationFn: async (params: { action: "submit" } | { action: "withdraw"; comment: string }) => {
|
||||||
if (!reimbursementId) {
|
if (!reimbursementId) {
|
||||||
throw new Error("missing reimbursementId");
|
throw new Error("missing reimbursementId");
|
||||||
}
|
}
|
||||||
@@ -277,13 +283,6 @@ export function ReimbursementDetailPage() {
|
|||||||
if (params.action === "withdraw") {
|
if (params.action === "withdraw") {
|
||||||
return withdrawReimbursement(reimbursementId, params.comment);
|
return withdrawReimbursement(reimbursementId, params.comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
return payReimbursement(reimbursementId, {
|
|
||||||
paymentVoucherNo: params.payload.paymentVoucherNo || undefined,
|
|
||||||
paymentDate: params.payload.paymentDate || undefined,
|
|
||||||
paymentChannel: params.payload.paymentChannel,
|
|
||||||
paymentNote: params.payload.paymentNote || undefined,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onSuccess: async (_data, params) => {
|
onSuccess: async (_data, params) => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -295,7 +294,7 @@ export function ReimbursementDetailPage() {
|
|||||||
if (params.action === "submit") {
|
if (params.action === "submit") {
|
||||||
setSubmitConfirmPrecheck(null);
|
setSubmitConfirmPrecheck(null);
|
||||||
}
|
}
|
||||||
const actionMessages: Record<string, string> = { submit: "已提交", withdraw: "已撤回", pay: "付款成功" };
|
const actionMessages: Record<string, string> = { submit: "已提交", withdraw: "已撤回" };
|
||||||
toast.success(actionMessages[params.action] ?? "操作成功");
|
toast.success(actionMessages[params.action] ?? "操作成功");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -991,7 +990,6 @@ export function ReimbursementDetailPage() {
|
|||||||
|
|
||||||
const itemCount = detail.items.length;
|
const itemCount = detail.items.length;
|
||||||
const invoiceCount = detail.invoices.length;
|
const invoiceCount = detail.invoices.length;
|
||||||
const attachmentCount = (detail.attachments ?? []).length;
|
|
||||||
const hasBlockingPrecheck = blockStandardChecks.length > 0;
|
const hasBlockingPrecheck = blockStandardChecks.length > 0;
|
||||||
const currentHandlerText = (() => {
|
const currentHandlerText = (() => {
|
||||||
if (detail.status === "reviewing") {
|
if (detail.status === "reviewing") {
|
||||||
@@ -1027,7 +1025,7 @@ export function ReimbursementDetailPage() {
|
|||||||
: "查看审批进度";
|
: "查看审批进度";
|
||||||
}
|
}
|
||||||
if (detail.status === "approved") {
|
if (detail.status === "approved") {
|
||||||
return canPay ? "执行付款" : "等待财务付款";
|
return "等待财务处理";
|
||||||
}
|
}
|
||||||
if (detail.status === "paid") {
|
if (detail.status === "paid") {
|
||||||
return "查看付款结果";
|
return "查看付款结果";
|
||||||
@@ -1083,9 +1081,7 @@ export function ReimbursementDetailPage() {
|
|||||||
if (detail.status === "approved") {
|
if (detail.status === "approved") {
|
||||||
return (
|
return (
|
||||||
<Button asChild data-testid="reimbursement-primary-action">
|
<Button asChild data-testid="reimbursement-primary-action">
|
||||||
<a href={canPay ? "#reimbursement-action-panel" : "#reimbursement-payment-panel"}>
|
<a href="#reimbursement-payment-panel">看付款状态</a>
|
||||||
{canPay ? "执行支付" : "看付款状态"}
|
|
||||||
</a>
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1237,10 +1233,10 @@ export function ReimbursementDetailPage() {
|
|||||||
<div className="detail-fact-card__label">付款凭证号</div>
|
<div className="detail-fact-card__label">付款凭证号</div>
|
||||||
<div className="detail-fact-card__value">{payment.paymentVoucherNo ?? "-"}</div>
|
<div className="detail-fact-card__value">{payment.paymentVoucherNo ?? "-"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-fact-card">
|
<div className="detail-fact-card">
|
||||||
<div className="detail-fact-card__label">付款渠道</div>
|
<div className="detail-fact-card__label">付款渠道</div>
|
||||||
<div className="detail-fact-card__value">{payment.paymentChannel ?? "-"}</div>
|
<div className="detail-fact-card__value">{paymentChannelLabel(payment.paymentChannel)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-fact-card">
|
<div className="detail-fact-card">
|
||||||
<div className="detail-fact-card__label">操作人 / 时间</div>
|
<div className="detail-fact-card__label">操作人 / 时间</div>
|
||||||
<div className="detail-fact-card__value">
|
<div className="detail-fact-card__value">
|
||||||
@@ -1251,54 +1247,44 @@ export function ReimbursementDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{showSecondaryDetail ? (
|
{showSecondaryDetail ? (
|
||||||
<div className="space-y-4 pt-4" data-testid="reimbursement-secondary-detail">
|
<div className="space-y-4 pt-4" data-testid="reimbursement-secondary-detail">
|
||||||
<div className="detail-command-panel">
|
<div className="detail-command-panel">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<h4 className="text-base font-semibold text-zinc-900">更多入口</h4>
|
<div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<h4 className="text-base font-semibold text-zinc-900">更多入口</h4>
|
||||||
{detail.status !== "paid" ? (
|
<p className="text-xs text-zinc-500">低频操作集中在这里,材料和关联事项已放在主内容区。</p>
|
||||||
<Button asChild variant="secondary">
|
</div>
|
||||||
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
|
<div className="flex flex-wrap gap-2">
|
||||||
</Button>
|
{detail.status !== "paid" ? (
|
||||||
) : null}
|
<Button asChild variant="secondary">
|
||||||
<Button asChild variant="secondary" data-testid="reimbursement-secondary-back">
|
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
|
||||||
<Link to="/reimbursements">报销列表</Link>
|
</Button>
|
||||||
</Button>
|
) : null}
|
||||||
{canDeleteReimbursement ? (
|
<Button asChild variant="secondary" data-testid="reimbursement-secondary-back">
|
||||||
<Button
|
<Link to="/reimbursements">报销列表</Link>
|
||||||
data-testid="reimbursement-delete-draft"
|
</Button>
|
||||||
disabled={deleteReimbursementMutation.isPending}
|
{canDeleteReimbursement ? (
|
||||||
onClick={() => setConfirmDeleteReimbursement(true)}
|
<Button
|
||||||
type="button"
|
data-testid="reimbursement-delete-draft"
|
||||||
variant="destructive"
|
disabled={deleteReimbursementMutation.isPending}
|
||||||
>
|
onClick={() => setConfirmDeleteReimbursement(true)}
|
||||||
删除草稿
|
type="button"
|
||||||
</Button>
|
variant="destructive"
|
||||||
) : null}
|
>
|
||||||
|
删除草稿
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
|
</SectionPanel>
|
||||||
|
|
||||||
<StatStrip columns={4} className="ops-status-strip">
|
<SectionPanel title="业务摘要">
|
||||||
<MetricCard
|
<div className="detail-command-panel" data-testid="reimbursement-business-summary">
|
||||||
title="关联项目"
|
<div className="detail-fact-grid xl:grid-cols-4">
|
||||||
value={
|
|
||||||
<span className="text-base">
|
|
||||||
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MetricCard title="明细数" value={detail.items.length} />
|
|
||||||
<MetricCard title="发票" value={detail.invoices.length} />
|
|
||||||
<MetricCard
|
|
||||||
title="付款状态"
|
|
||||||
value={<span className="text-base">{isPaidReimbursement ? "已付款" : "未付款"}</span>}
|
|
||||||
/>
|
|
||||||
</StatStrip>
|
|
||||||
|
|
||||||
<div className="detail-command-panel" data-testid="reimbursement-secondary-summary">
|
|
||||||
<div className="detail-fact-grid">
|
|
||||||
<div className="detail-fact-card">
|
<div className="detail-fact-card">
|
||||||
<div className="detail-fact-card__label">报销编号</div>
|
<div className="detail-fact-card__label">报销编号</div>
|
||||||
<div className="detail-fact-card__value">{displayCode}</div>
|
<div className="detail-fact-card__value">{displayCode}</div>
|
||||||
@@ -1312,195 +1298,17 @@ export function ReimbursementDetailPage() {
|
|||||||
<div className="detail-fact-card__value">{detail.submitter?.name ?? "-"} / {detail.owner?.name ?? "-"}</div>
|
<div className="detail-fact-card__value">{detail.submitter?.name ?? "-"} / {detail.owner?.name ?? "-"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-fact-card">
|
<div className="detail-fact-card">
|
||||||
<div className="detail-fact-card__label">说明</div>
|
<div className="detail-fact-card__label">关联项目</div>
|
||||||
<div className="detail-fact-card__value">{detail.description || "-"}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="detail-command-panel" data-testid="reimbursement-secondary-payment-status">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-base font-semibold text-zinc-900">付款状态</h4>
|
|
||||||
<p className="text-xs text-zinc-500">凭证、日期和操作人用于财务台账核对。</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
|
||||||
detail.status === "paid"
|
|
||||||
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
|
||||||
: "border-amber-200 bg-amber-50 text-amber-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{detail.status === "paid" ? "已付款" : "未付款"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="detail-fact-grid mt-3 xl:grid-cols-4">
|
|
||||||
<div className="detail-fact-card">
|
|
||||||
<div className="detail-fact-card__label">付款日期</div>
|
|
||||||
<div className="detail-fact-card__value">{payment.paymentDate ?? "-"}</div>
|
|
||||||
</div>
|
|
||||||
<div className="detail-fact-card">
|
|
||||||
<div className="detail-fact-card__label">付款凭证号</div>
|
|
||||||
<div className="detail-fact-card__value">{payment.paymentVoucherNo ?? "-"}</div>
|
|
||||||
</div>
|
|
||||||
<div className="detail-fact-card">
|
|
||||||
<div className="detail-fact-card__label">付款渠道</div>
|
|
||||||
<div className="detail-fact-card__value">{payment.paymentChannel ?? "-"}</div>
|
|
||||||
</div>
|
|
||||||
<div className="detail-fact-card">
|
|
||||||
<div className="detail-fact-card__label">操作人 / 时间</div>
|
|
||||||
<div className="detail-fact-card__value">
|
<div className="detail-fact-card__value">
|
||||||
{payment.paidBy?.name ?? "-"} · {formatDateTime(payment.paidAt)}
|
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
|
||||||
|
{detail.description || "未填写报销说明"}
|
||||||
{detail.linkedTravelRequest ? (
|
|
||||||
<div className="rounded-lg border border-sky-200 bg-sky-50/70 p-4 text-sm">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-base font-semibold text-zinc-900">
|
|
||||||
关联差旅申请:{detail.linkedTravelRequest.requestNo ?? detail.linkedTravelRequest.id}
|
|
||||||
</h4>
|
|
||||||
<p className="mt-1 text-zinc-600">{detail.linkedTravelRequest.purpose || "未填写出差目的"}</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`}
|
|
||||||
className="text-sm text-primary hover:underline"
|
|
||||||
>
|
|
||||||
查看申请
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
|
||||||
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
|
|
||||||
<div className="text-xs text-zinc-500">日期范围</div>
|
|
||||||
<div className="mt-1 font-medium text-zinc-900">
|
|
||||||
{detail.linkedTravelRequest.startDate ?? "-"} 至 {detail.linkedTravelRequest.endDate ?? "-"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
|
|
||||||
<div className="text-xs text-zinc-500">预估金额</div>
|
|
||||||
<div className="mt-1 font-medium text-zinc-900">¥{detail.linkedTravelRequest.estimatedAmount}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
|
|
||||||
<div className="text-xs text-zinc-500">申请编号</div>
|
|
||||||
<div className="mt-1 font-medium text-zinc-900">
|
|
||||||
{detail.linkedTravelRequest.requestNo ?? detail.linkedTravelRequest.id}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="detail-command-panel">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-base font-semibold text-zinc-900">关联项目</h4>
|
|
||||||
<p className="text-xs text-zinc-500">用于项目执行面板汇总。</p>
|
|
||||||
</div>
|
|
||||||
{detail.project ? (
|
|
||||||
<Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">
|
|
||||||
查看项目
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
className="select-std mt-4 max-w-xl"
|
|
||||||
value={detail.projectId ?? ""}
|
|
||||||
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
|
|
||||||
disabled={projectLinkMutation.isPending}
|
|
||||||
>
|
|
||||||
<option value="">不关联项目</option>
|
|
||||||
{(projectsQuery.data?.items ?? []).map((item) => (
|
|
||||||
<option key={item.id} value={item.id}>
|
|
||||||
{item.projectNo} · {item.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
</SectionPanel>
|
||||||
<div className="space-y-4 text-sm">
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="text-base font-semibold text-zinc-900">补充附件</h3>
|
|
||||||
<p className="text-xs text-zinc-500">合同、订单、支付截图或情况说明;敏感附件下载会要求填写用途。</p>
|
|
||||||
</div>
|
|
||||||
{canEditItems ? (
|
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
|
||||||
<label
|
|
||||||
htmlFor="reimbursement-attachment-upload"
|
|
||||||
className="inline-flex cursor-pointer items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-900"
|
|
||||||
>
|
|
||||||
{selectedAttachmentFiles.length
|
|
||||||
? selectedAttachmentFiles.length === 1
|
|
||||||
? selectedAttachmentFiles[0].name
|
|
||||||
: `已选择 ${selectedAttachmentFiles.length} 个文件`
|
|
||||||
: "选择附件"}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="reimbursement-attachment-upload"
|
|
||||||
className="hidden"
|
|
||||||
type="file"
|
|
||||||
accept="application/pdf,image/jpeg,image/png,image/webp"
|
|
||||||
multiple
|
|
||||||
onChange={(event) => setSelectedAttachmentFiles(Array.from(event.target.files ?? []))}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending}
|
|
||||||
onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)}
|
|
||||||
>
|
|
||||||
{uploadAttachmentsMutation.isPending ? "上传中..." : "上传附件"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(detail.attachments ?? []).length ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(detail.attachments ?? []).map((attachment) => (
|
|
||||||
<div key={attachment.id} className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate font-medium text-zinc-900">{attachment.originalName}</div>
|
|
||||||
<div className="text-xs text-zinc-500">
|
|
||||||
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
|
|
||||||
</div>
|
|
||||||
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
disabled={openAttachmentMutation.isPending}
|
|
||||||
onClick={() => void handleOpenAttachment(attachment)}
|
|
||||||
>
|
|
||||||
查看
|
|
||||||
</Button>
|
|
||||||
{canEditItems ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
disabled={deleteAttachmentMutation.isPending}
|
|
||||||
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-zinc-500">暂无附件材料</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</SectionPanel>
|
|
||||||
|
|
||||||
<SectionPanel title="明细与发票" description="明细决定金额,发票决定提交完整性。">
|
<SectionPanel title="明细与发票" description="明细决定金额,发票决定提交完整性。">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -1736,10 +1544,169 @@ export function ReimbursementDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
|
|
||||||
<SectionPanel title="预算与审核提示">
|
<SectionPanel title="附件材料" description="订单、付款截图、情况说明等补充材料;PDF 发票仍在发票区处理。">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 text-sm" data-testid="reimbursement-attachments">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">补充附件</h3>
|
||||||
|
<p className="text-xs text-zinc-500">合同、订单、支付截图或情况说明;敏感附件下载会要求填写用途。</p>
|
||||||
|
</div>
|
||||||
|
{canEditItems ? (
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||||
|
<label
|
||||||
|
htmlFor="reimbursement-attachment-upload"
|
||||||
|
className="inline-flex cursor-pointer items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-900"
|
||||||
|
>
|
||||||
|
{selectedAttachmentFiles.length
|
||||||
|
? selectedAttachmentFiles.length === 1
|
||||||
|
? selectedAttachmentFiles[0].name
|
||||||
|
: `已选择 ${selectedAttachmentFiles.length} 个文件`
|
||||||
|
: "选择附件"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reimbursement-attachment-upload"
|
||||||
|
className="hidden"
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,image/jpeg,image/png,image/webp"
|
||||||
|
multiple
|
||||||
|
onChange={(event) => setSelectedAttachmentFiles(Array.from(event.target.files ?? []))}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending}
|
||||||
|
onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)}
|
||||||
|
>
|
||||||
|
{uploadAttachmentsMutation.isPending ? "上传中..." : "上传附件"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(detail.attachments ?? []).length ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(detail.attachments ?? []).map((attachment) => (
|
||||||
|
<div key={attachment.id} className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium text-zinc-900">{attachment.originalName}</div>
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
|
||||||
|
</div>
|
||||||
|
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={openAttachmentMutation.isPending}
|
||||||
|
onClick={() => void handleOpenAttachment(attachment)}
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
{canEditItems ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={deleteAttachmentMutation.isPending}
|
||||||
|
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-zinc-500">暂无附件材料</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionPanel>
|
||||||
|
|
||||||
|
<SectionPanel title="关联事项" description="只显示项目、差旅等关联摘要;具体流程回到对应模块处理。">
|
||||||
|
<div className="space-y-4" data-testid="reimbursement-related-records">
|
||||||
|
<div className="detail-command-panel">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-base font-semibold text-zinc-900">关联项目</h4>
|
||||||
|
<p className="text-xs text-zinc-500">用于项目执行面板汇总,不在报销详情复制项目流程。</p>
|
||||||
|
</div>
|
||||||
|
{detail.project ? (
|
||||||
|
<Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">
|
||||||
|
查看项目
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{canEditItems ? (
|
||||||
|
<select
|
||||||
|
className="select-std mt-4 max-w-xl"
|
||||||
|
value={detail.projectId ?? ""}
|
||||||
|
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
|
||||||
|
disabled={projectLinkMutation.isPending}
|
||||||
|
>
|
||||||
|
<option value="">不关联项目</option>
|
||||||
|
{(projectsQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.projectNo} · {item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 text-sm text-zinc-600">
|
||||||
|
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联项目"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detail.linkedTravelRequest ? (
|
||||||
|
<div className="rounded-lg border border-sky-200 bg-sky-50/70 p-4 text-sm">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-base font-semibold text-zinc-900">
|
||||||
|
关联差旅申请:{detail.linkedTravelRequest.requestNo ?? "未生成申请编号"}
|
||||||
|
</h4>
|
||||||
|
<p className="mt-1 text-zinc-600">{detail.linkedTravelRequest.purpose || "未填写出差目的"}</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`}
|
||||||
|
className="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
查看申请
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||||||
|
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
|
||||||
|
<div className="text-xs text-zinc-500">日期范围</div>
|
||||||
|
<div className="mt-1 font-medium text-zinc-900">
|
||||||
|
{detail.linkedTravelRequest.startDate ?? "-"} 至 {detail.linkedTravelRequest.endDate ?? "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
|
||||||
|
<div className="text-xs text-zinc-500">预估金额</div>
|
||||||
|
<div className="mt-1 font-medium text-zinc-900">¥{detail.linkedTravelRequest.estimatedAmount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
|
||||||
|
<div className="text-xs text-zinc-500">申请编号</div>
|
||||||
|
<div className="mt-1 font-medium text-zinc-900">
|
||||||
|
{detail.linkedTravelRequest.requestNo ?? "未生成申请编号"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">
|
||||||
|
未关联差旅申请。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionPanel>
|
||||||
|
|
||||||
|
<SectionPanel title="预算与审核提示">
|
||||||
|
<div className="space-y-4">
|
||||||
{budgetHint ? (
|
{budgetHint ? (
|
||||||
<div className="detail-command-panel text-sm" data-testid="reimbursement-budget-hint">
|
<div className="detail-command-panel text-sm" data-testid="reimbursement-budget-hint">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">预算提示</h3>
|
<h3 className="text-base font-semibold text-zinc-900">预算提示</h3>
|
||||||
@@ -1831,85 +1798,100 @@ export function ReimbursementDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-5">
|
<SectionPanel title="审批与处理记录" description="审批进度和历史只用于追踪,不在这里复制审批或付款动作。">
|
||||||
{showSecondaryDetail ? (
|
<div id="reimbursement-workflow-progress-panel" className="space-y-4">
|
||||||
<DetailSideMeta title="报销摘要">
|
{detail.workflowProgress ? (
|
||||||
<div>报销编号:{displayCode}</div>
|
<div className="detail-command-panel">
|
||||||
<div>报销人:{detail.owner?.name ?? "-"}</div>
|
<div className="mb-3 text-sm text-zinc-500">
|
||||||
<div>提交人:{detail.submitter?.name ?? "-"}</div>
|
当前位于第 {detail.workflowProgress.currentStepIndex + 1} / {detail.workflowProgress.totalSteps} 步。
|
||||||
<div>当前审批:{detail.pendingWorkflow?.task?.step_name ?? "-"}</div>
|
|
||||||
<div>明细数:{detail.items.length}</div>
|
|
||||||
<div>票据数:{detail.invoices.length}</div>
|
|
||||||
<div>附件数:{attachmentCount}</div>
|
|
||||||
<div>关联项目:{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div>
|
|
||||||
</DetailSideMeta>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{detail.status === "reviewing" && detail.workflowProgress ? (
|
|
||||||
<div id="reimbursement-workflow-progress-panel">
|
|
||||||
<DetailSideMeta
|
|
||||||
title="审批进度"
|
|
||||||
description={`当前位于第 ${detail.workflowProgress.currentStepIndex + 1} / ${detail.workflowProgress.totalSteps} 步。`}
|
|
||||||
>
|
|
||||||
<div className="detail-timeline" data-testid={TEST_IDS.reimbursement.workflowProgress}>
|
|
||||||
{detail.workflowProgress.steps.map((step, index) => (
|
|
||||||
<div
|
|
||||||
key={step.id}
|
|
||||||
className={
|
|
||||||
step.status === "current"
|
|
||||||
? "detail-timeline-item detail-timeline-item--current"
|
|
||||||
: step.status === "completed"
|
|
||||||
? "detail-timeline-item detail-timeline-item--done"
|
|
||||||
: "detail-timeline-item"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-3 text-sm">
|
|
||||||
<span className="font-medium text-zinc-900">
|
|
||||||
第 {index + 1} 步 · {step.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-zinc-500">
|
|
||||||
{step.status === "completed" ? "已完成" : step.status === "current" ? "当前节点" : "待处理"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="detail-timeline" data-testid={TEST_IDS.reimbursement.workflowProgress}>
|
||||||
</div>
|
{detail.workflowProgress.steps.map((step, index) => (
|
||||||
</DetailSideMeta>
|
<div
|
||||||
</div>
|
key={step.id}
|
||||||
) : null}
|
className={
|
||||||
|
step.status === "current"
|
||||||
{showSecondaryDetail ? (
|
? "detail-timeline-item detail-timeline-item--current"
|
||||||
<DetailSideMeta title="审批记录">
|
: step.status === "completed"
|
||||||
{detail.approvalHistory.length ? (
|
? "detail-timeline-item detail-timeline-item--done"
|
||||||
<div className="detail-timeline" data-testid="reimbursement-approval-history">
|
: "detail-timeline-item"
|
||||||
{detail.approvalHistory.map((item) => (
|
}
|
||||||
<div key={item.id} className="detail-timeline-item">
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
<span className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</span>
|
<span className="font-medium text-zinc-900">
|
||||||
<span className="text-xs text-zinc-500">{formatDateTime(item.createdAt)}</span>
|
第 {index + 1} 步 · {step.name}
|
||||||
</div>
|
</span>
|
||||||
<div className="mt-1 text-xs text-zinc-500">
|
<span className="text-xs text-zinc-500">
|
||||||
{item.stepName ?? "-"} · {item.actorName || "系统"}
|
{step.status === "completed" ? "已完成" : step.status === "current" ? "当前节点" : "待处理"}
|
||||||
</div>
|
</span>
|
||||||
{item.comment ? <div className="mt-2 text-zinc-600">{item.comment}</div> : null}
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">
|
||||||
|
当前没有进行中的审批节点。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="detail-command-panel">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">审批记录</h3>
|
||||||
|
{detail.approvalHistory.length ? (
|
||||||
|
<div className="detail-timeline mt-3" data-testid="reimbursement-approval-history">
|
||||||
|
{detail.approvalHistory.map((item) => (
|
||||||
|
<div key={item.id} className="detail-timeline-item">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</span>
|
||||||
|
<span className="text-xs text-zinc-500">{formatDateTime(item.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
|
{item.stepName ?? "-"} · {item.actorName || "系统"}
|
||||||
|
</div>
|
||||||
|
{item.comment ? <div className="mt-2 text-zinc-600">{item.comment}</div> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 text-sm text-zinc-500">暂无审批记录。</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className="text-sm text-zinc-500">暂无审批记录。</div>
|
</SectionPanel>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<DetailSideMeta title="当前可做">
|
||||||
|
{["draft", "rejected"].includes(detail.status) ? (
|
||||||
|
<div>补齐明细和 PDF 发票后提交审批;附件作为补充材料放在主内容区。</div>
|
||||||
|
) : null}
|
||||||
|
{detail.status === "reviewing" ? (
|
||||||
|
<div>审批动作只在“待我处理”或任务详情执行;如需修改,可撤回后补正。</div>
|
||||||
|
) : null}
|
||||||
|
{detail.status === "approved" ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>本页只显示付款状态;付款登记、批量付款和回单处理请进入付款上下文。</div>
|
||||||
|
{canPay ? (
|
||||||
|
<Button asChild variant="secondary" size="sm">
|
||||||
|
<Link to="/payment-batches">进入付款批次</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{detail.status === "paid" ? (
|
||||||
|
<div>已付款报销用于查证据、看回单状态和打印归档,不再编辑或重复付款。</div>
|
||||||
|
) : null}
|
||||||
|
{detail.status === "cancelled" ? (
|
||||||
|
<div>该报销已取消,仅保留查看和归档记录。</div>
|
||||||
|
) : null}
|
||||||
</DetailSideMeta>
|
</DetailSideMeta>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ReimbursementActions
|
<ReimbursementActions
|
||||||
status={detail.status}
|
status={detail.status}
|
||||||
canPay={canPay}
|
|
||||||
showSubmitAction={false}
|
showSubmitAction={false}
|
||||||
showPayAction={detail.status === "approved"}
|
|
||||||
actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""}
|
actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""}
|
||||||
submitDisabled={blockStandardChecks.length > 0}
|
submitDisabled={blockStandardChecks.length > 0}
|
||||||
submitHint=""
|
submitHint=""
|
||||||
@@ -1917,7 +1899,6 @@ export function ReimbursementDetailPage() {
|
|||||||
isPending={mutation.isPending}
|
isPending={mutation.isPending}
|
||||||
onSubmit={() => void handleSubmitAction()}
|
onSubmit={() => void handleSubmitAction()}
|
||||||
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
|
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
|
||||||
onPay={(payload) => mutation.mutate({ action: "pay", payload })}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,63 +1,38 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { ReimbursementStatus } from "../../api/reimbursements";
|
import type { ReimbursementStatus } from "../../api/reimbursements";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Input } from "../../components/ui/input";
|
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
import { Textarea } from "../../components/ui/textarea";
|
||||||
|
|
||||||
type PaymentChannel = "bank" | "alipay" | "wechat" | "cash" | "other";
|
|
||||||
|
|
||||||
function todayDateString() {
|
|
||||||
return new Date().toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PayPayload = {
|
|
||||||
paymentVoucherNo: string;
|
|
||||||
paymentDate: string;
|
|
||||||
paymentChannel: PaymentChannel;
|
|
||||||
paymentNote: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ReimbursementActionsProps {
|
export interface ReimbursementActionsProps {
|
||||||
status: ReimbursementStatus;
|
status: ReimbursementStatus;
|
||||||
canPay: boolean;
|
|
||||||
actionError: string;
|
actionError: string;
|
||||||
submitDisabled?: boolean;
|
submitDisabled?: boolean;
|
||||||
submitHint?: string;
|
submitHint?: string;
|
||||||
submitNotice?: string;
|
submitNotice?: string;
|
||||||
showSubmitAction?: boolean;
|
showSubmitAction?: boolean;
|
||||||
showWithdrawAction?: boolean;
|
showWithdrawAction?: boolean;
|
||||||
showPayAction?: boolean;
|
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
onWithdraw: (comment: string) => void;
|
onWithdraw: (comment: string) => void;
|
||||||
onPay: (payload: PayPayload) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReimbursementActions({
|
export function ReimbursementActions({
|
||||||
status,
|
status,
|
||||||
canPay,
|
|
||||||
actionError,
|
actionError,
|
||||||
submitDisabled = false,
|
submitDisabled = false,
|
||||||
submitHint = "",
|
submitHint = "",
|
||||||
submitNotice = "",
|
submitNotice = "",
|
||||||
showSubmitAction = true,
|
showSubmitAction = true,
|
||||||
showWithdrawAction = true,
|
showWithdrawAction = true,
|
||||||
showPayAction = true,
|
|
||||||
isPending,
|
isPending,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onWithdraw,
|
onWithdraw,
|
||||||
onPay,
|
|
||||||
}: ReimbursementActionsProps) {
|
}: ReimbursementActionsProps) {
|
||||||
const [withdrawComment, setWithdrawComment] = useState("申请撤回并补充说明后重提");
|
const [withdrawComment, setWithdrawComment] = useState("申请撤回并补充说明后重提");
|
||||||
const [paymentVoucherNo, setPaymentVoucherNo] = useState("");
|
|
||||||
const [paymentDate, setPaymentDate] = useState(todayDateString);
|
|
||||||
const [paymentChannel, setPaymentChannel] = useState<PaymentChannel>("bank");
|
|
||||||
const [paymentNote, setPaymentNote] = useState("");
|
|
||||||
const canShowSubmit = showSubmitAction && ["draft", "rejected"].includes(status);
|
const canShowSubmit = showSubmitAction && ["draft", "rejected"].includes(status);
|
||||||
const canShowWithdraw = showWithdrawAction && status === "reviewing";
|
const canShowWithdraw = showWithdrawAction && status === "reviewing";
|
||||||
const canShowPay = showPayAction && canPay && status === "approved";
|
|
||||||
|
|
||||||
if (!canShowSubmit && !canShowWithdraw && !canShowPay && !actionError && !submitHint && !submitNotice) {
|
if (!canShowSubmit && !canShowWithdraw && !actionError && !submitHint && !submitNotice) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,61 +88,6 @@ export function ReimbursementActions({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canShowPay ? (
|
|
||||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
|
||||||
<p className="text-sm font-medium text-zinc-800">财务支付</p>
|
|
||||||
<Input
|
|
||||||
data-testid="reimbursement-pay-voucher-no"
|
|
||||||
onChange={(event) => setPaymentVoucherNo(event.target.value)}
|
|
||||||
placeholder="付款凭证号"
|
|
||||||
value={paymentVoucherNo}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
data-testid="reimbursement-pay-date"
|
|
||||||
onChange={(event) => setPaymentDate(event.target.value)}
|
|
||||||
type="date"
|
|
||||||
value={paymentDate}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
className="select-std"
|
|
||||||
data-testid="reimbursement-pay-channel"
|
|
||||||
onChange={(event) =>
|
|
||||||
setPaymentChannel(event.target.value as PaymentChannel)
|
|
||||||
}
|
|
||||||
value={paymentChannel}
|
|
||||||
>
|
|
||||||
<option value="bank">银行转账</option>
|
|
||||||
<option value="alipay">支付宝</option>
|
|
||||||
<option value="wechat">微信</option>
|
|
||||||
<option value="cash">现金</option>
|
|
||||||
<option value="other">其他</option>
|
|
||||||
</select>
|
|
||||||
<Textarea
|
|
||||||
data-testid="reimbursement-pay-note"
|
|
||||||
onChange={(event) => setPaymentNote(event.target.value)}
|
|
||||||
placeholder="付款备注(可选,用于审计留痕)"
|
|
||||||
rows={2}
|
|
||||||
value={paymentNote}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
data-testid="reimbursement-pay"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={isPending}
|
|
||||||
onClick={() =>
|
|
||||||
onPay({
|
|
||||||
paymentVoucherNo: paymentVoucherNo.trim(),
|
|
||||||
paymentDate,
|
|
||||||
paymentChannel,
|
|
||||||
paymentNote: paymentNote.trim(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
执行支付
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { APIRequestContext } from "@playwright/test";
|
||||||
|
|
||||||
|
import { apiGetData } from "./auth";
|
||||||
|
|
||||||
|
type FeatureFlagItem = {
|
||||||
|
flagKey: string;
|
||||||
|
overrideMode: "inherit" | "force_on" | "force_off";
|
||||||
|
rolloutType: "global" | "department_allowlist";
|
||||||
|
departmentIds: string[];
|
||||||
|
note: string;
|
||||||
|
version: number;
|
||||||
|
effectiveEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function ensureFeatureFlagOn(
|
||||||
|
api: APIRequestContext,
|
||||||
|
flagKey: string,
|
||||||
|
note = "playwright feature flag setup",
|
||||||
|
) {
|
||||||
|
const list = await apiGetData<{ items: FeatureFlagItem[] }>(api, "/api/system/feature-flags");
|
||||||
|
const item = list.items.find((flag) => flag.flagKey === flagKey);
|
||||||
|
if (!item) {
|
||||||
|
throw new Error(`Missing feature flag: ${flagKey}`);
|
||||||
|
}
|
||||||
|
if (item.overrideMode === "force_on" && item.effectiveEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await api.put(`/api/system/feature-flags/${flagKey}`, {
|
||||||
|
data: {
|
||||||
|
overrideMode: "force_on",
|
||||||
|
rolloutType: "global",
|
||||||
|
departmentIds: [],
|
||||||
|
note,
|
||||||
|
expectedVersion: item.version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new Error(`Failed to enable feature flag ${flagKey}: ${response.status()} ${await response.text()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import { test, expect } from "./helpers/fixtures";
|
import { test, expect } from "./helpers/fixtures";
|
||||||
import { EMPLOYEE, FINANCE, MANAGER, login, logout } from "./helpers/auth";
|
import { ADMIN, EMPLOYEE, FINANCE, FINANCE_MGR, MANAGER, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
|
||||||
|
import { ensureFeatureFlagOn } from "./helpers/featureFlags";
|
||||||
import { openNavLink } from "./helpers/navigation";
|
import { openNavLink } from "./helpers/navigation";
|
||||||
import { TEST_IDS } from "./helpers/testIds";
|
import { TEST_IDS } from "./helpers/testIds";
|
||||||
|
|
||||||
test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登出", async ({ page, reimbursementDetail, workflowDetail }) => {
|
test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登出", async ({ page, reimbursementDetail, workflowDetail }) => {
|
||||||
// 主链路包含 OCR 等待和三角色切换,full-local 下会超过默认 120s。
|
// 主链路包含 OCR 等待和三角色切换,full-local 下会超过默认 120s。
|
||||||
test.setTimeout(240_000);
|
test.setTimeout(300_000);
|
||||||
|
|
||||||
|
const adminApi = await createAuthenticatedApiClient(ADMIN);
|
||||||
|
await ensureFeatureFlagOn(adminApi.api, "payment_batch_v1", "playwright main flow");
|
||||||
|
await adminApi.dispose();
|
||||||
|
|
||||||
// 1) employee: create reimbursement
|
// 1) employee: create reimbursement
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
@@ -86,7 +91,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
|||||||
|
|
||||||
await logout(page, MANAGER.name);
|
await logout(page, MANAGER.name);
|
||||||
|
|
||||||
// 6) finance approve + pay
|
// 6) finance approve, then pay from the payment batch context
|
||||||
await login(page, FINANCE);
|
await login(page, FINANCE);
|
||||||
await (await openNavLink(page, "待我处理")).click();
|
await (await openNavLink(page, "待我处理")).click();
|
||||||
const financeTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementId }).first();
|
const financeTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementId }).first();
|
||||||
@@ -99,9 +104,73 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
|||||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||||
await reimbursementDetail.assertLoaded();
|
await reimbursementDetail.assertLoaded();
|
||||||
await expect(page.getByText(/状态:(approved|已批准)/)).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText(/状态:(approved|已批准)/)).toBeVisible({ timeout: 30_000 });
|
||||||
await page.getByTestId("reimbursement-pay-voucher-no").fill(`E2E-${Date.now()}`);
|
await expect(page.getByTestId(TEST_IDS.reimbursement.pay)).toHaveCount(0);
|
||||||
await reimbursementDetail.payBtn.click();
|
|
||||||
|
const paymentDate = new Date().toISOString().slice(0, 10);
|
||||||
|
const batchName = `E2E-报销付款批次-${Date.now()}`;
|
||||||
|
await page.goto("/payment-batches");
|
||||||
|
const createCard = page.getByTestId("payment-batch-create-panel");
|
||||||
|
const createBatchResponsePromise = page.waitForResponse((response) => {
|
||||||
|
const url = new URL(response.url());
|
||||||
|
return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok();
|
||||||
|
});
|
||||||
|
await createCard.getByTestId("payment-batch-create-entity-type").selectOption("reimbursement");
|
||||||
|
await createCard.getByTestId("payment-batch-create-name").fill(batchName);
|
||||||
|
await createCard.getByTestId("payment-batch-create-entity-ids").fill(reimbursementId);
|
||||||
|
await createCard.getByTestId("payment-batch-create-payment-date").fill(paymentDate);
|
||||||
|
await createCard.getByTestId("payment-batch-create-payment-channel").selectOption("bank");
|
||||||
|
await createCard.getByTestId("payment-batch-create-voucher-prefix").fill("E2EPB");
|
||||||
|
await createCard.getByRole("button", { name: "创建批次" }).click();
|
||||||
|
const createBatchPayload = (await (await createBatchResponsePromise).json()) as { data?: { item?: { id?: string } } };
|
||||||
|
const batchId = createBatchPayload.data?.item?.id;
|
||||||
|
if (!batchId) {
|
||||||
|
throw new Error(`创建支付批次后未返回 id: ${JSON.stringify(createBatchPayload)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await logout(page, FINANCE.name);
|
||||||
|
await login(page, FINANCE_MGR);
|
||||||
|
await page.goto("/ap-operations");
|
||||||
|
await page.getByRole("tab", { name: /待付款/ }).click();
|
||||||
|
const pendingRow = page.locator("tr").filter({ hasText: batchName }).first();
|
||||||
|
await expect(pendingRow).toBeVisible();
|
||||||
|
await pendingRow.getByRole("button", { name: "快捷复核" }).click();
|
||||||
|
await expect(page.getByRole("dialog")).toContainText(batchName);
|
||||||
|
await page.getByPlaceholder("请输入复核意见").fill("E2E main flow payment review");
|
||||||
|
await page.getByRole("button", { name: "提交复核" }).click();
|
||||||
|
await expect(pendingRow).toContainText("复核 1/1");
|
||||||
|
|
||||||
|
await logout(page, FINANCE_MGR.name);
|
||||||
|
await login(page, FINANCE);
|
||||||
|
await page.goto(`/payment-batches?id=${batchId}`);
|
||||||
|
const executedBatchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
|
||||||
|
await expect(executedBatchDetailCard).toBeVisible();
|
||||||
|
await expect(executedBatchDetailCard).toContainText("1/1");
|
||||||
|
const receiptCard = page.getByTestId("payment-batch-receipt-panel");
|
||||||
|
await expect(receiptCard).toBeVisible();
|
||||||
|
await receiptCard.getByTestId("payment-batch-receipt-file-input").setInputFiles({
|
||||||
|
name: "e2e-bank-receipt.pdf",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
buffer: Buffer.from(`%PDF-1.4\npayment receipt ${marker}\n%%EOF\n`, "utf8"),
|
||||||
|
});
|
||||||
|
const receiptUploadResponsePromise = page.waitForResponse((response) => {
|
||||||
|
const url = new URL(response.url());
|
||||||
|
return response.request().method() === "POST" &&
|
||||||
|
url.pathname === `/api/payment-batches/${batchId}/receipt-attachments` &&
|
||||||
|
response.ok();
|
||||||
|
});
|
||||||
|
await receiptCard.getByRole("button", { name: "上传" }).click();
|
||||||
|
await receiptUploadResponsePromise;
|
||||||
|
await expect(receiptCard).toContainText("e2e-bank-receipt.pdf");
|
||||||
|
await executedBatchDetailCard.getByRole("button", { name: "执行" }).click();
|
||||||
|
await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认后将执行付款批次");
|
||||||
|
await page.getByTestId("action-confirm-submit").click();
|
||||||
|
await expect(executedBatchDetailCard).toContainText("已执行", { timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||||
|
await reimbursementDetail.assertLoaded();
|
||||||
await expect(page.getByText(/状态:(paid|已支付)/)).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText(/状态:(paid|已支付)/)).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId("reimbursement-payment-status")).toContainText("已付款");
|
||||||
|
await expect(page.getByTestId(TEST_IDS.reimbursement.pay)).toHaveCount(0);
|
||||||
|
|
||||||
await logout(page, FINANCE.name);
|
await logout(page, FINANCE.name);
|
||||||
|
|
||||||
|
|||||||
@@ -1066,6 +1066,7 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
|||||||
await expect(page.getByTestId("reimbursement-payment-status")).toHaveCount(0);
|
await expect(page.getByTestId("reimbursement-payment-status")).toHaveCount(0);
|
||||||
await page.getByTestId("reimbursement-more-toggle").click();
|
await page.getByTestId("reimbursement-more-toggle").click();
|
||||||
await expect(page.getByTestId("reimbursement-secondary-detail")).toBeVisible();
|
await expect(page.getByTestId("reimbursement-secondary-detail")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("reimbursement-secondary-payment-status")).toHaveCount(0);
|
||||||
await page.screenshot({ path: screenshotPath("01-pc-reimbursement-detail.png"), fullPage: true });
|
await page.screenshot({ path: screenshotPath("01-pc-reimbursement-detail.png"), fullPage: true });
|
||||||
|
|
||||||
await page.goto("/workflow/tasks/task-wave3");
|
await page.goto("/workflow/tasks/task-wave3");
|
||||||
|
|||||||
@@ -339,9 +339,14 @@ test.describe("R102 reimbursement main flow", () => {
|
|||||||
|
|
||||||
await expect(page.getByRole("heading", { name: `报销详情 ${formNumber}` })).toBeVisible();
|
await expect(page.getByRole("heading", { name: `报销详情 ${formNumber}` })).toBeVisible();
|
||||||
await expect(page.getByText("报销ID")).toHaveCount(0);
|
await expect(page.getByText("报销ID")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("reimbursement-business-summary")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("reimbursement-attachments")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("reimbursement-related-records")).toBeVisible();
|
||||||
|
await expect(page.getByText("执行支付")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("reimbursement-delete-draft")).toHaveCount(0);
|
await expect(page.getByTestId("reimbursement-delete-draft")).toHaveCount(0);
|
||||||
await page.getByTestId("reimbursement-more-toggle").click();
|
await page.getByTestId("reimbursement-more-toggle").click();
|
||||||
await expect(page.getByTestId("reimbursement-delete-draft")).toBeVisible();
|
await expect(page.getByTestId("reimbursement-delete-draft")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("reimbursement-secondary-payment-status")).toHaveCount(0);
|
||||||
|
|
||||||
await page.getByTestId("reimbursement-invoice-add").click();
|
await page.getByTestId("reimbursement-invoice-add").click();
|
||||||
await expect(page.getByTestId("reimbursement-invoice-dialog")).toBeVisible();
|
await expect(page.getByTestId("reimbursement-invoice-dialog")).toBeVisible();
|
||||||
|
|||||||
@@ -522,6 +522,8 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
|||||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||||
await expect(page.getByText("报销详情 BX20260508001")).toBeVisible();
|
await expect(page.getByText("报销详情 BX20260508001")).toBeVisible();
|
||||||
await expect(page.getByText("预算与审核提示")).toBeVisible();
|
await expect(page.getByText("预算与审核提示")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("reimbursement-attachments")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("reimbursement-related-records")).toBeVisible();
|
||||||
await expect(page.getByText("ERP")).toHaveCount(0);
|
await expect(page.getByText("ERP")).toHaveCount(0);
|
||||||
|
|
||||||
await page.getByTestId("reimbursement-invoice-add").click();
|
await page.getByTestId("reimbursement-invoice-add").click();
|
||||||
|
|||||||
@@ -10,42 +10,10 @@ import {
|
|||||||
logout,
|
logout,
|
||||||
MANAGER,
|
MANAGER,
|
||||||
} from "./helpers/auth";
|
} from "./helpers/auth";
|
||||||
|
import { ensureFeatureFlagOn } from "./helpers/featureFlags";
|
||||||
import { openNavLink } from "./helpers/navigation";
|
import { openNavLink } from "./helpers/navigation";
|
||||||
import { TEST_IDS } from "./helpers/testIds";
|
import { TEST_IDS } from "./helpers/testIds";
|
||||||
|
|
||||||
type FeatureFlagItem = {
|
|
||||||
flagKey: string;
|
|
||||||
overrideMode: "inherit" | "force_on" | "force_off";
|
|
||||||
rolloutType: "global" | "department_allowlist";
|
|
||||||
departmentIds: string[];
|
|
||||||
note: string;
|
|
||||||
version: number;
|
|
||||||
effectiveEnabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function ensureFeatureFlagOn(api: Awaited<ReturnType<typeof createAuthenticatedApiClient>>["api"], flagKey: string) {
|
|
||||||
const list = await apiGetData<{ items: FeatureFlagItem[] }>(api, "/api/system/feature-flags");
|
|
||||||
const item = list.items.find((flag) => flag.flagKey === flagKey);
|
|
||||||
if (!item) {
|
|
||||||
throw new Error(`Missing feature flag: ${flagKey}`);
|
|
||||||
}
|
|
||||||
if (item.overrideMode === "force_on" && item.effectiveEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const response = await api.put(`/api/system/feature-flags/${flagKey}`, {
|
|
||||||
data: {
|
|
||||||
overrideMode: "force_on",
|
|
||||||
rolloutType: "global",
|
|
||||||
departmentIds: [],
|
|
||||||
note: "playwright reimbursement payment batch flow",
|
|
||||||
expectedVersion: item.version,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok()) {
|
|
||||||
throw new Error(`Failed to enable feature flag ${flagKey}: ${response.status()} ${await response.text()}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复核 -> 执行 -> 下游收口", async ({
|
test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复核 -> 执行 -> 下游收口", async ({
|
||||||
page,
|
page,
|
||||||
reimbursementDetail,
|
reimbursementDetail,
|
||||||
@@ -59,8 +27,8 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
|||||||
const paymentDate = new Date().toISOString().slice(0, 10);
|
const paymentDate = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureFeatureFlagOn(adminApi.api, "payment_batch_v1");
|
await ensureFeatureFlagOn(adminApi.api, "payment_batch_v1", "playwright reimbursement payment batch flow");
|
||||||
await ensureFeatureFlagOn(adminApi.api, "erp_sync");
|
await ensureFeatureFlagOn(adminApi.api, "erp_sync", "playwright reimbursement payment batch flow");
|
||||||
|
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
|
|
||||||
@@ -220,6 +188,8 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
|||||||
expect(reimbursementAfterPayment.status).toBe("paid");
|
expect(reimbursementAfterPayment.status).toBe("paid");
|
||||||
await expect(page.getByText("已支付", { exact: true }).first()).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText("已支付", { exact: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(page.getByTestId("reimbursement-payment-status")).toContainText("已付款");
|
await expect(page.getByTestId("reimbursement-payment-status")).toContainText("已付款");
|
||||||
|
await expect(page.getByTestId(TEST_IDS.reimbursement.pay)).toHaveCount(0);
|
||||||
|
await expect(page.getByText("执行支付")).toHaveCount(0);
|
||||||
|
|
||||||
const paymentAudit = await apiGetData<{
|
const paymentAudit = await apiGetData<{
|
||||||
items: Array<{ action: string; targetId: string | null; extra: Record<string, unknown> }>;
|
items: Array<{ action: string; targetId: string | null; extra: Record<string, unknown> }>;
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户