R102/A1-A8 优化报销主流程
这个提交包含在:
@@ -14,6 +14,7 @@ import { Button } from "../components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||
|
||||
const STATUS_TABS: Array<{ value: ReimbursementStatus; label: string }> = [
|
||||
@@ -49,12 +50,13 @@ function formatDate(value: string | null | undefined) {
|
||||
|
||||
function ReimbursementCard({ item }: { item: ReimbursementListItem }) {
|
||||
const actionLabel = item.status === "draft" || item.status === "rejected" ? "继续编辑" : "查看详情";
|
||||
const displayCode = resolveReimbursementDisplayCode(item);
|
||||
|
||||
return (
|
||||
<article className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursement-card">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{item.formNumber || item.id}</div>
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{displayCode}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{formatDate(item.reimbursementDate)}</div>
|
||||
</div>
|
||||
<span className="badge-neutral shrink-0">{STATUS_LABEL[item.status]}</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
@@ -9,6 +9,7 @@ import { exportReimbursementJournal, previewReimbursementJournal } from "../api/
|
||||
import { fetchProjects } from "../api/projects";
|
||||
import {
|
||||
createReimbursementItem,
|
||||
deleteReimbursement,
|
||||
deleteReimbursementItem,
|
||||
fetchReimbursementDetail,
|
||||
payReimbursement,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
} from "../api/reimbursements";
|
||||
import {
|
||||
bindInvoice,
|
||||
type BindableInvoiceSummary,
|
||||
fetchBindableInvoices,
|
||||
fetchInvoicePoolDetail,
|
||||
unbindInvoice,
|
||||
@@ -49,6 +51,7 @@ import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
|
||||
import {
|
||||
@@ -110,6 +113,7 @@ function resolveErpSyncStatus(
|
||||
|
||||
export function ReimbursementDetailPage() {
|
||||
const { reimbursementId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { permissions, me } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -118,6 +122,7 @@ export function ReimbursementDetailPage() {
|
||||
const [itemEditor, setItemEditor] = useState<ItemEditorState>(null);
|
||||
const [itemError, setItemError] = useState("");
|
||||
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
|
||||
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
|
||||
const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false);
|
||||
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
|
||||
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
|
||||
@@ -156,6 +161,7 @@ export function ReimbursementDetailPage() {
|
||||
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
|
||||
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
|
||||
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
|
||||
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||||
|
||||
const reimbursementPrecheckQuery = useQuery({
|
||||
enabled: Boolean(reimbursementId) && canSubmitExpense && ["draft", "rejected"].includes(detailQuery.data?.status ?? ""),
|
||||
@@ -343,6 +349,26 @@ export function ReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteReimbursementMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!reimbursementId) {
|
||||
throw new Error("missing reimbursementId");
|
||||
}
|
||||
|
||||
return deleteReimbursement(reimbursementId);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||
toast.success("草稿已删除");
|
||||
navigate("/reimbursements?status=draft");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = buildActionErrorMessage(error, "删除草稿失败");
|
||||
setActionError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const projectLinkMutation = useMutation({
|
||||
mutationFn: (projectId: string | null) => linkReimbursementProject(reimbursementId, projectId),
|
||||
onSuccess: async () => {
|
||||
@@ -568,9 +594,15 @@ export function ReimbursementDetailPage() {
|
||||
const bindableIds = new Set((refreshed.data?.items ?? []).map((item) => item.id));
|
||||
const selectableIds = result.availableIds.filter((invoiceId) => bindableIds.has(invoiceId));
|
||||
if (selectableIds.length) {
|
||||
const invoiceById = new Map((refreshed.data?.items ?? []).map((item) => [item.id, item]));
|
||||
setSelectedInvoices((previous) => ({
|
||||
...previous,
|
||||
...Object.fromEntries(selectableIds.map((invoiceId) => [invoiceId, previous[invoiceId] ?? ""])),
|
||||
...Object.fromEntries(
|
||||
selectableIds.map((invoiceId) => [
|
||||
invoiceId,
|
||||
previous[invoiceId] ?? resolveDefaultLinkedItemId(invoiceById.get(invoiceId)),
|
||||
]),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -579,7 +611,7 @@ export function ReimbursementDetailPage() {
|
||||
...result.ocrFailures,
|
||||
];
|
||||
if (result.pendingIds.length) {
|
||||
messages.push(`${result.pendingIds.length} 张发票仍在 OCR 处理中,请稍后刷新列表或到票据中心查看`);
|
||||
messages.push(`${result.pendingIds.length} 张发票仍在 OCR 识别中,可稍后刷新`);
|
||||
}
|
||||
|
||||
if (messages.length) {
|
||||
@@ -632,6 +664,7 @@ export function ReimbursementDetailPage() {
|
||||
}
|
||||
|
||||
const detail = detailQuery.data;
|
||||
const displayCode = resolveReimbursementDisplayCode(detail);
|
||||
const isPaidReimbursement = detail.status === "paid";
|
||||
const erpSyncStatus = resolveErpSyncStatus(erpPreviewQuery.data?.exportState ?? null);
|
||||
const budgetHint = detail.budgetHint;
|
||||
@@ -644,6 +677,10 @@ export function ReimbursementDetailPage() {
|
||||
canUploadInvoice &&
|
||||
canViewOwnInvoice;
|
||||
const canUnbindInvoices = canEditItems && (isReimbursementParticipant || canViewAllExpense);
|
||||
const canDeleteReimbursement =
|
||||
detail.status === "draft" &&
|
||||
canDeleteExpense &&
|
||||
(isReimbursementParticipant || canViewAllExpense);
|
||||
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
|
||||
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
|
||||
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
|
||||
@@ -749,6 +786,24 @@ export function ReimbursementDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveDefaultLinkedItemId = (invoice: BindableInvoiceSummary | null | undefined) => {
|
||||
if (!detail.items.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (detail.items.length === 1) {
|
||||
return detail.items[0].id;
|
||||
}
|
||||
|
||||
const invoiceAmount = Number(invoice?.totalAmount ?? NaN);
|
||||
if (!Number.isFinite(invoiceAmount)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const matchedItems = detail.items.filter((item) => Number(item.amount) === invoiceAmount);
|
||||
return matchedItems.length === 1 ? matchedItems[0].id : "";
|
||||
};
|
||||
|
||||
const toggleInvoiceSelection = (invoiceId: string, checked: boolean) => {
|
||||
setSelectedInvoices((previous) => {
|
||||
if (!checked) {
|
||||
@@ -757,9 +812,10 @@ export function ReimbursementDetailPage() {
|
||||
return next;
|
||||
}
|
||||
|
||||
const invoice = (bindableInvoicesQuery.data?.items ?? []).find((item) => item.id === invoiceId);
|
||||
return {
|
||||
...previous,
|
||||
[invoiceId]: previous[invoiceId] ?? "",
|
||||
[invoiceId]: previous[invoiceId] ?? resolveDefaultLinkedItemId(invoice),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -800,8 +856,8 @@ export function ReimbursementDetailPage() {
|
||||
return (
|
||||
<PageScaffold>
|
||||
<EntityOverviewHero
|
||||
title={`报销详情 ${detail.formNumber ?? detail.id}`}
|
||||
subtitle="查看、编辑、提交。"
|
||||
title={`报销详情 ${displayCode}`}
|
||||
subtitle="明细、发票和审批。"
|
||||
meta={(
|
||||
<>
|
||||
<StatusBadge status={detail.status} />
|
||||
@@ -819,6 +875,17 @@ export function ReimbursementDetailPage() {
|
||||
<Button asChild variant="secondary" data-testid="reimbursement-back">
|
||||
<Link to="/reimbursements">返回列表</Link>
|
||||
</Button>
|
||||
{canDeleteReimbursement ? (
|
||||
<Button
|
||||
data-testid="reimbursement-delete-draft"
|
||||
disabled={deleteReimbursementMutation.isPending}
|
||||
onClick={() => setConfirmDeleteReimbursement(true)}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
删除草稿
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
@@ -848,7 +915,7 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-summary">
|
||||
<div>状态:{detail.status}</div>
|
||||
<div>报销ID:{detail.id}</div>
|
||||
<div>报销编号:{displayCode}</div>
|
||||
<div>报销日期:{detail.reimbursementDate ?? "-"}</div>
|
||||
<div>金额:{detail.totalAmount ?? "-"}</div>
|
||||
<div>提交人:{detail.submitter?.name ?? "-"}</div>
|
||||
@@ -1248,7 +1315,7 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
<div className="space-y-6">
|
||||
<DetailSideMeta title="报销摘要">
|
||||
<div>报销编号:{detail.formNumber ?? detail.id}</div>
|
||||
<div>报销编号:{displayCode}</div>
|
||||
<div>报销人:{detail.owner?.name ?? "-"}</div>
|
||||
<div>提交人:{detail.submitter?.name ?? "-"}</div>
|
||||
<div>当前审批:{detail.pendingWorkflow?.task?.step_name ?? "-"}</div>
|
||||
@@ -1310,7 +1377,7 @@ export function ReimbursementDetailPage() {
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加发票</DialogTitle>
|
||||
<DialogDescription>选择当前报销人可绑定的可用发票,或上传新发票等待 OCR 完成后直接绑定。</DialogDescription>
|
||||
<DialogDescription>选择可用发票,或上传新发票。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -1376,6 +1443,7 @@ export function ReimbursementDetailPage() {
|
||||
<div className="text-xs text-zinc-500">绑定到明细(可选)</div>
|
||||
<select
|
||||
className="select-std mt-1"
|
||||
data-testid={`reimbursement-invoice-linked-item-${item.id}`}
|
||||
value={selectedInvoices[item.id] ?? ""}
|
||||
disabled={!isSelected}
|
||||
onChange={(event) => updateSelectedInvoiceItem(item.id, event.target.value)}
|
||||
@@ -1413,7 +1481,7 @@ export function ReimbursementDetailPage() {
|
||||
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-zinc-900">或者上传新发票</div>
|
||||
<p className="text-xs text-zinc-500">上传后会自动使用当前报销人的 owner 信息,并等待 OCR 识别完成。</p>
|
||||
<p className="text-xs text-zinc-500">支持 PDF 或图片,可多选。</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center">
|
||||
@@ -1444,7 +1512,7 @@ export function ReimbursementDetailPage() {
|
||||
>
|
||||
{uploadInvoiceMutation.isPending
|
||||
? `上传中 ${invoiceUploadProgress.completed}/${invoiceUploadProgress.total}`
|
||||
: "上传并等待 OCR"}
|
||||
: "上传发票"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1461,7 +1529,7 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
{pendingOcrInvoiceIds.length ? (
|
||||
<p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
|
||||
{pendingOcrInvoiceIds.length} 张发票正在识别,完成后自动刷新。
|
||||
{pendingOcrInvoiceIds.length} 张发票正在 OCR 识别,可稍后刷新。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1566,6 +1634,16 @@ export function ReimbursementDetailPage() {
|
||||
confirmLabel="删除"
|
||||
onConfirm={() => { itemDeleteMutation.mutate(confirmDeleteItemId); }}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDeleteReimbursement}
|
||||
onOpenChange={setConfirmDeleteReimbursement}
|
||||
title={`确认删除 ${displayCode}?`}
|
||||
description="删除后无法恢复,已绑定发票会回到可用状态。"
|
||||
variant="destructive"
|
||||
confirmLabel="删除草稿"
|
||||
onConfirm={() => { deleteReimbursementMutation.mutate(); }}
|
||||
/>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
return value || "-";
|
||||
@@ -85,6 +86,7 @@ export function ReimbursementPrintPage() {
|
||||
const detail = detailQuery.data;
|
||||
const totalAmount = detail.totalAmount ?? "0.00";
|
||||
const qrValue = `https://oa.hyinda.com/reimbursements/${detail.id}`;
|
||||
const displayCode = resolveReimbursementDisplayCode(detail);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-100 print:bg-white">
|
||||
@@ -131,7 +133,7 @@ export function ReimbursementPrintPage() {
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-zinc-500">表单号</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{detail.formNumber ?? detail.id}</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{displayCode}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -169,7 +171,7 @@ export function ReimbursementPrintPage() {
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-zinc-500">表单编号</span>
|
||||
<span className="font-medium text-zinc-900">{detail.formNumber ?? detail.id}</span>
|
||||
<span className="font-medium text-zinc-900">{displayCode}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-zinc-500">总金额</span>
|
||||
|
||||
@@ -8,9 +8,11 @@ import { exportReimbursements } from "../api/exports";
|
||||
import { fetchReimbursementTemplates } from "../api/reimbursementTemplates";
|
||||
import {
|
||||
createReimbursement,
|
||||
deleteReimbursement,
|
||||
fetchLinkableTravelRequests,
|
||||
fetchReimbursements,
|
||||
type ReimbursementCreatePayload,
|
||||
type ReimbursementListItem,
|
||||
type ReimbursementStatus,
|
||||
} from "../api/reimbursements";
|
||||
import {
|
||||
@@ -22,6 +24,7 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { toast } from "sonner";
|
||||
import { downloadBlob } from "../lib/download";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
@@ -107,6 +110,7 @@ export function ReimbursementsPage() {
|
||||
const page = Number(searchParams.get("page") ?? "1") || 1;
|
||||
const scope = (searchParams.get("scope") as "own" | "all" | null) ?? "own";
|
||||
const canCreateExpense = hasPermission(permissions, "expense.create");
|
||||
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||||
const canViewAll = hasPermission(permissions, "expense.view_all");
|
||||
const canApproveExpense = hasPermission(permissions, "expense.approve");
|
||||
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
|
||||
@@ -201,6 +205,25 @@ export function ReimbursementsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (reimbursementId: string) => deleteReimbursement(reimbursementId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||
toast.success("草稿已删除");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error, "删除草稿失败"));
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteDraft = (item: ReimbursementListItem) => {
|
||||
const displayCode = resolveReimbursementDisplayCode(item);
|
||||
if (!window.confirm(`确认删除 ${displayCode}?删除后无法恢复。`)) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(item.id);
|
||||
};
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: (format: "csv" | "xlsx") => exportReimbursements({ format, status, scope }),
|
||||
onSuccess: (response, format) => {
|
||||
@@ -594,6 +617,8 @@ export function ReimbursementsPage() {
|
||||
item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"),
|
||||
);
|
||||
const actionLabel = ["draft", "rejected"].includes(item.status) ? "继续编辑" : "查看详情";
|
||||
const displayCode = resolveReimbursementDisplayCode(item);
|
||||
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -604,7 +629,7 @@ export function ReimbursementsPage() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">
|
||||
{item.formNumber || item.id}
|
||||
{displayCode}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{item.reimbursementDate ?? "未填写日期"}</div>
|
||||
</div>
|
||||
@@ -648,7 +673,7 @@ export function ReimbursementsPage() {
|
||||
checked={selectedReimbursementIds.includes(item.id)}
|
||||
disabled={!canSelectRow}
|
||||
onChange={() => toggleBatchSelection(item.id)}
|
||||
aria-label={`选择报销单 ${item.formNumber ?? item.id}`}
|
||||
aria-label={`选择报销单 ${displayCode}`}
|
||||
/>
|
||||
选择
|
||||
</label>
|
||||
@@ -666,6 +691,18 @@ export function ReimbursementsPage() {
|
||||
{actionLabel}
|
||||
</Link>
|
||||
</Button>
|
||||
{canDeleteDraft ? (
|
||||
<Button
|
||||
className="min-h-11"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => handleDeleteDraft(item)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -696,6 +733,8 @@ export function ReimbursementsPage() {
|
||||
const canSelectRow = Boolean(
|
||||
item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"),
|
||||
);
|
||||
const displayCode = resolveReimbursementDisplayCode(item);
|
||||
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
|
||||
|
||||
return (
|
||||
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
|
||||
@@ -706,7 +745,7 @@ export function ReimbursementsPage() {
|
||||
type="checkbox"
|
||||
checked={selectedReimbursementIds.includes(item.id)}
|
||||
onChange={() => toggleBatchSelection(item.id)}
|
||||
aria-label={`选择报销单 ${item.formNumber ?? item.id}`}
|
||||
aria-label={`选择报销单 ${displayCode}`}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-400">-</span>
|
||||
@@ -714,10 +753,7 @@ export function ReimbursementsPage() {
|
||||
</td>
|
||||
) : null}
|
||||
<td className="table-td">
|
||||
<div className="font-semibold text-zinc-900">{item.formNumber || item.id}</div>
|
||||
{item.formNumber && item.formNumber !== item.id ? (
|
||||
<div className="text-xs text-zinc-500">{item.id}</div>
|
||||
) : null}
|
||||
<div className="font-semibold text-zinc-900">{displayCode}</div>
|
||||
{item.project ? (
|
||||
<div className="text-xs text-zinc-500">
|
||||
项目:{item.project.projectNo} · {item.project.name}
|
||||
@@ -749,13 +785,26 @@ export function ReimbursementsPage() {
|
||||
</td>
|
||||
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
|
||||
<td className="table-td">
|
||||
<Link
|
||||
to={`/reimbursements/${item.id}`}
|
||||
className={["draft", "rejected"].includes(item.status) ? "btn-primary" : "btn-secondary"}
|
||||
data-testid="reimbursement-detail-link"
|
||||
>
|
||||
{["draft", "rejected"].includes(item.status) ? "编辑" : "详情"}
|
||||
</Link>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
to={`/reimbursements/${item.id}`}
|
||||
className={["draft", "rejected"].includes(item.status) ? "btn-primary" : "btn-secondary"}
|
||||
data-testid="reimbursement-detail-link"
|
||||
>
|
||||
{["draft", "rejected"].includes(item.status) ? "编辑" : "详情"}
|
||||
</Link>
|
||||
{canDeleteDraft ? (
|
||||
<Button
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => handleDeleteDraft(item)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -819,7 +868,7 @@ export function ReimbursementsPage() {
|
||||
共 {selectedItems.length} 项:
|
||||
<div className="mt-2 max-h-40 space-y-1 overflow-auto">
|
||||
{selectedItems.map((item) => (
|
||||
<div key={item.id}>{item.formNumber ?? item.id}</div>
|
||||
<div key={item.id}>{resolveReimbursementDisplayCode(item)}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -874,7 +923,9 @@ export function ReimbursementsPage() {
|
||||
<div key={`${item.taskId}-${item.outcome}`} className="rounded-xl border border-zinc-200 bg-white px-4 py-3">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">{item.formNumber ?? item.reimbursementId ?? "-"}</div>
|
||||
<div className="font-medium text-zinc-900">
|
||||
{resolveReimbursementDisplayCode({ formNumber: item.formNumber, id: item.reimbursementId })}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">taskId: {item.taskId}</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-zinc-700">
|
||||
|
||||
@@ -72,24 +72,26 @@ export function ReimbursementActions({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||||
<p className="text-sm font-medium text-zinc-800">撤回报销</p>
|
||||
<Textarea
|
||||
data-testid="reimbursement-withdraw-comment"
|
||||
onChange={(event) => setWithdrawComment(event.target.value)}
|
||||
rows={2}
|
||||
value={withdrawComment}
|
||||
/>
|
||||
<Button
|
||||
data-testid="reimbursement-withdraw"
|
||||
variant="secondary"
|
||||
disabled={isPending || status !== "reviewing" || withdrawComment.trim().length < 1}
|
||||
onClick={() => onWithdraw(withdrawComment.trim())}
|
||||
type="button"
|
||||
>
|
||||
撤回
|
||||
</Button>
|
||||
</div>
|
||||
{status === "reviewing" ? (
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||||
<p className="text-sm font-medium text-zinc-800">撤回报销</p>
|
||||
<Textarea
|
||||
data-testid="reimbursement-withdraw-comment"
|
||||
onChange={(event) => setWithdrawComment(event.target.value)}
|
||||
rows={2}
|
||||
value={withdrawComment}
|
||||
/>
|
||||
<Button
|
||||
data-testid="reimbursement-withdraw"
|
||||
variant="secondary"
|
||||
disabled={isPending || withdrawComment.trim().length < 1}
|
||||
onClick={() => onWithdraw(withdrawComment.trim())}
|
||||
type="button"
|
||||
>
|
||||
撤回
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canPay ? (
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||||
@@ -114,11 +116,11 @@ export function ReimbursementActions({
|
||||
}
|
||||
value={paymentChannel}
|
||||
>
|
||||
<option value="bank">bank</option>
|
||||
<option value="alipay">alipay</option>
|
||||
<option value="wechat">wechat</option>
|
||||
<option value="cash">cash</option>
|
||||
<option value="other">other</option>
|
||||
<option value="bank">银行转账</option>
|
||||
<option value="alipay">支付宝</option>
|
||||
<option value="wechat">微信</option>
|
||||
<option value="cash">现金</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
<Button
|
||||
data-testid="reimbursement-pay"
|
||||
|
||||
在新工单中引用
屏蔽一个用户