feat(reimbursement-small-team-form-validation): 完善小团队报销填报校验

这个提交包含在:
2026-08-04 23:28:56 +08:00
父节点 f33c67eb20
当前提交 50c14c1b85
共修改 28 个文件,包含 1095 行新增和 30 行删除
@@ -35,7 +35,7 @@ import {
import { remindWorkflowTask } from "../api/workflow";
import type { PrecheckResponse } from "../api/feeControl";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
@@ -44,6 +44,8 @@ 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 { ReimbursementOperationalHints } from "../components/reimbursements/ReimbursementOperationalHints";
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
@@ -197,6 +199,7 @@ export function MobileReimbursementDetailPage() {
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
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 pendingTaskId = detail?.pendingWorkflow?.task?.id ?? null;
@@ -212,6 +215,12 @@ export function MobileReimbursementDetailPage() {
const canManageAttachments = Boolean(canEdit && (isParticipant || canViewAllExpense));
const selectedInvoiceEntries = Object.entries(selectedInvoices);
const reimbursementPrecheckQuery = useQuery({
enabled: Boolean(reimbursementId && canSubmitExpense && detail),
queryKey: ["reimbursements", reimbursementId, "precheck"],
queryFn: () => precheckReimbursement(reimbursementId),
});
function handleInvoicePdfSelection(files: FileList | null) {
const { accepted, rejected } = splitPdfFiles(files);
setSelectedInvoiceFiles(accepted);
@@ -919,7 +928,13 @@ export function MobileReimbursementDetailPage() {
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.submitter?.name ?? "-"}</div>
</div>
</div>
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
<ReimbursementHeaderEditor
reimbursementId={reimbursementId}
reimbursementDate={detail.reimbursementDate}
description={detail.description}
editable={Boolean(canEdit && isParticipant)}
mobile
/>
<ReimbursementTravelSummary
detail={detail}
reimbursementId={reimbursementId}
@@ -1037,7 +1052,7 @@ export function MobileReimbursementDetailPage() {
<div className="mobile-two-action-grid">
<Button
type="button"
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !itemForm.amount.trim() || itemSaveMutation.isPending}
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending}
onClick={() => itemSaveMutation.mutate(itemForm)}
>
{itemSaveMutation.isPending ? "保存中..." : "保存"}
@@ -1085,6 +1100,16 @@ export function MobileReimbursementDetailPage() {
</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">
+24 -8
查看文件
@@ -38,7 +38,7 @@ import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard";
@@ -47,6 +47,8 @@ import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDe
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor";
import { ReimbursementOperationalHints } from "../components/reimbursements/ReimbursementOperationalHints";
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
@@ -174,9 +176,10 @@ export function ReimbursementDetailPage() {
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
const reimbursementPrecheckQuery = useQuery({
enabled: Boolean(reimbursementId) && canSubmitExpense && ["draft", "rejected"].includes(detailQuery.data?.status ?? ""),
enabled: Boolean(reimbursementId) && canSubmitExpense && Boolean(detailQuery.data),
queryKey: ["reimbursements", reimbursementId, "precheck"],
queryFn: () => precheckReimbursement(reimbursementId),
});
@@ -1101,7 +1104,7 @@ export function ReimbursementDetailPage() {
<div data-testid="reimbursement-business-summary">
<div className="detail-fact-grid xl:grid-cols-3" data-testid="reimbursement-summary">
<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">{formatMoney(detail.totalAmount)}</div>
</div>
<div className="detail-fact-card">
@@ -1129,11 +1132,12 @@ export function ReimbursementDetailPage() {
</div>
</div>
</div>
{detail.description ? (
<div className="mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
{detail.description}
</div>
) : null}
<ReimbursementHeaderEditor
reimbursementId={reimbursementId}
reimbursementDate={detail.reimbursementDate}
description={detail.description}
editable={Boolean(canEditItems && isReimbursementParticipant)}
/>
<ReimbursementTravelSummary
detail={detail}
reimbursementId={reimbursementId}
@@ -1339,8 +1343,20 @@ export function ReimbursementDetailPage() {
</TableRow>
)}
</TableBody>
<tfoot className="border-t border-zinc-200 bg-zinc-50 font-medium text-zinc-900">
<tr>
<TableCell colSpan={2}>共 {detail.items.length} 条明细</TableCell>
<TableCell>{formatMoney(detail.totalAmount)}</TableCell>
<TableCell colSpan={2}>报销合计</TableCell>
</tr>
</tfoot>
</Table>
</div>
<ReimbursementOperationalHints
precheck={reimbursementPrecheckQuery.data}
items={detail.items}
canAccessAssets={canAccessAssets}
/>
{itemEditor ? (
<ReimbursementItemEditor
@@ -81,6 +81,7 @@ export function ReimbursementItemEditor({
const numericMaxAmount = Number(activeStandard?.maxAmount ?? 0);
const isExceeded = Boolean(activeStandard) && Number.isFinite(numericAmount) && numericAmount > numericMaxAmount;
const exceededAmount = isExceeded ? (numericAmount - numericMaxAmount).toFixed(2) : "0.00";
const hasPositiveAmount = Number.isFinite(numericAmount) && numericAmount > 0;
const handleSave = () => {
onSave({ expenseTypeId, expenseDate, amount, description });
@@ -185,7 +186,8 @@ export function ReimbursementItemEditor({
isPending ||
!expenseTypeId ||
!expenseDate ||
!amount.trim()
!amount.trim() ||
!hasPositiveAmount
}
onClick={handleSave}
type="button"