LP6 完善请假采购体验与审批说明
这个提交包含在:
@@ -299,6 +299,10 @@ export function mapApiErrorMessage(
|
||||
return "当前报销金额超出预算,系统已阻断提交。";
|
||||
}
|
||||
|
||||
if (details?.budget === "missing_budget_pool") {
|
||||
return "未匹配到预算池;当前强预算校验已阻断提交,请联系管理员补充预算配置。";
|
||||
}
|
||||
|
||||
if (details?.leave === "conflict_range") {
|
||||
return "当前时间段与已有请假记录冲突,请调整后再提交。";
|
||||
}
|
||||
|
||||
@@ -66,6 +66,28 @@ export type LeaveRequestItem = {
|
||||
employeeNo: string;
|
||||
name: string;
|
||||
} | null;
|
||||
pendingWorkflow: {
|
||||
instanceId: string;
|
||||
currentStatus: string;
|
||||
currentStepId: string;
|
||||
task: {
|
||||
id: string;
|
||||
stepId: string;
|
||||
stepName: string;
|
||||
assigneeId: string | null;
|
||||
assigneeRole: string | null;
|
||||
} | null;
|
||||
} | null;
|
||||
approvalHistory: Array<{
|
||||
id: string;
|
||||
taskId: string | null;
|
||||
actorId: string;
|
||||
actorName: string | null;
|
||||
actorEmployeeNo: string | null;
|
||||
action: string;
|
||||
comment: string | null;
|
||||
createdAt: string | null;
|
||||
}>;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
@@ -199,6 +199,16 @@ export type PurchaseRequestDetail = PurchaseRequestSummary & {
|
||||
assigneeRole: string | null;
|
||||
} | null;
|
||||
} | null;
|
||||
approvalHistory: Array<{
|
||||
id: string;
|
||||
taskId: string | null;
|
||||
actorId: string;
|
||||
actorName: string | null;
|
||||
actorEmployeeNo: string | null;
|
||||
action: string;
|
||||
comment: string | null;
|
||||
createdAt: string | null;
|
||||
}>;
|
||||
archiveOrder: PurchaseOrderArchive | null;
|
||||
permissions: {
|
||||
canEditDraft: boolean;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { isUuidLike } from "./reimbursementDisplay";
|
||||
|
||||
export type ApprovalHistoryDisplayItem = {
|
||||
id: string;
|
||||
action: string;
|
||||
comment: string | null;
|
||||
actorName: string | null;
|
||||
actorEmployeeNo?: string | null;
|
||||
createdAt: string | null;
|
||||
};
|
||||
|
||||
type PendingWorkflowLike = {
|
||||
currentStatus?: string | null;
|
||||
task?: {
|
||||
stepName?: string | null;
|
||||
assigneeRole?: string | null;
|
||||
} | null;
|
||||
} | null;
|
||||
|
||||
type LeaveStatusLike = {
|
||||
status: string;
|
||||
pendingWorkflow?: PendingWorkflowLike;
|
||||
approvalHistory?: ApprovalHistoryDisplayItem[];
|
||||
};
|
||||
|
||||
type PurchaseStatusLike = {
|
||||
status: string;
|
||||
requestNo?: string | null;
|
||||
title?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
budgetPool?: { name?: string | null } | null;
|
||||
pendingWorkflow?: PendingWorkflowLike;
|
||||
approvalHistory?: ApprovalHistoryDisplayItem[];
|
||||
};
|
||||
|
||||
export function workflowActionLabel(action: string | null | undefined) {
|
||||
const labels: Record<string, string> = {
|
||||
start: "提交",
|
||||
submit: "提交",
|
||||
approve: "通过",
|
||||
reject: "驳回",
|
||||
return: "退回",
|
||||
transfer: "转审",
|
||||
withdraw: "撤回",
|
||||
remind: "催办",
|
||||
overdue_remind: "超时提醒",
|
||||
skip: "跳过",
|
||||
cancel: "取消",
|
||||
};
|
||||
|
||||
return action ? labels[action] ?? action : "-";
|
||||
}
|
||||
|
||||
export function formatApprovalHistoryTime(value: string | null | undefined) {
|
||||
return value ? value.slice(0, 19).replace("T", " ") : "-";
|
||||
}
|
||||
|
||||
export function latestDecisionHistory(items: ApprovalHistoryDisplayItem[] | null | undefined) {
|
||||
return (items ?? []).find((item) => ["return", "reject", "withdraw", "approve"].includes(item.action)) ?? null;
|
||||
}
|
||||
|
||||
function commentSuffix(item: ApprovalHistoryDisplayItem | null) {
|
||||
if (!item?.comment?.trim()) {
|
||||
return "";
|
||||
}
|
||||
return `:${item.comment.trim()}`;
|
||||
}
|
||||
|
||||
export function resolveLeaveStatusHint(item: LeaveStatusLike) {
|
||||
const latest = latestDecisionHistory(item.approvalHistory);
|
||||
|
||||
if (item.status === "draft") {
|
||||
if (latest?.action === "return") {
|
||||
return `审批已退回${commentSuffix(latest)}。修改后可重新提交。`;
|
||||
}
|
||||
if (latest?.action === "withdraw") {
|
||||
return "已撤回为草稿,可修改后重新提交。";
|
||||
}
|
||||
return "草稿可继续编辑,确认后提交审批。";
|
||||
}
|
||||
|
||||
if (item.status === "reviewing") {
|
||||
const stepName = item.pendingWorkflow?.task?.stepName;
|
||||
return stepName ? `当前节点:${stepName},等待审批人处理。` : "已提交,等待审批人处理。";
|
||||
}
|
||||
|
||||
if (item.status === "approved") {
|
||||
return "已通过,系统会按请假结果更新额度和团队状态。";
|
||||
}
|
||||
|
||||
if (item.status === "rejected") {
|
||||
return latest?.action === "reject"
|
||||
? `审批已驳回${commentSuffix(latest)}。可修改后重新提交。`
|
||||
: "审批已驳回,可修改后重新提交。";
|
||||
}
|
||||
|
||||
if (item.status === "cancelled") {
|
||||
return "已取消,不再进入审批。";
|
||||
}
|
||||
|
||||
return "请查看审批记录确认下一步。";
|
||||
}
|
||||
|
||||
export function resolvePurchaseDisplayCode(item: PurchaseStatusLike) {
|
||||
const requestNo = item.requestNo?.trim();
|
||||
if (requestNo && !isUuidLike(requestNo)) {
|
||||
return requestNo;
|
||||
}
|
||||
|
||||
const title = item.title?.trim();
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
|
||||
const date = item.updatedAt?.slice(0, 10) ?? item.createdAt?.slice(0, 10) ?? "";
|
||||
if (item.status === "draft" || item.status === "rejected") {
|
||||
return date ? `采购草稿 · ${date}` : "采购草稿";
|
||||
}
|
||||
|
||||
return date ? `未编号采购 · ${date}` : "未编号采购";
|
||||
}
|
||||
|
||||
export function resolvePurchaseBudgetHint(item: PurchaseStatusLike) {
|
||||
if (item.budgetPool?.name) {
|
||||
return `已匹配预算池:${item.budgetPool.name}`;
|
||||
}
|
||||
|
||||
return "未匹配预算池;当前试运行按预警继续提交审批,如需强预算校验请由管理员补充预算配置。";
|
||||
}
|
||||
|
||||
export function resolvePurchaseSubmitWarning(warnings: string[]) {
|
||||
if (warnings.includes("missing_budget_pool")) {
|
||||
return "采购申请已提交;未匹配预算池,已按当前试运行规则继续进入审批。";
|
||||
}
|
||||
return "采购申请已提交";
|
||||
}
|
||||
|
||||
export function resolvePurchaseStatusHint(item: PurchaseStatusLike) {
|
||||
const latest = latestDecisionHistory(item.approvalHistory);
|
||||
|
||||
if (item.status === "draft") {
|
||||
if (latest?.action === "return") {
|
||||
return `审批已退回${commentSuffix(latest)}。修改后可重新提交。`;
|
||||
}
|
||||
if (latest?.action === "withdraw") {
|
||||
return "已撤回为草稿,可修改后重新提交。";
|
||||
}
|
||||
return "草稿可继续编辑,补齐明细和附件后提交审批。";
|
||||
}
|
||||
|
||||
if (item.status === "reviewing") {
|
||||
const stepName = item.pendingWorkflow?.task?.stepName;
|
||||
return stepName ? `当前节点:${stepName},等待审批人处理。` : "已提交,等待审批人处理。";
|
||||
}
|
||||
|
||||
if (item.status === "approved") {
|
||||
return "已通过,可由有权限人员完成 PO/合同归档或后续处理。";
|
||||
}
|
||||
|
||||
if (item.status === "rejected") {
|
||||
return latest?.action === "reject"
|
||||
? `审批已驳回${commentSuffix(latest)}。可修改后重新提交。`
|
||||
: "审批已驳回,可修改后重新提交。";
|
||||
}
|
||||
|
||||
if (item.status === "cancelled") {
|
||||
return "已取消,不再进入审批。";
|
||||
}
|
||||
|
||||
return "请查看审批记录确认下一步。";
|
||||
}
|
||||
@@ -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(", ") || "默认文档/图片"})`
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -89,6 +89,7 @@ test("E2E 请假编辑:草稿保存后保留原单并可继续提交", async (
|
||||
|
||||
const draftRow = page.getByRole("row").filter({ hasText: startDate }).first();
|
||||
await expect(draftRow).toContainText("草稿", { timeout: 30_000 });
|
||||
await expect(draftRow).toContainText("草稿可继续编辑", { timeout: 30_000 });
|
||||
|
||||
const updateResponsePromise = page.waitForResponse((response) => {
|
||||
return response.request().method() === "PATCH" && new URL(response.url()).pathname === `/api/leave-requests/${leaveId}` && response.ok();
|
||||
@@ -110,6 +111,7 @@ test("E2E 请假编辑:草稿保存后保留原单并可继续提交", async (
|
||||
await submitLeave(employeeClient.api, leaveId);
|
||||
await page.goto("/leave-requests");
|
||||
await expect(updatedRow).toContainText("审批中", { timeout: 30_000 });
|
||||
await expect(updatedRow).toContainText("当前节点", { timeout: 30_000 });
|
||||
} finally {
|
||||
await employeeClient.dispose();
|
||||
}
|
||||
@@ -157,9 +159,46 @@ test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", a
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
await page.goto("/leave-requests");
|
||||
await expect(page.getByRole("row").filter({ hasText: secondDate }).first()).toContainText("已通过", { timeout: 30_000 });
|
||||
const approvedRow = page.getByRole("row").filter({ hasText: secondDate }).first();
|
||||
await expect(approvedRow).toContainText("已通过", { timeout: 30_000 });
|
||||
await expect(approvedRow).toContainText("已通过,系统会按请假结果更新额度和团队状态", { timeout: 30_000 });
|
||||
await expect(page.getByRole("row").filter({ hasText: firstDate }).first()).toContainText("草稿", { timeout: 30_000 });
|
||||
} finally {
|
||||
await employeeClient.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("E2E 请假退回说明:审批退回后员工可看到处理意见", async ({ page, workflowDetail }) => {
|
||||
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
const baseOffset = uniqueFutureOffset(5200);
|
||||
const leaveDate = formatDate(baseOffset);
|
||||
const returnComment = `请补充外出交接安排 ${Date.now()}`;
|
||||
|
||||
try {
|
||||
await login(page, EMPLOYEE);
|
||||
await (await openNavLink(page, "请假")).click();
|
||||
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
||||
|
||||
const leaveId = await createLeaveDraft(page, leaveDate, leaveDate, `Playwright 退回请假 ${Date.now()}`);
|
||||
const submitResult = await submitLeave(employeeClient.api, leaveId);
|
||||
|
||||
await logout(page, EMPLOYEE.name);
|
||||
|
||||
await login(page, MANAGER);
|
||||
await page.goto(`/workflow/tasks/${submitResult.currentTaskId}`);
|
||||
await workflowDetail.assertLoaded();
|
||||
await workflowDetail.returnTask(returnComment);
|
||||
await expect(workflowDetail.summary).toContainText(/状态:(已处理|completed|已完成)/);
|
||||
|
||||
await logout(page, MANAGER.name);
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
await page.goto("/leave-requests");
|
||||
const returnedRow = page.getByRole("row").filter({ hasText: leaveDate }).first();
|
||||
await expect(returnedRow).toContainText("草稿", { timeout: 30_000 });
|
||||
await expect(returnedRow).toContainText("审批已退回", { timeout: 30_000 });
|
||||
await expect(returnedRow).toContainText(returnComment, { timeout: 30_000 });
|
||||
} finally {
|
||||
await employeeClient.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
await itemDescriptionInputs.first().fill("Playwright Laptop");
|
||||
await page.locator('[data-testid^="purchase-item-quantity-"]:visible').first().fill("2");
|
||||
await page.locator('[data-testid^="purchase-item-unit-price-"]:visible').first().fill("100");
|
||||
await expect(page.getByTestId("purchase-budget-hint")).toContainText("未匹配预算池");
|
||||
|
||||
const submitResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -78,6 +79,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
};
|
||||
const currentTaskId = submitPayload.data.currentTaskId;
|
||||
await expect(page.getByTestId("purchase-detail-status")).toHaveText("审批中", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("purchase-status-hint")).toContainText("当前节点", { timeout: 30_000 });
|
||||
requestNo = (await page.getByRole("heading", { level: 3 }).filter({ hasText: /^CG-/ }).first().textContent())?.trim() ?? "";
|
||||
expect(requestNo).toMatch(/^CG-/);
|
||||
|
||||
@@ -103,6 +105,8 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
await purchaseRow.click();
|
||||
|
||||
await expect(page.getByTestId("purchase-archive-po-no")).toBeVisible();
|
||||
await expect(page.getByTestId("purchase-status-hint")).toContainText("已通过", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("purchase-approval-history")).toContainText("通过", { timeout: 30_000 });
|
||||
await page.getByTestId("purchase-archive-po-no").fill("PO-E2E-001");
|
||||
await page.getByTestId("purchase-archive-contract-no").fill("CT-E2E-001");
|
||||
await page.getByTestId("purchase-archive-save").click();
|
||||
|
||||
在新工单中引用
屏蔽一个用户