R36-1/R36-2/R36-3/R36-5 收口请假编辑与团队日历
这个提交包含在:
+33
-1
@@ -90,6 +90,7 @@ export type LeaveListQuery = {
|
||||
pageSize?: number;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
departmentId?: string;
|
||||
};
|
||||
|
||||
function toQueryString(query: Record<string, string | number | undefined>) {
|
||||
@@ -194,7 +195,38 @@ export async function createLeaveRequest(payload: {
|
||||
export async function submitLeaveRequest(leaveRequestId: string) {
|
||||
return apiRequestData<Record<string, unknown>>(`/api/leave-requests/${leaveRequestId}/submit`, {
|
||||
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.view_all",
|
||||
"leave.approve",
|
||||
"leave.view_all",
|
||||
"procurement.approve",
|
||||
"procurement.view_all",
|
||||
"travel.approve",
|
||||
@@ -61,12 +62,9 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
"asset.view_all",
|
||||
"report.dashboard.view",
|
||||
"project.view_all",
|
||||
"project.manage",
|
||||
"budget.view",
|
||||
"project.view_all",
|
||||
"project.manage",
|
||||
"budget.manage",
|
||||
"project.view_all",
|
||||
"project.manage",
|
||||
],
|
||||
ceo: [
|
||||
...EMPLOYEE_BASE_PERMISSIONS,
|
||||
@@ -184,3 +182,15 @@ export function hasAnyPermission(permissionCodes: string[], candidates: string[]
|
||||
|
||||
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,
|
||||
fetchLeaveRequests,
|
||||
fetchLeaveTypes,
|
||||
patchLeaveRequest,
|
||||
submitLeaveRequest,
|
||||
withdrawLeaveRequest,
|
||||
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) {
|
||||
return item.startDate <= dateKey && item.endDate >= dateKey;
|
||||
}
|
||||
@@ -187,14 +196,18 @@ export function LeaveRequestPage() {
|
||||
const queryClient = useQueryClient();
|
||||
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 [scope, setScope] = useState<"own" | "all">("own");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageView, setPageView] = useState<LeavePageView>("records");
|
||||
const [calendarMode, setCalendarMode] = useState<LeaveCalendarMode>("week");
|
||||
const [calendarAnchorDate] = useState(() => new Date());
|
||||
const [calendarAnchorDate, setCalendarAnchorDate] = useState(() => new Date());
|
||||
|
||||
const [leaveTypeId, setLeaveTypeId] = useState("");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
@@ -203,6 +216,8 @@ export function LeaveRequestPage() {
|
||||
const [endSession, setEndSession] = useState<LeaveSession>("pm");
|
||||
const [reason, setReason] = useState("");
|
||||
const [attachmentFile, setAttachmentFile] = useState<File | null>(null);
|
||||
const [attachmentInputKey, setAttachmentInputKey] = useState(0);
|
||||
const [editingLeaveRequest, setEditingLeaveRequest] = useState<LeaveRequestItem | null>(null);
|
||||
const [formError, setFormError] = useState("");
|
||||
const [withdrawTarget, setWithdrawTarget] = useState<{ id: string; label: string } | null>(null);
|
||||
|
||||
@@ -239,8 +254,16 @@ export function LeaveRequestPage() {
|
||||
);
|
||||
|
||||
const calendarRequestsQuery = useQuery({
|
||||
queryKey: ["leave", "calendar", calendarMode, calendarWindow.queryFrom, calendarWindow.queryTo],
|
||||
enabled: canViewAll && pageView === "calendar",
|
||||
queryKey: [
|
||||
"leave",
|
||||
"calendar",
|
||||
calendarMode,
|
||||
calendarAnchorDate.toISOString(),
|
||||
calendarWindow.queryFrom,
|
||||
calendarWindow.queryTo,
|
||||
useDepartmentCalendarScope ? me?.departmentId ?? "no-department" : "all",
|
||||
],
|
||||
enabled: canViewTeamCalendar && pageView === "calendar",
|
||||
queryFn: () =>
|
||||
fetchLeaveRequests({
|
||||
scope: "all",
|
||||
@@ -248,6 +271,7 @@ export function LeaveRequestPage() {
|
||||
pageSize: CALENDAR_PAGE_SIZE,
|
||||
dateFrom: calendarWindow.queryFrom,
|
||||
dateTo: calendarWindow.queryTo,
|
||||
departmentId: useDepartmentCalendarScope ? me?.departmentId ?? undefined : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -280,40 +304,100 @@ export function LeaveRequestPage() {
|
||||
);
|
||||
}, [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({
|
||||
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 () => {
|
||||
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("附件类型不符合当前假种要求");
|
||||
if (!editingLeaveRequest) {
|
||||
throw new Error("未选择可编辑的请假单");
|
||||
}
|
||||
|
||||
return createLeaveRequest({
|
||||
leaveTypeId,
|
||||
startDate,
|
||||
endDate,
|
||||
startSession,
|
||||
endSession,
|
||||
reason,
|
||||
file: attachmentFile,
|
||||
});
|
||||
return patchLeaveRequest(
|
||||
editingLeaveRequest.id,
|
||||
buildFormPayload({ requireAttachment: false }),
|
||||
);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
@@ -321,15 +405,8 @@ export function LeaveRequestPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "calendar"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }),
|
||||
]);
|
||||
setLeaveTypeId("");
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setStartSession("am");
|
||||
setEndSession("pm");
|
||||
setReason("");
|
||||
setAttachmentFile(null);
|
||||
setFormError("");
|
||||
toast.success("请假草稿已创建");
|
||||
resetForm();
|
||||
toast.success("请假单已保存,可继续提交");
|
||||
},
|
||||
onError: (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 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 = (
|
||||
<SectionPanel title="请假记录" description="支持按状态和范围收敛列表,草稿与驳回单可再次提交。">
|
||||
@@ -439,6 +523,7 @@ export function LeaveRequestPage() {
|
||||
{(requestsQuery.data?.items ?? []).length ? (
|
||||
requestsQuery.data?.items.map((item) => {
|
||||
const isOwner = item.ownerId === me?.id;
|
||||
const canEdit = isOwner && ["draft", "rejected"].includes(item.status);
|
||||
const canSubmit = isOwner && ["draft", "rejected"].includes(item.status);
|
||||
const canWithdraw = isOwner && item.status === "reviewing";
|
||||
|
||||
@@ -460,6 +545,15 @@ export function LeaveRequestPage() {
|
||||
<TableCell>{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={!canEdit || isSavingForm}
|
||||
onClick={() => startEditing(item)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
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>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<div className="flex flex-col gap-2 md:items-end">
|
||||
<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="month" data-testid="leave-calendar-mode-month">月视图</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{calendarIsTruncated ? (
|
||||
@@ -612,7 +740,7 @@ export function LeaveRequestPage() {
|
||||
</section>
|
||||
|
||||
<div className="page-shell-grid">
|
||||
<SectionPanel title="新建请假草稿" description="先填写时间段与假种,再补充附件和说明。">
|
||||
<SectionPanel title={formTitle} description={formDescription}>
|
||||
<div className="space-y-4">
|
||||
{formError ? (
|
||||
<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">
|
||||
<Label htmlFor="leave-attachment">附件</Label>
|
||||
<Input
|
||||
key={attachmentInputKey}
|
||||
id="leave-attachment"
|
||||
type="file"
|
||||
accept={selectedLeaveType?.attachmentMimeTypes?.join(",") || undefined}
|
||||
@@ -690,6 +819,11 @@ export function LeaveRequestPage() {
|
||||
? `当前假种要求上传附件(允许:${selectedLeaveType.attachmentMimeTypes.join(", ") || "默认文档/图片"})`
|
||||
: "附件为可选项"}
|
||||
</p>
|
||||
{editingLeaveRequest ? (
|
||||
<p className="text-xs text-zinc-500">
|
||||
当前附件:{currentAttachmentLabel ?? "未上传"}。未重新选择文件时保留原附件。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -708,9 +842,32 @@ export function LeaveRequestPage() {
|
||||
<div>
|
||||
预计请假天数:<span className="font-semibold text-zinc-900">{requestedDaysPreview ?? "-"}</span>
|
||||
</div>
|
||||
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>
|
||||
{createMutation.isPending ? "创建中..." : "创建草稿"}
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{editingLeaveRequest ? (
|
||||
<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>
|
||||
</SectionPanel>
|
||||
@@ -774,7 +931,7 @@ export function LeaveRequestPage() {
|
||||
</SectionPanel>
|
||||
</div>
|
||||
|
||||
{canViewAll ? (
|
||||
{canViewTeamCalendar ? (
|
||||
<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">
|
||||
<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 { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { hasAnyPermission, isManagerRole } from "../auth/permissions";
|
||||
import { EmptyStateCard } from "../components/common/EmptyStateCard";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
@@ -201,7 +201,7 @@ export function PortalHomePage() {
|
||||
const { permissions } = useAuth();
|
||||
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() });
|
||||
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
|
||||
const isManagerView = hasAnyPermission(permissions, ["system.users", "report.dashboard.view", "*"]);
|
||||
const isManagerView = isManagerRole(permissions);
|
||||
|
||||
if (portalQuery.isLoading) {
|
||||
return <div className="card-std p-6 text-sm text-zinc-500">加载 Portal 中...</div>;
|
||||
|
||||
在新工单中引用
屏蔽一个用户