feat(P15B-1,P15B-6): 完成请假V1闭环
这个提交包含在:
@@ -0,0 +1,536 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import {
|
||||
createLeaveRequest,
|
||||
fetchLeaveBalances,
|
||||
fetchLeaveRequests,
|
||||
fetchLeaveTypes,
|
||||
submitLeaveRequest,
|
||||
withdrawLeaveRequest,
|
||||
type LeaveRequestStatus,
|
||||
type LeaveSession,
|
||||
} from "../api/leave";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
function resolveError(error: unknown) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return "操作失败,请稍后重试。";
|
||||
}
|
||||
|
||||
function calculateRequestedDaysPreview(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
startSession: LeaveSession,
|
||||
endSession: LeaveSession,
|
||||
) {
|
||||
if (!startDate || !endDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const start = new Date(`${startDate}T00:00:00.000Z`);
|
||||
const end = new Date(`${endDate}T00:00:00.000Z`);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dayIndex = (value: Date) => Math.floor(value.getTime() / 86400000);
|
||||
const startSlot = dayIndex(start) * 2 + (startSession === "pm" ? 1 : 0);
|
||||
const endSlot = dayIndex(end) * 2 + (endSession === "pm" ? 1 : 0);
|
||||
if (endSlot < startSlot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Number((((endSlot - startSlot + 1) / 2)).toFixed(1));
|
||||
}
|
||||
|
||||
function formatRangeLabel(item: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
startSession: LeaveSession;
|
||||
endSession: LeaveSession;
|
||||
}) {
|
||||
const sessionLabel = (session: LeaveSession) => (session === "am" ? "上午" : "下午");
|
||||
if (item.startDate === item.endDate && item.startSession === item.endSession) {
|
||||
return `${item.startDate} ${sessionLabel(item.startSession)}`;
|
||||
}
|
||||
return `${item.startDate} ${sessionLabel(item.startSession)} → ${item.endDate} ${sessionLabel(item.endSession)}`;
|
||||
}
|
||||
|
||||
export function LeaveRequestPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { me, permissions } = useAuth();
|
||||
|
||||
const canViewAll = hasPermission(permissions, "leave.view_all") || hasPermission(permissions, "leave.manage");
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<LeaveRequestStatus | "">("");
|
||||
const [scope, setScope] = useState<"own" | "all">("own");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [leaveTypeId, setLeaveTypeId] = useState("");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [startSession, setStartSession] = useState<LeaveSession>("am");
|
||||
const [endSession, setEndSession] = useState<LeaveSession>("pm");
|
||||
const [reason, setReason] = useState("");
|
||||
const [attachmentFile, setAttachmentFile] = useState<File | null>(null);
|
||||
const [formError, setFormError] = useState("");
|
||||
const [withdrawTarget, setWithdrawTarget] = useState<{ id: string; label: string } | null>(null);
|
||||
|
||||
const requestedDaysPreview = useMemo(
|
||||
() => calculateRequestedDaysPreview(startDate, endDate, startSession, endSession),
|
||||
[endDate, endSession, startDate, startSession],
|
||||
);
|
||||
|
||||
const leaveTypesQuery = useQuery({
|
||||
queryKey: ["leave", "types"],
|
||||
queryFn: () => fetchLeaveTypes({ enabled: true }),
|
||||
});
|
||||
|
||||
const balancesQuery = useQuery({
|
||||
queryKey: ["leave", "balances", me?.id],
|
||||
enabled: Boolean(me?.id),
|
||||
queryFn: () => fetchLeaveBalances(),
|
||||
});
|
||||
|
||||
const requestsQuery = useQuery({
|
||||
queryKey: ["leave", "requests", statusFilter, scope, page],
|
||||
queryFn: () =>
|
||||
fetchLeaveRequests({
|
||||
status: statusFilter || undefined,
|
||||
scope: canViewAll ? scope : "own",
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
}),
|
||||
});
|
||||
|
||||
const selectedLeaveType = useMemo(() => {
|
||||
return (leaveTypesQuery.data?.items ?? []).find((item) => item.id === leaveTypeId) ?? null;
|
||||
}, [leaveTypeId, leaveTypesQuery.data?.items]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!leaveTypeId) {
|
||||
throw new Error("请选择假期类型");
|
||||
}
|
||||
if (!startDate || !endDate) {
|
||||
throw new Error("请选择开始和结束日期");
|
||||
}
|
||||
if (!selectedLeaveType) {
|
||||
throw new Error("假期类型不存在或已失效");
|
||||
}
|
||||
if (requestedDaysPreview == null || requestedDaysPreview <= 0) {
|
||||
throw new Error("请检查开始/结束日期与半天时段");
|
||||
}
|
||||
if (selectedLeaveType.requiresAttachment && !attachmentFile) {
|
||||
throw new Error("当前假种必须上传附件");
|
||||
}
|
||||
if (
|
||||
attachmentFile &&
|
||||
selectedLeaveType.attachmentMimeTypes.length > 0 &&
|
||||
!selectedLeaveType.attachmentMimeTypes.includes(attachmentFile.type)
|
||||
) {
|
||||
throw new Error("附件类型不符合当前假种要求");
|
||||
}
|
||||
|
||||
return createLeaveRequest({
|
||||
leaveTypeId,
|
||||
startDate,
|
||||
endDate,
|
||||
startSession,
|
||||
endSession,
|
||||
reason,
|
||||
file: attachmentFile,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
|
||||
]);
|
||||
setLeaveTypeId("");
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setStartSession("am");
|
||||
setEndSession("pm");
|
||||
setReason("");
|
||||
setAttachmentFile(null);
|
||||
setFormError("");
|
||||
toast.success("请假草稿已创建");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveError(error);
|
||||
setFormError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: (leaveRequestId: string) => submitLeaveRequest(leaveRequestId),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
|
||||
]);
|
||||
toast.success("请假申请已提交");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const withdrawMutation = useMutation({
|
||||
mutationFn: (leaveRequestId: string) => withdrawLeaveRequest(leaveRequestId),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
|
||||
]);
|
||||
toast.success("请假申请已撤回");
|
||||
setWithdrawTarget(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((requestsQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="请假申请"
|
||||
subtitle="支持假种选择、半天粒度请假、额度冻结与撤回释放。"
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">额度概览</h3>
|
||||
{balancesQuery.isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<CardSkeleton lines={4} />
|
||||
<CardSkeleton lines={4} />
|
||||
<CardSkeleton lines={4} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{balancesQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveError(balancesQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!balancesQuery.isLoading && !balancesQuery.isError ? (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{(balancesQuery.data?.items ?? []).length ? (
|
||||
balancesQuery.data?.items.map((item) => (
|
||||
<div className="card-std space-y-2 p-5" key={item.leaveType.id}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-base font-semibold text-zinc-900">{item.leaveType.name}</p>
|
||||
<p className="text-xs text-zinc-500">{item.periodStart} ~ {item.periodEnd}</p>
|
||||
</div>
|
||||
<StatusBadge status={item.leaveType.enabled ? "active" : "archived"} label={item.leaveType.enabled ? "启用" : "停用"} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-zinc-500">可用</p>
|
||||
<p className="text-xl font-semibold text-zinc-900">{item.available}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">已冻结</p>
|
||||
<p className="text-xl font-semibold text-zinc-900">{item.reserved}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">已使用</p>
|
||||
<p className="text-xl font-semibold text-zinc-900">{item.used}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">授予额度</p>
|
||||
<p className="text-xl font-semibold text-zinc-900">{item.policy.annualQuotaDays}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="card-std p-6 text-sm text-zinc-500 md:col-span-3">
|
||||
暂无可用假种,请联系 HR 初始化请假类型与策略。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">新建请假草稿</h3>
|
||||
|
||||
{formError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{formError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-type">假期类型</Label>
|
||||
<select
|
||||
className="select-std"
|
||||
id="leave-type"
|
||||
value={leaveTypeId}
|
||||
onChange={(event) => setLeaveTypeId(event.target.value)}
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
{(leaveTypesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-start-date">开始日期</Label>
|
||||
<Input id="leave-start-date" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-start-session">开始时段</Label>
|
||||
<select
|
||||
className="select-std"
|
||||
id="leave-start-session"
|
||||
value={startSession}
|
||||
onChange={(event) => setStartSession(event.target.value as LeaveSession)}
|
||||
>
|
||||
<option value="am">上午</option>
|
||||
<option value="pm">下午</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-end-date">结束日期</Label>
|
||||
<Input id="leave-end-date" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-end-session">结束时段</Label>
|
||||
<select
|
||||
className="select-std"
|
||||
id="leave-end-session"
|
||||
value={endSession}
|
||||
onChange={(event) => setEndSession(event.target.value as LeaveSession)}
|
||||
>
|
||||
<option value="am">上午</option>
|
||||
<option value="pm">下午</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-attachment">附件</Label>
|
||||
<Input
|
||||
id="leave-attachment"
|
||||
type="file"
|
||||
accept={selectedLeaveType?.attachmentMimeTypes?.join(",") || undefined}
|
||||
onChange={(event) => {
|
||||
const nextFile = event.target.files?.[0] ?? null;
|
||||
setAttachmentFile(nextFile);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{selectedLeaveType?.requiresAttachment
|
||||
? `当前假种要求上传附件(允许:${selectedLeaveType.attachmentMimeTypes.join(", ") || "默认文档/图片"})`
|
||||
: "附件为可选项"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-reason">请假说明</Label>
|
||||
<Textarea
|
||||
id="leave-reason"
|
||||
rows={3}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="请输入请假原因、备注或补充说明"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700">
|
||||
<div>
|
||||
预计请假天数:<span className="font-semibold text-zinc-900">{requestedDaysPreview ?? "-"}</span>
|
||||
</div>
|
||||
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>
|
||||
{createMutation.isPending ? "创建中..." : "创建草稿"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<h3 className="text-base font-semibold text-zinc-900">请假记录</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canViewAll ? (
|
||||
<select
|
||||
className="select-std"
|
||||
value={scope}
|
||||
onChange={(event) => {
|
||||
setScope(event.target.value as "own" | "all");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="own">只看我的</option>
|
||||
<option value="all">查看全员</option>
|
||||
</select>
|
||||
) : null}
|
||||
<select
|
||||
className="select-std"
|
||||
value={statusFilter}
|
||||
onChange={(event) => {
|
||||
setStatusFilter(event.target.value as LeaveRequestStatus | "");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="reviewing">审批中</option>
|
||||
<option value="approved">已通过</option>
|
||||
<option value="rejected">已驳回</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
<Button type="button" variant="secondary" onClick={() => queryClient.invalidateQueries({ queryKey: ["leave", "requests"] })}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requestsQuery.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
|
||||
|
||||
{requestsQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveError(requestsQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!requestsQuery.isLoading && !requestsQuery.isError ? (
|
||||
<div className="rounded-lg border border-zinc-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<tr>
|
||||
<TableHead>假种 / 申请人</TableHead>
|
||||
<TableHead>时间段</TableHead>
|
||||
<TableHead>天数</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>附件</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<TableHead className="w-44">操作</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(requestsQuery.data?.items ?? []).length ? (
|
||||
requestsQuery.data?.items.map((item) => {
|
||||
const isOwner = item.ownerId === me?.id;
|
||||
const canSubmit = isOwner && ["draft", "rejected"].includes(item.status);
|
||||
const canWithdraw = isOwner && item.status === "reviewing";
|
||||
|
||||
return (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.leaveType?.name ?? "-"}</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{item.owner?.name ?? "-"}
|
||||
{item.owner?.employeeNo ? ` (${item.owner.employeeNo})` : ""}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{formatRangeLabel(item)}</TableCell>
|
||||
<TableCell>{item.requestedDays}</TableCell>
|
||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||
<TableCell className="text-xs text-zinc-500">
|
||||
{item.attachment?.originalName ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell>{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={!canSubmit || submitMutation.isPending}
|
||||
onClick={() => submitMutation.mutate(item.id)}
|
||||
>
|
||||
提交
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={!canWithdraw || withdrawMutation.isPending}
|
||||
onClick={() =>
|
||||
setWithdrawTarget({
|
||||
id: item.id,
|
||||
label: `${item.leaveType?.name ?? "请假"} · ${formatRangeLabel(item)}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
撤回
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell className="py-4 text-zinc-500" colSpan={7}>
|
||||
暂无请假记录
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
testIdPrefix="leave-requests"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(withdrawTarget)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setWithdrawTarget(null);
|
||||
}
|
||||
}}
|
||||
title="确认撤回请假申请?"
|
||||
description={withdrawTarget ? `将撤回:${withdrawTarget.label}` : undefined}
|
||||
confirmLabel="确认撤回"
|
||||
onConfirm={async () => {
|
||||
if (!withdrawTarget) return;
|
||||
await withdrawMutation.mutateAsync(withdrawTarget.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,16 @@ import { Textarea } from "../components/ui/textarea";
|
||||
|
||||
type MobileAction = "approve" | "reject" | "return" | "transfer";
|
||||
|
||||
function entityLabel(entityType: string | undefined) {
|
||||
if (entityType === "leave") {
|
||||
return "请假";
|
||||
}
|
||||
if (entityType === "reimbursement") {
|
||||
return "报销";
|
||||
}
|
||||
return entityType || "未知";
|
||||
}
|
||||
|
||||
export function MobileApprovalsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedTaskId, setSelectedTaskId] = useState("");
|
||||
@@ -97,6 +107,7 @@ export function MobileApprovalsPage() {
|
||||
|
||||
const pendingTasks = tasksQuery.data?.items ?? [];
|
||||
const selectedTask = detailQuery.data;
|
||||
const isLeaveTask = selectedTask?.workflowInstance?.entityType === "leave";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -130,7 +141,7 @@ export function MobileApprovalsPage() {
|
||||
>
|
||||
<div className="font-medium">{task.stepName}</div>
|
||||
<div className="text-xs text-zinc-500">任务ID: {task.id}</div>
|
||||
<div className="text-xs text-zinc-500">状态: {task.status}</div>
|
||||
<div className="text-xs text-zinc-500">业务: {entityLabel(task.workflowInstance?.entityType)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -150,9 +161,21 @@ export function MobileApprovalsPage() {
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<div>任务状态:{selectedTask.task.status}</div>
|
||||
<div>步骤:{selectedTask.task.stepName}</div>
|
||||
<div>报销单号:{selectedTask.reimbursement?.formNumber ?? "-"}</div>
|
||||
<div>金额:{selectedTask.reimbursement?.totalAmount ?? "-"}</div>
|
||||
<div>提交人:{selectedTask.reimbursement?.submitter?.name ?? "-"}</div>
|
||||
<div>对象类型:{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
|
||||
{selectedTask.reimbursement ? (
|
||||
<>
|
||||
<div>报销单号:{selectedTask.reimbursement.formNumber ?? "-"}</div>
|
||||
<div>金额:{selectedTask.reimbursement.totalAmount ?? "-"}</div>
|
||||
<div>提交人:{selectedTask.reimbursement.submitter?.name ?? "-"}</div>
|
||||
</>
|
||||
) : null}
|
||||
{selectedTask.leaveRequest ? (
|
||||
<>
|
||||
<div>假期类型:{selectedTask.leaveRequest.leaveType?.name ?? "-"}</div>
|
||||
<div>申请人:{selectedTask.leaveRequest.owner?.name ?? "-"}</div>
|
||||
<div>请假天数:{selectedTask.leaveRequest.requestedDays}</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
@@ -192,40 +215,42 @@ export function MobileApprovalsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<p className="font-medium text-zinc-800">转审</p>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||
data-testid="mobile-approvals-transfer-to"
|
||||
onChange={(event) => setTransferToApproverId(event.target.value)}
|
||||
value={transferToApproverId}
|
||||
>
|
||||
<option value="">请选择目标审批人</option>
|
||||
{(approversQuery.data?.items ?? []).map((approver) => (
|
||||
<option key={approver.id} value={approver.id}>
|
||||
{approver.name} ({approver.employeeNo})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!isLeaveTask && selectedTask.allowedActions.includes("transfer") ? (
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<p className="font-medium text-zinc-800">转审</p>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||
data-testid="mobile-approvals-transfer-to"
|
||||
onChange={(event) => setTransferToApproverId(event.target.value)}
|
||||
value={transferToApproverId}
|
||||
>
|
||||
<option value="">请选择目标审批人</option>
|
||||
{(approversQuery.data?.items ?? []).map((approver) => (
|
||||
<option key={approver.id} value={approver.id}>
|
||||
{approver.name} ({approver.employeeNo})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Textarea
|
||||
data-testid="mobile-approvals-transfer-reason"
|
||||
onChange={(event) => setTransferReason(event.target.value)}
|
||||
placeholder="转审理由(至少10字)"
|
||||
rows={2}
|
||||
value={transferReason}
|
||||
/>
|
||||
<Textarea
|
||||
data-testid="mobile-approvals-transfer-reason"
|
||||
onChange={(event) => setTransferReason(event.target.value)}
|
||||
placeholder="转审理由(至少10字)"
|
||||
rows={2}
|
||||
value={transferReason}
|
||||
/>
|
||||
|
||||
<Button
|
||||
data-testid="mobile-approvals-transfer"
|
||||
disabled={actionMutation.isPending || !selectedTask.allowedActions.includes("transfer")}
|
||||
onClick={() => actionMutation.mutate("transfer")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
提交转审
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
data-testid="mobile-approvals-transfer"
|
||||
disabled={actionMutation.isPending || !selectedTask.allowedActions.includes("transfer")}
|
||||
onClick={() => actionMutation.mutate("transfer")}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
提交转审
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,16 @@ import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
|
||||
function entityTitle(entityType: string | undefined) {
|
||||
if (entityType === "leave") {
|
||||
return "请假申请";
|
||||
}
|
||||
if (entityType === "reimbursement") {
|
||||
return "报销单";
|
||||
}
|
||||
return entityType || "业务单据";
|
||||
}
|
||||
|
||||
export function WorkflowTaskDetailPage() {
|
||||
const { taskId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -27,7 +37,7 @@ export function WorkflowTaskDetailPage() {
|
||||
const { permissions } = useAuth();
|
||||
|
||||
const [rejectComment, setRejectComment] = useState("请补充必要说明后重提");
|
||||
const [returnComment, setReturnComment] = useState("请补充报销说明并修正后重新提交");
|
||||
const [returnComment, setReturnComment] = useState("请补充必要信息并修正后重新提交");
|
||||
const [transferReason, setTransferReason] = useState("当前事项需转交对应审批人处理,请尽快处理。");
|
||||
const [selectedApprover, setSelectedApprover] = useState("");
|
||||
const [actionError, setActionError] = useState("");
|
||||
@@ -104,9 +114,7 @@ export function WorkflowTaskDetailPage() {
|
||||
}
|
||||
|
||||
if (detailQuery.isLoading) {
|
||||
return (
|
||||
<CardSkeleton lines={8} />
|
||||
);
|
||||
return <CardSkeleton lines={8} />;
|
||||
}
|
||||
|
||||
if (detailQuery.isError || !detailQuery.data) {
|
||||
@@ -133,6 +141,8 @@ export function WorkflowTaskDetailPage() {
|
||||
}
|
||||
|
||||
const detail = detailQuery.data;
|
||||
const isLeaveTask = detail.workflowInstance?.entityType === "leave";
|
||||
const canTransfer = allowedActions.has("transfer");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -149,10 +159,30 @@ export function WorkflowTaskDetailPage() {
|
||||
<div className="card-std space-y-2 p-6 text-sm" data-testid="workflow-task-summary">
|
||||
<div>状态:<StatusBadge status={detail.task.status} /></div>
|
||||
<div>实例:{detail.workflowInstance?.id ?? "-"}</div>
|
||||
<div>报销单号:{detail.reimbursement?.formNumber ?? "-"}</div>
|
||||
<div>报销金额:{detail.reimbursement?.totalAmount ?? "-"}</div>
|
||||
<div>提交人:{detail.reimbursement?.submitter?.name ?? "-"}</div>
|
||||
<div>报销人:{detail.reimbursement?.owner?.name ?? "-"}</div>
|
||||
<div>对象类型:{entityTitle(detail.workflowInstance?.entityType)}</div>
|
||||
{detail.reimbursement ? (
|
||||
<>
|
||||
<div>报销单号:{detail.reimbursement.formNumber ?? "-"}</div>
|
||||
<div>报销金额:{detail.reimbursement.totalAmount ?? "-"}</div>
|
||||
<div>提交人:{detail.reimbursement.submitter?.name ?? "-"}</div>
|
||||
<div>报销人:{detail.reimbursement.owner?.name ?? "-"}</div>
|
||||
</>
|
||||
) : null}
|
||||
{detail.leaveRequest ? (
|
||||
<>
|
||||
<div>假期类型:{detail.leaveRequest.leaveType?.name ?? "-"}</div>
|
||||
<div>申请人:{detail.leaveRequest.owner?.name ?? "-"}</div>
|
||||
<div>
|
||||
时间段:
|
||||
{detail.leaveRequest.startDate ?? "-"} {detail.leaveRequest.startSession === "am" ? "上午" : "下午"}
|
||||
{" → "}
|
||||
{detail.leaveRequest.endDate ?? "-"} {detail.leaveRequest.endSession === "am" ? "上午" : "下午"}
|
||||
</div>
|
||||
<div>请假天数:{detail.leaveRequest.requestedDays}</div>
|
||||
<div>说明:{detail.leaveRequest.reason ?? "-"}</div>
|
||||
<div>附件:{detail.leaveRequest.attachment?.originalName ?? "-"}</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
@@ -216,42 +246,44 @@ export function WorkflowTaskDetailPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||||
<p className="text-sm font-medium text-zinc-800">转审</p>
|
||||
<select
|
||||
className="select-std"
|
||||
data-testid="workflow-task-transfer-to-approver"
|
||||
onChange={(event) => setSelectedApprover(event.target.value)}
|
||||
value={selectedApprover}
|
||||
>
|
||||
<option value="">请选择目标审批人</option>
|
||||
{(approverQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} ({item.employeeNo})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Textarea
|
||||
data-testid="workflow-task-transfer-reason"
|
||||
onChange={(event) => setTransferReason(event.target.value)}
|
||||
rows={2}
|
||||
value={transferReason}
|
||||
/>
|
||||
<Button
|
||||
data-testid="workflow-task-transfer"
|
||||
variant="secondary"
|
||||
disabled={
|
||||
!allowedActions.has("transfer") ||
|
||||
mutation.isPending ||
|
||||
!selectedApprover ||
|
||||
transferReason.trim().length < 10
|
||||
}
|
||||
onClick={() => mutation.mutate("transfer")}
|
||||
type="button"
|
||||
>
|
||||
提交转审
|
||||
</Button>
|
||||
</div>
|
||||
{canTransfer && !isLeaveTask ? (
|
||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||||
<p className="text-sm font-medium text-zinc-800">转审</p>
|
||||
<select
|
||||
className="select-std"
|
||||
data-testid="workflow-task-transfer-to-approver"
|
||||
onChange={(event) => setSelectedApprover(event.target.value)}
|
||||
value={selectedApprover}
|
||||
>
|
||||
<option value="">请选择目标审批人</option>
|
||||
{(approverQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} ({item.employeeNo})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Textarea
|
||||
data-testid="workflow-task-transfer-reason"
|
||||
onChange={(event) => setTransferReason(event.target.value)}
|
||||
rows={2}
|
||||
value={transferReason}
|
||||
/>
|
||||
<Button
|
||||
data-testid="workflow-task-transfer"
|
||||
variant="secondary"
|
||||
disabled={
|
||||
!allowedActions.has("transfer") ||
|
||||
mutation.isPending ||
|
||||
!selectedApprover ||
|
||||
transferReason.trim().length < 10
|
||||
}
|
||||
onClick={() => mutation.mutate("transfer")}
|
||||
type="button"
|
||||
>
|
||||
提交转审
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-3 p-6">
|
||||
|
||||
@@ -13,6 +13,16 @@ import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function entityLabel(entityType: string | undefined) {
|
||||
if (entityType === "leave") {
|
||||
return "请假";
|
||||
}
|
||||
if (entityType === "reimbursement") {
|
||||
return "报销";
|
||||
}
|
||||
return entityType || "-";
|
||||
}
|
||||
|
||||
export function WorkflowTasksPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -35,7 +45,7 @@ export function WorkflowTasksPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="待我处理" subtitle="审批待办列表(支持筛选与详情动作联动)。" />
|
||||
<PageHeader title="待我处理" subtitle="审批待办列表,覆盖报销与请假流程。" />
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<Tabs
|
||||
@@ -68,10 +78,10 @@ export function WorkflowTasksPage() {
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{isApiError(query.error)
|
||||
? `${query.error.code}: ${mapApiErrorMessage(
|
||||
query.error.code,
|
||||
query.error.details,
|
||||
query.error.message,
|
||||
)}`
|
||||
query.error.code,
|
||||
query.error.details,
|
||||
query.error.message,
|
||||
)}`
|
||||
: "加载待办失败,请稍后重试"}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -82,7 +92,7 @@ export function WorkflowTasksPage() {
|
||||
<TableHeader>
|
||||
<tr>
|
||||
<TableHead>任务</TableHead>
|
||||
<TableHead>报销ID</TableHead>
|
||||
<TableHead>业务对象</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="w-24">操作</TableHead>
|
||||
@@ -96,8 +106,13 @@ export function WorkflowTasksPage() {
|
||||
<div className="font-medium text-zinc-900">{task.stepName}</div>
|
||||
<div className="text-xs text-zinc-500">{task.id}</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{task.workflowInstance?.entityId}
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">
|
||||
{entityLabel(task.workflowInstance?.entityType)}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-zinc-500">
|
||||
{task.workflowInstance?.entityId ?? "-"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell><StatusBadge status={task.status} /></TableCell>
|
||||
<TableCell>{new Date(task.createdAt).toLocaleString()}</TableCell>
|
||||
|
||||
在新工单中引用
屏蔽一个用户