实现 B-2b 统一报销工作台迁移(reimbursement-form-shared-component-migration)
这个提交包含在:
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, 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 { toast } from "sonner";
|
||||
import { fetchExpenseTypes } from "../api/expenseTypes";
|
||||
import {
|
||||
@@ -33,24 +32,21 @@ import {
|
||||
type ReimbursementItem,
|
||||
type ReimbursementStatus,
|
||||
} from "../api/reimbursements";
|
||||
import { remindWorkflowTask } from "../api/workflow";
|
||||
import type { PrecheckResponse } from "../api/feeControl";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
||||
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
|
||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
||||
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
|
||||
import { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor";
|
||||
import { isTravelReimbursement } from "../lib/reimbursementTravel";
|
||||
import { ReimbursementOperationalHints } from "../components/reimbursements/ReimbursementOperationalHints";
|
||||
import { ReimbursementMobileWorkspaceShell } from "../components/reimbursements/ReimbursementMobileWorkspaceShell";
|
||||
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { MOBILE_STICKY_ACTION_PAGE_CLASS, MobileStickyActionBar } from "../components/patterns/MobileStickyActionBar";
|
||||
import { MobileStickyActionBar } from "../components/patterns/MobileStickyActionBar";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -63,17 +59,17 @@ import { Input } from "../components/ui/input";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
|
||||
import { buildPrecheckDialogMessage } from "../lib/precheck";
|
||||
import { buildReimbursementApprovalEvidence } from "../lib/reimbursementApprovalProjection";
|
||||
import { buildReimbursementWorkspaceHeader, buildReimbursementWorkspaceIssueAnchors } from "../lib/reimbursementWorkspace";
|
||||
import {
|
||||
formatInvoiceDisplayTitle,
|
||||
resolveInvoicePartyText,
|
||||
resolveInvoiceNextStepText,
|
||||
resolveInvoiceStatusLabel,
|
||||
resolveInvoiceUserMessage,
|
||||
} from "../lib/invoiceStatus";
|
||||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
|
||||
import { formatDisplayDateTime, formatMoney } from "../lib/displayFormat";
|
||||
import { formatMoney } from "../lib/displayFormat";
|
||||
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { withUploadTimeout } from "../lib/uploadTimeout";
|
||||
@@ -84,9 +80,6 @@ import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||
import { shouldShowOfficeElectronicsHint } from "../lib/reimbursementPolicyHints";
|
||||
import {
|
||||
focusFirstInvoiceGuardIssue,
|
||||
invoiceGuardInvoiceMessages,
|
||||
invoiceGuardIssueCount,
|
||||
invoiceGuardItemMessages,
|
||||
parseReimbursementInvoiceGuardIssues,
|
||||
resolveInvoiceGuardIssuesAfterLink,
|
||||
type ReimbursementInvoiceGuardIssues,
|
||||
@@ -160,12 +153,6 @@ function isEditableStatus(status: ReimbursementStatus | undefined) {
|
||||
return status === "draft" || status === "rejected";
|
||||
}
|
||||
|
||||
function itemLabel(item: ReimbursementItem) {
|
||||
const typeName = item.expenseType?.name ?? "费用";
|
||||
const desc = item.description?.trim();
|
||||
return desc ? `${typeName} · ${desc}` : typeName;
|
||||
}
|
||||
|
||||
export function MobileReimbursementDetailPage() {
|
||||
const { reimbursementId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -178,7 +165,6 @@ export function MobileReimbursementDetailPage() {
|
||||
const [itemError, setItemError] = useState("");
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [invoiceGuardIssues, setInvoiceGuardIssues] = useState<ReimbursementInvoiceGuardIssues | null>(null);
|
||||
const [invoiceItemSelections, setInvoiceItemSelections] = useState<Record<string, string>>({});
|
||||
const [invoiceItemLinkErrors, setInvoiceItemLinkErrors] = useState<Record<string, string>>({});
|
||||
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
|
||||
const [confirmDeleteInvoiceId, setConfirmDeleteInvoiceId] = useState("");
|
||||
@@ -194,7 +180,6 @@ export function MobileReimbursementDetailPage() {
|
||||
const [invoiceUploadLinkedItemId, setInvoiceUploadLinkedItemId] = useState("");
|
||||
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
|
||||
const [invoiceActionError, setInvoiceActionError] = useState("");
|
||||
const [materialPreviewError, setMaterialPreviewError] = useState("");
|
||||
const [invoiceActionMessage, setInvoiceActionMessage] = useState("");
|
||||
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
|
||||
const [pendingOcrLinkedItemIds, setPendingOcrLinkedItemIds] = useState<Record<string, string>>({});
|
||||
@@ -216,21 +201,12 @@ export function MobileReimbursementDetailPage() {
|
||||
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
|
||||
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||||
const canSubmitExpense = hasPermission(permissions, "expense.submit");
|
||||
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
||||
const isParticipant = Boolean(detail && me?.id && [detail.ownerId, detail.submitterId].includes(me.id));
|
||||
const canEdit = isEditableStatus(detail?.status);
|
||||
const canMaintainReimbursement = Boolean(canEdit && isParticipant);
|
||||
const pendingTaskId = detail?.pendingWorkflow?.task?.id ?? null;
|
||||
const canRemindPendingWorkflow = Boolean(
|
||||
detail?.status === "reviewing" &&
|
||||
pendingTaskId &&
|
||||
isParticipant &&
|
||||
detail?.pendingWorkflow?.task?.assignee_id !== me?.id,
|
||||
);
|
||||
const canOpenInvoiceDialog = Boolean(canMaintainReimbursement && canUploadInvoice && canViewOwnInvoice);
|
||||
const canUnbindInvoices = canMaintainReimbursement;
|
||||
const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && isParticipant);
|
||||
const canManageAttachments = canMaintainReimbursement;
|
||||
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
||||
|
||||
const reimbursementPrecheckQuery = useQuery({
|
||||
@@ -444,21 +420,6 @@ export function MobileReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const remindMutation = useMutation({
|
||||
mutationFn: (taskId: string) => remindWorkflowTask(taskId),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
invalidateDetail(),
|
||||
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
|
||||
]);
|
||||
toast.success("已催办当前审批人");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const bindInvoicesMutation = useMutation({
|
||||
mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => {
|
||||
const results: Array<{ invoiceId: string; success: boolean; message?: string }> = [];
|
||||
@@ -575,11 +536,6 @@ export function MobileReimbursementDetailPage() {
|
||||
variables.invoiceId,
|
||||
variables.linkedItemId,
|
||||
));
|
||||
setInvoiceItemSelections((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[variables.invoiceId];
|
||||
return next;
|
||||
});
|
||||
setInvoiceItemLinkErrors((previous) => {
|
||||
const next = { ...previous };
|
||||
delete next[variables.invoiceId];
|
||||
@@ -808,7 +764,6 @@ export function MobileReimbursementDetailPage() {
|
||||
const materialPreviewMutation = useMutation({
|
||||
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason),
|
||||
onSuccess: async (bundle) => {
|
||||
setMaterialPreviewError("");
|
||||
if (isWecomWebView()) {
|
||||
try {
|
||||
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
|
||||
@@ -821,8 +776,7 @@ export function MobileReimbursementDetailPage() {
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveErrorMessage(error) || "连续预览生成失败,可逐份打开原件";
|
||||
setMaterialPreviewError(message);
|
||||
toast.error("连续预览生成失败,可逐份打开原件");
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -913,14 +867,6 @@ export function MobileReimbursementDetailPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const resolveLinkedItemLabel = (linkedItemId: string | null) => {
|
||||
if (!linkedItemId) {
|
||||
return "未关联";
|
||||
}
|
||||
const item = detail?.items.find((candidate) => candidate.id === linkedItemId);
|
||||
return item ? itemLabel(item) : "无法识别";
|
||||
};
|
||||
|
||||
const expenseTypes = expenseTypesQuery.data?.items ?? [];
|
||||
|
||||
if (!reimbursementId) {
|
||||
@@ -954,617 +900,87 @@ export function MobileReimbursementDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const displayCode = resolveReimbursementDisplayCode(detail);
|
||||
const showSubmitAction = Boolean(canMaintainReimbursement && canSubmitExpense);
|
||||
const showStickyActions = showSubmitAction;
|
||||
const showSecondaryActions = Boolean(actionError || canDeleteDraft);
|
||||
|
||||
const workspaceEvidence = buildReimbursementApprovalEvidence(detail, {
|
||||
standardViolations: (reimbursementPrecheckQuery.data?.standardChecks ?? []).filter((item) => item.exceeded),
|
||||
budgetCheck: null,
|
||||
budgetCheckStatus: "not_applicable",
|
||||
parseStatus: "ready",
|
||||
});
|
||||
const workspaceModel = {
|
||||
header: buildReimbursementWorkspaceHeader(detail),
|
||||
evidence: workspaceEvidence,
|
||||
issueAnchors: buildReimbursementWorkspaceIssueAnchors(workspaceEvidence),
|
||||
};
|
||||
const workspaceCapabilities = {
|
||||
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
|
||||
onOpenAttachment: (attachment: { id: string }) => {
|
||||
const sourceAttachment = detail.attachments.find((item) => item.id === attachment.id);
|
||||
if (sourceAttachment) void handleOpenAttachment(sourceAttachment);
|
||||
},
|
||||
onOpenMaterialBundle: detail.invoices.length || detail.attachments.length ? () => void handleOpenAllMaterials() : undefined,
|
||||
onEditItem: canMaintainReimbursement ? (item: { id: string }) => {
|
||||
const sourceItem = detail.items.find((candidate) => candidate.id === item.id);
|
||||
if (sourceItem) startEditItem(sourceItem);
|
||||
} : undefined,
|
||||
onDeleteItem: canMaintainReimbursement ? (item: { id: string }) => setConfirmDeleteItemId(item.id) : undefined,
|
||||
onLinkInvoice: canMaintainReimbursement ? (invoice: { id: string }, linkedItemId: string) => linkInvoiceItemMutation.mutate({ invoiceId: invoice.id, linkedItemId }) : undefined,
|
||||
linkInvoicePending: linkInvoiceItemMutation.isPending,
|
||||
linkInvoiceErrors: invoiceItemLinkErrors,
|
||||
onUnlinkInvoice: canUnbindInvoices ? (invoice: { id: string }) => unbindInvoiceMutation.mutate(invoice.id) : undefined,
|
||||
onDeleteAttachment: canMaintainReimbursement ? (attachment: { id: string; originalName: string }) => setDeleteAttachmentTarget({ id: attachment.id, name: attachment.originalName }) : undefined,
|
||||
};
|
||||
const workspaceHeaderDetails = (
|
||||
<>
|
||||
{!isParticipant ? <p className="text-xs text-zinc-500">只读查看{detail.status === "draft" ? " · 等待报销人完善" : detail.status === "rejected" ? " · 等待报销人修改后重提" : ""}</p> : null}
|
||||
{canMaintainReimbursement ? <ReimbursementHeaderEditor reimbursementId={reimbursementId} reimbursementDate={detail.reimbursementDate} description={detail.description} editable mobile showDescription={!isTravelReimbursement(detail)} /> : null}
|
||||
<ReimbursementTravelSummary detail={detail} reimbursementId={reimbursementId} editable={canMaintainReimbursement} mobile embedded />
|
||||
{detail.project ? <p className="text-sm text-zinc-600">项目:{detail.project.projectNo} · {detail.project.name}</p> : null}
|
||||
</>
|
||||
);
|
||||
const workspaceBeforeEvidence = (
|
||||
<>
|
||||
{invoiceGuardIssues ? <section className="mobile-card border-red-300 bg-red-50" role="alert" aria-live="assertive"><h2 className="text-base font-semibold text-red-800">票据检查未通过</h2><p className="mt-2 text-sm leading-6 text-red-700">请按明细和票据中的提示处理后再次提交。</p></section> : null}
|
||||
{actionError ? <p className="mobile-card border-red-200 bg-red-50 text-sm text-red-700" data-testid="reimbursement-action-error" role="alert" aria-live="assertive">{actionError}</p> : null}
|
||||
{itemError ? <p className="mobile-card border-red-200 bg-red-50 text-sm text-red-700" role="alert">{itemError}</p> : null}
|
||||
{canMaintainReimbursement ? (
|
||||
<section className="mobile-card space-y-3">
|
||||
<div className="mobile-section-header"><h2 className="mobile-section-title">维护报销内容</h2></div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button className="min-h-11" onClick={startCreateItem} type="button" variant="secondary">新增明细</Button>
|
||||
<Button className="min-h-11" data-testid={TEST_IDS.reimbursement.invoiceAdd} onClick={() => closeInvoiceDialog(true)} type="button" variant="secondary">添加发票</Button>
|
||||
<Button className="min-h-11" onClick={() => attachmentInputRef.current?.click()} type="button" variant="secondary">选择附件</Button>
|
||||
<Button className="min-h-11" disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending} onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)} type="button" variant="secondary">{uploadAttachmentsMutation.isPending ? "上传中..." : selectedAttachmentFiles.length ? `上传 ${selectedAttachmentFiles.length} 个附件` : "上传附件"}</Button>
|
||||
</div>
|
||||
<input ref={attachmentInputRef} className="sr-only" type="file" accept="application/pdf,image/jpeg,image/png,image/webp" multiple onChange={(event) => { const files=Array.from(event.target.files ?? []); if(files.length>MAX_REIMBURSEMENT_ATTACHMENT_FILES){ setSelectedAttachmentFiles([]); event.target.value=""; toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`); return;} setSelectedAttachmentFiles(files); }} />
|
||||
{itemForm ? (
|
||||
<div className="space-y-3 rounded-lg border border-primary-100 bg-primary-50/40 p-3">
|
||||
<label className="block space-y-1 text-sm text-zinc-700"><span>费用类型</span><select className="select-std w-full" disabled={!expenseTypes.length || itemSaveMutation.isPending} value={itemForm.expenseTypeId} onChange={(event) => setItemForm((current) => current ? { ...current, expenseTypeId: event.target.value } : current)}>{!expenseTypes.length ? <option value="">暂无可用类型</option> : null}{expenseTypes.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</select></label>
|
||||
<label className="block space-y-1 text-sm text-zinc-700"><span>日期</span><Input disabled={itemSaveMutation.isPending} type="date" value={itemForm.expenseDate} onChange={(event) => setItemForm((current) => current ? { ...current, expenseDate: event.target.value } : current)} /></label>
|
||||
<label className="block space-y-1 text-sm text-zinc-700"><span>金额</span><Input disabled={itemSaveMutation.isPending} inputMode="decimal" value={itemForm.amount} onChange={(event) => setItemForm((current) => current ? { ...current, amount: event.target.value } : current)} /></label>
|
||||
<label className="block space-y-1 text-sm text-zinc-700"><span>说明</span><Textarea disabled={itemSaveMutation.isPending} rows={2} value={itemForm.description} onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)} /></label>
|
||||
{shouldShowOfficeElectronicsHint(expenseTypes.find((type) => type.id === itemForm.expenseTypeId), itemForm.amount) ? <p className="text-xs text-amber-700">该金额通常应选择办公电子设备。</p> : null}
|
||||
<div className="grid grid-cols-2 gap-2"><Button disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending} onClick={() => itemSaveMutation.mutate(itemForm)} type="button">保存</Button><Button disabled={itemSaveMutation.isPending} onClick={() => setItemForm(null)} type="button" variant="secondary">取消</Button></div>
|
||||
</div>
|
||||
) : <p className="text-xs text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const workspaceAfterEvidence = (
|
||||
<>
|
||||
{(detail.budgetHint?.exceedsThreshold || detail.riskFlags.length) ? <section className="mobile-card space-y-3"><h2 className="text-base font-semibold text-zinc-900">需要关注</h2>{detail.budgetHint?.exceedsThreshold ? <p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">预算 {detail.budgetHint.budgetCode}:提交后预计使用 {detail.budgetHint.nextUsagePercent.toFixed(2)}%,已达到预警阈值。</p> : null}{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}</section> : null}
|
||||
<section className="mobile-card"><h2 className="mb-3 text-base font-semibold text-zinc-900">处理进度</h2><ProcessingProgress progress={detail.processingProgress ?? { stages: [] }} ariaLabel="报销处理进度" testId="mobile-reimbursement-processing-progress" targetLinkLabel="查看所在付款批次" mobile /></section>
|
||||
{canDeleteDraft ? <section className="mobile-card"><Button className="w-full" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除草稿</Button></section> : null}
|
||||
</>
|
||||
);
|
||||
const mobileActions = showSubmitAction ? <MobileStickyActionBar testId="mobile-reimbursement-sticky-actions"><Button className="min-h-12 w-full" data-testid={TEST_IDS.reimbursement.submit} disabled={submitMutation.isPending} onClick={() => void handleSubmit()} type="button">{submitMutation.isPending ? "提交中..." : "提交审批"}</Button></MobileStickyActionBar> : null;
|
||||
|
||||
return (
|
||||
<PageScaffold
|
||||
className={cn(
|
||||
"space-y-4",
|
||||
showStickyActions ? MOBILE_STICKY_ACTION_PAGE_CLASS : "pb-6",
|
||||
)}
|
||||
data-testid="mobile-reimbursement-detail"
|
||||
>
|
||||
<>
|
||||
{downloadReasonDialog}
|
||||
<MobileDetailHeader
|
||||
title={displayCode}
|
||||
subtitle="报销详情"
|
||||
fallbackTo={reimbursementFallback(detail.status)}
|
||||
status={
|
||||
<span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>
|
||||
{STATUS_LABEL[detail.status]}
|
||||
</span>
|
||||
}
|
||||
testId="mobile-reimbursement-detail-header"
|
||||
/>
|
||||
|
||||
<section className="mobile-card mobile-focus-card mobile-page-stack" data-testid="mobile-reimbursement-detail-focus">
|
||||
<div className="mobile-section-header mobile-section-header--top">
|
||||
<div>
|
||||
<h2 className="mobile-section-title">报销概况</h2>
|
||||
{!isParticipant ? (
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
只读查看
|
||||
{detail.status === "draft"
|
||||
? " · 等待报销人完善"
|
||||
: detail.status === "rejected"
|
||||
? " · 等待报销人修改后重提"
|
||||
: ""}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div data-testid="mobile-reimbursement-summary">
|
||||
<div className="text-xs text-zinc-500">合计金额</div>
|
||||
<div className="mt-1 text-3xl font-semibold text-zinc-900">{formatMoney(detail.totalAmount)}</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">报销日期</div>
|
||||
<div className="mt-1 truncate font-semibold text-zinc-900">{formatDate(detail.reimbursementDate)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">报销人</div>
|
||||
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.owner?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">部门</div>
|
||||
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.department?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">申请人</div>
|
||||
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.submitter?.name ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ReimbursementHeaderEditor
|
||||
reimbursementId={reimbursementId}
|
||||
reimbursementDate={detail.reimbursementDate}
|
||||
description={detail.description}
|
||||
editable={Boolean(canEdit && isParticipant)}
|
||||
mobile
|
||||
showDescription={!isTravelReimbursement(detail)}
|
||||
/>
|
||||
<ReimbursementTravelSummary
|
||||
detail={detail}
|
||||
reimbursementId={reimbursementId}
|
||||
editable={Boolean(canEdit && isParticipant)}
|
||||
mobile
|
||||
embedded
|
||||
/>
|
||||
{detail.project || detail.linkedTravelRequest ? (
|
||||
<div className="mt-4 border-t border-zinc-200 pt-4 text-sm text-zinc-600">
|
||||
{detail.project ? <p>项目:{detail.project.projectNo} · {detail.project.name}</p> : null}
|
||||
{detail.linkedTravelRequest ? (
|
||||
<Link className="mt-2 inline-flex min-h-11 items-center text-primary hover:underline" to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`}>
|
||||
差旅申请:{detail.linkedTravelRequest.requestNo ?? "未编号"}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{detail.riskFlags.length ? (
|
||||
<div className="mt-4 border-t border-zinc-200 pt-4">
|
||||
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-reimbursement-processing-section">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-900">处理进度</h2>
|
||||
</div>
|
||||
{canRemindPendingWorkflow && pendingTaskId ? (
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={remindMutation.isPending}
|
||||
onClick={() => remindMutation.mutate(pendingTaskId)}
|
||||
>
|
||||
{remindMutation.isPending ? "催办中..." : "催办"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<ProcessingProgress
|
||||
progress={detail.processingProgress ?? { stages: [] }}
|
||||
ariaLabel="报销处理进度"
|
||||
testId="mobile-reimbursement-processing-progress"
|
||||
targetLinkLabel="查看所在付款批次"
|
||||
mobile
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{invoiceGuardIssues ? (
|
||||
<section
|
||||
className="mobile-card border-red-300 bg-red-50"
|
||||
data-testid="mobile-reimbursement-invoice-guard-summary"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<h2 className="text-base font-semibold text-red-800">票据检查未通过</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-red-700">
|
||||
共 {invoiceGuardIssueCount(invoiceGuardIssues)} 项需要处理,请按下方明细和发票提示修改。
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-reimbursement-items">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
|
||||
{canMaintainReimbursement ? (
|
||||
<Button size="sm" variant="secondary" type="button" onClick={startCreateItem}>
|
||||
新增
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{itemError ? (
|
||||
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{itemError}</div>
|
||||
) : null}
|
||||
|
||||
{itemForm ? (
|
||||
<div className="mt-3 space-y-3 rounded-lg border border-primary-100 bg-primary-50/40 p-3">
|
||||
<div className="mobile-info-grid">
|
||||
<label className="col-span-2 block space-y-1 text-sm text-zinc-700">
|
||||
<span>费用类型</span>
|
||||
<select
|
||||
className="select-std"
|
||||
disabled={!expenseTypes.length || itemSaveMutation.isPending}
|
||||
value={itemForm.expenseTypeId}
|
||||
onChange={(event) => setItemForm((current) => current ? { ...current, expenseTypeId: event.target.value } : current)}
|
||||
>
|
||||
{!expenseTypes.length ? <option value="">暂无可用类型</option> : null}
|
||||
{expenseTypes.map((type) => (
|
||||
<option key={type.id} value={type.id}>
|
||||
{type.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.description ? (
|
||||
<span className="text-xs text-zinc-500">
|
||||
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.description}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<label className="block space-y-1 text-sm text-zinc-700">
|
||||
<span>日期</span>
|
||||
<Input
|
||||
disabled={itemSaveMutation.isPending}
|
||||
type="date"
|
||||
value={itemForm.expenseDate}
|
||||
onChange={(event) => setItemForm((current) => current ? { ...current, expenseDate: event.target.value } : current)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1 text-sm text-zinc-700">
|
||||
<span>金额</span>
|
||||
<Input
|
||||
disabled={itemSaveMutation.isPending}
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
value={itemForm.amount}
|
||||
onChange={(event) => setItemForm((current) => current ? { ...current, amount: event.target.value } : current)}
|
||||
/>
|
||||
</label>
|
||||
<label className="col-span-2 block space-y-1 text-sm text-zinc-700">
|
||||
<span>说明</span>
|
||||
<Textarea
|
||||
disabled={itemSaveMutation.isPending}
|
||||
rows={2}
|
||||
value={itemForm.description}
|
||||
onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)}
|
||||
/>
|
||||
</label>
|
||||
{shouldShowOfficeElectronicsHint(
|
||||
expenseTypes.find((type) => type.id === itemForm.expenseTypeId),
|
||||
itemForm.amount,
|
||||
) ? (
|
||||
<p className="col-span-2 text-xs text-amber-600">该金额通常应选择办公电子设备。</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mobile-two-action-grid">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending}
|
||||
onClick={() => itemSaveMutation.mutate(itemForm)}
|
||||
>
|
||||
{itemSaveMutation.isPending ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" disabled={itemSaveMutation.isPending} onClick={() => setItemForm(null)}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{detail.items.length ? (
|
||||
detail.items.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"mobile-list-card",
|
||||
invoiceGuardItemMessages(invoiceGuardIssues, item.id).length && "border-red-300 bg-red-50/60",
|
||||
)}
|
||||
data-reimbursement-guard-issue={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? "true" : undefined}
|
||||
tabIndex={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? -1 : undefined}
|
||||
>
|
||||
<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.expenseType?.name ?? "费用"}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{formatDate(item.expenseDate)}</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-base font-semibold text-zinc-900">{formatMoney(item.amount)}</div>
|
||||
</div>
|
||||
{item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null}
|
||||
{shouldShowOfficeElectronicsHint(item.expenseType, item.amount) ? (
|
||||
<p className="mt-2 text-xs text-amber-600">该金额通常应选择办公电子设备。</p>
|
||||
) : null}
|
||||
{invoiceGuardItemMessages(invoiceGuardIssues, item.id).map((message) => (
|
||||
<p
|
||||
key={message}
|
||||
className="mt-2 rounded-md border border-red-200 bg-white px-3 py-2 text-sm leading-5 text-red-700"
|
||||
role="alert"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
{canMaintainReimbursement ? (
|
||||
<div className="mobile-two-action-grid mt-3">
|
||||
<Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={itemDeleteMutation.isPending}
|
||||
onClick={() => setConfirmDeleteItemId(item.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
|
||||
暂无明细
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between rounded-lg bg-zinc-50 px-3 py-3 text-sm font-medium text-zinc-900">
|
||||
<span>共 {detail.items.length} 条明细</span>
|
||||
<span>报销合计 {formatMoney(detail.totalAmount)}</span>
|
||||
</div>
|
||||
<ReimbursementOperationalHints
|
||||
precheck={reimbursementPrecheckQuery.data}
|
||||
items={detail.items}
|
||||
canAccessAssets={canAccessAssets}
|
||||
mobile
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-reimbursement-materials">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-900">报销材料</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detail.invoiceAmountManualReviewRequired ? (
|
||||
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-800">
|
||||
本单包含票面金额未自动读取的发票,系统未自动比较相关明细金额;财务审批时需查看发票原件核对。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{detail.invoices.length || (detail.attachments ?? []).length ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-xs leading-5 text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</p>
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
data-testid="mobile-reimbursement-materials-open-all"
|
||||
type="button"
|
||||
disabled={materialPreviewMutation.isPending}
|
||||
onClick={() => void handleOpenAllMaterials()}
|
||||
>
|
||||
{materialPreviewMutation.isPending ? "正在准备材料预览…" : "查看材料合并预览"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{materialPreviewError ? (
|
||||
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
{materialPreviewError},可在下方逐份打开原件。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{canOpenInvoiceDialog || canManageAttachments ? (
|
||||
<div className="mobile-two-action-grid mt-3">
|
||||
{canOpenInvoiceDialog ? (
|
||||
<Button
|
||||
data-testid={TEST_IDS.reimbursement.invoiceAdd}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => closeInvoiceDialog(true)}
|
||||
>
|
||||
添加发票
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageAttachments ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => attachmentInputRef.current?.click()}
|
||||
>
|
||||
添加附件
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{invoiceActionError ? (
|
||||
<p className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{invoiceActionError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{canManageAttachments ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
className="sr-only"
|
||||
type="file"
|
||||
accept="application/pdf,image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
if (files.length > MAX_REIMBURSEMENT_ATTACHMENT_FILES) {
|
||||
setSelectedAttachmentFiles([]);
|
||||
event.target.value = "";
|
||||
toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`);
|
||||
return;
|
||||
}
|
||||
setSelectedAttachmentFiles(files);
|
||||
}}
|
||||
/>
|
||||
{selectedAttachmentFiles.length ? (
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
{selectedAttachmentFiles.slice(0, 4).map((file) => (
|
||||
<div key={`${file.name}-${file.size}`} className="truncate">
|
||||
{file.name} · {Math.ceil(file.size / 1024)} KB
|
||||
</div>
|
||||
))}
|
||||
{selectedAttachmentFiles.length > 4 ? <div>另有 {selectedAttachmentFiles.length - 4} 个文件</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending}
|
||||
onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)}
|
||||
>
|
||||
{uploadAttachmentsMutation.isPending ? "上传中..." : "上传附件"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detail.invoices.length || (detail.attachments ?? []).length ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div data-testid={TEST_IDS.reimbursement.invoices} className="space-y-2">
|
||||
{detail.invoices.map((invoice) => {
|
||||
const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
|
||||
? "已关联"
|
||||
: resolveInvoiceNextStepText(invoice);
|
||||
const guardMessages = invoiceGuardInvoiceMessages(
|
||||
invoiceGuardIssues,
|
||||
invoice.id,
|
||||
invoice.linkedItemId,
|
||||
);
|
||||
return (
|
||||
<article
|
||||
key={invoice.id}
|
||||
className={cn(
|
||||
"mobile-list-card",
|
||||
guardMessages.length && "border-red-300 bg-red-50/60",
|
||||
)}
|
||||
data-reimbursement-guard-issue={guardMessages.length ? "true" : undefined}
|
||||
tabIndex={guardMessages.length ? -1 : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-11 w-full items-start gap-3 rounded-lg text-left transition-colors hover:bg-zinc-50 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="inline-flex rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">发票</span>
|
||||
<span className="mt-2 block truncate text-sm font-semibold text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</span>
|
||||
<span className="mt-1 block text-xs text-zinc-500">
|
||||
{[resolveInvoicePartyText(invoice), invoice.invoiceDate ? formatDate(invoice.invoiceDate) : "", formatMoney(invoice.totalAmount)].filter(Boolean).join(" · ")}
|
||||
</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="shrink-0 text-right">
|
||||
<span className="inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
|
||||
{resolveInvoiceStatusLabel(invoice.status)}
|
||||
</span>
|
||||
<ChevronRight aria-hidden="true" className="ml-auto mt-2 size-4 text-zinc-400" />
|
||||
</span>
|
||||
</button>
|
||||
{guardMessages.map((message) => (
|
||||
<p
|
||||
key={message}
|
||||
className="mt-3 rounded-md border border-red-200 bg-white px-3 py-2 text-sm leading-5 text-red-700"
|
||||
role="alert"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
{invoice.amountEvidenceStatus === "manual_review" ? (
|
||||
<p className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-5 text-amber-800">
|
||||
票面金额未自动识别,需由财务查看原件核对。
|
||||
</p>
|
||||
) : null}
|
||||
{invoice.amountEvidenceStatus === "ocr_failed" ? (
|
||||
<p className="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm leading-5 text-red-700">
|
||||
该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。
|
||||
</p>
|
||||
) : null}
|
||||
{canMaintainReimbursement && !invoice.linkedItemId ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<label className="block text-sm font-medium text-zinc-700" htmlFor={`mobile-invoice-item-link-${invoice.id}`}>
|
||||
选择报销明细
|
||||
</label>
|
||||
<select
|
||||
id={`mobile-invoice-item-link-${invoice.id}`}
|
||||
className="select-std min-h-11 w-full"
|
||||
value={invoiceItemSelections[invoice.id] ?? ""}
|
||||
onChange={(event) => setInvoiceItemSelections((previous) => ({
|
||||
...previous,
|
||||
[invoice.id]: event.target.value,
|
||||
}))}
|
||||
>
|
||||
<option value="">请选择明细</option>
|
||||
{detail.items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.expenseType?.name ?? "费用"} · {item.description ?? formatMoney(item.amount)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
className="min-h-11 w-full"
|
||||
type="button"
|
||||
disabled={!invoiceItemSelections[invoice.id] || linkInvoiceItemMutation.isPending}
|
||||
onClick={() => linkInvoiceItemMutation.mutate({
|
||||
invoiceId: invoice.id,
|
||||
linkedItemId: invoiceItemSelections[invoice.id],
|
||||
})}
|
||||
>
|
||||
{linkInvoiceItemMutation.isPending ? "关联中..." : "确认关联"}
|
||||
</Button>
|
||||
{invoiceItemLinkErrors[invoice.id] ? (
|
||||
<p className="text-sm leading-5 text-red-700" role="alert">
|
||||
{invoiceItemLinkErrors[invoice.id]}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{canUnbindInvoices ? (
|
||||
<Button
|
||||
className="mt-3 w-full"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={unbindInvoiceMutation.isPending}
|
||||
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div data-testid="mobile-reimbursement-attachments" className="space-y-2">
|
||||
{(detail.attachments ?? []).map((attachment) => (
|
||||
<article key={attachment.id} className="mobile-list-card">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-11 w-full items-center gap-3 rounded-lg text-left transition-colors hover:bg-zinc-50 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="min-w-0 flex-1">
|
||||
<span className="inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-600">补充附件</span>
|
||||
<span className="mt-2 block truncate text-sm font-semibold 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>
|
||||
{canManageAttachments ? (
|
||||
<Button
|
||||
className="mt-3 w-full"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={deleteAttachmentMutation.isPending}
|
||||
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, name: attachment.originalName })}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
|
||||
暂无报销材料
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{showSecondaryActions ? (
|
||||
<section className="mobile-card" data-testid="mobile-reimbursement-actions">
|
||||
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
|
||||
{actionError ? (
|
||||
<div
|
||||
className="mt-3 whitespace-pre-wrap rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 grid gap-2">
|
||||
{canDeleteDraft ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={deleteReimbursementMutation.isPending}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setConfirmDeleteReimbursement(true)}
|
||||
>
|
||||
删除草稿
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{showStickyActions ? (
|
||||
<MobileStickyActionBar testId="mobile-reimbursement-sticky-actions">
|
||||
{showSubmitAction ? (
|
||||
<Button
|
||||
className="min-h-11 min-w-0 px-2 text-sm whitespace-nowrap"
|
||||
data-testid={TEST_IDS.reimbursement.submit}
|
||||
disabled={submitMutation.isPending}
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{submitMutation.isPending ? "提交中..." : "提交审批"}
|
||||
</Button>
|
||||
) : null}
|
||||
</MobileStickyActionBar>
|
||||
) : null}
|
||||
|
||||
<ReimbursementMobileWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} fallbackTo={reimbursementFallback(detail.status)} slots={{ status: <span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>{STATUS_LABEL[detail.status]}</span>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, mobileActions }} />
|
||||
<Dialog open={isInvoiceDialogOpen} onOpenChange={closeInvoiceDialog}>
|
||||
<DialogContent
|
||||
className="overflow-y-auto p-4"
|
||||
@@ -1898,7 +1314,7 @@ export function MobileReimbursementDetailPage() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PageScaffold>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户