收敛报销明细与材料交互(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchExpenseTypes } from "../api/expenseTypes";
|
||||
@@ -1251,24 +1252,22 @@ export function ReimbursementDetailPage() {
|
||||
) : null}
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="明细与发票">
|
||||
<SectionPanel title="报销明细">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4" data-testid="reimbursement-items">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<h3 className="text-base font-semibold text-zinc-900">报销明细</h3>
|
||||
|
||||
{canEditItems ? (
|
||||
<Button
|
||||
data-testid="reimbursement-item-add"
|
||||
disabled={itemSaveMutation.isPending || itemDeleteMutation.isPending}
|
||||
onClick={startCreateItem}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
新增明细
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{canEditItems ? (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
data-testid="reimbursement-item-add"
|
||||
disabled={itemSaveMutation.isPending || itemDeleteMutation.isPending}
|
||||
onClick={startCreateItem}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
新增明细
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{itemError ? (
|
||||
<p
|
||||
@@ -1340,6 +1339,28 @@ export function ReimbursementDetailPage() {
|
||||
{check.standardName}:当前金额超出费用标准。
|
||||
</div>
|
||||
))}
|
||||
{detail.invoices
|
||||
.filter((invoice) => invoice.linkedItemId === item.id)
|
||||
.map((invoice) => (
|
||||
<button
|
||||
key={invoice.id}
|
||||
type="button"
|
||||
className="mt-2 flex min-h-11 w-full items-center gap-2 rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2 text-left transition-colors hover:bg-zinc-100 active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
|
||||
disabled={openInvoiceMutation.isPending}
|
||||
aria-label={`查看关联发票:${formatInvoiceDisplayTitle(invoice)}`}
|
||||
onClick={() => openInvoiceMutation.mutate(invoice.id)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-semibold text-zinc-800">
|
||||
发票 · {formatInvoiceDisplayTitle(invoice)}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-zinc-500">
|
||||
{invoice.sellerName || "未识别销售方"} · ¥{invoice.totalAmount ?? "-"}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
|
||||
</button>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canEditItems ? (
|
||||
@@ -1396,12 +1417,15 @@ export function ReimbursementDetailPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 text-sm" data-testid={TEST_IDS.reimbursement.invoices}>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="报销材料">
|
||||
<div className="space-y-4 text-sm" data-testid="reimbursement-materials">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">发票</h3>
|
||||
{canEditItems ? <p className="mt-1 text-xs text-zinc-500">提交前至少添加 1 张发票。</p> : null}
|
||||
</div>
|
||||
<p className="text-sm text-zinc-600">
|
||||
发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}
|
||||
</p>
|
||||
{canOpenInvoiceBindDialog ? (
|
||||
<Button
|
||||
data-testid={TEST_IDS.reimbursement.invoiceAdd}
|
||||
@@ -1411,8 +1435,6 @@ export function ReimbursementDetailPage() {
|
||||
>
|
||||
添加发票
|
||||
</Button>
|
||||
) : canEditItems ? (
|
||||
<p className="text-xs text-zinc-500">当前账号不可在此页添加发票</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1425,74 +1447,47 @@ export function ReimbursementDetailPage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{(detail.invoices ?? []).length ? (
|
||||
<div className="space-y-3">
|
||||
{detail.invoices.map((item) => {
|
||||
const invoiceHint = resolveInvoiceNextStepText(item);
|
||||
return (
|
||||
<div
|
||||
className="detail-evidence-card"
|
||||
key={item.id}
|
||||
<div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}>
|
||||
{detail.invoices.map((invoice) => {
|
||||
const invoiceHint = resolveInvoiceNextStepText(invoice);
|
||||
return (
|
||||
<div className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center" key={invoice.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-11 min-w-0 flex-1 items-center gap-3 rounded-md text-left transition-colors hover:bg-white active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
|
||||
disabled={openInvoiceMutation.isPending}
|
||||
aria-label={`查看发票原件:${formatInvoiceDisplayTitle(invoice)}`}
|
||||
onClick={() => openInvoiceMutation.mutate(invoice.id)}
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-zinc-900">
|
||||
{formatInvoiceDisplayTitle(item)}
|
||||
</div>
|
||||
<div className="text-zinc-600">
|
||||
{item.sellerName || "未识别销售方"} · {item.invoiceDate ? formatDisplayDate(item.invoiceDate) : "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
原始文件:{item.originalName || "未记录原始文件"}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
明细:{resolveLinkedItemLabel(item.linkedItemId)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2 md:items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="badge-neutral">
|
||||
{resolveInvoiceStatusLabel(item.status)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={openInvoiceMutation.isPending}
|
||||
onClick={() => openInvoiceMutation.mutate(item.id)}
|
||||
>
|
||||
查看原件
|
||||
</Button>
|
||||
{canUnbindInvoices ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={unbindInvoiceMutation.isPending}
|
||||
onClick={() => unbindInvoiceMutation.mutate(item.id)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{invoiceHint ? (
|
||||
<div className="max-w-[260px] text-xs text-zinc-500 md:text-right">{invoiceHint}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">暂无发票</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
<span className="inline-flex shrink-0 rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">发票</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</span>
|
||||
<span className="mt-1 block text-xs text-zinc-500">
|
||||
{invoice.sellerName || "未识别销售方"} · {invoice.invoiceDate ? formatDisplayDate(invoice.invoiceDate) : "未识别日期"} · ¥{invoice.totalAmount ?? "-"}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</span>
|
||||
{invoiceHint ? <span className="mt-1 block text-xs text-zinc-500">{invoiceHint}</span> : null}
|
||||
</span>
|
||||
<span className="badge-neutral shrink-0">{resolveInvoiceStatusLabel(invoice.status)}</span>
|
||||
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
|
||||
</button>
|
||||
{canUnbindInvoices ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={unbindInvoiceMutation.isPending}
|
||||
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<SectionPanel title="附件材料">
|
||||
<div className="space-y-4 text-sm" data-testid="reimbursement-attachments">
|
||||
<div className="space-y-4" data-testid="reimbursement-attachments">
|
||||
{canEditItems ? (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-xs text-zinc-500">支持 PDF/JPG/PNG/WebP,单次最多 5 个。</p>
|
||||
@@ -1539,42 +1534,42 @@ export function ReimbursementDetailPage() {
|
||||
{(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 · {formatDisplayDateTime(attachment.createdAt)}
|
||||
</div>
|
||||
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div key={attachment.id} className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-11 min-w-0 flex-1 items-center gap-3 rounded-md text-left transition-colors hover:bg-white active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
|
||||
disabled={openAttachmentMutation.isPending}
|
||||
aria-label={`查看补充附件:${attachment.originalName}`}
|
||||
onClick={() => void handleOpenAttachment(attachment)}
|
||||
>
|
||||
<span className="inline-flex shrink-0 rounded-full bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-600">补充附件</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-zinc-900">{attachment.originalName}</span>
|
||||
<span className="mt-1 block text-xs text-zinc-500">
|
||||
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDisplayDateTime(attachment.createdAt)}
|
||||
</span>
|
||||
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
|
||||
</span>
|
||||
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
|
||||
</button>
|
||||
{canEditItems ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={openAttachmentMutation.isPending}
|
||||
onClick={() => void handleOpenAttachment(attachment)}
|
||||
disabled={deleteAttachmentMutation.isPending}
|
||||
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName })}
|
||||
>
|
||||
查看原件
|
||||
删除
|
||||
</Button>
|
||||
{canEditItems ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleteAttachmentMutation.isPending}
|
||||
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName })}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">暂无附件材料</p>
|
||||
) : detail.invoices.length ? null : (
|
||||
<p className="text-sm text-zinc-500">暂无报销材料</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户