R36-1/R36-2/R36-3/R36-5 收口请假编辑与团队日历
这个提交包含在:
+33
-1
@@ -90,6 +90,7 @@ export type LeaveListQuery = {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
dateFrom?: string;
|
dateFrom?: string;
|
||||||
dateTo?: string;
|
dateTo?: string;
|
||||||
|
departmentId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toQueryString(query: Record<string, string | number | undefined>) {
|
function toQueryString(query: Record<string, string | number | undefined>) {
|
||||||
@@ -194,7 +195,38 @@ export async function createLeaveRequest(payload: {
|
|||||||
export async function submitLeaveRequest(leaveRequestId: string) {
|
export async function submitLeaveRequest(leaveRequestId: string) {
|
||||||
return apiRequestData<Record<string, unknown>>(`/api/leave-requests/${leaveRequestId}/submit`, {
|
return apiRequestData<Record<string, unknown>>(`/api/leave-requests/${leaveRequestId}/submit`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchLeaveRequest(
|
||||||
|
leaveRequestId: string,
|
||||||
|
payload: {
|
||||||
|
leaveTypeId: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
startSession: LeaveSession;
|
||||||
|
endSession: LeaveSession;
|
||||||
|
reason?: string;
|
||||||
|
file?: File | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("leaveTypeId", payload.leaveTypeId);
|
||||||
|
formData.append("startDate", payload.startDate);
|
||||||
|
formData.append("endDate", payload.endDate);
|
||||||
|
formData.append("startSession", payload.startSession);
|
||||||
|
formData.append("endSession", payload.endSession);
|
||||||
|
if (payload.reason?.trim()) {
|
||||||
|
formData.append("reason", payload.reason.trim());
|
||||||
|
}
|
||||||
|
if (payload.file) {
|
||||||
|
formData.append("file", payload.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiRequestData<LeaveRequestItem>(`/api/leave-requests/${leaveRequestId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: formData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
"expense.transfer",
|
"expense.transfer",
|
||||||
"expense.view_all",
|
"expense.view_all",
|
||||||
"leave.approve",
|
"leave.approve",
|
||||||
|
"leave.view_all",
|
||||||
"procurement.approve",
|
"procurement.approve",
|
||||||
"procurement.view_all",
|
"procurement.view_all",
|
||||||
"travel.approve",
|
"travel.approve",
|
||||||
@@ -61,12 +62,9 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
"asset.view_all",
|
"asset.view_all",
|
||||||
"report.dashboard.view",
|
"report.dashboard.view",
|
||||||
"project.view_all",
|
"project.view_all",
|
||||||
|
"project.manage",
|
||||||
"budget.view",
|
"budget.view",
|
||||||
"project.view_all",
|
|
||||||
"project.manage",
|
|
||||||
"budget.manage",
|
"budget.manage",
|
||||||
"project.view_all",
|
|
||||||
"project.manage",
|
|
||||||
],
|
],
|
||||||
ceo: [
|
ceo: [
|
||||||
...EMPLOYEE_BASE_PERMISSIONS,
|
...EMPLOYEE_BASE_PERMISSIONS,
|
||||||
@@ -184,3 +182,15 @@ export function hasAnyPermission(permissionCodes: string[], candidates: string[]
|
|||||||
|
|
||||||
return candidates.some((candidate) => hasPermission(permissionCodes, candidate));
|
return candidates.some((candidate) => hasPermission(permissionCodes, candidate));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isManagerRole(permissionCodes: string[]) {
|
||||||
|
if (hasPermission(permissionCodes, "*") || hasPermission(permissionCodes, "system.users")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissionCodes.some(
|
||||||
|
(permission) =>
|
||||||
|
permission.endsWith(".approve") ||
|
||||||
|
(permission.endsWith(".manage") && !permission.endsWith(".view_all")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
fetchLeaveBalances,
|
fetchLeaveBalances,
|
||||||
fetchLeaveRequests,
|
fetchLeaveRequests,
|
||||||
fetchLeaveTypes,
|
fetchLeaveTypes,
|
||||||
|
patchLeaveRequest,
|
||||||
submitLeaveRequest,
|
submitLeaveRequest,
|
||||||
withdrawLeaveRequest,
|
withdrawLeaveRequest,
|
||||||
type LeaveRequestItem,
|
type LeaveRequestItem,
|
||||||
@@ -109,6 +110,14 @@ function buildCalendarWindow(anchorDate: Date, mode: LeaveCalendarMode) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shiftCalendarAnchorDate(anchorDate: Date, mode: LeaveCalendarMode, direction: -1 | 1) {
|
||||||
|
if (mode === "week") {
|
||||||
|
return addUtcDays(anchorDate, direction * 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(Date.UTC(anchorDate.getUTCFullYear(), anchorDate.getUTCMonth() + direction, 1));
|
||||||
|
}
|
||||||
|
|
||||||
function leaveRequestIntersectsDate(item: LeaveRequestItem, dateKey: string) {
|
function leaveRequestIntersectsDate(item: LeaveRequestItem, dateKey: string) {
|
||||||
return item.startDate <= dateKey && item.endDate >= dateKey;
|
return item.startDate <= dateKey && item.endDate >= dateKey;
|
||||||
}
|
}
|
||||||
@@ -187,14 +196,18 @@ export function LeaveRequestPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { me, permissions } = useAuth();
|
const { me, permissions } = useAuth();
|
||||||
|
|
||||||
const canViewAll = hasPermission(permissions, "leave.view_all") || hasPermission(permissions, "leave.manage");
|
const hasLeaveViewAll = hasPermission(permissions, "leave.view_all");
|
||||||
|
const hasLeaveManage = hasPermission(permissions, "leave.manage");
|
||||||
|
const canViewAll = hasLeaveViewAll || hasLeaveManage;
|
||||||
|
const canViewTeamCalendar = hasLeaveViewAll;
|
||||||
|
const useDepartmentCalendarScope = hasLeaveViewAll && !hasLeaveManage && Boolean(me?.departmentId);
|
||||||
|
|
||||||
const [statusFilter, setStatusFilter] = useState<LeaveRequestStatus | "">("");
|
const [statusFilter, setStatusFilter] = useState<LeaveRequestStatus | "">("");
|
||||||
const [scope, setScope] = useState<"own" | "all">("own");
|
const [scope, setScope] = useState<"own" | "all">("own");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageView, setPageView] = useState<LeavePageView>("records");
|
const [pageView, setPageView] = useState<LeavePageView>("records");
|
||||||
const [calendarMode, setCalendarMode] = useState<LeaveCalendarMode>("week");
|
const [calendarMode, setCalendarMode] = useState<LeaveCalendarMode>("week");
|
||||||
const [calendarAnchorDate] = useState(() => new Date());
|
const [calendarAnchorDate, setCalendarAnchorDate] = useState(() => new Date());
|
||||||
|
|
||||||
const [leaveTypeId, setLeaveTypeId] = useState("");
|
const [leaveTypeId, setLeaveTypeId] = useState("");
|
||||||
const [startDate, setStartDate] = useState("");
|
const [startDate, setStartDate] = useState("");
|
||||||
@@ -203,6 +216,8 @@ export function LeaveRequestPage() {
|
|||||||
const [endSession, setEndSession] = useState<LeaveSession>("pm");
|
const [endSession, setEndSession] = useState<LeaveSession>("pm");
|
||||||
const [reason, setReason] = useState("");
|
const [reason, setReason] = useState("");
|
||||||
const [attachmentFile, setAttachmentFile] = useState<File | null>(null);
|
const [attachmentFile, setAttachmentFile] = useState<File | null>(null);
|
||||||
|
const [attachmentInputKey, setAttachmentInputKey] = useState(0);
|
||||||
|
const [editingLeaveRequest, setEditingLeaveRequest] = useState<LeaveRequestItem | null>(null);
|
||||||
const [formError, setFormError] = useState("");
|
const [formError, setFormError] = useState("");
|
||||||
const [withdrawTarget, setWithdrawTarget] = useState<{ id: string; label: string } | null>(null);
|
const [withdrawTarget, setWithdrawTarget] = useState<{ id: string; label: string } | null>(null);
|
||||||
|
|
||||||
@@ -239,8 +254,16 @@ export function LeaveRequestPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const calendarRequestsQuery = useQuery({
|
const calendarRequestsQuery = useQuery({
|
||||||
queryKey: ["leave", "calendar", calendarMode, calendarWindow.queryFrom, calendarWindow.queryTo],
|
queryKey: [
|
||||||
enabled: canViewAll && pageView === "calendar",
|
"leave",
|
||||||
|
"calendar",
|
||||||
|
calendarMode,
|
||||||
|
calendarAnchorDate.toISOString(),
|
||||||
|
calendarWindow.queryFrom,
|
||||||
|
calendarWindow.queryTo,
|
||||||
|
useDepartmentCalendarScope ? me?.departmentId ?? "no-department" : "all",
|
||||||
|
],
|
||||||
|
enabled: canViewTeamCalendar && pageView === "calendar",
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchLeaveRequests({
|
fetchLeaveRequests({
|
||||||
scope: "all",
|
scope: "all",
|
||||||
@@ -248,6 +271,7 @@ export function LeaveRequestPage() {
|
|||||||
pageSize: CALENDAR_PAGE_SIZE,
|
pageSize: CALENDAR_PAGE_SIZE,
|
||||||
dateFrom: calendarWindow.queryFrom,
|
dateFrom: calendarWindow.queryFrom,
|
||||||
dateTo: calendarWindow.queryTo,
|
dateTo: calendarWindow.queryTo,
|
||||||
|
departmentId: useDepartmentCalendarScope ? me?.departmentId ?? undefined : undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -280,40 +304,100 @@ export function LeaveRequestPage() {
|
|||||||
);
|
);
|
||||||
}, [calendarRequestsQuery.data?.items, calendarWindow.dates]);
|
}, [calendarRequestsQuery.data?.items, calendarWindow.dates]);
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
setEditingLeaveRequest(null);
|
||||||
|
setLeaveTypeId("");
|
||||||
|
setStartDate("");
|
||||||
|
setEndDate("");
|
||||||
|
setStartSession("am");
|
||||||
|
setEndSession("pm");
|
||||||
|
setReason("");
|
||||||
|
setAttachmentFile(null);
|
||||||
|
setAttachmentInputKey((current) => current + 1);
|
||||||
|
setFormError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditing(item: LeaveRequestItem) {
|
||||||
|
setEditingLeaveRequest(item);
|
||||||
|
setLeaveTypeId(item.leaveType?.id ?? "");
|
||||||
|
setStartDate(item.startDate);
|
||||||
|
setEndDate(item.endDate);
|
||||||
|
setStartSession(item.startSession);
|
||||||
|
setEndSession(item.endSession);
|
||||||
|
setReason(item.reason ?? "");
|
||||||
|
setAttachmentFile(null);
|
||||||
|
setAttachmentInputKey((current) => current + 1);
|
||||||
|
setFormError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFormPayload({ requireAttachment }: { requireAttachment: boolean }) {
|
||||||
|
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 (
|
||||||
|
requireAttachment &&
|
||||||
|
selectedLeaveType.requiresAttachment &&
|
||||||
|
!attachmentFile &&
|
||||||
|
!editingLeaveRequest?.attachment
|
||||||
|
) {
|
||||||
|
throw new Error("当前假种必须上传附件");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
attachmentFile &&
|
||||||
|
selectedLeaveType.attachmentMimeTypes.length > 0 &&
|
||||||
|
!selectedLeaveType.attachmentMimeTypes.includes(attachmentFile.type)
|
||||||
|
) {
|
||||||
|
throw new Error("附件类型不符合当前假种要求");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
leaveTypeId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
startSession,
|
||||||
|
endSession,
|
||||||
|
reason,
|
||||||
|
file: attachmentFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async () => createLeaveRequest(buildFormPayload({ requireAttachment: true })),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["leave", "requests"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["leave", "calendar"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
|
||||||
|
]);
|
||||||
|
resetForm();
|
||||||
|
toast.success("请假草稿已创建");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = resolveError(error);
|
||||||
|
setFormError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!leaveTypeId) {
|
if (!editingLeaveRequest) {
|
||||||
throw new Error("请选择假期类型");
|
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({
|
return patchLeaveRequest(
|
||||||
leaveTypeId,
|
editingLeaveRequest.id,
|
||||||
startDate,
|
buildFormPayload({ requireAttachment: false }),
|
||||||
endDate,
|
);
|
||||||
startSession,
|
|
||||||
endSession,
|
|
||||||
reason,
|
|
||||||
file: attachmentFile,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -321,15 +405,8 @@ export function LeaveRequestPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["leave", "calendar"] }),
|
queryClient.invalidateQueries({ queryKey: ["leave", "calendar"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
|
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
|
||||||
]);
|
]);
|
||||||
setLeaveTypeId("");
|
resetForm();
|
||||||
setStartDate("");
|
toast.success("请假单已保存,可继续提交");
|
||||||
setEndDate("");
|
|
||||||
setStartSession("am");
|
|
||||||
setEndSession("pm");
|
|
||||||
setReason("");
|
|
||||||
setAttachmentFile(null);
|
|
||||||
setFormError("");
|
|
||||||
toast.success("请假草稿已创建");
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = resolveError(error);
|
const message = resolveError(error);
|
||||||
@@ -371,6 +448,13 @@ export function LeaveRequestPage() {
|
|||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil((requestsQuery.data?.total ?? 0) / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil((requestsQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||||
const calendarIsTruncated = (calendarRequestsQuery.data?.total ?? 0) > (calendarRequestsQuery.data?.items.length ?? 0);
|
const calendarIsTruncated = (calendarRequestsQuery.data?.total ?? 0) > (calendarRequestsQuery.data?.items.length ?? 0);
|
||||||
|
const isSavingForm = createMutation.isPending || updateMutation.isPending;
|
||||||
|
const formTitle = editingLeaveRequest ? "编辑请假单" : "新建请假草稿";
|
||||||
|
const formDescription = editingLeaveRequest
|
||||||
|
? "修改原单内容并保留同一请假单 ID,保存后仍可继续提交。"
|
||||||
|
: "先填写时间段与假种,再补充附件和说明。";
|
||||||
|
const currentAttachmentLabel =
|
||||||
|
attachmentFile?.name ?? (editingLeaveRequest?.attachment?.originalName ?? null);
|
||||||
|
|
||||||
const leaveRecordsPanel = (
|
const leaveRecordsPanel = (
|
||||||
<SectionPanel title="请假记录" description="支持按状态和范围收敛列表,草稿与驳回单可再次提交。">
|
<SectionPanel title="请假记录" description="支持按状态和范围收敛列表,草稿与驳回单可再次提交。">
|
||||||
@@ -439,6 +523,7 @@ export function LeaveRequestPage() {
|
|||||||
{(requestsQuery.data?.items ?? []).length ? (
|
{(requestsQuery.data?.items ?? []).length ? (
|
||||||
requestsQuery.data?.items.map((item) => {
|
requestsQuery.data?.items.map((item) => {
|
||||||
const isOwner = item.ownerId === me?.id;
|
const isOwner = item.ownerId === me?.id;
|
||||||
|
const canEdit = isOwner && ["draft", "rejected"].includes(item.status);
|
||||||
const canSubmit = isOwner && ["draft", "rejected"].includes(item.status);
|
const canSubmit = isOwner && ["draft", "rejected"].includes(item.status);
|
||||||
const canWithdraw = isOwner && item.status === "reviewing";
|
const canWithdraw = isOwner && item.status === "reviewing";
|
||||||
|
|
||||||
@@ -460,6 +545,15 @@ export function LeaveRequestPage() {
|
|||||||
<TableCell>{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-"}</TableCell>
|
<TableCell>{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-"}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
disabled={!canEdit || isSavingForm}
|
||||||
|
onClick={() => startEditing(item)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -516,15 +610,49 @@ export function LeaveRequestPage() {
|
|||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold text-zinc-900">{calendarWindow.title}</div>
|
<div className="text-sm font-semibold text-zinc-900">{calendarWindow.title}</div>
|
||||||
<div className="text-xs text-zinc-500">固定拉取当前可见窗口内的请假记录;若超出 100 条会提示截断。</div>
|
<div className="text-xs text-zinc-500">
|
||||||
|
{useDepartmentCalendarScope ? "当前仅展示本部门窗口内请假记录;若超出 100 条会提示截断。" : "固定拉取当前可见窗口内的请假记录;若超出 100 条会提示截断。"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs value={calendarMode} onValueChange={(value) => setCalendarMode(value as LeaveCalendarMode)} data-testid="leave-calendar-mode-tabs">
|
<div className="flex flex-col gap-2 md:items-end">
|
||||||
<TabsList className="flex w-full flex-wrap justify-start gap-1 md:w-auto">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
data-testid="leave-calendar-prev"
|
||||||
|
onClick={() => setCalendarAnchorDate((current) => shiftCalendarAnchorDate(current, calendarMode, -1))}
|
||||||
|
>
|
||||||
|
上一个
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
data-testid="leave-calendar-today"
|
||||||
|
onClick={() => setCalendarAnchorDate(new Date())}
|
||||||
|
>
|
||||||
|
今天
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
data-testid="leave-calendar-next"
|
||||||
|
onClick={() => setCalendarAnchorDate((current) => shiftCalendarAnchorDate(current, calendarMode, 1))}
|
||||||
|
>
|
||||||
|
下一个
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={calendarMode} onValueChange={(value) => setCalendarMode(value as LeaveCalendarMode)} data-testid="leave-calendar-mode-tabs">
|
||||||
|
<TabsList className="flex w-full flex-wrap justify-start gap-1 md:w-auto">
|
||||||
<TabsTrigger value="week" data-testid="leave-calendar-mode-week">周视图</TabsTrigger>
|
<TabsTrigger value="week" data-testid="leave-calendar-mode-week">周视图</TabsTrigger>
|
||||||
<TabsTrigger value="month" data-testid="leave-calendar-mode-month">月视图</TabsTrigger>
|
<TabsTrigger value="month" data-testid="leave-calendar-mode-month">月视图</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{calendarIsTruncated ? (
|
{calendarIsTruncated ? (
|
||||||
@@ -612,7 +740,7 @@ export function LeaveRequestPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="page-shell-grid">
|
<div className="page-shell-grid">
|
||||||
<SectionPanel title="新建请假草稿" description="先填写时间段与假种,再补充附件和说明。">
|
<SectionPanel title={formTitle} description={formDescription}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{formError ? (
|
{formError ? (
|
||||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
@@ -677,6 +805,7 @@ export function LeaveRequestPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="leave-attachment">附件</Label>
|
<Label htmlFor="leave-attachment">附件</Label>
|
||||||
<Input
|
<Input
|
||||||
|
key={attachmentInputKey}
|
||||||
id="leave-attachment"
|
id="leave-attachment"
|
||||||
type="file"
|
type="file"
|
||||||
accept={selectedLeaveType?.attachmentMimeTypes?.join(",") || undefined}
|
accept={selectedLeaveType?.attachmentMimeTypes?.join(",") || undefined}
|
||||||
@@ -690,6 +819,11 @@ export function LeaveRequestPage() {
|
|||||||
? `当前假种要求上传附件(允许:${selectedLeaveType.attachmentMimeTypes.join(", ") || "默认文档/图片"})`
|
? `当前假种要求上传附件(允许:${selectedLeaveType.attachmentMimeTypes.join(", ") || "默认文档/图片"})`
|
||||||
: "附件为可选项"}
|
: "附件为可选项"}
|
||||||
</p>
|
</p>
|
||||||
|
{editingLeaveRequest ? (
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
当前附件:{currentAttachmentLabel ?? "未上传"}。未重新选择文件时保留原附件。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -708,9 +842,32 @@ export function LeaveRequestPage() {
|
|||||||
<div>
|
<div>
|
||||||
预计请假天数:<span className="font-semibold text-zinc-900">{requestedDaysPreview ?? "-"}</span>
|
预计请假天数:<span className="font-semibold text-zinc-900">{requestedDaysPreview ?? "-"}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>
|
<div className="flex flex-wrap gap-2">
|
||||||
{createMutation.isPending ? "创建中..." : "创建草稿"}
|
{editingLeaveRequest ? (
|
||||||
</Button>
|
<Button type="button" variant="secondary" onClick={resetForm} disabled={isSavingForm}>
|
||||||
|
取消编辑
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={isSavingForm}
|
||||||
|
onClick={() => {
|
||||||
|
if (editingLeaveRequest) {
|
||||||
|
updateMutation.mutate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createMutation.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editingLeaveRequest
|
||||||
|
? updateMutation.isPending
|
||||||
|
? "保存中..."
|
||||||
|
: "保存修改"
|
||||||
|
: createMutation.isPending
|
||||||
|
? "创建中..."
|
||||||
|
: "创建草稿"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
@@ -774,7 +931,7 @@ export function LeaveRequestPage() {
|
|||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canViewAll ? (
|
{canViewTeamCalendar ? (
|
||||||
<Tabs data-testid="leave-page-view-tabs" value={pageView} onValueChange={(value) => setPageView(value as LeavePageView)}>
|
<Tabs data-testid="leave-page-view-tabs" value={pageView} onValueChange={(value) => setPageView(value as LeavePageView)}>
|
||||||
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
||||||
<TabsTrigger data-testid="leave-view-records" value="records">请假记录</TabsTrigger>
|
<TabsTrigger data-testid="leave-view-records" value="records">请假记录</TabsTrigger>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { fetchPortalHome, type PortalHomePayload } from "../api/portal";
|
|||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasAnyPermission } from "../auth/permissions";
|
import { hasAnyPermission, isManagerRole } from "../auth/permissions";
|
||||||
import { EmptyStateCard } from "../components/common/EmptyStateCard";
|
import { EmptyStateCard } from "../components/common/EmptyStateCard";
|
||||||
import { MetricCard } from "../components/patterns/MetricCard";
|
import { MetricCard } from "../components/patterns/MetricCard";
|
||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
@@ -201,7 +201,7 @@ export function PortalHomePage() {
|
|||||||
const { permissions } = useAuth();
|
const { permissions } = useAuth();
|
||||||
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() });
|
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() });
|
||||||
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
|
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
|
||||||
const isManagerView = hasAnyPermission(permissions, ["system.users", "report.dashboard.view", "*"]);
|
const isManagerView = isManagerRole(permissions);
|
||||||
|
|
||||||
if (portalQuery.isLoading) {
|
if (portalQuery.isLoading) {
|
||||||
return <div className="card-std p-6 text-sm text-zinc-500">加载 Portal 中...</div>;
|
return <div className="card-std p-6 text-sm text-zinc-500">加载 Portal 中...</div>;
|
||||||
|
|||||||
@@ -69,6 +69,48 @@ async function withdrawLeave(api: Awaited<ReturnType<typeof createAuthenticatedA
|
|||||||
return apiPostData<{ leaveRequestId: string; leaveStatus: string }>(api, `/api/leave-requests/${leaveRequestId}/withdraw`, {});
|
return apiPostData<{ leaveRequestId: string; leaveStatus: string }>(api, `/api/leave-requests/${leaveRequestId}/withdraw`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("E2E 请假编辑:草稿保存后保留原单并可继续提交", async ({ page }) => {
|
||||||
|
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
||||||
|
const baseOffset = 80 + Number(String(Date.now()).slice(-2));
|
||||||
|
const startDate = formatDate(baseOffset);
|
||||||
|
const updatedEndDate = formatDate(baseOffset + 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(page, EMPLOYEE);
|
||||||
|
await (await openNavLink(page, "请假")).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
||||||
|
|
||||||
|
const leaveId = await createLeaveDraft(page, startDate, startDate, `Playwright 编辑请假 ${Date.now()}`);
|
||||||
|
await page.goto("/leave-requests");
|
||||||
|
|
||||||
|
const draftRow = page.getByRole("row").filter({ hasText: startDate }).first();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
await draftRow.getByRole("button", { name: "编辑" }).click();
|
||||||
|
await expect(page.getByText("编辑请假单")).toBeVisible();
|
||||||
|
await page.locator("#leave-end-date").fill(updatedEndDate);
|
||||||
|
await page.locator("#leave-reason").fill(`更新后的请假说明 ${Date.now()}`);
|
||||||
|
await page.getByRole("button", { name: "保存修改" }).click();
|
||||||
|
|
||||||
|
const updatePayload = (await (await updateResponsePromise).json()) as { data?: { id?: string } };
|
||||||
|
expect(updatePayload.data?.id).toBe(leaveId);
|
||||||
|
|
||||||
|
await page.goto("/leave-requests");
|
||||||
|
const updatedRow = page.getByRole("row").filter({ hasText: updatedEndDate }).first();
|
||||||
|
await expect(updatedRow).toContainText("草稿", { timeout: 30_000 });
|
||||||
|
|
||||||
|
await submitLeave(employeeClient.api, leaveId);
|
||||||
|
await page.goto("/leave-requests");
|
||||||
|
await expect(updatedRow).toContainText("审批中", { timeout: 30_000 });
|
||||||
|
} finally {
|
||||||
|
await employeeClient.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", async ({ page, workflowDetail }) => {
|
test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", async ({ page, workflowDetail }) => {
|
||||||
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
||||||
const baseOffset = 30 + Number(String(Date.now()).slice(-2));
|
const baseOffset = 30 + Number(String(Date.now()).slice(-2));
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
import { EMPLOYEE, login, type E2EUser } from "./helpers/auth";
|
import { EMPLOYEE, MANAGER, login, type E2EUser } from "./helpers/auth";
|
||||||
|
|
||||||
const LEAVE_CALENDAR_MANAGER: E2EUser = {
|
const LEAVE_CALENDAR_HR: E2EUser = {
|
||||||
identifier: "[email protected]",
|
identifier: "[email protected]",
|
||||||
password: "HrPassword1234",
|
password: "HrPassword1234",
|
||||||
name: "HR",
|
name: "HR",
|
||||||
@@ -149,7 +149,12 @@ function buildCalendarItems(dateFrom: string, dateTo: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function mockLeaveRequests(page: Page) {
|
async function mockLeaveRequests(page: Page) {
|
||||||
const observedCalendarQueries: Array<{ dateFrom: string; dateTo: string; pageSize: string }> = [];
|
const observedCalendarQueries: Array<{
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
pageSize: string;
|
||||||
|
departmentId: string | null;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
await page.route("**/api/leave-requests?**", async (route) => {
|
await page.route("**/api/leave-requests?**", async (route) => {
|
||||||
const url = new URL(route.request().url());
|
const url = new URL(route.request().url());
|
||||||
@@ -157,9 +162,10 @@ async function mockLeaveRequests(page: Page) {
|
|||||||
const pageSize = url.searchParams.get("pageSize") ?? "10";
|
const pageSize = url.searchParams.get("pageSize") ?? "10";
|
||||||
const dateFrom = url.searchParams.get("dateFrom");
|
const dateFrom = url.searchParams.get("dateFrom");
|
||||||
const dateTo = url.searchParams.get("dateTo");
|
const dateTo = url.searchParams.get("dateTo");
|
||||||
|
const departmentId = url.searchParams.get("departmentId");
|
||||||
|
|
||||||
if (scope === "all" && dateFrom && dateTo) {
|
if (scope === "all" && dateFrom && dateTo) {
|
||||||
observedCalendarQueries.push({ dateFrom, dateTo, pageSize });
|
observedCalendarQueries.push({ dateFrom, dateTo, pageSize, departmentId });
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
@@ -194,10 +200,10 @@ async function mockLeaveRequests(page: Page) {
|
|||||||
return observedCalendarQueries;
|
return observedCalendarQueries;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("E2E 请假团队日历:具备团队查看权限的用户可见周/月视图并显示截断提示", async ({ page }) => {
|
test("E2E 请假团队日历:HR 可见全员周/月视图并显示截断提示", async ({ page }) => {
|
||||||
const observedCalendarQueries = await mockLeaveRequests(page);
|
const observedCalendarQueries = await mockLeaveRequests(page);
|
||||||
|
|
||||||
await login(page, LEAVE_CALENDAR_MANAGER);
|
await login(page, LEAVE_CALENDAR_HR);
|
||||||
await page.goto("/leave-requests");
|
await page.goto("/leave-requests");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
||||||
@@ -229,6 +235,82 @@ test("E2E 请假团队日历:具备团队查看权限的用户可见周/月视
|
|||||||
await expect.poll(() => observedCalendarQueries.length).toBeGreaterThanOrEqual(2);
|
await expect.poll(() => observedCalendarQueries.length).toBeGreaterThanOrEqual(2);
|
||||||
expect(observedCalendarQueries.every((query) => Boolean(query.dateFrom && query.dateTo))).toBe(true);
|
expect(observedCalendarQueries.every((query) => Boolean(query.dateFrom && query.dateTo))).toBe(true);
|
||||||
expect(observedCalendarQueries.every((query) => query.pageSize === "100")).toBe(true);
|
expect(observedCalendarQueries.every((query) => query.pageSize === "100")).toBe(true);
|
||||||
|
expect(observedCalendarQueries.every((query) => query.departmentId == null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("E2E 请假团队日历:经理只拉本部门数据且导航会触发新查询", async ({ page }) => {
|
||||||
|
const observedCalendarQueries = await mockLeaveRequests(page);
|
||||||
|
|
||||||
|
await login(page, MANAGER);
|
||||||
|
await page.goto("/leave-requests");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("leave-view-calendar")).toBeVisible();
|
||||||
|
|
||||||
|
const firstResponse = page.waitForResponse((response) => {
|
||||||
|
const url = new URL(response.url());
|
||||||
|
return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all";
|
||||||
|
});
|
||||||
|
await page.getByTestId("leave-view-calendar").click();
|
||||||
|
await firstResponse;
|
||||||
|
|
||||||
|
await expect(page.getByTestId("leave-team-calendar-grid")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("leave-team-calendar-entry").filter({ hasText: "王小明" }).first()).toBeVisible();
|
||||||
|
await expect.poll(() => observedCalendarQueries.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(observedCalendarQueries.every((query) => Boolean(query.departmentId))).toBe(true);
|
||||||
|
|
||||||
|
const firstQuery = observedCalendarQueries.at(-1);
|
||||||
|
if (!firstQuery) {
|
||||||
|
throw new Error("经理团队日历未记录到首个查询");
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousResponse = page.waitForResponse((response) => {
|
||||||
|
const url = new URL(response.url());
|
||||||
|
return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all";
|
||||||
|
});
|
||||||
|
await page.getByTestId("leave-calendar-prev").click();
|
||||||
|
await previousResponse;
|
||||||
|
|
||||||
|
const previousQuery = observedCalendarQueries.at(-1);
|
||||||
|
if (!previousQuery) {
|
||||||
|
throw new Error("经理团队日历未记录到上一窗口查询");
|
||||||
|
}
|
||||||
|
expect(previousQuery.dateFrom).not.toBe(firstQuery.dateFrom);
|
||||||
|
|
||||||
|
const todayResponse = page.waitForResponse((response) => {
|
||||||
|
const url = new URL(response.url());
|
||||||
|
return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all";
|
||||||
|
});
|
||||||
|
await page.getByTestId("leave-calendar-today").click();
|
||||||
|
await todayResponse;
|
||||||
|
|
||||||
|
const todayQuery = observedCalendarQueries.at(-1);
|
||||||
|
if (!todayQuery) {
|
||||||
|
throw new Error("经理团队日历未记录到今天查询");
|
||||||
|
}
|
||||||
|
expect(todayQuery.dateFrom).toBe(firstQuery.dateFrom);
|
||||||
|
|
||||||
|
const monthResponse = page.waitForResponse((response) => {
|
||||||
|
const url = new URL(response.url());
|
||||||
|
return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all";
|
||||||
|
});
|
||||||
|
await page.getByTestId("leave-calendar-mode-month").click();
|
||||||
|
await monthResponse;
|
||||||
|
|
||||||
|
await expect.poll(() => page.getByTestId("leave-team-calendar-cell").count()).toBeGreaterThan(7);
|
||||||
|
|
||||||
|
const nextMonthResponse = page.waitForResponse((response) => {
|
||||||
|
const url = new URL(response.url());
|
||||||
|
return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all";
|
||||||
|
});
|
||||||
|
await page.getByTestId("leave-calendar-next").click();
|
||||||
|
await nextMonthResponse;
|
||||||
|
|
||||||
|
const nextMonthQuery = observedCalendarQueries.at(-1);
|
||||||
|
if (!nextMonthQuery) {
|
||||||
|
throw new Error("经理团队日历未记录到下一窗口查询");
|
||||||
|
}
|
||||||
|
expect(nextMonthQuery.dateFrom).not.toBe(todayQuery.dateFrom);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("E2E 请假团队日历:员工视角不显示团队日历 Tab", async ({ page }) => {
|
test("E2E 请假团队日历:员工视角不显示团队日历 Tab", async ({ page }) => {
|
||||||
|
|||||||
@@ -223,6 +223,18 @@ test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page
|
|||||||
await expect(page.getByRole("link", { name: "采购申请" })).toBeVisible();
|
await expect(page.getByRole("link", { name: "采购申请" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Portal 域管理角色也进入经理视图", async ({ page }) => {
|
||||||
|
await mockNavigation(page, "manager");
|
||||||
|
await mockPortalHome(page, "manager");
|
||||||
|
await mockMe(page, ["administration"], "Administration");
|
||||||
|
|
||||||
|
await login(page, EMPLOYEE);
|
||||||
|
|
||||||
|
await expect(page.getByText("今日管理重点")).toBeVisible();
|
||||||
|
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("企微 WebView 下顶栏显示入口提示", async ({ page }) => {
|
test("企微 WebView 下顶栏显示入口提示", async ({ page }) => {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
Object.defineProperty(window.navigator, "userAgent", {
|
Object.defineProperty(window.navigator, "userAgent", {
|
||||||
|
|||||||
+159
-3
@@ -44,6 +44,7 @@ const leaveListQuerySchema = z.object({
|
|||||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||||
dateFrom: DATE_ONLY_SCHEMA.optional(),
|
dateFrom: DATE_ONLY_SCHEMA.optional(),
|
||||||
dateTo: DATE_ONLY_SCHEMA.optional(),
|
dateTo: DATE_ONLY_SCHEMA.optional(),
|
||||||
|
departmentId: z.string().uuid().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const leaveBalanceQuerySchema = z.object({
|
const leaveBalanceQuerySchema = z.object({
|
||||||
@@ -120,6 +121,9 @@ function parseLeaveListQuery(query) {
|
|||||||
if (dateFrom && dateTo && dateFrom > dateTo) {
|
if (dateFrom && dateTo && dateFrom > dateTo) {
|
||||||
throw buildValidationError({ dateTo: "must_be_after_dateFrom" });
|
throw buildValidationError({ dateTo: "must_be_after_dateFrom" });
|
||||||
}
|
}
|
||||||
|
if (parsed.data.departmentId && parsed.data.scope !== "all") {
|
||||||
|
throw buildValidationError({ departmentId: "scope_all_only" });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: parsed.data.status,
|
status: parsed.data.status,
|
||||||
@@ -128,6 +132,7 @@ function parseLeaveListQuery(query) {
|
|||||||
pageSize: parsed.data.pageSize ?? 20,
|
pageSize: parsed.data.pageSize ?? 20,
|
||||||
dateFrom,
|
dateFrom,
|
||||||
dateTo,
|
dateTo,
|
||||||
|
departmentId: parsed.data.departmentId ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,12 +352,18 @@ async function loadLeaveTypeOrThrow(prisma, leaveTypeId) {
|
|||||||
return leaveType;
|
return leaveType;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertLeaveTypeAttachment(fastify, leaveType, filePart, employeeId) {
|
async function resolveLeaveTypeAttachment(
|
||||||
|
fastify,
|
||||||
|
leaveType,
|
||||||
|
filePart,
|
||||||
|
employeeId,
|
||||||
|
{ requireWhenMissing = true } = {},
|
||||||
|
) {
|
||||||
const requiresAttachment = Boolean(leaveType.requires_attachment);
|
const requiresAttachment = Boolean(leaveType.requires_attachment);
|
||||||
const allowedMimeTypes = normalizeAttachmentMimeTypes(leaveType.attachment_mime_types);
|
const allowedMimeTypes = normalizeAttachmentMimeTypes(leaveType.attachment_mime_types);
|
||||||
|
|
||||||
if (!filePart) {
|
if (!filePart) {
|
||||||
if (requiresAttachment) {
|
if (requireWhenMissing && requiresAttachment) {
|
||||||
throw buildValidationError({ file: "required" }, 422);
|
throw buildValidationError({ file: "required" }, 422);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -417,6 +428,7 @@ async function leaveRoutes(fastify) {
|
|||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const query = parseLeaveListQuery(request.query);
|
const query = parseLeaveListQuery(request.query);
|
||||||
const canViewAll = fastify.hasPermission(request.employee, "leave.view_all");
|
const canViewAll = fastify.hasPermission(request.employee, "leave.view_all");
|
||||||
|
const canManageAll = fastify.hasPermission(request.employee, "leave.manage");
|
||||||
if (query.scope === "all" && !canViewAll) {
|
if (query.scope === "all" && !canViewAll) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
code: "PERM_001",
|
code: "PERM_001",
|
||||||
@@ -424,6 +436,18 @@ async function leaveRoutes(fastify) {
|
|||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
query.departmentId &&
|
||||||
|
!canManageAll &&
|
||||||
|
query.departmentId !== (request.employee.department_id ?? null)
|
||||||
|
) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "PERM_001",
|
||||||
|
message: "无权限",
|
||||||
|
statusCode: 403,
|
||||||
|
details: { departmentId: "forbidden" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const page = query.page;
|
const page = query.page;
|
||||||
const pageSize = query.pageSize;
|
const pageSize = query.pageSize;
|
||||||
@@ -431,6 +455,7 @@ async function leaveRoutes(fastify) {
|
|||||||
const where = {
|
const where = {
|
||||||
...(query.status ? { status: query.status } : {}),
|
...(query.status ? { status: query.status } : {}),
|
||||||
...(query.scope === "all" && canViewAll ? {} : { owner_id: request.employee.id }),
|
...(query.scope === "all" && canViewAll ? {} : { owner_id: request.employee.id }),
|
||||||
|
...(query.scope === "all" && query.departmentId ? { department_id: query.departmentId } : {}),
|
||||||
...(query.dateFrom || query.dateTo
|
...(query.dateFrom || query.dateTo
|
||||||
? {
|
? {
|
||||||
AND: [
|
AND: [
|
||||||
@@ -510,7 +535,7 @@ async function leaveRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
const { body, filePart } = await collectCreatePayload(request);
|
const { body, filePart } = await collectCreatePayload(request);
|
||||||
const leaveType = await loadLeaveTypeOrThrow(fastify.prisma, body.leaveTypeId);
|
const leaveType = await loadLeaveTypeOrThrow(fastify.prisma, body.leaveTypeId);
|
||||||
const attachment = await assertLeaveTypeAttachment(
|
const attachment = await resolveLeaveTypeAttachment(
|
||||||
fastify,
|
fastify,
|
||||||
leaveType,
|
leaveType,
|
||||||
filePart,
|
filePart,
|
||||||
@@ -576,6 +601,137 @@ async function leaveRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
fastify.patch(
|
||||||
|
"/leave-requests/:id",
|
||||||
|
{
|
||||||
|
schema: { tags: ["Leave"], summary: "Update Leave Request" },
|
||||||
|
preHandler: fastify.requireEntityPermission("leave", "submit"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const leaveId = parseLeaveId(request.params?.id);
|
||||||
|
const { body, filePart } = await collectCreatePayload(request);
|
||||||
|
let response = null;
|
||||||
|
|
||||||
|
await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
const leaveRows = await tx.$queryRaw`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
owner_id,
|
||||||
|
department_id,
|
||||||
|
attachment_storage_path,
|
||||||
|
attachment_original_name,
|
||||||
|
attachment_mime_type,
|
||||||
|
status
|
||||||
|
FROM "leave_requests"
|
||||||
|
WHERE id = ${leaveId}::uuid
|
||||||
|
FOR UPDATE
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!Array.isArray(leaveRows) || leaveRows.length === 0) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "SYS_404",
|
||||||
|
message: "Not Found",
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const leave = leaveRows[0];
|
||||||
|
await ensureCutoverAllowed({
|
||||||
|
prisma: tx,
|
||||||
|
config: fastify.config,
|
||||||
|
request,
|
||||||
|
domain: "leave",
|
||||||
|
departmentId: leave.department_id ?? request.employee.department_id ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (leave.owner_id !== request.employee.id) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "PERM_001",
|
||||||
|
message: "无权限",
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!["draft", "rejected"].includes(leave.status)) {
|
||||||
|
throw buildValidationError({ status: "invalid_edit_status" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaveType = await loadLeaveTypeOrThrow(tx, body.leaveTypeId);
|
||||||
|
const attachment = await resolveLeaveTypeAttachment(
|
||||||
|
fastify,
|
||||||
|
leaveType,
|
||||||
|
filePart,
|
||||||
|
request.employee.id,
|
||||||
|
{ requireWhenMissing: false },
|
||||||
|
);
|
||||||
|
const requestedDays = await resolveRequestedDaysForCreate(
|
||||||
|
fastify,
|
||||||
|
request.employee,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedLeave = await tx.leaveRequest.update({
|
||||||
|
where: { id: leaveId },
|
||||||
|
data: {
|
||||||
|
department_id: request.employee.department_id ?? leave.department_id ?? null,
|
||||||
|
leave_type_id: body.leaveTypeId,
|
||||||
|
start_date: body.startDate,
|
||||||
|
end_date: body.endDate,
|
||||||
|
start_session: body.startSession,
|
||||||
|
end_session: body.endSession,
|
||||||
|
requested_days: requestedDays,
|
||||||
|
reason: body.reason,
|
||||||
|
attachment_storage_path: attachment?.storagePath ?? leave.attachment_storage_path ?? null,
|
||||||
|
attachment_original_name: attachment
|
||||||
|
? attachment.originalName ?? null
|
||||||
|
: leave.attachment_original_name ?? null,
|
||||||
|
attachment_mime_type: attachment
|
||||||
|
? attachment.mimeType ?? null
|
||||||
|
: leave.attachment_mime_type ?? null,
|
||||||
|
updated_by: request.employee.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
owner_id: true,
|
||||||
|
department_id: true,
|
||||||
|
leave_type_id: true,
|
||||||
|
start_date: true,
|
||||||
|
end_date: true,
|
||||||
|
start_session: true,
|
||||||
|
end_session: true,
|
||||||
|
requested_days: true,
|
||||||
|
reason: true,
|
||||||
|
attachment_original_name: true,
|
||||||
|
attachment_mime_type: true,
|
||||||
|
status: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true,
|
||||||
|
leave_type: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
requires_attachment: true,
|
||||||
|
attachment_mime_types: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
employee_no: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
response = mapLeaveRequestResponse(updatedLeave);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, response);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/leave-requests/:id",
|
"/leave-requests/:id",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
const Fastify = require("fastify");
|
||||||
|
|
||||||
|
const { AppError } = require("../../../src/utils/AppError");
|
||||||
|
const leaveRoutes = require("../../../src/routes/leave");
|
||||||
|
|
||||||
|
function buildLeaveType() {
|
||||||
|
const now = new Date("2026-03-25T08:00:00.000Z");
|
||||||
|
return {
|
||||||
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
code: "annual",
|
||||||
|
name: "年假",
|
||||||
|
description: null,
|
||||||
|
enabled: true,
|
||||||
|
requires_attachment: false,
|
||||||
|
attachment_mime_types: [],
|
||||||
|
sort_order: 1,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
leave_policy: {
|
||||||
|
id: "policy-1",
|
||||||
|
annual_quota_days: 10,
|
||||||
|
enabled: true,
|
||||||
|
description: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLockedLeave({
|
||||||
|
ownerId = "employee-1",
|
||||||
|
status = "draft",
|
||||||
|
} = {}) {
|
||||||
|
return {
|
||||||
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
owner_id: ownerId,
|
||||||
|
department_id: "department-1",
|
||||||
|
attachment_storage_path: null,
|
||||||
|
attachment_original_name: null,
|
||||||
|
attachment_mime_type: null,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUpdatedLeave({
|
||||||
|
id = "11111111-1111-4111-8111-111111111111",
|
||||||
|
ownerId = "employee-1",
|
||||||
|
status = "draft",
|
||||||
|
} = {}) {
|
||||||
|
const now = new Date("2026-03-25T09:00:00.000Z");
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
owner_id: ownerId,
|
||||||
|
department_id: "department-1",
|
||||||
|
leave_type_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
start_date: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
end_date: new Date("2026-04-02T00:00:00.000Z"),
|
||||||
|
start_session: "am",
|
||||||
|
end_session: "pm",
|
||||||
|
requested_days: 2,
|
||||||
|
reason: "更新后的请假原因",
|
||||||
|
attachment_original_name: null,
|
||||||
|
attachment_mime_type: null,
|
||||||
|
status,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
leave_type: {
|
||||||
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
code: "annual",
|
||||||
|
name: "年假",
|
||||||
|
requires_attachment: false,
|
||||||
|
attachment_mime_types: [],
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
id: ownerId,
|
||||||
|
employee_no: "E001",
|
||||||
|
name: "张三",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTx({ lockedLeave, updatedLeave }) {
|
||||||
|
return {
|
||||||
|
$queryRaw: vi.fn().mockResolvedValue([lockedLeave]),
|
||||||
|
leaveType: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(buildLeaveType()),
|
||||||
|
},
|
||||||
|
leaveRequest: {
|
||||||
|
update: vi.fn().mockResolvedValue(updatedLeave),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrisma(tx) {
|
||||||
|
return {
|
||||||
|
$transaction: vi.fn().mockImplementation((callback) => callback(tx)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildApp({ employee, prisma }) {
|
||||||
|
const app = Fastify();
|
||||||
|
|
||||||
|
app.decorate("prisma", prisma);
|
||||||
|
app.decorate("config", {
|
||||||
|
cutoverConsoleEnabled: false,
|
||||||
|
leaveHolidayCalendarEnabled: false,
|
||||||
|
});
|
||||||
|
app.decorate("authenticate", async (request) => {
|
||||||
|
if (!request.headers.authorization) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "AUTH_001",
|
||||||
|
message: "未登录",
|
||||||
|
statusCode: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
request.employee = employee;
|
||||||
|
});
|
||||||
|
app.decorate("hasPermission", (actor, permissionCode) => {
|
||||||
|
return (actor?.permissionCodes ?? []).includes("*") || (actor?.permissionCodes ?? []).includes(permissionCode);
|
||||||
|
});
|
||||||
|
app.decorate("requireEntityPermission", (_entity, action) => async (request) => {
|
||||||
|
await app.authenticate(request);
|
||||||
|
const permissionCode = action === "submit" ? "leave.submit" : "leave.view_own";
|
||||||
|
if (!app.hasPermission(request.employee, permissionCode)) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "PERM_001",
|
||||||
|
message: "无权限",
|
||||||
|
statusCode: 403,
|
||||||
|
details: { permission: permissionCode },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.setErrorHandler((error, request, reply) => {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return reply.status(error.statusCode).send({
|
||||||
|
requestId: request.id,
|
||||||
|
error: {
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
details: error.details ?? {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.status(500).send({
|
||||||
|
requestId: request.id,
|
||||||
|
error: { code: "SYS_001", message: "服务器内部错误", details: {} },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(leaveRoutes, { prefix: "/api" });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("leave edit route", () => {
|
||||||
|
it("allows owner to edit draft leave on the same record", async () => {
|
||||||
|
const lockedLeave = buildLockedLeave({ status: "draft" });
|
||||||
|
const updatedLeave = buildUpdatedLeave({ status: "draft" });
|
||||||
|
const tx = buildTx({ lockedLeave, updatedLeave });
|
||||||
|
const prisma = buildPrisma(tx);
|
||||||
|
const app = await buildApp({
|
||||||
|
employee: {
|
||||||
|
id: "employee-1",
|
||||||
|
department_id: "department-1",
|
||||||
|
permissionCodes: ["leave.submit"],
|
||||||
|
},
|
||||||
|
prisma,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/leave-requests/${lockedLeave.id}`,
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
payload: {
|
||||||
|
leaveTypeId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
startDate: "2026-04-01",
|
||||||
|
endDate: "2026-04-02",
|
||||||
|
startSession: "am",
|
||||||
|
endSession: "pm",
|
||||||
|
reason: "更新后的请假原因",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: lockedLeave.id,
|
||||||
|
status: "draft",
|
||||||
|
startDate: "2026-04-01",
|
||||||
|
endDate: "2026-04-02",
|
||||||
|
requestedDays: 2,
|
||||||
|
reason: "更新后的请假原因",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(tx.leaveRequest.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { id: lockedLeave.id },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
leave_type_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
requested_days: 2,
|
||||||
|
updated_by: "employee-1",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows owner to edit rejected leave on the same record", async () => {
|
||||||
|
const lockedLeave = buildLockedLeave({ status: "rejected" });
|
||||||
|
const updatedLeave = buildUpdatedLeave({ status: "rejected" });
|
||||||
|
const tx = buildTx({ lockedLeave, updatedLeave });
|
||||||
|
const app = await buildApp({
|
||||||
|
employee: {
|
||||||
|
id: "employee-1",
|
||||||
|
department_id: "department-1",
|
||||||
|
permissionCodes: ["leave.submit"],
|
||||||
|
},
|
||||||
|
prisma: buildPrisma(tx),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/leave-requests/${lockedLeave.id}`,
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
payload: {
|
||||||
|
leaveTypeId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
startDate: "2026-04-01",
|
||||||
|
endDate: "2026-04-02",
|
||||||
|
startSession: "am",
|
||||||
|
endSession: "pm",
|
||||||
|
reason: "更新后的请假原因",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data.status).toBe("rejected");
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects editing when leave is already reviewing", async () => {
|
||||||
|
const lockedLeave = buildLockedLeave({ status: "reviewing" });
|
||||||
|
const tx = buildTx({ lockedLeave, updatedLeave: buildUpdatedLeave({ status: "reviewing" }) });
|
||||||
|
const app = await buildApp({
|
||||||
|
employee: {
|
||||||
|
id: "employee-1",
|
||||||
|
department_id: "department-1",
|
||||||
|
permissionCodes: ["leave.submit"],
|
||||||
|
},
|
||||||
|
prisma: buildPrisma(tx),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/leave-requests/${lockedLeave.id}`,
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
payload: {
|
||||||
|
leaveTypeId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
startDate: "2026-04-01",
|
||||||
|
endDate: "2026-04-02",
|
||||||
|
startSession: "am",
|
||||||
|
endSession: "pm",
|
||||||
|
reason: "更新后的请假原因",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(response.json().error).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "VALID_001",
|
||||||
|
details: expect.objectContaining({
|
||||||
|
status: "invalid_edit_status",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(tx.leaveRequest.update).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects editing by non-owner", async () => {
|
||||||
|
const lockedLeave = buildLockedLeave({ ownerId: "employee-9", status: "draft" });
|
||||||
|
const tx = buildTx({ lockedLeave, updatedLeave: buildUpdatedLeave({ ownerId: "employee-9", status: "draft" }) });
|
||||||
|
const app = await buildApp({
|
||||||
|
employee: {
|
||||||
|
id: "employee-1",
|
||||||
|
department_id: "department-1",
|
||||||
|
permissionCodes: ["leave.submit"],
|
||||||
|
},
|
||||||
|
prisma: buildPrisma(tx),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/leave-requests/${lockedLeave.id}`,
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
payload: {
|
||||||
|
leaveTypeId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
startDate: "2026-04-01",
|
||||||
|
endDate: "2026-04-02",
|
||||||
|
startSession: "am",
|
||||||
|
endSession: "pm",
|
||||||
|
reason: "更新后的请假原因",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
expect(response.json().error.code).toBe("PERM_001");
|
||||||
|
expect(tx.leaveRequest.update).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户