LP6 完善请假采购体验与审批说明

这个提交包含在:
2026-05-12 13:02:00 +08:00
父节点 21887ce28a
当前提交 3d1e4c43f6
共修改 14 个文件,包含 502 行新增和 39 行删除
+46 -3
查看文件
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
@@ -33,6 +33,11 @@ import { Label } from "../components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Textarea } from "../components/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import {
formatApprovalHistoryTime,
resolveLeaveStatusHint,
workflowActionLabel,
} from "../lib/approvalDisplay";
const PAGE_SIZE = 10;
const CALENDAR_PAGE_SIZE = 100;
@@ -218,6 +223,7 @@ export function LeaveRequestPage() {
const [reason, setReason] = useState("");
const [attachmentFile, setAttachmentFile] = useState<File | null>(null);
const [attachmentInputKey, setAttachmentInputKey] = useState(0);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const [editingLeaveRequest, setEditingLeaveRequest] = useState<LeaveRequestItem | null>(null);
const [formError, setFormError] = useState("");
const [withdrawTarget, setWithdrawTarget] = useState<{ id: string; label: string } | null>(null);
@@ -340,6 +346,9 @@ export function LeaveRequestPage() {
setReason("");
setAttachmentFile(null);
setAttachmentInputKey((current) => current + 1);
if (attachmentInputRef.current) {
attachmentInputRef.current.value = "";
}
setFormError("");
}
@@ -353,6 +362,9 @@ export function LeaveRequestPage() {
setReason(item.reason ?? "");
setAttachmentFile(null);
setAttachmentInputKey((current) => current + 1);
if (attachmentInputRef.current) {
attachmentInputRef.current.value = "";
}
setFormError("");
}
@@ -564,7 +576,22 @@ export function LeaveRequestPage() {
</TableCell>
<TableCell>{formatRangeLabel(item)}</TableCell>
<TableCell>{item.requestedDays}</TableCell>
<TableCell><StatusBadge status={item.status} /></TableCell>
<TableCell>
<div className="space-y-1">
<StatusBadge status={item.status} />
<div className="max-w-xs text-xs text-zinc-500" data-testid="leave-status-hint">
{resolveLeaveStatusHint(item)}
</div>
{item.approvalHistory.length ? (
<div className="max-w-xs text-xs text-zinc-400">
最近审批:{workflowActionLabel(item.approvalHistory[0].action)}
{item.approvalHistory[0].comment ? ` · ${item.approvalHistory[0].comment}` : ""}
{" · "}
{formatApprovalHistoryTime(item.approvalHistory[0].createdAt)}
</div>
) : null}
</div>
</TableCell>
<TableCell className="text-xs text-zinc-500">
{item.attachment?.originalName ?? "-"}
</TableCell>
@@ -856,9 +883,11 @@ export function LeaveRequestPage() {
<div className="space-y-2">
<Label htmlFor="leave-attachment">附件</Label>
<Input
<input
key={attachmentInputKey}
ref={attachmentInputRef}
id="leave-attachment"
className="hidden"
type="file"
accept={selectedLeaveType?.attachmentMimeTypes?.join(",") || undefined}
onChange={(event) => {
@@ -866,6 +895,20 @@ export function LeaveRequestPage() {
setAttachmentFile(nextFile);
}}
/>
<div className="flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={!selectedLeaveType}
onClick={() => attachmentInputRef.current?.click()}
>
选择附件
</Button>
<span className="min-w-0 flex-1 truncate text-sm text-zinc-500" data-testid="leave-attachment-name">
{currentAttachmentLabel ?? "未选择文件"}
</span>
</div>
<p className="text-xs text-zinc-500">
{selectedLeaveType?.requiresAttachment
? `当前假种要求上传附件(允许:${selectedLeaveType.attachmentMimeTypes.join(", ") || "默认文档/图片"})`
+18 -1
查看文件
@@ -19,6 +19,11 @@ import { Button } from "../components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import {
formatApprovalHistoryTime,
resolveLeaveStatusHint,
workflowActionLabel,
} from "../lib/approvalDisplay";
type LeaveStatusFilter = "all" | LeaveRequestStatus;
@@ -27,6 +32,7 @@ const STATUS_TABS: Array<{ value: LeaveStatusFilter; label: string }> = [
{ value: "draft", label: "草稿" },
{ value: "reviewing", label: "审批中" },
{ value: "approved", label: "已通过" },
{ value: "rejected", label: "已驳回" },
];
const STATUS_LABEL: Record<LeaveRequestStatus, string> = {
@@ -85,6 +91,17 @@ function LeaveRecordCard({
</div>
{item.reason ? <p className="mt-3 line-clamp-2 text-sm text-zinc-500">{item.reason}</p> : null}
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600" data-testid="mobile-leave-status-hint">
{resolveLeaveStatusHint(item)}
</div>
{item.approvalHistory.length ? (
<div className="mt-2 text-xs text-zinc-500">
最近审批:{workflowActionLabel(item.approvalHistory[0].action)}
{item.approvalHistory[0].comment ? ` · ${item.approvalHistory[0].comment}` : ""}
{" · "}
{formatApprovalHistoryTime(item.approvalHistory[0].createdAt)}
</div>
) : null}
{canEdit || canSubmit ? (
<div className="mobile-two-action-grid mt-4">
@@ -403,7 +420,7 @@ export function MobileLeavePage() {
<section className="mobile-card" data-testid="mobile-leave-records">
<h3 className="text-base font-semibold text-zinc-900">请假记录</h3>
<Tabs value={statusFilter} onValueChange={(value) => setStatusFilter(value as LeaveStatusFilter)}>
<TabsList className="mt-3 grid h-auto w-full grid-cols-4">
<TabsList className="mt-3 grid h-auto w-full grid-cols-5">
{STATUS_TABS.map((item) => (
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-leave-tab-${item.value}`}>
{item.label}
+50 -13
查看文件
@@ -47,6 +47,14 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
import { Textarea } from "../components/ui/textarea";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import {
formatApprovalHistoryTime,
resolvePurchaseBudgetHint,
resolvePurchaseDisplayCode,
resolvePurchaseStatusHint,
resolvePurchaseSubmitWarning,
workflowActionLabel,
} from "../lib/approvalDisplay";
const PAGE_SIZE = 20;
@@ -496,9 +504,7 @@ export function PurchaseRequestsPage() {
toast.success(
data.precheckDecision === "manual_review"
? `采购申请已提交,已生成 ${data.openedRiskFlagCount} 条待复核风险`
: data.warnings.includes("missing_budget_pool")
? "采购申请已提交(未匹配预算池)"
: "采购申请已提交",
: resolvePurchaseSubmitWarning(data.warnings),
);
},
onError: (error) => {
@@ -884,7 +890,7 @@ export function PurchaseRequestsPage() {
<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.requestNo || item.title || item.id}
{resolvePurchaseDisplayCode(item)}
</div>
<div className="mt-1 truncate text-xs text-zinc-500">{item.title || "未命名采购申请"}</div>
</div>
@@ -928,7 +934,7 @@ export function PurchaseRequestsPage() {
}}
>
<TableCell className="cursor-pointer">
<div className="font-medium text-zinc-900">{item.requestNo || item.title || item.id}</div>
<div className="font-medium text-zinc-900">{resolvePurchaseDisplayCode(item)}</div>
<div className="text-xs text-zinc-500">{item.title || "未命名采购申请"}</div>
</TableCell>
<TableCell>{item.applicant?.name ?? "-"}</TableCell>
@@ -976,8 +982,11 @@ export function PurchaseRequestsPage() {
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-zinc-900">{detail.requestNo ?? "采购草稿"}</h3>
<h3 className="text-lg font-semibold text-zinc-900">{resolvePurchaseDisplayCode(detail)}</h3>
<p className="text-sm text-zinc-500">申请人:{detail.applicant?.name ?? "-"}</p>
<p className="mt-1 text-sm text-zinc-500" data-testid="purchase-status-hint">
{resolvePurchaseStatusHint(detail)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge data-testid="purchase-detail-status" status={detail.status} />
@@ -1042,6 +1051,26 @@ export function PurchaseRequestsPage() {
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<SectionPanel title="审批记录" description="展示最近审批动作、退回或驳回说明,便于申请人按意见修改。">
{detail.approvalHistory.length ? (
<div className="space-y-2" data-testid="purchase-approval-history">
{detail.approvalHistory.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium text-zinc-900">
{workflowActionLabel(item.action)} · {item.actorName ?? item.actorId}
</span>
<span className="text-xs text-zinc-500">{formatApprovalHistoryTime(item.createdAt)}</span>
</div>
<div className="mt-1 text-zinc-600">{item.comment || "无补充说明"}</div>
</div>
))}
</div>
) : (
<div className="text-sm text-zinc-500">暂无审批记录。</div>
)}
</SectionPanel>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">标题</label>
@@ -1119,14 +1148,19 @@ export function PurchaseRequestsPage() {
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-600">
<div>预算池:{detail.budgetPool?.name ?? "待提交时自动解析"}</div>
<div>预算池:{detail.budgetPool?.name ?? "未匹配"}</div>
<div>待办节点:{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
<div>总金额:<span className="font-semibold text-zinc-900">{totalAmountPreview}</span></div>
{!detail.budgetPool ? (
<div className="mt-2 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700">
未匹配预算池时仍会按当前规则提交审批;如需强预算校验,请由管理员补充预算配置。
</div>
) : null}
<div
className={`mt-2 rounded-md border px-2 py-1 ${
detail.budgetPool
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-amber-200 bg-amber-50 text-amber-700"
}`}
data-testid="purchase-budget-hint"
>
{resolvePurchaseBudgetHint(detail)}
</div>
</div>
<div className="space-y-3">
@@ -1355,7 +1389,10 @@ export function PurchaseRequestsPage() {
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-base font-semibold text-zinc-900">附件</h4>
<div>
<h4 className="text-base font-semibold text-zinc-900">附件</h4>
<p className="text-sm text-zinc-500">用于订单、报价、验收或情况说明;草稿状态可增删,下载会留痕。</p>
</div>
<div className="flex min-w-0 items-center gap-3">
<input
data-testid="purchase-attachment-file-input"
+2 -1
查看文件
@@ -23,6 +23,7 @@ import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Textarea } from "../components/ui/textarea";
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
function entityTitle(entityType: string | undefined) {
if (entityType === "leave") {
@@ -302,7 +303,7 @@ export function WorkflowTaskDetailPage() {
) : null}
{detail.purchaseRequest ? (
<>
<div>采购单号:{detail.purchaseRequest.requestNo ?? detail.purchaseRequest.id}</div>
<div>采购单号:{resolvePurchaseDisplayCode(detail.purchaseRequest)}</div>
<div>标题:{detail.purchaseRequest.title || "-"}</div>
<div>申请人:{detail.purchaseRequest.applicant?.name ?? "-"}</div>
<div>供应商:{detail.purchaseRequest.supplier?.name ?? "-"}</div>