feat: 完成 R13/R14/R15/R16 小团队管理核心能力
这个提交包含在:
@@ -24,6 +24,79 @@ export type EmployeeListResponse = {
|
||||
items: EmployeeSummary[];
|
||||
};
|
||||
|
||||
export type EmployeeLifecycleEvent = {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
eventType: "onboard" | "regularize" | "transfer" | "offboard";
|
||||
effectiveDate: string | null;
|
||||
payloadJson: Record<string, unknown> | null;
|
||||
createdAt: string | null;
|
||||
createdBy: string | null;
|
||||
};
|
||||
|
||||
export type EmployeeChecklistTask = {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
scene: "onboarding" | "offboarding" | "transfer";
|
||||
taskType:
|
||||
| "account_setup"
|
||||
| "asset_issue"
|
||||
| "policy_ack"
|
||||
| "asset_return"
|
||||
| "access_revoke"
|
||||
| "expense_clearance"
|
||||
| "handover_note";
|
||||
title: string;
|
||||
status: "pending" | "completed" | "skipped";
|
||||
dueDate: string | null;
|
||||
completedAt: string | null;
|
||||
completedBy: string | null;
|
||||
payloadJson: Record<string, unknown> | null;
|
||||
createdAt: string | null;
|
||||
};
|
||||
|
||||
export type EmployeeProfile = {
|
||||
employee: {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
status: EmployeeStatus;
|
||||
employmentStatus: "onboarding" | "probation" | "active" | "offboarding" | "inactive";
|
||||
departmentId: string;
|
||||
title: string;
|
||||
directManagerId: string | null;
|
||||
hireDate: string | null;
|
||||
probationEndDate: string | null;
|
||||
lastWorkingDate: string | null;
|
||||
workLocation: string | null;
|
||||
emergencyContactName: string | null;
|
||||
emergencyContactPhone: string | null;
|
||||
notes: string | null;
|
||||
archivedAt: string | null;
|
||||
roleCodes: string[];
|
||||
department: { id: string; name: string } | null;
|
||||
directManager: { id: string; employeeNo: string; name: string } | null;
|
||||
};
|
||||
lifecycle: {
|
||||
events: EmployeeLifecycleEvent[];
|
||||
checklistTasks: EmployeeChecklistTask[];
|
||||
blockers: {
|
||||
openAssetCount: number;
|
||||
pendingWorkflowTaskCount: number;
|
||||
canCompleteOffboarding: boolean;
|
||||
};
|
||||
};
|
||||
assignedAssets: Array<{
|
||||
id: string;
|
||||
assetNo: string;
|
||||
name: string;
|
||||
status: string;
|
||||
location: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function fetchEmployees(params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
@@ -39,3 +112,90 @@ export async function fetchEmployees(params: {
|
||||
return apiRequestData<EmployeeListResponse>(`/api/employees?${search.toString()}`);
|
||||
}
|
||||
|
||||
export async function fetchEmployeeProfile(employeeId: string) {
|
||||
return apiRequestData<EmployeeProfile>(`/api/employees/${employeeId}/profile`);
|
||||
}
|
||||
|
||||
export async function patchEmployeeProfile(
|
||||
employeeId: string,
|
||||
payload: Partial<{
|
||||
departmentId: string;
|
||||
directManagerId: string | null;
|
||||
title: string;
|
||||
probationEndDate: string | null;
|
||||
lastWorkingDate: string | null;
|
||||
workLocation: string | null;
|
||||
emergencyContactName: string | null;
|
||||
emergencyContactPhone: string | null;
|
||||
notes: string | null;
|
||||
}>,
|
||||
) {
|
||||
return apiRequestData<EmployeeProfile>(`/api/employees/${employeeId}/profile`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startEmployeeOnboarding(
|
||||
employeeId: string,
|
||||
payload: { effectiveDate?: string; probationEndDate?: string | null; notes?: string | null } = {},
|
||||
) {
|
||||
return apiRequestData<EmployeeProfile>(`/api/employees/${employeeId}/onboarding/start`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function regularizeEmployee(
|
||||
employeeId: string,
|
||||
payload: { effectiveDate?: string; notes?: string | null } = {},
|
||||
) {
|
||||
return apiRequestData<EmployeeProfile>(`/api/employees/${employeeId}/regularize`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function transferEmployee(
|
||||
employeeId: string,
|
||||
payload: {
|
||||
effectiveDate?: string;
|
||||
departmentId?: string;
|
||||
directManagerId?: string | null;
|
||||
title?: string;
|
||||
notes?: string | null;
|
||||
} = {},
|
||||
) {
|
||||
return apiRequestData<EmployeeProfile>(`/api/employees/${employeeId}/transfer`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startEmployeeOffboarding(
|
||||
employeeId: string,
|
||||
payload: { effectiveDate?: string; lastWorkingDate?: string | null; notes?: string | null } = {},
|
||||
) {
|
||||
return apiRequestData<EmployeeProfile>(`/api/employees/${employeeId}/offboarding/start`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchEmployeeLifecycle(employeeId: string) {
|
||||
return apiRequestData<EmployeeProfile["lifecycle"]>(`/api/employees/${employeeId}/lifecycle`);
|
||||
}
|
||||
|
||||
export async function completeEmployeeChecklistTask(employeeId: string, taskId: string) {
|
||||
return apiRequestData<EmployeeProfile["lifecycle"]>(`/api/employees/${employeeId}/checklist-tasks/${taskId}/complete`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function skipEmployeeChecklistTask(employeeId: string, taskId: string) {
|
||||
return apiRequestData<EmployeeProfile["lifecycle"]>(`/api/employees/${employeeId}/checklist-tasks/${taskId}/skip`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type FeatureFlagKey = "erp_sync" | "invoice_verification" | "leave_holiday_calendar" | "budget_precheck_v1" | "payment_batch_v1" | "portal_home_v1" | "global_search_v1" | "announcements_v1" | "workflow_definition_admin_v1" | "cutover_console_v1";
|
||||
export type FeatureFlagKey = "erp_sync" | "invoice_verification" | "leave_holiday_calendar" | "budget_precheck_v1" | "payment_batch_v1" | "portal_home_v1" | "global_search_v1" | "announcements_v1" | "workflow_definition_admin_v1" | "cutover_console_v1" | "presence_board_v1" | "weekly_report_v1" | "employee_lifecycle_v1" | "todo_board_v1";
|
||||
export type FeatureFlagOverrideMode = "inherit" | "force_on" | "force_off";
|
||||
export type FeatureFlagRolloutType = "global" | "department_allowlist";
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type PortalHomePayload = {
|
||||
workspace: {
|
||||
id: string | null;
|
||||
persisted: boolean;
|
||||
profile: "small_team_internal";
|
||||
};
|
||||
greeting: { name: string };
|
||||
cards: Array<{ key: string; title: string; value: string | number; href: string; severity?: "normal" | "warning" | "high" }>;
|
||||
pendingTasks: { count: number; href: string };
|
||||
@@ -8,6 +13,22 @@ export type PortalHomePayload = {
|
||||
riskSummary: { openCount: number; href: string } | null;
|
||||
announcements: Array<{ id: string; title: string; summary: string; href: string | null }>;
|
||||
quickLinks: Array<{ key: string; title: string; href: string }>;
|
||||
todayPresence: { date: string; status: string | null; label: string; note: string | null; href: string } | null;
|
||||
teamPresence:
|
||||
| ({ date: string; href: string } & {
|
||||
office: number;
|
||||
remote: number;
|
||||
out: number;
|
||||
travel: number;
|
||||
leave: number;
|
||||
unspecified: number;
|
||||
total: number;
|
||||
})
|
||||
| null;
|
||||
weeklyReport: { weekStart: string; status: "draft" | "submitted"; needsHelp: boolean; href: string } | null;
|
||||
weeklySummary: { weekStart: string; submittedCount: number; missingCount: number; needsHelpCount: number; href: string } | null;
|
||||
todoSummary: { openCount: number; highPriorityCount: number; href: string } | null;
|
||||
peopleLifecycle: { onboardingCount: number; offboardingCount: number; assetReturnPendingCount: number; href: string } | null;
|
||||
};
|
||||
|
||||
export async function fetchPortalHome() {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type PresenceStatus = "office" | "remote" | "out" | "travel" | "leave";
|
||||
export type PresenceTimeRange = "full_day" | "morning" | "afternoon";
|
||||
export type PresenceSource = "manual" | "leave_sync";
|
||||
|
||||
export type PresenceItem = {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
date: string;
|
||||
status: PresenceStatus;
|
||||
timeRange: PresenceTimeRange;
|
||||
note: string | null;
|
||||
source: PresenceSource;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type PresenceEmployee = {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
name: string;
|
||||
title: string;
|
||||
employmentStatus: string;
|
||||
department: { id: string; name: string } | null;
|
||||
};
|
||||
|
||||
export type TeamPresenceItem = {
|
||||
employee: PresenceEmployee;
|
||||
presence: PresenceItem | null;
|
||||
};
|
||||
|
||||
export type PresenceSummary = {
|
||||
office: number;
|
||||
remote: number;
|
||||
out: number;
|
||||
travel: number;
|
||||
leave: number;
|
||||
unspecified: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export async function fetchMyPresence(date?: string) {
|
||||
const suffix = date ? `?date=${encodeURIComponent(date)}` : "";
|
||||
return apiRequestData<{ item: PresenceItem | null; date: string }>(`/api/presence/me${suffix}`);
|
||||
}
|
||||
|
||||
export async function upsertMyPresence(payload: {
|
||||
date?: string;
|
||||
status: Extract<PresenceStatus, "office" | "remote" | "out" | "travel">;
|
||||
timeRange?: PresenceTimeRange;
|
||||
note?: string | null;
|
||||
}) {
|
||||
return apiRequestData<{ item: PresenceItem }>("/api/presence/me", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchTeamPresence(date?: string) {
|
||||
const suffix = date ? `?date=${encodeURIComponent(date)}` : "";
|
||||
return apiRequestData<{ date: string; summary: PresenceSummary; items: TeamPresenceItem[] }>(`/api/presence/team${suffix}`);
|
||||
}
|
||||
|
||||
export async function fetchPresenceSummary(date?: string) {
|
||||
const suffix = date ? `?date=${encodeURIComponent(date)}` : "";
|
||||
return apiRequestData<{ date: string; summary: PresenceSummary }>(`/api/presence/summary${suffix}`);
|
||||
}
|
||||
+10
-1
@@ -1,7 +1,16 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type GlobalSearchHit = {
|
||||
entityType: "reimbursement" | "leave" | "purchase_request" | "travel_request" | "asset";
|
||||
entityType:
|
||||
| "employee"
|
||||
| "announcement"
|
||||
| "reimbursement"
|
||||
| "leave"
|
||||
| "purchase_request"
|
||||
| "travel_request"
|
||||
| "asset"
|
||||
| "weekly_report"
|
||||
| "todo";
|
||||
entityId: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type TodoPriority = "low" | "normal" | "high";
|
||||
export type TodoStatus = "todo" | "doing" | "done" | "cancelled";
|
||||
export type TodoSource = "manual" | "weekly_report";
|
||||
|
||||
export type TodoItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
assigneeEmployeeId: string;
|
||||
createdBy: string;
|
||||
dueDate: string | null;
|
||||
priority: TodoPriority;
|
||||
status: TodoStatus;
|
||||
source: TodoSource;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
completedAt: string | null;
|
||||
assignee: { id: string; employeeNo: string; name: string } | null;
|
||||
creator: { id: string; employeeNo: string; name: string } | null;
|
||||
};
|
||||
|
||||
export async function fetchTodos(params: {
|
||||
status?: TodoStatus;
|
||||
scope?: "mine" | "team";
|
||||
assigneeId?: string;
|
||||
q?: string;
|
||||
} = {}) {
|
||||
const search = new URLSearchParams();
|
||||
if (params.status) search.set("status", params.status);
|
||||
if (params.scope) search.set("scope", params.scope);
|
||||
if (params.assigneeId) search.set("assigneeId", params.assigneeId);
|
||||
if (params.q) search.set("q", params.q);
|
||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||
return apiRequestData<{ items: TodoItem[] }>(`/api/todos${suffix}`);
|
||||
}
|
||||
|
||||
export async function createTodo(payload: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
assigneeEmployeeId?: string;
|
||||
dueDate?: string | null;
|
||||
priority?: TodoPriority;
|
||||
source?: TodoSource;
|
||||
}) {
|
||||
return apiRequestData<{ item: TodoItem }>("/api/todos", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchTodo(id: string, payload: Partial<{
|
||||
title: string;
|
||||
description: string | null;
|
||||
assigneeEmployeeId: string;
|
||||
dueDate: string | null;
|
||||
priority: TodoPriority;
|
||||
status: TodoStatus;
|
||||
}>) {
|
||||
return apiRequestData<{ item: TodoItem }>(`/api/todos/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeTodo(id: string) {
|
||||
return apiRequestData<{ item: TodoItem }>(`/api/todos/${id}/complete`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelTodo(id: string) {
|
||||
return apiRequestData<{ item: TodoItem }>(`/api/todos/${id}/cancel`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type WeeklyReport = {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
weekStart: string;
|
||||
status: "draft" | "submitted";
|
||||
highlightsText: string;
|
||||
blockersText: string;
|
||||
nextWeekText: string;
|
||||
needsHelp: boolean;
|
||||
submittedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type WeeklyReportTeamItem = {
|
||||
employee: {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
name: string;
|
||||
title: string;
|
||||
department: { id: string; name: string } | null;
|
||||
};
|
||||
report: WeeklyReport | null;
|
||||
};
|
||||
|
||||
export type WeeklyReportTeamSummary = {
|
||||
totalEmployees: number;
|
||||
submittedCount: number;
|
||||
draftCount: number;
|
||||
missingCount: number;
|
||||
needsHelpCount: number;
|
||||
};
|
||||
|
||||
export async function fetchMyWeeklyReport(weekStart?: string) {
|
||||
const suffix = weekStart ? `?weekStart=${encodeURIComponent(weekStart)}` : "";
|
||||
return apiRequestData<{ item: WeeklyReport }>(`/api/weekly-reports/me${suffix}`);
|
||||
}
|
||||
|
||||
export async function updateMyWeeklyReport(payload: {
|
||||
weekStart?: string;
|
||||
highlightsText?: string;
|
||||
blockersText?: string;
|
||||
nextWeekText?: string;
|
||||
needsHelp?: boolean;
|
||||
}) {
|
||||
return apiRequestData<{ item: WeeklyReport }>("/api/weekly-reports/me", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitMyWeeklyReport(payload: { weekStart?: string } = {}) {
|
||||
return apiRequestData<{ item: WeeklyReport }>("/api/weekly-reports/me/submit", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchTeamWeeklyReports(weekStart?: string) {
|
||||
const suffix = weekStart ? `?weekStart=${encodeURIComponent(weekStart)}` : "";
|
||||
return apiRequestData<{
|
||||
weekStart: string;
|
||||
summary: WeeklyReportTeamSummary;
|
||||
items: WeeklyReportTeamItem[];
|
||||
}>(`/api/weekly-reports/team${suffix}`);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type WorkspaceModuleVisibilityMode = "main" | "admin" | "hidden";
|
||||
|
||||
export type WorkspaceSettings = {
|
||||
id: string | null;
|
||||
persisted: boolean;
|
||||
profile: "small_team_internal";
|
||||
orgMode: "single_company";
|
||||
primaryEntryMode: "password";
|
||||
notificationStrategy: "in_app";
|
||||
devicePriority: "desktop";
|
||||
moduleVisibility: Record<string, WorkspaceModuleVisibilityMode>;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
updatedBy: string | null;
|
||||
defaults?: Record<string, WorkspaceModuleVisibilityMode>;
|
||||
};
|
||||
|
||||
export type NavigationItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
href: string;
|
||||
placement: WorkspaceModuleVisibilityMode;
|
||||
permissionCodes: string[];
|
||||
featureFlagKey: string | null;
|
||||
};
|
||||
|
||||
export type RolePreset = {
|
||||
key: string;
|
||||
label: string;
|
||||
roleCodes: string[];
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type NavigationPayload = {
|
||||
workspace: WorkspaceSettings;
|
||||
sections: {
|
||||
main: NavigationItem[];
|
||||
admin: NavigationItem[];
|
||||
backup: NavigationItem[];
|
||||
};
|
||||
recommendedRolePresets: RolePreset[];
|
||||
};
|
||||
|
||||
export async function fetchWorkspaceSettings() {
|
||||
return apiRequestData<WorkspaceSettings>("/api/system/workspace");
|
||||
}
|
||||
|
||||
export async function updateWorkspaceSettings(payload: {
|
||||
moduleVisibility: Record<string, WorkspaceModuleVisibilityMode>;
|
||||
}) {
|
||||
return apiRequestData<{ item: WorkspaceSettings }>("/api/system/workspace", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSystemNavigation() {
|
||||
return apiRequestData<NavigationPayload>("/api/system/navigation");
|
||||
}
|
||||
@@ -8,6 +8,19 @@ import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
|
||||
|
||||
function entityLabel(entityType: string) {
|
||||
if (entityType === "employee") return "人员";
|
||||
if (entityType === "announcement") return "公告";
|
||||
if (entityType === "weekly_report") return "周报";
|
||||
if (entityType === "todo") return "待办";
|
||||
if (entityType === "reimbursement") return "报销";
|
||||
if (entityType === "leave") return "请假";
|
||||
if (entityType === "purchase_request") return "采购";
|
||||
if (entityType === "travel_request") return "差旅";
|
||||
if (entityType === "asset") return "资产";
|
||||
return entityType;
|
||||
}
|
||||
|
||||
export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const navigate = useNavigate();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
@@ -19,7 +32,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
|
||||
});
|
||||
|
||||
const message = useMemo(() => {
|
||||
if (!trimmed) return "输入编号、标题或关键字开始搜索。";
|
||||
if (!trimmed) return "输入姓名、公告、资产、请假、报销、周报或待办关键字开始搜索。";
|
||||
if (trimmed.length < 2) return "至少输入 2 个字符。";
|
||||
if (searchQuery.isError) {
|
||||
return isApiError(searchQuery.error)
|
||||
@@ -38,7 +51,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="搜索报销、请假、采购、差旅、资产"
|
||||
placeholder="搜索人员、公告、资产、请假、报销、周报、待办"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
/>
|
||||
@@ -61,7 +74,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
|
||||
<div className="text-xs uppercase text-zinc-500">{item.matchedBy}</div>
|
||||
</div>
|
||||
{item.subtitle ? <div className="mt-1 text-sm text-zinc-600">{item.subtitle}</div> : null}
|
||||
<div className="mt-1 text-xs text-zinc-500">{item.entityType} · {item.status ?? "-"}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{entityLabel(item.entityType)} · {item.status ?? "-"}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchSystemNavigation } from "../../api/workspace";
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../../auth/permissions";
|
||||
import { cn } from "../../lib/utils";
|
||||
@@ -12,183 +14,57 @@ export type NavItem = {
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/portal", label: "Portal", icon: "home_app_logo" },
|
||||
{ to: "/dashboard", label: "仪表盘", icon: "dashboard" },
|
||||
{
|
||||
to: "/reimbursements",
|
||||
label: "我的报销",
|
||||
icon: "request_quote",
|
||||
anyOf: ["expense.view_own", "expense.view_all"],
|
||||
},
|
||||
{
|
||||
to: "/leave-requests",
|
||||
label: "请假申请",
|
||||
icon: "event_available",
|
||||
anyOf: ["leave.view_own", "leave.view_all"],
|
||||
},
|
||||
{
|
||||
to: "/purchase-requests",
|
||||
label: "采购申请",
|
||||
icon: "shopping_cart",
|
||||
anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"],
|
||||
},
|
||||
{
|
||||
to: "/travel-requests",
|
||||
label: "差旅申请",
|
||||
icon: "flight",
|
||||
anyOf: ["travel.create", "travel.view_own", "travel.view_all", "travel.manage", "travel.payment.manage", "integration.manage"],
|
||||
},
|
||||
{
|
||||
to: "/workflow/tasks",
|
||||
label: "待我处理",
|
||||
icon: "fact_check",
|
||||
anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"],
|
||||
},
|
||||
{
|
||||
to: "/payment-batches",
|
||||
label: "支付批次",
|
||||
icon: "payments",
|
||||
anyOf: ["payment.batch.manage", "risk.review.manage"],
|
||||
},
|
||||
{
|
||||
to: "/ap-operations",
|
||||
label: "AP 运营",
|
||||
icon: "account_balance_wallet",
|
||||
anyOf: ["payment.batch.manage", "risk.review.manage"],
|
||||
},
|
||||
{
|
||||
to: "/assets",
|
||||
label: "资产台账",
|
||||
icon: "inventory_2",
|
||||
anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"],
|
||||
},
|
||||
{
|
||||
to: "/inventory-checks",
|
||||
label: "资产盘点",
|
||||
icon: "fact_check",
|
||||
anyOf: ["asset.inventory"],
|
||||
},
|
||||
{
|
||||
to: "/invoice-pool",
|
||||
label: "票据中心",
|
||||
icon: "receipt_long",
|
||||
anyOf: ["invoice.upload", "invoice.view_own", "invoice.manage"],
|
||||
},
|
||||
{
|
||||
to: "/mobile/capture",
|
||||
label: "移动拍照",
|
||||
icon: "document_scanner",
|
||||
anyOf: ["invoice.upload", "invoice.manage"],
|
||||
},
|
||||
{
|
||||
to: "/mobile/assets/scan",
|
||||
label: "资产扫码",
|
||||
icon: "qr_code_scanner",
|
||||
anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"],
|
||||
},
|
||||
{
|
||||
to: "/mobile/approvals",
|
||||
label: "移动审批",
|
||||
icon: "mobile_friendly",
|
||||
anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"],
|
||||
},
|
||||
{
|
||||
to: "/budgets",
|
||||
label: "预算管理",
|
||||
icon: "account_balance",
|
||||
anyOf: ["budget.view", "budget.manage"],
|
||||
},
|
||||
{
|
||||
to: "/audit-logs",
|
||||
label: "审计工作台",
|
||||
icon: "manage_search",
|
||||
anyOf: ["system.audit.view"],
|
||||
},
|
||||
{
|
||||
to: "/delegations",
|
||||
label: "委派管理",
|
||||
icon: "swap_horiz",
|
||||
anyOf: ["expense.proxy_manage"],
|
||||
},
|
||||
{
|
||||
to: "/system/users",
|
||||
label: "用户管理",
|
||||
icon: "group",
|
||||
anyOf: ["system.users"],
|
||||
},
|
||||
{
|
||||
to: "/system/departments",
|
||||
label: "部门管理",
|
||||
icon: "account_tree",
|
||||
anyOf: ["system.users"],
|
||||
},
|
||||
{
|
||||
to: "/system/master-data",
|
||||
label: "主数据",
|
||||
icon: "dataset",
|
||||
anyOf: ["masterdata.view", "masterdata.manage"],
|
||||
},
|
||||
{
|
||||
to: "/system/notification-channels",
|
||||
label: "通知渠道",
|
||||
icon: "notifications_active",
|
||||
anyOf: ["notification.channel.manage"],
|
||||
},
|
||||
{
|
||||
to: "/system/integrations/erp",
|
||||
label: "ERP 集成",
|
||||
icon: "hub",
|
||||
anyOf: ["integration.manage"],
|
||||
},
|
||||
{
|
||||
to: "/system/approval-rules",
|
||||
label: "审批规则",
|
||||
icon: "rule",
|
||||
anyOf: ["system.settings"],
|
||||
},
|
||||
{
|
||||
to: "/system/workflow-definitions",
|
||||
label: "流程定义",
|
||||
icon: "schema",
|
||||
anyOf: ["system.settings"],
|
||||
},
|
||||
{
|
||||
to: "/system/announcements",
|
||||
label: "公告管理",
|
||||
icon: "campaign",
|
||||
anyOf: ["system.settings"],
|
||||
},
|
||||
{
|
||||
to: "/system/cutover",
|
||||
label: "切换控制台",
|
||||
icon: "swap_calls",
|
||||
anyOf: ["system.settings"],
|
||||
},
|
||||
{
|
||||
to: "/system/feature-flags",
|
||||
label: "功能开关",
|
||||
icon: "toggle_on",
|
||||
anyOf: ["system.settings"],
|
||||
},
|
||||
{ to: "/team", label: "团队", icon: "groups" },
|
||||
{ to: "/leave-requests", label: "请假", icon: "event_available", anyOf: ["leave.view_own", "leave.view_all", "leave.manage"] },
|
||||
{ to: "/reimbursements", label: "报销", icon: "request_quote", anyOf: ["expense.view_own", "expense.view_all"] },
|
||||
{ to: "/assets", label: "资产", icon: "inventory_2", anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"] },
|
||||
{ to: "/weekly-reports", label: "周报", icon: "article" },
|
||||
{ to: "/todos", label: "待办", icon: "check_circle" },
|
||||
{ to: "/announcements", label: "公告", icon: "campaign" },
|
||||
{ to: "/system/workspace", label: "工作区设置", icon: "tune", anyOf: ["system.settings"] },
|
||||
{ to: "/people", label: "人员管理", icon: "group", anyOf: ["system.users", "report.dashboard.view"] },
|
||||
{ to: "/system/workflow-definitions", label: "流程定义", icon: "schema", anyOf: ["system.settings"] },
|
||||
{ to: "/system/settings", label: "系统设置", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.view", "masterdata.manage", "notification.channel.manage", "integration.manage"] },
|
||||
{ to: "/system/feature-flags", label: "功能开关", icon: "toggle_on", anyOf: ["system.settings"] },
|
||||
{ to: "/audit-logs", label: "审计日志", icon: "manage_search", anyOf: ["system.audit.view"] },
|
||||
{ to: "/workflow/tasks", label: "审批待办", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"] },
|
||||
{ to: "/purchase-requests", label: "采购申请", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
|
||||
{ to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.create", "travel.view_own", "travel.view_all", "travel.manage", "travel.payment.manage", "integration.manage"] },
|
||||
{ to: "/budgets", label: "预算管理", icon: "account_balance", anyOf: ["budget.view", "budget.manage"] },
|
||||
{ to: "/payment-batches", label: "支付批次", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage"] },
|
||||
{ to: "/ap-operations", label: "AP 运营", icon: "account_balance_wallet", anyOf: ["payment.batch.manage", "risk.review.manage"] },
|
||||
{ to: "/invoice-pool", label: "票据中心", icon: "receipt_long", anyOf: ["invoice.upload", "invoice.view_own", "invoice.manage"] },
|
||||
{ to: "/inventory-checks", label: "资产盘点", icon: "inventory", anyOf: ["asset.inventory"] },
|
||||
{ to: "/system/cutover", label: "切换控制台", icon: "swap_calls", anyOf: ["system.settings"] },
|
||||
{ to: "/system/integrations/erp", label: "ERP 集成", icon: "hub", anyOf: ["integration.manage"] },
|
||||
{ to: "/dashboard", label: "仪表盘", icon: "dashboard", anyOf: ["report.dashboard.view"] },
|
||||
{ to: "/settings/profile", label: "个人设置", icon: "manage_accounts" },
|
||||
];
|
||||
|
||||
export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const { permissions } = useAuth();
|
||||
const LEGACY_ACCESSIBLE_LABELS: Record<string, string> = {
|
||||
"/reimbursements": "我的报销",
|
||||
"/workflow/tasks": "待我处理",
|
||||
"/assets": "资产台账",
|
||||
};
|
||||
|
||||
const visibleItems = NAV_ITEMS.filter((item) => {
|
||||
if (!item.anyOf) {
|
||||
return true;
|
||||
function renderSection(
|
||||
title: string,
|
||||
items: Array<{ key?: string; href: string; label: string; icon: string }>,
|
||||
onNavigate?: () => void,
|
||||
) {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hasAnyPermission(permissions, item.anyOf);
|
||||
});
|
||||
|
||||
return (
|
||||
<nav className="space-y-1">
|
||||
{visibleItems.map((item) => (
|
||||
<div className="space-y-1">
|
||||
<div className="px-3 pt-2 text-[11px] font-semibold uppercase tracking-wide text-zinc-400">{title}</div>
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
key={item.key ?? item.href}
|
||||
to={item.href}
|
||||
aria-label={LEGACY_ACCESSIBLE_LABELS[item.href] ?? item.label}
|
||||
title={LEGACY_ACCESSIBLE_LABELS[item.href] ?? item.label}
|
||||
onClick={() => onNavigate?.()}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
@@ -203,6 +79,34 @@ export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildFallbackItems(permissions: string[]) {
|
||||
const mapItems = (paths: string[]) =>
|
||||
NAV_ITEMS.filter((item) => paths.includes(item.to))
|
||||
.filter((item) => !item.anyOf || hasAnyPermission(permissions, item.anyOf))
|
||||
.map((item) => ({ key: item.to, href: item.to, label: item.label, icon: item.icon }));
|
||||
|
||||
return {
|
||||
main: mapItems(["/portal", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements"]),
|
||||
admin: mapItems(["/system/workspace", "/people", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"]),
|
||||
backup: mapItems(["/workflow/tasks", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard"]),
|
||||
};
|
||||
}
|
||||
|
||||
export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const { permissions } = useAuth();
|
||||
const navigationQuery = useQuery({ queryKey: ["system-navigation"], queryFn: fetchSystemNavigation, staleTime: 60_000 });
|
||||
|
||||
const sections = navigationQuery.data?.sections ?? buildFallbackItems(permissions);
|
||||
|
||||
return (
|
||||
<nav className="space-y-3">
|
||||
{renderSection("主导航", sections.main, onNavigate)}
|
||||
{renderSection("管理", sections.admin, onNavigate)}
|
||||
{renderSection("备用", sections.backup, onNavigate)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { NavLinks } from "./NavLinks";
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="hidden w-64 border-r border-zinc-200 bg-white lg:flex lg:flex-col fixed inset-y-0 z-20">
|
||||
<div className="border-b border-zinc-200 p-6 flex items-center gap-3">
|
||||
<div className="size-8 bg-zinc-900 rounded-lg flex items-center justify-center text-white font-bold text-lg">H</div>
|
||||
<span className="font-bold text-zinc-900 tracking-tight">海音达工作台</span>
|
||||
<aside className="fixed inset-y-0 z-20 hidden w-64 border-r border-zinc-200 bg-white lg:flex lg:flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-3 border-b border-zinc-200 p-6">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-zinc-900 text-lg font-bold text-white">H</div>
|
||||
<span className="font-bold tracking-tight text-zinc-900">海音达工作台</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-3">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<NavLinks />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -9,7 +9,8 @@ import { writeRecentVisit } from "../lib/recentVisits";
|
||||
const EXTRA_TITLE_ENTRIES = [
|
||||
{ path: "/notifications", title: "通知中心" },
|
||||
{ path: "/reimbursements/", title: "报销详情" },
|
||||
{ path: "/workflow/tasks/", title: "待办详情" },
|
||||
{ path: "/workflow/tasks/", title: "审批详情" },
|
||||
{ path: "/people/", title: "员工档案" },
|
||||
];
|
||||
|
||||
function resolveVisitTitle(pathname: string) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchAnnouncements } from "../api/announcements";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
|
||||
export function AnnouncementsPage() {
|
||||
const announcementsQuery = useQuery({ queryKey: ["announcements", "public"], queryFn: () => fetchAnnouncements(false) });
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="公告" subtitle="全员查看公司通知、制度链接和 FAQ 入口。" />
|
||||
<div className="space-y-4">
|
||||
{(announcementsQuery.data?.items ?? []).map((item) => (
|
||||
<div key={item.id} className="card-std p-6">
|
||||
<div className="text-lg font-semibold text-zinc-900">{item.title}</div>
|
||||
<div className="mt-2 text-sm text-zinc-600">{item.summary}</div>
|
||||
{item.linkUrl ? <a className="mt-3 inline-block text-sm text-primary" href={item.linkUrl} target="_blank" rel="noreferrer">查看链接</a> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnnouncementsPage;
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
completeEmployeeChecklistTask,
|
||||
fetchEmployeeProfile,
|
||||
patchEmployeeProfile,
|
||||
regularizeEmployee,
|
||||
skipEmployeeChecklistTask,
|
||||
startEmployeeOffboarding,
|
||||
transferEmployee,
|
||||
} from "../api/employees";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function EmployeeProfilePage() {
|
||||
const { employeeId = "" } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const profileQuery = useQuery({
|
||||
enabled: Boolean(employeeId),
|
||||
queryKey: ["employee-profile", employeeId],
|
||||
queryFn: () => fetchEmployeeProfile(employeeId),
|
||||
});
|
||||
|
||||
const profile = profileQuery.data;
|
||||
const [form, setForm] = useState({
|
||||
workLocation: "",
|
||||
emergencyContactName: "",
|
||||
emergencyContactPhone: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile) return;
|
||||
setForm({
|
||||
workLocation: profile.employee.workLocation ?? "",
|
||||
emergencyContactName: profile.employee.emergencyContactName ?? "",
|
||||
emergencyContactPhone: profile.employee.emergencyContactPhone ?? "",
|
||||
notes: profile.employee.notes ?? "",
|
||||
});
|
||||
}, [profile]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => patchEmployeeProfile(employeeId, { ...form }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
||||
toast.success("档案已保存");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "保存失败")),
|
||||
});
|
||||
|
||||
const taskMutation = useMutation({
|
||||
mutationFn: ({ taskId, action }: { taskId: string; action: "complete" | "skip" }) =>
|
||||
action === "complete"
|
||||
? completeEmployeeChecklistTask(employeeId, taskId)
|
||||
: skipEmployeeChecklistTask(employeeId, taskId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
||||
toast.success("清单已更新");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "更新清单失败")),
|
||||
});
|
||||
|
||||
const regularizeMutation = useMutation({
|
||||
mutationFn: () => regularizeEmployee(employeeId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
||||
toast.success("已转正");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "转正失败")),
|
||||
});
|
||||
|
||||
const transferMutation = useMutation({
|
||||
mutationFn: () => transferEmployee(employeeId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
||||
toast.success("已记录调岗事件");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "调岗失败")),
|
||||
});
|
||||
|
||||
const offboardingMutation = useMutation({
|
||||
mutationFn: () => startEmployeeOffboarding(employeeId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
||||
toast.success("已发起离职");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "离职失败")),
|
||||
});
|
||||
|
||||
if (profileQuery.isLoading || !profile) {
|
||||
return <div className="card-std p-6 text-sm text-zinc-500">加载员工档案中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={`${profile.employee.name} 档案`}
|
||||
subtitle={`${profile.employee.employeeNo} · ${profile.employee.title}`}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => regularizeMutation.mutate()} disabled={regularizeMutation.isPending}>
|
||||
转正
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => transferMutation.mutate()} disabled={transferMutation.isPending}>
|
||||
记录调岗
|
||||
</Button>
|
||||
<Button type="button" onClick={() => offboardingMutation.mutate()} disabled={offboardingMutation.isPending}>
|
||||
发起离职
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<div className="card-std p-6 space-y-4 xl:col-span-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500">账号状态</div>
|
||||
<div className="mt-2 flex gap-2"><StatusBadge status={profile.employee.status} /><StatusBadge status={profile.employee.employmentStatus} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500">部门 / 上级</div>
|
||||
<div className="mt-2 text-sm text-zinc-900">{profile.employee.department?.name ?? "-"} / {profile.employee.directManager?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500">入职 / 试用期</div>
|
||||
<div className="mt-2 text-sm text-zinc-900">{profile.employee.hireDate ?? "-"} / {profile.employee.probationEndDate ?? "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500">离职日期</div>
|
||||
<div className="mt-2 text-sm text-zinc-900">{profile.employee.lastWorkingDate ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">办公地点</label>
|
||||
<Input value={form.workLocation} onChange={(event) => setForm((prev) => ({ ...prev, workLocation: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">紧急联系人</label>
|
||||
<Input value={form.emergencyContactName} onChange={(event) => setForm((prev) => ({ ...prev, emergencyContactName: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">紧急联系电话</label>
|
||||
<Input value={form.emergencyContactPhone} onChange={(event) => setForm((prev) => ({ ...prev, emergencyContactPhone: event.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">备注</label>
|
||||
<Textarea value={form.notes} onChange={(event) => setForm((prev) => ({ ...prev, notes: event.target.value }))} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存档案</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<h3 className="text-base font-semibold text-zinc-900">离职阻塞项</h3>
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
资产未归还:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.openAssetCount}</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
待办审批任务:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.pendingWorkflowTaskCount}</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
可完成离职:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.canCompleteOffboarding ? "是" : "否"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="card-std p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">生命周期事件</h3>
|
||||
<div className="mt-4 space-y-3">
|
||||
{profile.lifecycle.events.length ? profile.lifecycle.events.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<div className="font-medium text-zinc-900">{item.eventType}</div>
|
||||
<div className="mt-1 text-zinc-500">生效日期:{item.effectiveDate ?? "-"}</div>
|
||||
</div>
|
||||
)) : <div className="text-sm text-zinc-500">暂无生命周期事件</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">名下资产</h3>
|
||||
<div className="mt-4 space-y-3">
|
||||
{profile.assignedAssets.length ? profile.assignedAssets.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<div className="font-medium text-zinc-900">{item.assetNo} · {item.name}</div>
|
||||
<div className="mt-1 text-zinc-500">位置:{item.location ?? "-"} · 状态:{item.status}</div>
|
||||
</div>
|
||||
)) : <div className="text-sm text-zinc-500">当前无挂名资产</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">Checklist</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>场景</TableHead>
|
||||
<TableHead>任务</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{profile.lifecycle.checklistTasks.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.scene}</TableCell>
|
||||
<TableCell>{item.title}</TableCell>
|
||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" type="button" disabled={taskMutation.isPending || item.status !== "pending"} onClick={() => taskMutation.mutate({ taskId: item.id, action: "complete" })}>完成</Button>
|
||||
<Button size="sm" variant="secondary" type="button" disabled={taskMutation.isPending || item.status !== "pending"} onClick={() => taskMutation.mutate({ taskId: item.id, action: "skip" })}>跳过</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmployeeProfilePage;
|
||||
@@ -40,6 +40,10 @@ function toDisplayName(flagKey: string) {
|
||||
if (flagKey === "announcements_v1") return "公告中心";
|
||||
if (flagKey === "workflow_definition_admin_v1") return "流程定义治理";
|
||||
if (flagKey === "cutover_console_v1") return "切换控制台";
|
||||
if (flagKey === "presence_board_v1") return "团队状态板";
|
||||
if (flagKey === "weekly_report_v1") return "周报工作台";
|
||||
if (flagKey === "employee_lifecycle_v1") return "人员生命周期";
|
||||
if (flagKey === "todo_board_v1") return "待办工作台";
|
||||
return flagKey;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchEmployees,
|
||||
regularizeEmployee,
|
||||
startEmployeeOffboarding,
|
||||
startEmployeeOnboarding,
|
||||
} from "../api/employees";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function PeoplePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
const employeesQuery = useQuery({
|
||||
queryKey: ["employees", "people-page", q],
|
||||
queryFn: () => fetchEmployees({ page: 1, pageSize: 100, q: q || undefined }),
|
||||
});
|
||||
|
||||
const onboardingMutation = useMutation({
|
||||
mutationFn: (employeeId: string) => startEmployeeOnboarding(employeeId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employees"] });
|
||||
toast.success("已发起入职流程");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = isApiError(error)
|
||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "发起入职失败";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const regularizeMutation = useMutation({
|
||||
mutationFn: (employeeId: string) => regularizeEmployee(employeeId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employees"] });
|
||||
toast.success("已完成转正");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = isApiError(error)
|
||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "转正失败";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const offboardingMutation = useMutation({
|
||||
mutationFn: (employeeId: string) => startEmployeeOffboarding(employeeId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employees"] });
|
||||
toast.success("已发起离职流程");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = isApiError(error)
|
||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "发起离职失败";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const items = employeesQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="人员管理"
|
||||
subtitle="小团队用这里管入职、转正、离职与档案入口。"
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/system/users">先新建账号</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="card-std p-4">
|
||||
<Input placeholder="搜索姓名、工号、手机号、邮箱" value={q} onChange={(event) => setQ(event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>员工</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>账号状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.name}</div>
|
||||
<div className="text-xs text-zinc-500">{item.employeeNo} · {item.email}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.roleCodes.join(" / ") || "-"}</TableCell>
|
||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild size="sm" variant="secondary" type="button">
|
||||
<Link to={`/people/${item.id}`}>查看档案</Link>
|
||||
</Button>
|
||||
<Button size="sm" type="button" disabled={onboardingMutation.isPending} onClick={() => onboardingMutation.mutate(item.id)}>
|
||||
发起入职
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" type="button" disabled={regularizeMutation.isPending} onClick={() => regularizeMutation.mutate(item.id)}>
|
||||
转正
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" type="button" disabled={offboardingMutation.isPending} onClick={() => offboardingMutation.mutate(item.id)}>
|
||||
发起离职
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PeoplePage;
|
||||
@@ -5,21 +5,21 @@ import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { readRecentVisits } from "../lib/recentVisits";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { Button } from "../components/ui/button";
|
||||
|
||||
function domainLabel(domain: string) {
|
||||
if (domain === "reimbursement") return "报销";
|
||||
if (domain === "leave") return "请假";
|
||||
if (domain === "purchase_request") return "采购";
|
||||
if (domain === "travel_request") return "差旅";
|
||||
return domain;
|
||||
}
|
||||
|
||||
export function PortalHomePage() {
|
||||
const portalQuery = useQuery({
|
||||
queryKey: ["portal-home"],
|
||||
queryFn: () => fetchPortalHome(),
|
||||
});
|
||||
const { permissions } = useAuth();
|
||||
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() });
|
||||
const recentVisits = readRecentVisits();
|
||||
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
|
||||
|
||||
if (portalQuery.isLoading) {
|
||||
return <div className="card-std p-6 text-sm text-zinc-500">加载 Portal 中...</div>;
|
||||
@@ -31,9 +31,8 @@ export function PortalHomePage() {
|
||||
: "Portal 加载失败";
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Portal" subtitle="统一入口与待办概览。" />
|
||||
<PageHeader title="Portal" subtitle="小团队统一入口与高频待办概览。" />
|
||||
<div className="card-std p-6 text-sm text-red-600">{message}</div>
|
||||
<div className="card-std p-6 text-sm text-zinc-600">如 `portal_home_v1` 尚未开启,可先使用 `仪表盘`。</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,7 +43,19 @@ export function PortalHomePage() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Portal" subtitle={`你好,${payload.greeting.name}。`} />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{!payload.workspace.persisted && canSetupWorkspace ? (
|
||||
<div className="card-std border-amber-200 bg-amber-50 p-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">工作区尚未初始化</div>
|
||||
<div className="text-sm text-zinc-600">建议先完成小团队模式的导航与模块设置,再邀请全员使用。</div>
|
||||
</div>
|
||||
<Button asChild type="button">
|
||||
<Link to="/system/workspace">去初始化</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
|
||||
{payload.cards.map((card) => (
|
||||
<Link
|
||||
key={card.key}
|
||||
@@ -85,6 +96,12 @@ export function PortalHomePage() {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{payload.todayPresence ? (
|
||||
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm">
|
||||
今日状态:<span className="font-medium text-zinc-900">{payload.todayPresence.label}</span>
|
||||
{payload.todayPresence.note ? <div className="mt-1 text-zinc-500">{payload.todayPresence.note}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-3 p-6">
|
||||
@@ -115,19 +132,41 @@ export function PortalHomePage() {
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">待办与风险</h3>
|
||||
<h3 className="text-base font-semibold text-zinc-900">工作台摘要</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
{payload.teamPresence ? (
|
||||
<Link to={payload.teamPresence.href} className="block rounded-lg border border-zinc-200 px-3 py-3 hover:bg-zinc-50">
|
||||
今日团队状态:在岗 {payload.teamPresence.office} / 总数 {payload.teamPresence.total},请假 {payload.teamPresence.leave},未更新 {payload.teamPresence.unspecified}
|
||||
</Link>
|
||||
) : null}
|
||||
{payload.weeklySummary ? (
|
||||
<Link to={payload.weeklySummary.href} className="block rounded-lg border border-zinc-200 px-3 py-3 hover:bg-zinc-50">
|
||||
本周周报:未提交 {payload.weeklySummary.missingCount},需协助 {payload.weeklySummary.needsHelpCount}
|
||||
</Link>
|
||||
) : null}
|
||||
{payload.todoSummary ? (
|
||||
<Link to={payload.todoSummary.href} className="block rounded-lg border border-zinc-200 px-3 py-3 hover:bg-zinc-50">
|
||||
我的待办:未完成 {payload.todoSummary.openCount},高优先级 {payload.todoSummary.highPriorityCount}
|
||||
</Link>
|
||||
) : null}
|
||||
{payload.peopleLifecycle ? (
|
||||
<Link to={payload.peopleLifecycle.href} className="block rounded-lg border border-zinc-200 px-3 py-3 hover:bg-zinc-50">
|
||||
人员闭环:入职 {payload.peopleLifecycle.onboardingCount},离职 {payload.peopleLifecycle.offboardingCount},资产待归还 {payload.peopleLifecycle.assetReturnPendingCount}
|
||||
</Link>
|
||||
) : null}
|
||||
<Link to={payload.pendingTasks.href} className="block rounded-lg border border-zinc-200 px-3 py-3 text-sm hover:bg-zinc-50">
|
||||
我的待办:<span className="font-medium text-zinc-900">{payload.pendingTasks.count}</span>
|
||||
审批待办:<span className="font-medium text-zinc-900">{payload.pendingTasks.count}</span>
|
||||
</Link>
|
||||
{payload.riskSummary ? (
|
||||
<Link to={payload.riskSummary.href} className="block rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm hover:bg-amber-100">
|
||||
待复核风险:<span className="font-medium text-zinc-900">{payload.riskSummary.openCount}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">当前没有开放风险项</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PortalHomePage;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
|
||||
const ITEMS = [
|
||||
{ to: "/system/workspace", title: "工作区设置", description: "调整小团队模式的导航与模块显隐。" },
|
||||
{ to: "/people", title: "人员管理", description: "处理入职、转正、调岗、离职与档案。" },
|
||||
{ to: "/system/approval-rules", title: "审批规则", description: "维护仍在使用的简单审批链与规则。" },
|
||||
{ to: "/system/master-data", title: "主数据", description: "维护费用、法体、成本中心等主数据。" },
|
||||
{ to: "/system/notification-channels", title: "通知渠道", description: "继续保留 IM/邮件等补充通知渠道。" },
|
||||
{ to: "/system/integrations/erp", title: "ERP 集成", description: "查看备用集成能力与出入口。" },
|
||||
];
|
||||
|
||||
export function SystemSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="系统设置" subtitle="小团队模式下的后台设置总入口。" />
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{ITEMS.map((item) => (
|
||||
<Link key={item.to} to={item.to} className="card-std p-6 hover:bg-zinc-50">
|
||||
<div className="text-base font-semibold text-zinc-900">{item.title}</div>
|
||||
<div className="mt-2 text-sm text-zinc-600">{item.description}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SystemSettingsPage;
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchMyPresence, fetchTeamPresence, upsertMyPresence } from "../api/presence";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
|
||||
function statusLabel(status: string | null) {
|
||||
if (status === "office") return "在岗";
|
||||
if (status === "remote") return "居家";
|
||||
if (status === "out") return "外出";
|
||||
if (status === "travel") return "出差";
|
||||
if (status === "leave") return "请假";
|
||||
return "未更新";
|
||||
}
|
||||
|
||||
export function TeamPresencePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [date, setDate] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [status, setStatus] = useState<"office" | "remote" | "out" | "travel">("office");
|
||||
const [filter, setFilter] = useState<"all" | "onsite" | "away">("all");
|
||||
|
||||
const myPresenceQuery = useQuery({ queryKey: ["presence", "me", date], queryFn: () => fetchMyPresence(date || undefined) });
|
||||
const teamPresenceQuery = useQuery({ queryKey: ["presence", "team", date], queryFn: () => fetchTeamPresence(date || undefined) });
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => upsertMyPresence({ date: date || undefined, status, note: note || undefined }),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["presence", "me"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["presence", "team"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("今日状态已更新");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = isApiError(error)
|
||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "更新失败";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const teamItems = useMemo(() => {
|
||||
const items = teamPresenceQuery.data?.items ?? [];
|
||||
if (filter === "all") return items;
|
||||
if (filter === "onsite") return items.filter((item) => item.presence?.status === "office");
|
||||
return items.filter((item) => item.presence?.status !== "office");
|
||||
}, [filter, teamPresenceQuery.data?.items]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="团队状态" subtitle="不做打卡,只管理今天谁在岗 / 居家 / 外出 / 出差 / 请假。" />
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<h3 className="text-base font-semibold text-zinc-900">更新我的今日状态</h3>
|
||||
<Input type="date" value={date} onChange={(event) => setDate(event.target.value)} />
|
||||
<select className="select-std" value={status} onChange={(event) => setStatus(event.target.value as typeof status)}>
|
||||
<option value="office">在岗</option>
|
||||
<option value="remote">居家</option>
|
||||
<option value="out">外出</option>
|
||||
<option value="travel">出差</option>
|
||||
</select>
|
||||
<Input value={note} onChange={(event) => setNote(event.target.value)} placeholder="可选备注,如下午外出办事" />
|
||||
<Button type="button" onClick={() => mutation.mutate()} disabled={mutation.isPending}>保存状态</Button>
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-sm text-zinc-600">
|
||||
当前:{statusLabel(myPresenceQuery.data?.item?.status ?? null)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6 xl:col-span-2">
|
||||
<h3 className="text-base font-semibold text-zinc-900">今日汇总</h3>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
{teamPresenceQuery.data?.summary ? Object.entries(teamPresenceQuery.data.summary).map(([key, value]) => (
|
||||
<div key={key} className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||
<div className="text-zinc-500">{key}</div>
|
||||
<div className="mt-2 text-lg font-semibold text-zinc-900">{value}</div>
|
||||
</div>
|
||||
)) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">团队列表</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant={filter === "all" ? "default" : "secondary"} type="button" onClick={() => setFilter("all")}>全部</Button>
|
||||
<Button size="sm" variant={filter === "onsite" ? "default" : "secondary"} type="button" onClick={() => setFilter("onsite")}>在岗</Button>
|
||||
<Button size="sm" variant={filter === "away" ? "default" : "secondary"} type="button" onClick={() => setFilter("away")}>不在岗</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>成员</TableHead>
|
||||
<TableHead>部门</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{teamItems.map((item) => (
|
||||
<TableRow key={item.employee.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.employee.name}</div>
|
||||
<div className="text-xs text-zinc-500">{item.employee.title}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.employee.department?.name ?? "-"}</TableCell>
|
||||
<TableCell>{statusLabel(item.presence?.status ?? null)}</TableCell>
|
||||
<TableCell>{item.presence?.note ?? "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamPresencePage;
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { cancelTodo, completeTodo, createTodo, fetchTodos } from "../api/todos";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function TodosPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"todo" | "doing" | "done" | "cancelled" | "">("");
|
||||
const todosQuery = useQuery({
|
||||
queryKey: ["todos", statusFilter],
|
||||
queryFn: () => fetchTodos({ status: statusFilter || undefined }),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createTodo({ title, description: description || undefined }),
|
||||
onSuccess: async () => {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["todos"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("待办已创建");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "创建待办失败")),
|
||||
});
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: (id: string) => completeTodo(id),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["todos"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("待办已完成");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "完成待办失败")),
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (id: string) => cancelTodo(id),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["todos"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("待办已取消");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "取消待办失败")),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="待办" subtitle="员工维护个人待办,老板看高优先级和未完成项。" />
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<Input placeholder="待办标题" value={title} onChange={(event) => setTitle(event.target.value)} />
|
||||
<Input placeholder="补充说明(可选)" value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||
<div className="flex gap-2">
|
||||
<select className="select-std" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as typeof statusFilter)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="todo">todo</option>
|
||||
<option value="doing">doing</option>
|
||||
<option value="done">done</option>
|
||||
<option value="cancelled">cancelled</option>
|
||||
</select>
|
||||
<Button type="button" onClick={() => createMutation.mutate()} disabled={createMutation.isPending || !title.trim()}>新建待办</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>负责人</TableHead>
|
||||
<TableHead>优先级</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(todosQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="text-xs text-zinc-500">{item.description ?? "-"}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.assignee?.name ?? "-"}</TableCell>
|
||||
<TableCell>{item.priority}</TableCell>
|
||||
<TableCell>{item.status}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" type="button" disabled={completeMutation.isPending || item.status === "done"} onClick={() => completeMutation.mutate(item.id)}>完成</Button>
|
||||
<Button size="sm" variant="secondary" type="button" disabled={cancelMutation.isPending || item.status === "cancelled"} onClick={() => cancelMutation.mutate(item.id)}>取消</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TodosPage;
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createTodo } from "../api/todos";
|
||||
import { fetchTeamWeeklyReports, fetchMyWeeklyReport, submitMyWeeklyReport, updateMyWeeklyReport } from "../api/weeklyReports";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function WeeklyReportsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const canViewTeam = hasAnyPermission(permissions, ["report.dashboard.view", "system.users"]);
|
||||
const [weekStart, setWeekStart] = useState("");
|
||||
const reportQuery = useQuery({ queryKey: ["weekly-report", "me", weekStart], queryFn: () => fetchMyWeeklyReport(weekStart || undefined) });
|
||||
const teamQuery = useQuery({ enabled: canViewTeam, queryKey: ["weekly-report", "team", weekStart], queryFn: () => fetchTeamWeeklyReports(weekStart || undefined) });
|
||||
|
||||
const report = reportQuery.data?.item;
|
||||
const [draft, setDraft] = useState({ highlightsText: "", blockersText: "", nextWeekText: "", needsHelp: false });
|
||||
|
||||
useEffect(() => {
|
||||
if (!report) return;
|
||||
setDraft({
|
||||
highlightsText: report.highlightsText,
|
||||
blockersText: report.blockersText,
|
||||
nextWeekText: report.nextWeekText,
|
||||
needsHelp: report.needsHelp,
|
||||
});
|
||||
}, [report]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => updateMyWeeklyReport({ weekStart: weekStart || undefined, ...draft }),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["weekly-report", "me"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["weekly-report", "team"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("周报已保存");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "保存失败")),
|
||||
});
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: () => submitMyWeeklyReport({ weekStart: weekStart || undefined }),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["weekly-report", "me"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["weekly-report", "team"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("周报已提交");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "提交失败")),
|
||||
});
|
||||
|
||||
const createTodoMutation = useMutation({
|
||||
mutationFn: () => createTodo({
|
||||
title: `${report?.weekStart ?? "本周"} 周计划`,
|
||||
description: draft.nextWeekText || "来自周报的下周计划",
|
||||
source: "weekly_report",
|
||||
priority: draft.needsHelp ? "high" : "normal",
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["todos"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("已从周报生成待办");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "生成待办失败")),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="周报" subtitle="固定三段结构:本周完成、当前阻塞、下周计划。" />
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-sm text-zinc-500">选择周起始日(默认自动按周一归档)</div>
|
||||
<input className="input-std" type="date" value={weekStart} onChange={(event) => setWeekStart(event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">本周完成</label>
|
||||
<Textarea value={draft.highlightsText} onChange={(event) => setDraft((prev) => ({ ...prev, highlightsText: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">当前阻塞</label>
|
||||
<Textarea value={draft.blockersText} onChange={(event) => setDraft((prev) => ({ ...prev, blockersText: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">下周计划</label>
|
||||
<Textarea value={draft.nextWeekText} onChange={(event) => setDraft((prev) => ({ ...prev, nextWeekText: event.target.value }))} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
||||
<input type="checkbox" checked={draft.needsHelp} onChange={(event) => setDraft((prev) => ({ ...prev, needsHelp: event.target.checked }))} />
|
||||
需要老板/管理员协助
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成待办</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存草稿</Button>
|
||||
<Button type="button" onClick={() => submitMutation.mutate()} disabled={submitMutation.isPending}>提交周报</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canViewTeam ? (
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-zinc-900">团队汇总</h3>
|
||||
<div className="text-sm text-zinc-500">
|
||||
未提交 {teamQuery.data?.summary.missingCount ?? 0} · 需协助 {teamQuery.data?.summary.needsHelpCount ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>成员</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>需协助</TableHead>
|
||||
<TableHead>摘要</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(teamQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.employee.id}>
|
||||
<TableCell>{item.employee.name}</TableCell>
|
||||
<TableCell>{item.report?.status ?? "missing"}</TableCell>
|
||||
<TableCell>{item.report?.needsHelp ? "是" : "否"}</TableCell>
|
||||
<TableCell>{item.report?.highlightsText || item.report?.nextWeekText || item.report?.blockersText || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WeeklyReportsPage;
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchSystemNavigation, fetchWorkspaceSettings, updateWorkspaceSettings } from "../api/workspace";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
portal: "Portal",
|
||||
team: "团队",
|
||||
leaveRequests: "请假",
|
||||
reimbursements: "报销",
|
||||
assets: "资产",
|
||||
weeklyReports: "周报",
|
||||
todos: "待办",
|
||||
announcements: "公告",
|
||||
workspace: "工作区设置",
|
||||
people: "人员管理",
|
||||
workflowDefinitions: "流程定义",
|
||||
systemSettings: "系统设置",
|
||||
featureFlags: "功能开关",
|
||||
auditLogs: "审计日志",
|
||||
purchaseRequests: "采购申请",
|
||||
travelRequests: "差旅申请",
|
||||
budgets: "预算管理",
|
||||
paymentBatches: "支付批次",
|
||||
apOperations: "AP 运营",
|
||||
cutover: "切换控制台",
|
||||
erpIntegrations: "ERP 集成",
|
||||
};
|
||||
|
||||
export function WorkspaceSetupPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const workspaceQuery = useQuery({ queryKey: ["workspace-settings"], queryFn: fetchWorkspaceSettings });
|
||||
const navigationQuery = useQuery({ queryKey: ["system-navigation"], queryFn: fetchSystemNavigation });
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: updateWorkspaceSettings,
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["workspace-settings"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["system-navigation"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("工作区设置已保存");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = isApiError(error)
|
||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "保存失败";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const workspace = workspaceQuery.data;
|
||||
const rolePresets = navigationQuery.data?.recommendedRolePresets ?? [];
|
||||
const moduleEntries = useMemo(() => Object.entries(workspace?.moduleVisibility ?? {}), [workspace?.moduleVisibility]);
|
||||
|
||||
if (workspaceQuery.isLoading || !workspace) {
|
||||
return <div className="card-std p-6 text-sm text-zinc-500">加载工作区设置中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="工作区设置"
|
||||
subtitle="按 10 人以下小团队模式收敛主导航与默认入口。"
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<div className="card-std p-6">
|
||||
<div className="text-sm text-zinc-500">产品模式</div>
|
||||
<div className="mt-2 text-xl font-semibold text-zinc-900">{workspace.profile}</div>
|
||||
<div className="mt-3 text-sm text-zinc-600">单公司、账号密码、站内通知、桌面优先。</div>
|
||||
</div>
|
||||
<div className="card-std p-6">
|
||||
<div className="text-sm text-zinc-500">初始化状态</div>
|
||||
<div className="mt-2 text-xl font-semibold text-zinc-900">{workspace.persisted ? "已完成" : "待初始化"}</div>
|
||||
<div className="mt-3 text-sm text-zinc-600">首次管理员进入此页后保存一次,即完成小团队工作区初始化。</div>
|
||||
</div>
|
||||
<div className="card-std p-6">
|
||||
<div className="text-sm text-zinc-500">推荐角色预设</div>
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{rolePresets.map((item) => (
|
||||
<div key={item.key} className="rounded-lg border border-zinc-200 p-3">
|
||||
<div className="font-medium text-zinc-900">{item.label}</div>
|
||||
<div className="mt-1 text-zinc-600">{item.description}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">角色:{item.roleCodes.join(" / ")}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">模块显隐</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">主导航只保留高频入口;备用模块保留代码与数据,但默认放在管理员备用区。</p>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>模块</TableHead>
|
||||
<TableHead>当前显隐</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{moduleEntries.map(([key, mode]) => (
|
||||
<TableRow key={key}>
|
||||
<TableCell>{MODULE_LABELS[key] ?? key}</TableCell>
|
||||
<TableCell>{mode}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["main", "admin", "hidden"] as const).map((nextMode) => (
|
||||
<Button
|
||||
key={nextMode}
|
||||
size="sm"
|
||||
variant={mode === nextMode ? "default" : "secondary"}
|
||||
type="button"
|
||||
disabled={mutation.isPending}
|
||||
onClick={() => {
|
||||
const nextVisibility = { ...(workspace.moduleVisibility ?? {}), [key]: nextMode };
|
||||
mutation.mutate({ moduleVisibility: nextVisibility });
|
||||
}}
|
||||
>
|
||||
{nextMode}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkspaceSetupPage;
|
||||
@@ -32,6 +32,13 @@ export interface CreateUserDialogProps {
|
||||
roleOptions: Array<{ code: string; label: string }>;
|
||||
}
|
||||
|
||||
const ROLE_PRESETS = [
|
||||
{ key: "owner", label: "老板", roles: ["ceo"] },
|
||||
{ key: "admin", label: "管理员", roles: ["admin"] },
|
||||
{ key: "employee", label: "员工", roles: ["employee"] },
|
||||
{ key: "manager", label: "主管", roles: ["manager"] },
|
||||
];
|
||||
|
||||
export function CreateUserDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -204,6 +211,13 @@ export function CreateUserDialog({
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>角色</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ROLE_PRESETS.map((preset) => (
|
||||
<Button key={preset.key} size="sm" variant="secondary" type="button" onClick={() => setCreateRoles(new Set(preset.roles))}>
|
||||
预设:{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
{roleOptions.map((item) => (
|
||||
<label className="flex items-center gap-2" key={item.code}>
|
||||
|
||||
@@ -18,6 +18,13 @@ export interface UserRoleDialogProps {
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
const ROLE_PRESETS = [
|
||||
{ key: "owner", label: "老板", roles: ["ceo"] },
|
||||
{ key: "admin", label: "管理员", roles: ["admin"] },
|
||||
{ key: "employee", label: "员工", roles: ["employee"] },
|
||||
{ key: "manager", label: "主管", roles: ["manager"] },
|
||||
];
|
||||
|
||||
export function UserRoleDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -57,6 +64,15 @@ export function UserRoleDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ROLE_PRESETS.map((preset) => (
|
||||
<Button key={preset.key} size="sm" variant="secondary" type="button" onClick={() => setRoleDraft(new Set(preset.roles))}>
|
||||
预设:{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
{roleOptions.map((item) => (
|
||||
<label className="flex items-center gap-2" key={item.code}>
|
||||
@@ -80,6 +96,7 @@ export function UserRoleDialog({
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
|
||||
@@ -13,9 +13,11 @@ import { ProfilePage } from "../pages/ProfilePage";
|
||||
import {
|
||||
ApOperationsPage,
|
||||
ApprovalRulesPage,
|
||||
AnnouncementsPage,
|
||||
AssetRegistryPage,
|
||||
AuditLogsPage,
|
||||
CutoverConsolePage,
|
||||
EmployeeProfilePage,
|
||||
DelegationsPage,
|
||||
DepartmentsPage,
|
||||
ErpIntegrationPage,
|
||||
@@ -30,13 +32,19 @@ import {
|
||||
NotificationChannelsPage,
|
||||
NotificationsPage,
|
||||
PaymentBatchesPage,
|
||||
PeoplePage,
|
||||
PortalAnnouncementsPage,
|
||||
PortalHomePage,
|
||||
SystemSettingsPage,
|
||||
TeamPresencePage,
|
||||
TodosPage,
|
||||
PurchaseRequestsPage,
|
||||
ReimbursementDetailPage,
|
||||
ReimbursementsPage,
|
||||
TravelRequestsPage,
|
||||
UserManagementPage,
|
||||
WeeklyReportsPage,
|
||||
WorkspaceSetupPage,
|
||||
WorkflowDefinitionsPage,
|
||||
WorkflowTaskDetailPage,
|
||||
WorkflowTasksPage,
|
||||
@@ -82,6 +90,10 @@ export function AppRouter() {
|
||||
>
|
||||
<Route index element={<Navigate to="/portal" replace />} />
|
||||
<Route path="portal" element={withRouteFallback(<PortalHomePage />)} />
|
||||
<Route path="announcements" element={withRouteFallback(<AnnouncementsPage />)} />
|
||||
<Route path="team" element={withRouteFallback(<TeamPresencePage />)} />
|
||||
<Route path="weekly-reports" element={withRouteFallback(<WeeklyReportsPage />)} />
|
||||
<Route path="todos" element={withRouteFallback(<TodosPage />)} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
|
||||
<Route
|
||||
@@ -263,6 +275,17 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="people"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.users", "report.dashboard.view"]}>
|
||||
{withRouteFallback(<PeoplePage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="people/:employeeId" element={withRouteFallback(<EmployeeProfilePage />)} />
|
||||
|
||||
<Route
|
||||
path="system/users"
|
||||
element={
|
||||
@@ -308,6 +331,24 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="system/workspace"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.settings"]}>
|
||||
{withRouteFallback(<WorkspaceSetupPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="system/settings"
|
||||
element={
|
||||
<RequirePermission anyOf={["system.settings", "system.users", "masterdata.view", "masterdata.manage", "notification.channel.manage", "integration.manage"]}>
|
||||
{withRouteFallback(<SystemSettingsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="system/approval-rules"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { AnnouncementsPage as default } from "../../pages/AnnouncementsPage";
|
||||
@@ -0,0 +1 @@
|
||||
export { EmployeeProfilePage as default } from "../../pages/EmployeeProfilePage";
|
||||
@@ -0,0 +1 @@
|
||||
export { PeoplePage as default } from "../../pages/PeoplePage";
|
||||
@@ -0,0 +1 @@
|
||||
export { SystemSettingsPage as default } from "../../pages/SystemSettingsPage";
|
||||
@@ -0,0 +1 @@
|
||||
export { TeamPresencePage as default } from "../../pages/TeamPresencePage";
|
||||
@@ -0,0 +1 @@
|
||||
export { TodosPage as default } from "../../pages/TodosPage";
|
||||
@@ -0,0 +1 @@
|
||||
export { WeeklyReportsPage as default } from "../../pages/WeeklyReportsPage";
|
||||
@@ -0,0 +1 @@
|
||||
export { WorkspaceSetupPage as default } from "../../pages/WorkspaceSetupPage";
|
||||
@@ -18,7 +18,15 @@ export const MobileAssetScanPage = lazy(() => import("./MobileAssetScanPage"));
|
||||
export const MobileCapturePage = lazy(() => import("./MobileCapturePage"));
|
||||
export const NotificationChannelsPage = lazy(() => import("./NotificationChannelsPage"));
|
||||
export const NotificationsPage = lazy(() => import("./NotificationsPage"));
|
||||
export const AnnouncementsPage = lazy(() => import("./AnnouncementsPage"));
|
||||
export const PaymentBatchesPage = lazy(() => import("./PaymentBatchesPage"));
|
||||
export const PeoplePage = lazy(() => import("./PeoplePage"));
|
||||
export const EmployeeProfilePage = lazy(() => import("./EmployeeProfilePage"));
|
||||
export const TeamPresencePage = lazy(() => import("./TeamPresencePage"));
|
||||
export const WeeklyReportsPage = lazy(() => import("./WeeklyReportsPage"));
|
||||
export const TodosPage = lazy(() => import("./TodosPage"));
|
||||
export const WorkspaceSetupPage = lazy(() => import("./WorkspaceSetupPage"));
|
||||
export const SystemSettingsPage = lazy(() => import("./SystemSettingsPage"));
|
||||
export const PortalAnnouncementsPage = lazy(() => import("./PortalAnnouncementsPage"));
|
||||
export const PortalHomePage = lazy(() => import("./PortalHomePage"));
|
||||
export const PurchaseRequestsPage = lazy(() => import("./PurchaseRequestsPage"));
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WorkspaceProfile" AS ENUM ('small_team_internal');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WorkspaceOrgMode" AS ENUM ('single_company');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WorkspacePrimaryEntryMode" AS ENUM ('password');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WorkspaceNotificationStrategy" AS ENUM ('in_app');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WorkspaceDevicePriority" AS ENUM ('desktop');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EmploymentLifecycleStatus" AS ENUM ('onboarding', 'probation', 'active', 'offboarding', 'inactive');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EmployeeLifecycleEventType" AS ENUM ('onboard', 'regularize', 'transfer', 'offboard');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EmployeeChecklistScene" AS ENUM ('onboarding', 'offboarding', 'transfer');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EmployeeChecklistTaskType" AS ENUM ('account_setup', 'asset_issue', 'policy_ack', 'asset_return', 'access_revoke', 'expense_clearance', 'handover_note');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EmployeeChecklistTaskStatus" AS ENUM ('pending', 'completed', 'skipped');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EmployeePresenceStatus" AS ENUM ('office', 'remote', 'out', 'travel', 'leave');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EmployeePresenceTimeRange" AS ENUM ('full_day', 'morning', 'afternoon');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EmployeePresenceSource" AS ENUM ('manual', 'leave_sync');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WeeklyReportStatus" AS ENUM ('draft', 'submitted');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TodoPriority" AS ENUM ('low', 'normal', 'high');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TodoStatus" AS ENUM ('todo', 'doing', 'done', 'cancelled');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TodoSource" AS ENUM ('manual', 'weekly_report');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "employees" ADD COLUMN "emergency_contact_name" TEXT,
|
||||
ADD COLUMN "emergency_contact_phone" TEXT,
|
||||
ADD COLUMN "employment_status" "EmploymentLifecycleStatus" NOT NULL DEFAULT 'active',
|
||||
ADD COLUMN "last_working_date" DATE,
|
||||
ADD COLUMN "notes" TEXT,
|
||||
ADD COLUMN "probation_end_date" DATE,
|
||||
ADD COLUMN "work_location" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "workspace_settings" (
|
||||
"id" UUID NOT NULL,
|
||||
"profile" "WorkspaceProfile" NOT NULL DEFAULT 'small_team_internal',
|
||||
"org_mode" "WorkspaceOrgMode" NOT NULL DEFAULT 'single_company',
|
||||
"primary_entry_mode" "WorkspacePrimaryEntryMode" NOT NULL DEFAULT 'password',
|
||||
"notification_strategy" "WorkspaceNotificationStrategy" NOT NULL DEFAULT 'in_app',
|
||||
"device_priority" "WorkspaceDevicePriority" NOT NULL DEFAULT 'desktop',
|
||||
"module_visibility_json" JSONB,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_by" UUID,
|
||||
|
||||
CONSTRAINT "workspace_settings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "employee_lifecycle_events" (
|
||||
"id" UUID NOT NULL,
|
||||
"employee_id" UUID NOT NULL,
|
||||
"event_type" "EmployeeLifecycleEventType" NOT NULL,
|
||||
"effective_date" DATE NOT NULL,
|
||||
"payload_json" JSONB,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" UUID,
|
||||
|
||||
CONSTRAINT "employee_lifecycle_events_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "employee_checklist_tasks" (
|
||||
"id" UUID NOT NULL,
|
||||
"employee_id" UUID NOT NULL,
|
||||
"scene" "EmployeeChecklistScene" NOT NULL,
|
||||
"task_type" "EmployeeChecklistTaskType" NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"status" "EmployeeChecklistTaskStatus" NOT NULL DEFAULT 'pending',
|
||||
"due_date" DATE,
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"completed_by" UUID,
|
||||
"payload_json" JSONB,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "employee_checklist_tasks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "employee_presence" (
|
||||
"id" UUID NOT NULL,
|
||||
"employee_id" UUID NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"status" "EmployeePresenceStatus" NOT NULL,
|
||||
"time_range" "EmployeePresenceTimeRange" NOT NULL DEFAULT 'full_day',
|
||||
"note" TEXT,
|
||||
"source" "EmployeePresenceSource" NOT NULL DEFAULT 'manual',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_by" UUID,
|
||||
|
||||
CONSTRAINT "employee_presence_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "weekly_reports" (
|
||||
"id" UUID NOT NULL,
|
||||
"employee_id" UUID NOT NULL,
|
||||
"week_start" DATE NOT NULL,
|
||||
"status" "WeeklyReportStatus" NOT NULL DEFAULT 'draft',
|
||||
"highlights_text" TEXT NOT NULL DEFAULT '',
|
||||
"blockers_text" TEXT NOT NULL DEFAULT '',
|
||||
"next_week_text" TEXT NOT NULL DEFAULT '',
|
||||
"needs_help" BOOLEAN NOT NULL DEFAULT false,
|
||||
"submitted_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "weekly_reports_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "todo_items" (
|
||||
"id" UUID NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"assignee_employee_id" UUID NOT NULL,
|
||||
"created_by" UUID NOT NULL,
|
||||
"due_date" DATE,
|
||||
"priority" "TodoPriority" NOT NULL DEFAULT 'normal',
|
||||
"status" "TodoStatus" NOT NULL DEFAULT 'todo',
|
||||
"source" "TodoSource" NOT NULL DEFAULT 'manual',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completed_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "todo_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_employee_lifecycle_events_employee_date" ON "employee_lifecycle_events"("employee_id", "effective_date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_employee_lifecycle_events_type_created" ON "employee_lifecycle_events"("event_type", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_employee_checklist_tasks_employee_scene_status" ON "employee_checklist_tasks"("employee_id", "scene", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_employee_checklist_tasks_scene_due" ON "employee_checklist_tasks"("scene", "due_date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_employee_presence_date_status" ON "employee_presence"("date", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "employee_presence_employee_date_ux" ON "employee_presence"("employee_id", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_weekly_reports_week_status" ON "weekly_reports"("week_start", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "weekly_reports_employee_week_start_ux" ON "weekly_reports"("employee_id", "week_start");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_todo_items_assignee_status_due" ON "todo_items"("assignee_employee_id", "status", "due_date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_todo_items_creator_created" ON "todo_items"("created_by", "created_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "employee_lifecycle_events" ADD CONSTRAINT "employee_lifecycle_events_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "employee_checklist_tasks" ADD CONSTRAINT "employee_checklist_tasks_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "employee_presence" ADD CONSTRAINT "employee_presence_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "weekly_reports" ADD CONSTRAINT "weekly_reports_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "todo_items" ADD CONSTRAINT "todo_items_assignee_employee_id_fkey" FOREIGN KEY ("assignee_employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "todo_items" ADD CONSTRAINT "todo_items_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@@ -265,6 +265,105 @@ enum BudgetControlRuleEntityType {
|
||||
travel_request
|
||||
}
|
||||
|
||||
enum WorkspaceProfile {
|
||||
small_team_internal
|
||||
}
|
||||
|
||||
enum WorkspaceOrgMode {
|
||||
single_company
|
||||
}
|
||||
|
||||
enum WorkspacePrimaryEntryMode {
|
||||
password
|
||||
}
|
||||
|
||||
enum WorkspaceNotificationStrategy {
|
||||
in_app
|
||||
}
|
||||
|
||||
enum WorkspaceDevicePriority {
|
||||
desktop
|
||||
}
|
||||
|
||||
enum EmploymentLifecycleStatus {
|
||||
onboarding
|
||||
probation
|
||||
active
|
||||
offboarding
|
||||
inactive
|
||||
}
|
||||
|
||||
enum EmployeeLifecycleEventType {
|
||||
onboard
|
||||
regularize
|
||||
transfer
|
||||
offboard
|
||||
}
|
||||
|
||||
enum EmployeeChecklistScene {
|
||||
onboarding
|
||||
offboarding
|
||||
transfer
|
||||
}
|
||||
|
||||
enum EmployeeChecklistTaskType {
|
||||
account_setup
|
||||
asset_issue
|
||||
policy_ack
|
||||
asset_return
|
||||
access_revoke
|
||||
expense_clearance
|
||||
handover_note
|
||||
}
|
||||
|
||||
enum EmployeeChecklistTaskStatus {
|
||||
pending
|
||||
completed
|
||||
skipped
|
||||
}
|
||||
|
||||
enum EmployeePresenceStatus {
|
||||
office
|
||||
remote
|
||||
out
|
||||
travel
|
||||
leave
|
||||
}
|
||||
|
||||
enum EmployeePresenceTimeRange {
|
||||
full_day
|
||||
morning
|
||||
afternoon
|
||||
}
|
||||
|
||||
enum EmployeePresenceSource {
|
||||
manual
|
||||
leave_sync
|
||||
}
|
||||
|
||||
enum WeeklyReportStatus {
|
||||
draft
|
||||
submitted
|
||||
}
|
||||
|
||||
enum TodoPriority {
|
||||
low
|
||||
normal
|
||||
high
|
||||
}
|
||||
|
||||
enum TodoStatus {
|
||||
todo
|
||||
doing
|
||||
done
|
||||
cancelled
|
||||
}
|
||||
|
||||
enum TodoSource {
|
||||
manual
|
||||
weekly_report
|
||||
}
|
||||
|
||||
model Department {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
@@ -307,6 +406,13 @@ model Employee {
|
||||
direct_manager_id String? @db.Uuid
|
||||
hire_date DateTime @db.Date
|
||||
status EmployeeStatus @default(active)
|
||||
employment_status EmploymentLifecycleStatus @default(active)
|
||||
probation_end_date DateTime? @db.Date
|
||||
last_working_date DateTime? @db.Date
|
||||
work_location String?
|
||||
emergency_contact_name String?
|
||||
emergency_contact_phone String?
|
||||
notes String?
|
||||
|
||||
bank_account_encrypted String
|
||||
bank_name String
|
||||
@@ -376,6 +482,12 @@ model Employee {
|
||||
approval_transfers_from ApprovalTransfer[] @relation("TransferFrom")
|
||||
approval_transfers_to ApprovalTransfer[] @relation("TransferTo")
|
||||
password_reset_codes PasswordResetCode[]
|
||||
lifecycle_events EmployeeLifecycleEvent[]
|
||||
checklist_tasks EmployeeChecklistTask[]
|
||||
presence_records EmployeePresence[]
|
||||
weekly_reports WeeklyReport[]
|
||||
todo_items_assigned TodoItem[] @relation("TodoAssignee")
|
||||
todo_items_created TodoItem[] @relation("TodoCreator")
|
||||
|
||||
@@index([department_id])
|
||||
@@index([legal_entity_id], map: "idx_employees_legal_entity")
|
||||
@@ -1408,6 +1520,124 @@ model PortalAnnouncement {
|
||||
@@map("portal_announcements")
|
||||
}
|
||||
|
||||
model WorkspaceSetting {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
profile WorkspaceProfile @default(small_team_internal)
|
||||
org_mode WorkspaceOrgMode @default(single_company)
|
||||
primary_entry_mode WorkspacePrimaryEntryMode @default(password)
|
||||
notification_strategy WorkspaceNotificationStrategy @default(in_app)
|
||||
device_priority WorkspaceDevicePriority @default(desktop)
|
||||
module_visibility_json Json?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
@@map("workspace_settings")
|
||||
}
|
||||
|
||||
model EmployeeLifecycleEvent {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
employee_id String @db.Uuid
|
||||
event_type EmployeeLifecycleEventType
|
||||
effective_date DateTime @db.Date
|
||||
payload_json Json?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([employee_id, effective_date], map: "idx_employee_lifecycle_events_employee_date")
|
||||
@@index([event_type, created_at], map: "idx_employee_lifecycle_events_type_created")
|
||||
@@map("employee_lifecycle_events")
|
||||
}
|
||||
|
||||
model EmployeeChecklistTask {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
employee_id String @db.Uuid
|
||||
scene EmployeeChecklistScene
|
||||
task_type EmployeeChecklistTaskType
|
||||
title String
|
||||
status EmployeeChecklistTaskStatus @default(pending)
|
||||
due_date DateTime? @db.Date
|
||||
completed_at DateTime?
|
||||
completed_by String? @db.Uuid
|
||||
payload_json Json?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([employee_id, scene, status], map: "idx_employee_checklist_tasks_employee_scene_status")
|
||||
@@index([scene, due_date], map: "idx_employee_checklist_tasks_scene_due")
|
||||
@@map("employee_checklist_tasks")
|
||||
}
|
||||
|
||||
model EmployeePresence {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
employee_id String @db.Uuid
|
||||
date DateTime @db.Date
|
||||
status EmployeePresenceStatus
|
||||
time_range EmployeePresenceTimeRange @default(full_day)
|
||||
note String?
|
||||
source EmployeePresenceSource @default(manual)
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([employee_id, date], map: "employee_presence_employee_date_ux")
|
||||
@@index([date, status], map: "idx_employee_presence_date_status")
|
||||
@@map("employee_presence")
|
||||
}
|
||||
|
||||
model WeeklyReport {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
employee_id String @db.Uuid
|
||||
week_start DateTime @db.Date
|
||||
status WeeklyReportStatus @default(draft)
|
||||
highlights_text String @default("")
|
||||
blockers_text String @default("")
|
||||
next_week_text String @default("")
|
||||
needs_help Boolean @default(false)
|
||||
submitted_at DateTime?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([employee_id, week_start], map: "weekly_reports_employee_week_start_ux")
|
||||
@@index([week_start, status], map: "idx_weekly_reports_week_status")
|
||||
@@map("weekly_reports")
|
||||
}
|
||||
|
||||
model TodoItem {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
title String
|
||||
description String?
|
||||
assignee_employee_id String @db.Uuid
|
||||
created_by String @db.Uuid
|
||||
due_date DateTime? @db.Date
|
||||
priority TodoPriority @default(normal)
|
||||
status TodoStatus @default(todo)
|
||||
source TodoSource @default(manual)
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
completed_at DateTime?
|
||||
|
||||
assignee Employee @relation("TodoAssignee", fields: [assignee_employee_id], references: [id], onDelete: Restrict)
|
||||
creator Employee @relation("TodoCreator", fields: [created_by], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([assignee_employee_id, status, due_date], map: "idx_todo_items_assignee_status_due")
|
||||
@@index([created_by, created_at], map: "idx_todo_items_creator_created")
|
||||
@@map("todo_items")
|
||||
}
|
||||
|
||||
model MigrationRun {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
run_type String @db.VarChar(32)
|
||||
|
||||
@@ -48,6 +48,10 @@ const announcementsRoutes = require("./routes/announcements");
|
||||
const workflowDefinitionsRoutes = require("./routes/workflowDefinitions");
|
||||
const migrationRunsRoutes = require("./routes/migrationRuns");
|
||||
const cutoverRoutes = require("./routes/cutover");
|
||||
const systemWorkspaceRoutes = require("./routes/systemWorkspace");
|
||||
const presenceRoutes = require("./routes/presence");
|
||||
const weeklyReportsRoutes = require("./routes/weeklyReports");
|
||||
const todosRoutes = require("./routes/todos");
|
||||
const purchaseRequestRoutes = require("./routes/purchaseRequests");
|
||||
const assetsRoutes = require("./routes/assets");
|
||||
const assetMovementsRoutes = require("./routes/assetMovements");
|
||||
@@ -324,6 +328,10 @@ async function buildApp() {
|
||||
await app.register(workflowDefinitionsRoutes, { prefix: "/api" });
|
||||
await app.register(migrationRunsRoutes, { prefix: "/api" });
|
||||
await app.register(cutoverRoutes, { prefix: "/api" });
|
||||
await app.register(systemWorkspaceRoutes, { prefix: "/api" });
|
||||
await app.register(presenceRoutes, { prefix: "/api" });
|
||||
await app.register(weeklyReportsRoutes, { prefix: "/api" });
|
||||
await app.register(todosRoutes, { prefix: "/api" });
|
||||
await app.register(purchaseRequestRoutes, { prefix: "/api" });
|
||||
await app.register(assetsRoutes, { prefix: "/api" });
|
||||
await app.register(assetMovementsRoutes, { prefix: "/api" });
|
||||
|
||||
@@ -111,6 +111,10 @@ function getConfig() {
|
||||
announcementsEnabled: process.env.ANNOUNCEMENTS_ENABLED === "true",
|
||||
workflowDefinitionAdminEnabled: process.env.WORKFLOW_DEFINITION_ADMIN_ENABLED === "true",
|
||||
cutoverConsoleEnabled: process.env.CUTOVER_CONSOLE_ENABLED === "true",
|
||||
presenceBoardEnabled: process.env.PRESENCE_BOARD_ENABLED === "true",
|
||||
weeklyReportEnabled: process.env.WEEKLY_REPORT_ENABLED === "true",
|
||||
employeeLifecycleEnabled: process.env.EMPLOYEE_LIFECYCLE_ENABLED === "true",
|
||||
todoBoardEnabled: process.env.TODO_BOARD_ENABLED === "true",
|
||||
|
||||
aiAssistEnabled: process.env.AI_ASSIST_ENABLED === "true",
|
||||
aiProvider: process.env.AI_PROVIDER ?? "mock",
|
||||
|
||||
+516
-1
@@ -1,8 +1,523 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const usersRoutes = require("./users");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const PROFILE_PATCH_SCHEMA = z
|
||||
.object({
|
||||
departmentId: UUID_SCHEMA.optional(),
|
||||
directManagerId: UUID_SCHEMA.nullable().optional(),
|
||||
title: z.string().trim().min(1).max(100).optional(),
|
||||
probationEndDate: DATE_SCHEMA.nullable().optional(),
|
||||
lastWorkingDate: DATE_SCHEMA.nullable().optional(),
|
||||
workLocation: z.string().trim().max(100).nullable().optional(),
|
||||
emergencyContactName: z.string().trim().max(100).nullable().optional(),
|
||||
emergencyContactPhone: z.string().trim().max(50).nullable().optional(),
|
||||
notes: z.string().trim().max(1000).nullable().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
|
||||
const ONBOARDING_SCHEMA = z.object({
|
||||
effectiveDate: DATE_SCHEMA.optional(),
|
||||
probationEndDate: DATE_SCHEMA.nullable().optional(),
|
||||
notes: z.string().trim().max(500).nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
const REGULARIZE_SCHEMA = z.object({
|
||||
effectiveDate: DATE_SCHEMA.optional(),
|
||||
notes: z.string().trim().max(500).nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
const TRANSFER_SCHEMA = z.object({
|
||||
effectiveDate: DATE_SCHEMA.optional(),
|
||||
departmentId: UUID_SCHEMA.optional(),
|
||||
directManagerId: UUID_SCHEMA.nullable().optional(),
|
||||
title: z.string().trim().min(1).max(100).optional(),
|
||||
notes: z.string().trim().max(500).nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
const OFFBOARDING_SCHEMA = z.object({
|
||||
effectiveDate: DATE_SCHEMA.optional(),
|
||||
lastWorkingDate: DATE_SCHEMA.nullable().optional(),
|
||||
notes: z.string().trim().max(500).nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
|
||||
function parseId(value) {
|
||||
const parsed = UUID_SCHEMA.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError({ id: "invalid_uuid" });
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseBody(schema, body) {
|
||||
const parsed = schema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
async function ensureFeatureEnabled(fastify, request) {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.employeeLifecycle,
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
if (!gate.enabled) {
|
||||
throw buildValidationError({ featureFlag: "employee_lifecycle_v1_disabled", reason: gate.reason }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEffectiveDate(rawValue) {
|
||||
return rawValue ? new Date(`${rawValue}T00:00:00.000Z`) : new Date();
|
||||
}
|
||||
|
||||
function canManageProfile(fastify, request, employeeId) {
|
||||
return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "report.dashboard.view");
|
||||
}
|
||||
|
||||
function canManageLifecycle(fastify, request) {
|
||||
return fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "system.settings") || fastify.hasPermission(request.employee, "*");
|
||||
}
|
||||
|
||||
function mapChecklistTask(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
employeeId: item.employee_id,
|
||||
scene: item.scene,
|
||||
taskType: item.task_type,
|
||||
title: item.title,
|
||||
status: item.status,
|
||||
dueDate: item.due_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
completedAt: item.completed_at?.toISOString?.() ?? null,
|
||||
completedBy: item.completed_by ?? null,
|
||||
payloadJson: item.payload_json ?? null,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapLifecycleEvent(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
employeeId: item.employee_id,
|
||||
eventType: item.event_type,
|
||||
effectiveDate: item.effective_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
payloadJson: item.payload_json ?? null,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
createdBy: item.created_by ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getLifecycleBlockers(prisma, employeeId) {
|
||||
const [openAssetCount, pendingWorkflowTaskCount] = await Promise.all([
|
||||
prisma.assetCard.count({ where: { assignee_employee_id: employeeId } }),
|
||||
prisma.workflowTask.count({ where: { assignee_id: employeeId, status: "pending" } }),
|
||||
]);
|
||||
return {
|
||||
openAssetCount,
|
||||
pendingWorkflowTaskCount,
|
||||
canCompleteOffboarding: openAssetCount === 0 && pendingWorkflowTaskCount === 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureEmployeeExists(prisma, employeeId) {
|
||||
const employee = await prisma.employee.findUnique({ where: { id: employeeId } });
|
||||
if (!employee) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
return employee;
|
||||
}
|
||||
|
||||
function buildDefaultChecklist(scene, employeeId, actorId) {
|
||||
const common = { employee_id: employeeId };
|
||||
if (scene === "onboarding") {
|
||||
return [
|
||||
{ ...common, scene, task_type: "account_setup", title: "完成账号与权限初始化", status: "pending" },
|
||||
{ ...common, scene, task_type: "asset_issue", title: "确认办公资产发放", status: "pending" },
|
||||
{ ...common, scene, task_type: "policy_ack", title: "确认制度与入职须知", status: "pending" },
|
||||
];
|
||||
}
|
||||
if (scene === "transfer") {
|
||||
return [{ ...common, scene, task_type: "handover_note", title: "确认调岗交接说明", status: "pending" }];
|
||||
}
|
||||
return [
|
||||
{ ...common, scene, task_type: "asset_return", title: "确认资产归还/转交", status: "pending" },
|
||||
{ ...common, scene, task_type: "access_revoke", title: "确认账号和权限回收", status: "pending" },
|
||||
{ ...common, scene, task_type: "expense_clearance", title: "确认费用与审批待办已清理", status: "pending" },
|
||||
{ ...common, scene, task_type: "handover_note", title: "确认离职交接说明", status: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
async function maybeAdvanceLifecycle(tx, employeeId) {
|
||||
const employee = await tx.employee.findUnique({ where: { id: employeeId } });
|
||||
if (!employee) {
|
||||
return { autoChanged: false, completedOffboarding: false, blockers: null };
|
||||
}
|
||||
|
||||
const pendingOnboarding = await tx.employeeChecklistTask.count({ where: { employee_id: employeeId, scene: "onboarding", status: "pending" } });
|
||||
if (employee.employment_status === "onboarding" && pendingOnboarding === 0) {
|
||||
await tx.employee.update({ where: { id: employeeId }, data: { employment_status: "probation" } });
|
||||
return { autoChanged: true, completedOffboarding: false, blockers: null };
|
||||
}
|
||||
|
||||
const pendingOffboarding = await tx.employeeChecklistTask.count({ where: { employee_id: employeeId, scene: "offboarding", status: "pending" } });
|
||||
if (employee.employment_status === "offboarding" && pendingOffboarding === 0) {
|
||||
const [openAssetCount, pendingWorkflowTaskCount] = await Promise.all([
|
||||
tx.assetCard.count({ where: { assignee_employee_id: employeeId } }),
|
||||
tx.workflowTask.count({ where: { assignee_id: employeeId, status: "pending" } }),
|
||||
]);
|
||||
|
||||
if (openAssetCount === 0 && pendingWorkflowTaskCount === 0) {
|
||||
await tx.employee.update({
|
||||
where: { id: employeeId },
|
||||
data: {
|
||||
employment_status: "inactive",
|
||||
status: "archived",
|
||||
archived_at: new Date(),
|
||||
token_version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
return { autoChanged: true, completedOffboarding: true, blockers: { openAssetCount, pendingWorkflowTaskCount } };
|
||||
}
|
||||
|
||||
return { autoChanged: false, completedOffboarding: false, blockers: { openAssetCount, pendingWorkflowTaskCount } };
|
||||
}
|
||||
|
||||
return { autoChanged: false, completedOffboarding: false, blockers: null };
|
||||
}
|
||||
|
||||
async function buildEmployeeProfile(prisma, employeeId) {
|
||||
const [employee, checklistTasks, lifecycleEvents, assignedAssets, blockers] = await Promise.all([
|
||||
prisma.employee.findUnique({
|
||||
where: { id: employeeId },
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
status: true,
|
||||
employment_status: true,
|
||||
department_id: true,
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
probation_end_date: true,
|
||||
last_working_date: true,
|
||||
work_location: true,
|
||||
emergency_contact_name: true,
|
||||
emergency_contact_phone: true,
|
||||
notes: true,
|
||||
archived_at: true,
|
||||
roles: { select: { role_code: true } },
|
||||
department: { select: { id: true, name: true } },
|
||||
direct_manager: { select: { id: true, employee_no: true, name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.employeeChecklistTask.findMany({ where: { employee_id: employeeId }, orderBy: [{ created_at: "desc" }] }),
|
||||
prisma.employeeLifecycleEvent.findMany({ where: { employee_id: employeeId }, orderBy: [{ effective_date: "desc" }, { created_at: "desc" }] }),
|
||||
prisma.assetCard.findMany({
|
||||
where: { assignee_employee_id: employeeId },
|
||||
orderBy: { asset_no: "asc" },
|
||||
select: { id: true, asset_no: true, name: true, status: true, location: true },
|
||||
}),
|
||||
getLifecycleBlockers(prisma, employeeId),
|
||||
]);
|
||||
|
||||
if (!employee) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
|
||||
return {
|
||||
employee: {
|
||||
id: employee.id,
|
||||
employeeNo: employee.employee_no,
|
||||
name: employee.name,
|
||||
phone: employee.phone,
|
||||
email: employee.email,
|
||||
status: employee.status,
|
||||
employmentStatus: employee.employment_status,
|
||||
departmentId: employee.department_id,
|
||||
title: employee.title,
|
||||
directManagerId: employee.direct_manager_id ?? null,
|
||||
hireDate: employee.hire_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
probationEndDate: employee.probation_end_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
lastWorkingDate: employee.last_working_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
workLocation: employee.work_location ?? null,
|
||||
emergencyContactName: employee.emergency_contact_name ?? null,
|
||||
emergencyContactPhone: employee.emergency_contact_phone ?? null,
|
||||
notes: employee.notes ?? null,
|
||||
archivedAt: employee.archived_at?.toISOString?.() ?? null,
|
||||
roleCodes: employee.roles.map((item) => item.role_code),
|
||||
department: employee.department ? { id: employee.department.id, name: employee.department.name } : null,
|
||||
directManager: employee.direct_manager ? { id: employee.direct_manager.id, employeeNo: employee.direct_manager.employee_no, name: employee.direct_manager.name } : null,
|
||||
},
|
||||
lifecycle: {
|
||||
events: lifecycleEvents.map(mapLifecycleEvent),
|
||||
checklistTasks: checklistTasks.map(mapChecklistTask),
|
||||
blockers,
|
||||
},
|
||||
assignedAssets: assignedAssets.map((item) => ({
|
||||
id: item.id,
|
||||
assetNo: item.asset_no,
|
||||
name: item.name,
|
||||
status: item.status,
|
||||
location: item.location ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function employeesRoutes(fastify) {
|
||||
await usersRoutes(fastify, { basePath: "/employees" });
|
||||
|
||||
fastify.get("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const employeeId = parseId(request.params?.id);
|
||||
if (!canManageProfile(fastify, request, employeeId)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.profile.view" } });
|
||||
}
|
||||
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
||||
return sendData(reply, request, profile);
|
||||
});
|
||||
|
||||
fastify.patch("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const employeeId = parseId(request.params?.id);
|
||||
if (!canManageProfile(fastify, request, employeeId)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.profile.write" } });
|
||||
}
|
||||
const body = parseBody(PROFILE_PATCH_SCHEMA, request.body);
|
||||
const managerMode = canManageLifecycle(fastify, request);
|
||||
const selfEditableKeys = ["workLocation", "emergencyContactName", "emergencyContactPhone", "notes"];
|
||||
if (!managerMode && request.employee.id === employeeId) {
|
||||
const invalidKeys = Object.keys(body).filter((key) => !selfEditableKeys.includes(key));
|
||||
if (invalidKeys.length) {
|
||||
throw buildValidationError({ body: `self_profile_fields_forbidden:${invalidKeys.join(",")}` }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
await ensureEmployeeExists(fastify.prisma, employeeId);
|
||||
await fastify.prisma.employee.update({
|
||||
where: { id: employeeId },
|
||||
data: {
|
||||
...(body.departmentId !== undefined ? { department_id: body.departmentId } : {}),
|
||||
...(body.directManagerId !== undefined ? { direct_manager_id: body.directManagerId } : {}),
|
||||
...(body.title !== undefined ? { title: body.title } : {}),
|
||||
...(body.probationEndDate !== undefined ? { probation_end_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null } : {}),
|
||||
...(body.lastWorkingDate !== undefined ? { last_working_date: body.lastWorkingDate ? new Date(`${body.lastWorkingDate}T00:00:00.000Z`) : null } : {}),
|
||||
...(body.workLocation !== undefined ? { work_location: body.workLocation ?? null } : {}),
|
||||
...(body.emergencyContactName !== undefined ? { emergency_contact_name: body.emergencyContactName ?? null } : {}),
|
||||
...(body.emergencyContactPhone !== undefined ? { emergency_contact_phone: body.emergencyContactPhone ?? null } : {}),
|
||||
...(body.notes !== undefined ? { notes: body.notes ?? null } : {}),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
||||
return sendData(reply, request, profile);
|
||||
});
|
||||
|
||||
fastify.post("/employees/:id/onboarding/start", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!canManageLifecycle(fastify, request)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||
}
|
||||
const employeeId = parseId(request.params?.id);
|
||||
const body = parseBody(ONBOARDING_SCHEMA, request.body);
|
||||
const effectiveDate = resolveEffectiveDate(body.effectiveDate);
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
await ensureEmployeeExists(tx, employeeId);
|
||||
await tx.employee.update({
|
||||
where: { id: employeeId },
|
||||
data: {
|
||||
employment_status: "onboarding",
|
||||
probation_end_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null,
|
||||
notes: body.notes !== undefined ? body.notes ?? null : undefined,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
await tx.employeeLifecycleEvent.create({
|
||||
data: {
|
||||
employee_id: employeeId,
|
||||
event_type: "onboard",
|
||||
effective_date: effectiveDate,
|
||||
payload_json: { notes: body.notes ?? null },
|
||||
created_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
const checklist = buildDefaultChecklist("onboarding", employeeId, request.employee.id).map((item) => ({ ...item, due_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null }));
|
||||
await tx.employeeChecklistTask.createMany({ data: checklist, skipDuplicates: false });
|
||||
});
|
||||
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
||||
return sendData(reply, request, profile);
|
||||
});
|
||||
|
||||
fastify.post("/employees/:id/regularize", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!canManageLifecycle(fastify, request)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||
}
|
||||
const employeeId = parseId(request.params?.id);
|
||||
const body = parseBody(REGULARIZE_SCHEMA, request.body);
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
await ensureEmployeeExists(tx, employeeId);
|
||||
await tx.employee.update({ where: { id: employeeId }, data: { employment_status: "active", updated_by: request.employee.id } });
|
||||
await tx.employeeLifecycleEvent.create({
|
||||
data: {
|
||||
employee_id: employeeId,
|
||||
event_type: "regularize",
|
||||
effective_date: resolveEffectiveDate(body.effectiveDate),
|
||||
payload_json: { notes: body.notes ?? null },
|
||||
created_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
||||
return sendData(reply, request, profile);
|
||||
});
|
||||
|
||||
fastify.post("/employees/:id/transfer", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!canManageLifecycle(fastify, request)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||
}
|
||||
const employeeId = parseId(request.params?.id);
|
||||
const body = parseBody(TRANSFER_SCHEMA, request.body);
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
await ensureEmployeeExists(tx, employeeId);
|
||||
await tx.employee.update({
|
||||
where: { id: employeeId },
|
||||
data: {
|
||||
...(body.departmentId !== undefined ? { department_id: body.departmentId } : {}),
|
||||
...(body.directManagerId !== undefined ? { direct_manager_id: body.directManagerId } : {}),
|
||||
...(body.title !== undefined ? { title: body.title } : {}),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
await tx.employeeLifecycleEvent.create({
|
||||
data: {
|
||||
employee_id: employeeId,
|
||||
event_type: "transfer",
|
||||
effective_date: resolveEffectiveDate(body.effectiveDate),
|
||||
payload_json: {
|
||||
departmentId: body.departmentId ?? null,
|
||||
directManagerId: body.directManagerId ?? null,
|
||||
title: body.title ?? null,
|
||||
notes: body.notes ?? null,
|
||||
},
|
||||
created_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
await tx.employeeChecklistTask.createMany({
|
||||
data: buildDefaultChecklist("transfer", employeeId, request.employee.id),
|
||||
});
|
||||
});
|
||||
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
||||
return sendData(reply, request, profile);
|
||||
});
|
||||
|
||||
fastify.post("/employees/:id/offboarding/start", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!canManageLifecycle(fastify, request)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||
}
|
||||
const employeeId = parseId(request.params?.id);
|
||||
const body = parseBody(OFFBOARDING_SCHEMA, request.body);
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
await ensureEmployeeExists(tx, employeeId);
|
||||
await tx.employee.update({
|
||||
where: { id: employeeId },
|
||||
data: {
|
||||
employment_status: "offboarding",
|
||||
...(body.lastWorkingDate !== undefined ? { last_working_date: body.lastWorkingDate ? new Date(`${body.lastWorkingDate}T00:00:00.000Z`) : null } : {}),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
await tx.employeeLifecycleEvent.create({
|
||||
data: {
|
||||
employee_id: employeeId,
|
||||
event_type: "offboard",
|
||||
effective_date: resolveEffectiveDate(body.effectiveDate),
|
||||
payload_json: { notes: body.notes ?? null, lastWorkingDate: body.lastWorkingDate ?? null },
|
||||
created_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
await tx.employeeChecklistTask.createMany({ data: buildDefaultChecklist("offboarding", employeeId, request.employee.id) });
|
||||
});
|
||||
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
||||
return sendData(reply, request, profile);
|
||||
});
|
||||
|
||||
fastify.get("/employees/:id/lifecycle", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const employeeId = parseId(request.params?.id);
|
||||
if (!canManageProfile(fastify, request, employeeId)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.lifecycle.view" } });
|
||||
}
|
||||
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
||||
return sendData(reply, request, profile.lifecycle);
|
||||
});
|
||||
|
||||
async function updateChecklistTaskStatus(request, reply, nextStatus) {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const employeeId = parseId(request.params?.id);
|
||||
const taskId = parseId(request.params?.taskId);
|
||||
if (!canManageProfile(fastify, request, employeeId)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.lifecycle.write" } });
|
||||
}
|
||||
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
await ensureEmployeeExists(tx, employeeId);
|
||||
const task = await tx.employeeChecklistTask.findFirst({ where: { id: taskId, employee_id: employeeId } });
|
||||
if (!task) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
await tx.employeeChecklistTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: nextStatus,
|
||||
completed_at: nextStatus === "completed" ? new Date() : task.completed_at,
|
||||
completed_by: nextStatus === "completed" ? request.employee.id : task.completed_by,
|
||||
},
|
||||
});
|
||||
return maybeAdvanceLifecycle(tx, employeeId);
|
||||
});
|
||||
|
||||
if (result.completedOffboarding) {
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "EMPLOYEE_OFFBOARDING_COMPLETED",
|
||||
targetType: "employee",
|
||||
targetId: employeeId,
|
||||
extra: result.blockers ?? {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
|
||||
return sendData(reply, request, { ...profile.lifecycle, autoAdvanced: result.autoChanged });
|
||||
}
|
||||
|
||||
fastify.post("/employees/:id/checklist-tasks/:taskId/complete", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
return updateChecklistTaskStatus(request, reply, "completed");
|
||||
});
|
||||
|
||||
fastify.post("/employees/:id/checklist-tasks/:taskId/skip", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
return updateChecklistTaskStatus(request, reply, "skipped");
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = employeesRoutes;
|
||||
|
||||
|
||||
+297
-35
@@ -1,6 +1,8 @@
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { getWorkspaceSetting } = require("../services/workspaceSettings");
|
||||
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
|
||||
async function buildTaskVisibilityFilters(tx, request) {
|
||||
const roleFilters = (request.employee.roleCodes ?? []).length
|
||||
@@ -27,78 +29,310 @@ async function ensureFeatureEnabled(fastify, request) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildQuickLinks(request) {
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
|
||||
async function evaluateOptionalFlag(fastify, request, flagKey) {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey,
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
return gate.enabled;
|
||||
}
|
||||
|
||||
function resolveWeekStart(rawDateString) {
|
||||
const date = parseDateOnlyString(rawDateString);
|
||||
const day = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() - (day - 1));
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatPresenceStatus(item) {
|
||||
if (!item) return "未更新";
|
||||
if (item.status === "office") return "在岗";
|
||||
if (item.status === "remote") return "居家";
|
||||
if (item.status === "out") return "外出";
|
||||
if (item.status === "travel") return "出差";
|
||||
if (item.status === "leave") return "请假";
|
||||
return item.status;
|
||||
}
|
||||
|
||||
async function resolvePresenceForEmployee(prisma, employeeId, dateString) {
|
||||
const date = parseDateOnlyString(dateString);
|
||||
const leave = await prisma.leaveRequest.findFirst({
|
||||
where: {
|
||||
owner_id: employeeId,
|
||||
status: "approved",
|
||||
start_date: { lte: date },
|
||||
end_date: { gte: date },
|
||||
},
|
||||
select: { id: true, reason: true },
|
||||
});
|
||||
if (leave) {
|
||||
return { status: "leave", note: leave.reason ?? null, source: "leave_sync" };
|
||||
}
|
||||
|
||||
const travel = await prisma.travelRequest.findFirst({
|
||||
where: {
|
||||
applicant_employee_id: employeeId,
|
||||
status: "approved",
|
||||
start_date: { lte: date },
|
||||
end_date: { gte: date },
|
||||
},
|
||||
select: { id: true, purpose: true },
|
||||
});
|
||||
if (travel) {
|
||||
return { status: "travel", note: travel.purpose ?? null, source: "leave_sync" };
|
||||
}
|
||||
|
||||
const manual = await prisma.employeePresence.findUnique({
|
||||
where: {
|
||||
employee_id_date: {
|
||||
employee_id: employeeId,
|
||||
date,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!manual) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
status: manual.status,
|
||||
note: manual.note ?? null,
|
||||
source: manual.source,
|
||||
timeRange: manual.time_range,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildTeamPresenceSummary(prisma, dateString) {
|
||||
const employees = await prisma.employee.findMany({ where: { status: "active" }, select: { id: true } });
|
||||
const summary = { office: 0, remote: 0, out: 0, travel: 0, leave: 0, unspecified: 0, total: employees.length };
|
||||
for (const employee of employees) {
|
||||
const item = await resolvePresenceForEmployee(prisma, employee.id, dateString);
|
||||
const status = item?.status;
|
||||
if (!status || !(status in summary)) {
|
||||
summary.unspecified += 1;
|
||||
continue;
|
||||
}
|
||||
summary[status] += 1;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
function buildQuickLinks({ request, featureEnabled }) {
|
||||
const items = [];
|
||||
items.push({ key: "team", title: "团队状态", href: "/team" });
|
||||
if (request.employee.permissionCodes.includes("leave.view_own") || request.employee.permissionCodes.includes("leave.view_all")) {
|
||||
items.push({ key: "leave", title: "请假", href: "/leave-requests" });
|
||||
}
|
||||
if (request.employee.permissionCodes.includes("expense.view_own") || request.employee.permissionCodes.includes("expense.view_all")) {
|
||||
items.push({ key: "expense", title: "我的报销", href: "/reimbursements" });
|
||||
items.push({ key: "expense", title: "报销", href: "/reimbursements" });
|
||||
}
|
||||
if (request.employee.permissionCodes.includes("procurement.view_own") || request.employee.permissionCodes.includes("procurement.view_all")) {
|
||||
items.push({ key: "procurement", title: "采购申请", href: "/purchase-requests" });
|
||||
items.push({ key: "assets", title: "资产", href: "/assets" });
|
||||
if (featureEnabled.weeklyReport) {
|
||||
items.push({ key: "weekly", title: "周报", href: "/weekly-reports" });
|
||||
}
|
||||
if (request.employee.permissionCodes.includes("travel.view_own") || request.employee.permissionCodes.includes("travel.view_all")) {
|
||||
items.push({ key: "travel", title: "差旅申请", href: "/travel-requests" });
|
||||
if (featureEnabled.todoBoard) {
|
||||
items.push({ key: "todo", title: "待办", href: "/todos" });
|
||||
}
|
||||
if (request.employee.permissionCodes.includes("budget.view") || request.employee.permissionCodes.includes("budget.manage")) {
|
||||
items.push({ key: "budget", title: "预算管理", href: "/budgets" });
|
||||
if (request.employee.permissionCodes.includes("system.users") || request.employee.permissionCodes.includes("system.settings")) {
|
||||
items.push({ key: "people", title: "人员管理", href: "/people" });
|
||||
items.push({ key: "workspace", title: "工作区设置", href: "/system/workspace" });
|
||||
}
|
||||
if (request.employee.permissionCodes.includes("payment.batch.manage")) {
|
||||
items.push({ key: "payment-batches", title: "支付批次", href: "/payment-batches" });
|
||||
}
|
||||
return items;
|
||||
return items.slice(0, 8);
|
||||
}
|
||||
|
||||
async function portalRoutes(fastify) {
|
||||
fastify.get("/portal/home", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
|
||||
const workspace = await getWorkspaceSetting(fastify.prisma);
|
||||
const today = currentDateString(fastify.config.appTimezone);
|
||||
const currentWeekStart = resolveWeekStart(today);
|
||||
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
|
||||
const now = new Date();
|
||||
const isManagerView =
|
||||
fastify.hasPermission(request.employee, "system.users") ||
|
||||
fastify.hasPermission(request.employee, "report.dashboard.view") ||
|
||||
fastify.hasPermission(request.employee, "*");
|
||||
|
||||
const [pendingTaskCount, reimbursementsCount, leaveCount, purchaseCount, travelCount, riskCount, announcementRows, paymentBatchCount] = await Promise.all([
|
||||
const featureEnabled = {
|
||||
announcements: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.announcements),
|
||||
presenceBoard: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.presenceBoard),
|
||||
weeklyReport: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.weeklyReport),
|
||||
employeeLifecycle: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.employeeLifecycle),
|
||||
todoBoard: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.todoBoard),
|
||||
paymentBatch: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.paymentBatch),
|
||||
};
|
||||
|
||||
const [
|
||||
pendingTaskCount,
|
||||
reimbursementsCount,
|
||||
leaveCount,
|
||||
assetCount,
|
||||
riskCount,
|
||||
announcementRows,
|
||||
todoRows,
|
||||
myWeeklyReport,
|
||||
onboardingCount,
|
||||
offboardingCount,
|
||||
assetReturnPendingCount,
|
||||
] = await Promise.all([
|
||||
fastify.prisma.workflowTask.count({ where: { status: "pending", OR: assigneeFilters } }),
|
||||
fastify.prisma.reimbursementForm.count({ where: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] } }),
|
||||
fastify.prisma.leaveRequest.count({ where: { owner_id: request.employee.id } }),
|
||||
fastify.prisma.purchaseRequest.count({ where: { applicant_id: request.employee.id } }),
|
||||
fastify.prisma.travelRequest.count({ where: { OR: [{ applicant_employee_id: request.employee.id }, { submitter_id: request.employee.id }] } }),
|
||||
(fastify.hasPermission(request.employee, "payment.batch.manage") || fastify.hasPermission(request.employee, "risk.review.manage"))
|
||||
fastify.prisma.assetCard.count({ where: { assignee_employee_id: request.employee.id } }),
|
||||
fastify.hasPermission(request.employee, "payment.batch.manage") || fastify.hasPermission(request.employee, "risk.review.manage")
|
||||
? fastify.prisma.entityRiskFlag.count({ where: { status: "open" } })
|
||||
: Promise.resolve(0),
|
||||
fastify.prisma.portalAnnouncement.findMany({
|
||||
featureEnabled.announcements
|
||||
? fastify.prisma.portalAnnouncement.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
OR: [{ starts_at: null }, { starts_at: { lte: now } }],
|
||||
AND: [{ OR: [{ ends_at: null }, { ends_at: { gte: now } }] }],
|
||||
OR: [{ starts_at: null }, { starts_at: { lte: new Date() } }],
|
||||
AND: [{ OR: [{ ends_at: null }, { ends_at: { gte: new Date() } }] }],
|
||||
},
|
||||
orderBy: [{ priority: "asc" }, { created_at: "desc" }],
|
||||
take: 6,
|
||||
}),
|
||||
fastify.hasPermission(request.employee, "payment.batch.manage")
|
||||
? fastify.prisma.paymentBatch.count({ where: { status: { in: ["draft", "reviewed", "failed"] } } })
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
featureEnabled.todoBoard
|
||||
? fastify.prisma.todoItem.findMany({
|
||||
where: { assignee_employee_id: request.employee.id, status: { in: ["todo", "doing"] } },
|
||||
orderBy: [{ priority: "desc" }, { due_date: "asc" }, { updated_at: "desc" }],
|
||||
take: 10,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
featureEnabled.weeklyReport
|
||||
? fastify.prisma.weeklyReport.findUnique({
|
||||
where: {
|
||||
employee_id_week_start: {
|
||||
employee_id: request.employee.id,
|
||||
week_start: parseDateOnlyString(currentWeekStart),
|
||||
},
|
||||
},
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
featureEnabled.employeeLifecycle && isManagerView
|
||||
? fastify.prisma.employee.count({ where: { employment_status: "onboarding", status: "active" } })
|
||||
: Promise.resolve(0),
|
||||
featureEnabled.employeeLifecycle && isManagerView
|
||||
? fastify.prisma.employee.count({ where: { employment_status: "offboarding" } })
|
||||
: Promise.resolve(0),
|
||||
featureEnabled.employeeLifecycle && isManagerView
|
||||
? fastify.prisma.assetCard.count({ where: { assignee: { is: { employment_status: "offboarding" } } } })
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
|
||||
const cards = [
|
||||
{ key: "pending_tasks", title: "我的待办", value: pendingTaskCount, href: "/workflow/tasks", severity: pendingTaskCount > 0 ? "warning" : "normal" },
|
||||
{ key: "my_reimbursements", title: "报销单", value: reimbursementsCount, href: "/reimbursements", severity: "normal" },
|
||||
{ key: "my_purchases", title: "采购单", value: purchaseCount, href: "/purchase-requests", severity: "normal" },
|
||||
{ key: "my_travel", title: "差旅单", value: travelCount, href: "/travel-requests", severity: "normal" },
|
||||
];
|
||||
if (paymentBatchCount > 0) {
|
||||
cards.push({ key: "payment_batches", title: "待处理支付批次", value: paymentBatchCount, href: "/payment-batches", severity: "warning" });
|
||||
const todayPresence = featureEnabled.presenceBoard
|
||||
? await resolvePresenceForEmployee(fastify.prisma, request.employee.id, today)
|
||||
: null;
|
||||
const teamPresenceSummary = featureEnabled.presenceBoard && isManagerView
|
||||
? await buildTeamPresenceSummary(fastify.prisma, today)
|
||||
: null;
|
||||
|
||||
const teamWeeklySummary = featureEnabled.weeklyReport && isManagerView
|
||||
? await (async () => {
|
||||
const [employeeCount, submittedCount, needsHelpCount] = await Promise.all([
|
||||
fastify.prisma.employee.count({ where: { status: "active" } }),
|
||||
fastify.prisma.weeklyReport.count({ where: { week_start: parseDateOnlyString(currentWeekStart), status: "submitted" } }),
|
||||
fastify.prisma.weeklyReport.count({ where: { week_start: parseDateOnlyString(currentWeekStart), needs_help: true } }),
|
||||
]);
|
||||
return {
|
||||
weekStart: currentWeekStart,
|
||||
submittedCount,
|
||||
missingCount: Math.max(0, employeeCount - submittedCount),
|
||||
needsHelpCount,
|
||||
};
|
||||
})()
|
||||
: null;
|
||||
|
||||
const cards = [];
|
||||
if (isManagerView) {
|
||||
cards.push({
|
||||
key: "team_presence",
|
||||
title: "今日团队状态",
|
||||
value: teamPresenceSummary ? `${teamPresenceSummary.office}/${teamPresenceSummary.total} 在岗` : "-",
|
||||
href: "/team",
|
||||
severity: teamPresenceSummary && (teamPresenceSummary.leave > 0 || teamPresenceSummary.unspecified > 0) ? "warning" : "normal",
|
||||
});
|
||||
cards.push({
|
||||
key: "weekly_missing",
|
||||
title: "未提交周报",
|
||||
value: teamWeeklySummary?.missingCount ?? 0,
|
||||
href: "/weekly-reports",
|
||||
severity: (teamWeeklySummary?.missingCount ?? 0) > 0 ? "warning" : "normal",
|
||||
});
|
||||
cards.push({
|
||||
key: "weekly_help",
|
||||
title: "需协助员工",
|
||||
value: teamWeeklySummary?.needsHelpCount ?? 0,
|
||||
href: "/weekly-reports",
|
||||
severity: (teamWeeklySummary?.needsHelpCount ?? 0) > 0 ? "high" : "normal",
|
||||
});
|
||||
cards.push({
|
||||
key: "people_lifecycle",
|
||||
title: "入/离职待办",
|
||||
value: (onboardingCount ?? 0) + (offboardingCount ?? 0),
|
||||
href: "/people",
|
||||
severity: (offboardingCount ?? 0) > 0 ? "warning" : "normal",
|
||||
});
|
||||
cards.push({
|
||||
key: "asset_return_pending",
|
||||
title: "资产待归还",
|
||||
value: assetReturnPendingCount ?? 0,
|
||||
href: "/people",
|
||||
severity: (assetReturnPendingCount ?? 0) > 0 ? "warning" : "normal",
|
||||
});
|
||||
cards.push({
|
||||
key: "pending_approvals",
|
||||
title: "审批待办",
|
||||
value: pendingTaskCount,
|
||||
href: "/workflow/tasks",
|
||||
severity: pendingTaskCount > 0 ? "warning" : "normal",
|
||||
});
|
||||
} else {
|
||||
cards.push({
|
||||
key: "my_todos",
|
||||
title: "我的待办",
|
||||
value: featureEnabled.todoBoard ? todoRows.length : 0,
|
||||
href: "/todos",
|
||||
severity: todoRows.some((item) => item.priority === "high") ? "warning" : "normal",
|
||||
});
|
||||
cards.push({
|
||||
key: "today_presence",
|
||||
title: "今日状态",
|
||||
value: featureEnabled.presenceBoard ? formatPresenceStatus(todayPresence) : "未启用",
|
||||
href: "/team",
|
||||
severity: todayPresence?.status === "leave" ? "warning" : "normal",
|
||||
});
|
||||
cards.push({ key: "my_leave", title: "我的请假", value: leaveCount, href: "/leave-requests", severity: "normal" });
|
||||
cards.push({ key: "my_reimbursements", title: "我的报销", value: reimbursementsCount, href: "/reimbursements", severity: "normal" });
|
||||
cards.push({ key: "my_assets", title: "我的资产", value: assetCount, href: "/assets", severity: "normal" });
|
||||
cards.push({
|
||||
key: "my_weekly_report",
|
||||
title: "本周周报",
|
||||
value: featureEnabled.weeklyReport ? (myWeeklyReport?.status === "submitted" ? "已提交" : "待填写") : "未启用",
|
||||
href: "/weekly-reports",
|
||||
severity: myWeeklyReport?.status === "submitted" ? "normal" : "warning",
|
||||
});
|
||||
}
|
||||
if (riskCount > 0) {
|
||||
|
||||
if (featureEnabled.paymentBatch && riskCount > 0) {
|
||||
cards.push({ key: "risk_flags", title: "待复核风险", value: riskCount, href: "/ap-operations", severity: "high" });
|
||||
}
|
||||
|
||||
return sendData(reply, request, {
|
||||
workspace,
|
||||
greeting: { name: request.employee.name ?? request.employee.id },
|
||||
cards,
|
||||
pendingTasks: { count: pendingTaskCount, href: "/workflow/tasks" },
|
||||
myApplications: [
|
||||
{ domain: "reimbursement", count: reimbursementsCount, href: "/reimbursements" },
|
||||
{ domain: "leave", count: leaveCount, href: "/leave-requests" },
|
||||
{ domain: "purchase_request", count: purchaseCount, href: "/purchase-requests" },
|
||||
{ domain: "travel_request", count: travelCount, href: "/travel-requests" },
|
||||
],
|
||||
riskSummary: riskCount > 0 ? { openCount: riskCount, href: "/ap-operations" } : null,
|
||||
announcements: announcementRows.map((item) => ({
|
||||
@@ -107,7 +341,35 @@ async function portalRoutes(fastify) {
|
||||
summary: item.summary,
|
||||
href: item.link_url ?? null,
|
||||
})),
|
||||
quickLinks: buildQuickLinks(request),
|
||||
quickLinks: buildQuickLinks({ request, featureEnabled }),
|
||||
todayPresence: featureEnabled.presenceBoard
|
||||
? { date: today, status: todayPresence?.status ?? null, label: formatPresenceStatus(todayPresence), note: todayPresence?.note ?? null, href: "/team" }
|
||||
: null,
|
||||
teamPresence: teamPresenceSummary ? { date: today, href: "/team", ...teamPresenceSummary } : null,
|
||||
weeklyReport: featureEnabled.weeklyReport
|
||||
? {
|
||||
weekStart: currentWeekStart,
|
||||
status: myWeeklyReport?.status ?? "draft",
|
||||
needsHelp: myWeeklyReport?.needs_help ?? false,
|
||||
href: "/weekly-reports",
|
||||
}
|
||||
: null,
|
||||
weeklySummary: teamWeeklySummary ? { ...teamWeeklySummary, href: "/weekly-reports" } : null,
|
||||
todoSummary: featureEnabled.todoBoard
|
||||
? {
|
||||
openCount: todoRows.length,
|
||||
highPriorityCount: todoRows.filter((item) => item.priority === "high").length,
|
||||
href: "/todos",
|
||||
}
|
||||
: null,
|
||||
peopleLifecycle: featureEnabled.employeeLifecycle && isManagerView
|
||||
? {
|
||||
onboardingCount,
|
||||
offboardingCount,
|
||||
assetReturnPendingCount,
|
||||
href: "/people",
|
||||
}
|
||||
: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { parseDateOnlyString, currentDateString } = require("../utils/date");
|
||||
|
||||
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const QUERY_SCHEMA = z.object({ date: DATE_SCHEMA.optional() });
|
||||
const WRITE_SCHEMA = z
|
||||
.object({
|
||||
date: DATE_SCHEMA.optional(),
|
||||
status: z.enum(["office", "remote", "out", "travel"]),
|
||||
timeRange: z.enum(["full_day", "morning", "afternoon"]).optional(),
|
||||
note: z.string().trim().max(300).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
|
||||
function parseBody(schema, body) {
|
||||
const parsed = schema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function formatDateOnly(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return new Date(`${value.toISOString().slice(0, 10)}T00:00:00.000Z`).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function resolveDateInput(rawDate, timeZone) {
|
||||
return rawDate ? rawDate : currentDateString(timeZone ?? "Asia/Shanghai");
|
||||
}
|
||||
|
||||
async function ensureFeatureEnabled(fastify, request) {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.presenceBoard,
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
if (!gate.enabled) {
|
||||
throw buildValidationError({ featureFlag: "presence_board_v1_disabled", reason: gate.reason }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
function computeLeaveTimeRange(leave, dateString) {
|
||||
const startDate = formatDateOnly(leave.start_date);
|
||||
const endDate = formatDateOnly(leave.end_date);
|
||||
if (startDate === dateString && endDate === dateString) {
|
||||
if (leave.start_session === "pm" && leave.end_session === "pm") return "afternoon";
|
||||
if (leave.start_session === "am" && leave.end_session === "am") return "morning";
|
||||
return "full_day";
|
||||
}
|
||||
if (startDate === dateString && leave.start_session === "pm") {
|
||||
return "afternoon";
|
||||
}
|
||||
if (endDate === dateString && leave.end_session === "am") {
|
||||
return "morning";
|
||||
}
|
||||
return "full_day";
|
||||
}
|
||||
|
||||
function mapPresenceRow(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
employeeId: row.employee_id,
|
||||
date: formatDateOnly(row.date),
|
||||
status: row.status,
|
||||
timeRange: row.time_range,
|
||||
note: row.note ?? null,
|
||||
source: row.source,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function findLeaveOverlay(prisma, employeeIds, dateString) {
|
||||
if (!employeeIds.length) {
|
||||
return new Map();
|
||||
}
|
||||
const date = parseDateOnlyString(dateString);
|
||||
const rows = await prisma.leaveRequest.findMany({
|
||||
where: {
|
||||
owner_id: { in: employeeIds },
|
||||
status: "approved",
|
||||
start_date: { lte: date },
|
||||
end_date: { gte: date },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
owner_id: true,
|
||||
start_date: true,
|
||||
end_date: true,
|
||||
start_session: true,
|
||||
end_session: true,
|
||||
reason: true,
|
||||
},
|
||||
});
|
||||
const map = new Map();
|
||||
for (const row of rows) {
|
||||
if (!map.has(row.owner_id)) {
|
||||
map.set(row.owner_id, {
|
||||
id: `leave-${row.id}`,
|
||||
employeeId: row.owner_id,
|
||||
date: dateString,
|
||||
status: "leave",
|
||||
timeRange: computeLeaveTimeRange(row, dateString),
|
||||
note: row.reason ?? null,
|
||||
source: "leave_sync",
|
||||
updatedAt: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function findTravelOverlay(prisma, employeeIds, dateString) {
|
||||
if (!employeeIds.length) {
|
||||
return new Map();
|
||||
}
|
||||
const date = parseDateOnlyString(dateString);
|
||||
const rows = await prisma.travelRequest.findMany({
|
||||
where: {
|
||||
applicant_employee_id: { in: employeeIds },
|
||||
status: "approved",
|
||||
start_date: { lte: date },
|
||||
end_date: { gte: date },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
applicant_employee_id: true,
|
||||
purpose: true,
|
||||
},
|
||||
});
|
||||
const map = new Map();
|
||||
for (const row of rows) {
|
||||
if (!map.has(row.applicant_employee_id)) {
|
||||
map.set(row.applicant_employee_id, {
|
||||
id: `travel-${row.id}`,
|
||||
employeeId: row.applicant_employee_id,
|
||||
date: dateString,
|
||||
status: "travel",
|
||||
timeRange: "full_day",
|
||||
note: row.purpose ?? null,
|
||||
source: "leave_sync",
|
||||
updatedAt: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function buildPresenceState(prisma, employeeIds, dateString) {
|
||||
const [manualRows, leaveOverlay, travelOverlay] = await Promise.all([
|
||||
prisma.employeePresence.findMany({
|
||||
where: { employee_id: { in: employeeIds }, date: parseDateOnlyString(dateString) },
|
||||
}),
|
||||
findLeaveOverlay(prisma, employeeIds, dateString),
|
||||
findTravelOverlay(prisma, employeeIds, dateString),
|
||||
]);
|
||||
|
||||
const manualMap = new Map(manualRows.map((row) => [row.employee_id, mapPresenceRow(row)]));
|
||||
const result = new Map();
|
||||
for (const employeeId of employeeIds) {
|
||||
if (leaveOverlay.has(employeeId)) {
|
||||
result.set(employeeId, leaveOverlay.get(employeeId));
|
||||
continue;
|
||||
}
|
||||
if (travelOverlay.has(employeeId)) {
|
||||
result.set(employeeId, travelOverlay.get(employeeId));
|
||||
continue;
|
||||
}
|
||||
if (manualMap.has(employeeId)) {
|
||||
result.set(employeeId, manualMap.get(employeeId));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildSummary(items) {
|
||||
const summary = {
|
||||
office: 0,
|
||||
remote: 0,
|
||||
out: 0,
|
||||
travel: 0,
|
||||
leave: 0,
|
||||
unspecified: 0,
|
||||
total: items.length,
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const status = item.presence?.status;
|
||||
if (!status || !(status in summary)) {
|
||||
summary.unspecified += 1;
|
||||
continue;
|
||||
}
|
||||
summary[status] += 1;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function presenceRoutes(fastify) {
|
||||
fastify.get("/presence/me", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
const dateString = resolveDateInput(parsed.data.date, fastify.config.appTimezone);
|
||||
const map = await buildPresenceState(fastify.prisma, [request.employee.id], dateString);
|
||||
return sendData(reply, request, { item: map.get(request.employee.id) ?? null, date: dateString });
|
||||
});
|
||||
|
||||
fastify.put("/presence/me", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const body = parseBody(WRITE_SCHEMA, request.body);
|
||||
const dateString = resolveDateInput(body.date, fastify.config.appTimezone);
|
||||
const leaveOverlay = await findLeaveOverlay(fastify.prisma, [request.employee.id], dateString);
|
||||
if (leaveOverlay.has(request.employee.id)) {
|
||||
throw buildValidationError({ date: "covered_by_approved_leave" }, 409);
|
||||
}
|
||||
const record = await fastify.prisma.employeePresence.upsert({
|
||||
where: {
|
||||
employee_id_date: {
|
||||
employee_id: request.employee.id,
|
||||
date: parseDateOnlyString(dateString),
|
||||
},
|
||||
},
|
||||
update: {
|
||||
status: body.status,
|
||||
time_range: body.timeRange ?? "full_day",
|
||||
note: body.note ?? null,
|
||||
source: "manual",
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
create: {
|
||||
employee_id: request.employee.id,
|
||||
date: parseDateOnlyString(dateString),
|
||||
status: body.status,
|
||||
time_range: body.timeRange ?? "full_day",
|
||||
note: body.note ?? null,
|
||||
source: "manual",
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
return sendData(reply, request, { item: mapPresenceRow(record) });
|
||||
});
|
||||
|
||||
fastify.get("/presence/team", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
const dateString = resolveDateInput(parsed.data.date, fastify.config.appTimezone);
|
||||
const employees = await fastify.prisma.employee.findMany({
|
||||
where: { status: "active" },
|
||||
orderBy: [{ department_id: "asc" }, { employee_no: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
title: true,
|
||||
employment_status: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
const map = await buildPresenceState(fastify.prisma, employees.map((item) => item.id), dateString);
|
||||
const items = employees.map((item) => ({
|
||||
employee: {
|
||||
id: item.id,
|
||||
employeeNo: item.employee_no,
|
||||
name: item.name,
|
||||
title: item.title,
|
||||
employmentStatus: item.employment_status,
|
||||
department: item.department ? { id: item.department.id, name: item.department.name } : null,
|
||||
},
|
||||
presence: map.get(item.id) ?? null,
|
||||
}));
|
||||
return sendData(reply, request, { date: dateString, summary: buildSummary(items), items });
|
||||
});
|
||||
|
||||
fastify.get("/presence/summary", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
const dateString = resolveDateInput(parsed.data.date, fastify.config.appTimezone);
|
||||
const employeeIds = (
|
||||
await fastify.prisma.employee.findMany({ where: { status: "active" }, select: { id: true } })
|
||||
).map((item) => item.id);
|
||||
const map = await buildPresenceState(fastify.prisma, employeeIds, dateString);
|
||||
const items = employeeIds.map((employeeId) => ({ employeeId, presence: map.get(employeeId) ?? null }));
|
||||
return sendData(reply, request, { date: dateString, summary: buildSummary(items) });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = presenceRoutes;
|
||||
+198
-51
@@ -9,6 +9,18 @@ const QUERY_SCHEMA = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(20).optional(),
|
||||
});
|
||||
|
||||
const ENTITY_PRIORITY = {
|
||||
employee: 0,
|
||||
announcement: 1,
|
||||
asset: 2,
|
||||
leave: 3,
|
||||
reimbursement: 4,
|
||||
weekly_report: 5,
|
||||
todo: 6,
|
||||
purchase_request: 7,
|
||||
travel_request: 8,
|
||||
};
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
@@ -39,6 +51,17 @@ function compareMatchedBy(left, right) {
|
||||
return score[left] - score[right];
|
||||
}
|
||||
|
||||
async function isFlagEnabled(fastify, request, flagKey) {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey,
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
return gate.enabled;
|
||||
}
|
||||
|
||||
async function searchRoutes(fastify) {
|
||||
fastify.get("/search", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
@@ -47,8 +70,131 @@ async function searchRoutes(fastify) {
|
||||
const q = parsed.data.q.trim();
|
||||
const limit = parsed.data.limit ?? 10;
|
||||
|
||||
const tasks = [];
|
||||
const isAdmin = fastify.hasPermission(request.employee, "*");
|
||||
const featureEnabled = {
|
||||
announcements: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.announcements),
|
||||
weeklyReport: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.weeklyReport),
|
||||
todoBoard: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.todoBoard),
|
||||
};
|
||||
|
||||
const tasks = [];
|
||||
|
||||
tasks.push(
|
||||
fastify.prisma.employee.findMany({
|
||||
where: {
|
||||
status: "active",
|
||||
OR: [
|
||||
{ employee_no: { contains: q, mode: "insensitive" } },
|
||||
{ name: { contains: q, mode: "insensitive" } },
|
||||
{ email: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, employee_no: true, name: true, email: true, title: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "employee",
|
||||
entityId: item.id,
|
||||
title: item.name,
|
||||
subtitle: `${item.employee_no} · ${item.title ?? item.email ?? "成员"}`,
|
||||
status: "active",
|
||||
href: `/people/${item.id}`,
|
||||
matchedBy: matchByValue(q, item.employee_no, item.name, item.email),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
|
||||
if (featureEnabled.announcements) {
|
||||
tasks.push(
|
||||
fastify.prisma.portalAnnouncement.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
OR: [
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ summary: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, title: true, summary: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "announcement",
|
||||
entityId: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.summary,
|
||||
status: null,
|
||||
href: "/announcements",
|
||||
matchedBy: matchByValue(q, item.title, item.summary),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
fastify.hasPermission(request.employee, "asset.view_own") ||
|
||||
fastify.hasPermission(request.employee, "asset.view_all") ||
|
||||
fastify.hasPermission(request.employee, "asset.manage") ||
|
||||
fastify.hasPermission(request.employee, "asset.transfer") ||
|
||||
fastify.hasPermission(request.employee, "asset.inventory")
|
||||
) {
|
||||
tasks.push(
|
||||
fastify.prisma.assetCard.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
fastify.hasPermission(request.employee, "asset.view_all") || fastify.hasPermission(request.employee, "asset.manage") || fastify.hasPermission(request.employee, "asset.transfer") || fastify.hasPermission(request.employee, "asset.inventory") || isAdmin
|
||||
? {}
|
||||
: { assignee_employee_id: request.employee.id },
|
||||
{
|
||||
OR: [
|
||||
{ asset_no: { contains: q, mode: "insensitive" } },
|
||||
{ serial_no: { contains: q, mode: "insensitive" } },
|
||||
{ name: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, asset_no: true, serial_no: true, name: true, status: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "asset",
|
||||
entityId: item.id,
|
||||
title: item.asset_no,
|
||||
subtitle: item.name ?? item.serial_no ?? null,
|
||||
status: item.status,
|
||||
href: "/assets",
|
||||
matchedBy: matchByValue(q, item.asset_no, item.serial_no, item.name),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
if (fastify.hasPermission(request.employee, "leave.view_own") || fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage")) {
|
||||
tasks.push(
|
||||
fastify.prisma.leaveRequest.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage") || isAdmin
|
||||
? {}
|
||||
: { owner_id: request.employee.id },
|
||||
{ OR: [{ reason: { contains: q, mode: "insensitive" } }] },
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, reason: true, status: true, start_date: true, end_date: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "leave",
|
||||
entityId: item.id,
|
||||
title: `请假 ${item.start_date?.toISOString?.()?.slice(0, 10) ?? ""} ~ ${item.end_date?.toISOString?.()?.slice(0, 10) ?? ""}`,
|
||||
subtitle: item.reason ?? null,
|
||||
status: item.status,
|
||||
href: "/leave-requests",
|
||||
matchedBy: matchByValue(q, item.reason),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
if (fastify.hasPermission(request.employee, "expense.view_own") || fastify.hasPermission(request.employee, "expense.view_all")) {
|
||||
tasks.push(
|
||||
@@ -82,28 +228,66 @@ async function searchRoutes(fastify) {
|
||||
);
|
||||
}
|
||||
|
||||
if (fastify.hasPermission(request.employee, "leave.view_own") || fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage")) {
|
||||
if (featureEnabled.weeklyReport) {
|
||||
tasks.push(
|
||||
fastify.prisma.leaveRequest.findMany({
|
||||
fastify.prisma.weeklyReport.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage") || isAdmin
|
||||
fastify.hasPermission(request.employee, "report.dashboard.view") || fastify.hasPermission(request.employee, "system.users") || isAdmin
|
||||
? {}
|
||||
: { owner_id: request.employee.id },
|
||||
{ OR: [{ reason: { contains: q, mode: "insensitive" } }] },
|
||||
: { employee_id: request.employee.id },
|
||||
{
|
||||
OR: [
|
||||
{ highlights_text: { contains: q, mode: "insensitive" } },
|
||||
{ blockers_text: { contains: q, mode: "insensitive" } },
|
||||
{ next_week_text: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, reason: true, status: true, start_date: true, end_date: true, updated_at: true },
|
||||
include: { employee: { select: { name: true } } },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "leave",
|
||||
entityType: "weekly_report",
|
||||
entityId: item.id,
|
||||
title: `请假 ${item.start_date?.toISOString?.()?.slice(0, 10) ?? ""} ~ ${item.end_date?.toISOString?.()?.slice(0, 10) ?? ""}`,
|
||||
subtitle: item.reason ?? null,
|
||||
title: `${item.employee?.name ?? "成员"} 周报 ${item.week_start?.toISOString?.()?.slice(0, 10) ?? ""}`,
|
||||
subtitle: item.highlights_text || item.next_week_text || item.blockers_text || null,
|
||||
status: item.status,
|
||||
href: "/leave-requests",
|
||||
matchedBy: matchByValue(q, item.reason),
|
||||
href: "/weekly-reports",
|
||||
matchedBy: matchByValue(q, item.highlights_text, item.blockers_text, item.next_week_text),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
if (featureEnabled.todoBoard) {
|
||||
tasks.push(
|
||||
fastify.prisma.todoItem.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
fastify.hasPermission(request.employee, "report.dashboard.view") || fastify.hasPermission(request.employee, "system.users") || isAdmin
|
||||
? {}
|
||||
: { OR: [{ assignee_employee_id: request.employee.id }, { created_by: request.employee.id }] },
|
||||
{
|
||||
OR: [
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ description: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, title: true, description: true, status: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "todo",
|
||||
entityId: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.description ?? null,
|
||||
status: item.status,
|
||||
href: "/todos",
|
||||
matchedBy: matchByValue(q, item.title, item.description),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
@@ -173,49 +357,12 @@ async function searchRoutes(fastify) {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
fastify.hasPermission(request.employee, "asset.view_own") ||
|
||||
fastify.hasPermission(request.employee, "asset.view_all") ||
|
||||
fastify.hasPermission(request.employee, "asset.manage") ||
|
||||
fastify.hasPermission(request.employee, "asset.transfer") ||
|
||||
fastify.hasPermission(request.employee, "asset.inventory")
|
||||
) {
|
||||
tasks.push(
|
||||
fastify.prisma.assetCard.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
fastify.hasPermission(request.employee, "asset.view_all") || fastify.hasPermission(request.employee, "asset.manage") || fastify.hasPermission(request.employee, "asset.transfer") || fastify.hasPermission(request.employee, "asset.inventory") || isAdmin
|
||||
? {}
|
||||
: { assignee_employee_id: request.employee.id },
|
||||
{
|
||||
OR: [
|
||||
{ asset_no: { contains: q, mode: "insensitive" } },
|
||||
{ serial_no: { contains: q, mode: "insensitive" } },
|
||||
{ name: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, asset_no: true, serial_no: true, name: true, status: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "asset",
|
||||
entityId: item.id,
|
||||
title: item.asset_no,
|
||||
subtitle: item.name ?? item.serial_no ?? null,
|
||||
status: item.status,
|
||||
href: "/assets",
|
||||
matchedBy: matchByValue(q, item.asset_no, item.serial_no, item.name),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
const items = (await Promise.all(tasks)).flat();
|
||||
items.sort((left, right) => {
|
||||
const matchedDiff = compareMatchedBy(left.matchedBy, right.matchedBy);
|
||||
if (matchedDiff !== 0) return matchedDiff;
|
||||
const entityDiff = (ENTITY_PRIORITY[left.entityType] ?? 999) - (ENTITY_PRIORITY[right.entityType] ?? 999);
|
||||
if (entityDiff !== 0) return entityDiff;
|
||||
return String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const {
|
||||
DEFAULT_MODULE_VISIBILITY,
|
||||
getWorkspaceSetting,
|
||||
getSystemNavigation,
|
||||
normalizeModuleVisibilityJson,
|
||||
upsertWorkspaceSetting,
|
||||
} = require("../services/workspaceSettings");
|
||||
|
||||
const MODULE_VISIBILITY_SCHEMA = z.record(z.string(), z.enum(["main", "admin", "hidden"])).optional();
|
||||
const PATCH_SCHEMA = z
|
||||
.object({
|
||||
moduleVisibility: MODULE_VISIBILITY_SCHEMA,
|
||||
})
|
||||
.strict()
|
||||
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
|
||||
function parseBody(schema, body) {
|
||||
const parsed = schema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
async function writeWorkspaceAuditLog(fastify, request, workspace) {
|
||||
try {
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "WORKSPACE_SETTING_UPDATED",
|
||||
targetType: "workspace_setting",
|
||||
targetId: workspace.id,
|
||||
extra: { moduleVisibility: workspace.moduleVisibility },
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.error({ err: error, workspaceId: workspace.id }, "workspace setting audit failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function systemWorkspaceRoutes(fastify) {
|
||||
fastify.get("/system/workspace", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
const workspace = await getWorkspaceSetting(fastify.prisma);
|
||||
return sendData(reply, request, {
|
||||
...workspace,
|
||||
defaults: DEFAULT_MODULE_VISIBILITY,
|
||||
});
|
||||
});
|
||||
|
||||
fastify.patch("/system/workspace", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
|
||||
const body = parseBody(PATCH_SCHEMA, request.body);
|
||||
const workspace = await upsertWorkspaceSetting(fastify.prisma, request.employee.id, {
|
||||
moduleVisibility: normalizeModuleVisibilityJson(body.moduleVisibility ?? null),
|
||||
});
|
||||
|
||||
await writeWorkspaceAuditLog(fastify, request, workspace);
|
||||
return sendData(reply, request, { item: workspace });
|
||||
});
|
||||
|
||||
fastify.get("/system/navigation", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
const data = await getSystemNavigation({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
employee: request.employee,
|
||||
hasPermission: fastify.hasPermission,
|
||||
});
|
||||
return sendData(reply, request, data);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = systemWorkspaceRoutes;
|
||||
@@ -0,0 +1,238 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { createNotifications } = require("../services/notifications");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const QUERY_SCHEMA = z.object({
|
||||
status: z.enum(["todo", "doing", "done", "cancelled"]).optional(),
|
||||
scope: z.enum(["mine", "team"]).optional(),
|
||||
assigneeId: UUID_SCHEMA.optional(),
|
||||
q: z.string().trim().max(100).optional(),
|
||||
});
|
||||
const CREATE_SCHEMA = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
description: z.string().trim().max(2000).nullable().optional(),
|
||||
assigneeEmployeeId: UUID_SCHEMA.optional(),
|
||||
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
|
||||
priority: z.enum(["low", "normal", "high"]).optional(),
|
||||
source: z.enum(["manual", "weekly_report"]).optional(),
|
||||
}).strict();
|
||||
const PATCH_SCHEMA = z.object({
|
||||
title: z.string().trim().min(1).max(200).optional(),
|
||||
description: z.string().trim().max(2000).nullable().optional(),
|
||||
assigneeEmployeeId: UUID_SCHEMA.optional(),
|
||||
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
|
||||
priority: z.enum(["low", "normal", "high"]).optional(),
|
||||
status: z.enum(["todo", "doing", "done", "cancelled"]).optional(),
|
||||
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
|
||||
function parseBody(schema, body) {
|
||||
const parsed = schema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseUuid(value, fieldName = "id") {
|
||||
const parsed = UUID_SCHEMA.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError({ [fieldName]: "invalid_uuid" });
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function mapTodo(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description ?? null,
|
||||
assigneeEmployeeId: row.assignee_employee_id,
|
||||
createdBy: row.created_by,
|
||||
dueDate: row.due_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
priority: row.priority,
|
||||
status: row.status,
|
||||
source: row.source,
|
||||
createdAt: row.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? null,
|
||||
completedAt: row.completed_at?.toISOString?.() ?? null,
|
||||
assignee: row.assignee ? { id: row.assignee.id, employeeNo: row.assignee.employee_no, name: row.assignee.name } : null,
|
||||
creator: row.creator ? { id: row.creator.id, employeeNo: row.creator.employee_no, name: row.creator.name } : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureFeatureEnabled(fastify, request) {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.todoBoard,
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
if (!gate.enabled) {
|
||||
throw buildValidationError({ featureFlag: "todo_board_v1_disabled", reason: gate.reason }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
function canManageTeamTodos(fastify, employee) {
|
||||
return fastify.hasPermission(employee, "report.dashboard.view") || fastify.hasPermission(employee, "system.users");
|
||||
}
|
||||
|
||||
async function todosRoutes(fastify) {
|
||||
fastify.get("/todos", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
const query = parsed.data;
|
||||
const canManage = canManageTeamTodos(fastify, request.employee);
|
||||
const scope = query.scope ?? "mine";
|
||||
const where = {
|
||||
AND: [
|
||||
query.status ? { status: query.status } : {},
|
||||
query.q
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: query.q, mode: "insensitive" } },
|
||||
{ description: { contains: query.q, mode: "insensitive" } },
|
||||
],
|
||||
}
|
||||
: {},
|
||||
scope === "team" && canManage
|
||||
? query.assigneeId
|
||||
? { assignee_employee_id: query.assigneeId }
|
||||
: {}
|
||||
: {
|
||||
OR: [{ assignee_employee_id: request.employee.id }, { created_by: request.employee.id }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const rows = await fastify.prisma.todoItem.findMany({
|
||||
where,
|
||||
orderBy: [{ status: "asc" }, { priority: "desc" }, { due_date: "asc" }, { updated_at: "desc" }],
|
||||
include: {
|
||||
assignee: { select: { id: true, employee_no: true, name: true } },
|
||||
creator: { select: { id: true, employee_no: true, name: true } },
|
||||
},
|
||||
});
|
||||
return sendData(reply, request, { items: rows.map(mapTodo) });
|
||||
});
|
||||
|
||||
fastify.post("/todos", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const body = parseBody(CREATE_SCHEMA, request.body);
|
||||
const assigneeEmployeeId = body.assigneeEmployeeId ?? request.employee.id;
|
||||
if (assigneeEmployeeId !== request.employee.id && !canManageTeamTodos(fastify, request.employee)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "report.dashboard.view" } });
|
||||
}
|
||||
const created = await fastify.prisma.todoItem.create({
|
||||
data: {
|
||||
title: body.title,
|
||||
description: body.description ?? null,
|
||||
assignee_employee_id: assigneeEmployeeId,
|
||||
created_by: request.employee.id,
|
||||
due_date: body.dueDate ? new Date(`${body.dueDate}T00:00:00.000Z`) : null,
|
||||
priority: body.priority ?? "normal",
|
||||
status: "todo",
|
||||
source: body.source ?? "manual",
|
||||
},
|
||||
include: {
|
||||
assignee: { select: { id: true, employee_no: true, name: true } },
|
||||
creator: { select: { id: true, employee_no: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (assigneeEmployeeId !== request.employee.id) {
|
||||
await createNotifications(fastify.prisma, {
|
||||
recipientEmployeeIds: [assigneeEmployeeId],
|
||||
type: "todo.assigned",
|
||||
title: `新的待办:${created.title}`,
|
||||
body: created.description ?? "请到待办页查看详情。",
|
||||
targetType: "todo_item",
|
||||
targetId: created.id,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(201).send({ requestId: request.id, data: { item: mapTodo(created) } });
|
||||
});
|
||||
|
||||
fastify.patch("/todos/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const todoId = parseUuid(request.params?.id);
|
||||
const body = parseBody(PATCH_SCHEMA, request.body);
|
||||
const current = await fastify.prisma.todoItem.findUnique({ where: { id: todoId } });
|
||||
if (!current) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
const canManage = canManageTeamTodos(fastify, request.employee);
|
||||
if (!(canManage || current.assignee_employee_id === request.employee.id || current.created_by === request.employee.id)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "todo.write" } });
|
||||
}
|
||||
if (body.assigneeEmployeeId && body.assigneeEmployeeId !== request.employee.id && !canManage) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "report.dashboard.view" } });
|
||||
}
|
||||
const updated = await fastify.prisma.todoItem.update({
|
||||
where: { id: todoId },
|
||||
data: {
|
||||
...(body.title !== undefined ? { title: body.title } : {}),
|
||||
...(body.description !== undefined ? { description: body.description ?? null } : {}),
|
||||
...(body.assigneeEmployeeId !== undefined ? { assignee_employee_id: body.assigneeEmployeeId } : {}),
|
||||
...(body.dueDate !== undefined ? { due_date: body.dueDate ? new Date(`${body.dueDate}T00:00:00.000Z`) : null } : {}),
|
||||
...(body.priority !== undefined ? { priority: body.priority } : {}),
|
||||
...(body.status !== undefined ? { status: body.status, completed_at: body.status === "done" ? new Date() : body.status === "cancelled" ? current.completed_at : null } : {}),
|
||||
},
|
||||
include: {
|
||||
assignee: { select: { id: true, employee_no: true, name: true } },
|
||||
creator: { select: { id: true, employee_no: true, name: true } },
|
||||
},
|
||||
});
|
||||
return sendData(reply, request, { item: mapTodo(updated) });
|
||||
});
|
||||
|
||||
fastify.post("/todos/:id/complete", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const todoId = parseUuid(request.params?.id);
|
||||
const current = await fastify.prisma.todoItem.findUnique({ where: { id: todoId } });
|
||||
if (!current) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (!(canManageTeamTodos(fastify, request.employee) || current.assignee_employee_id === request.employee.id || current.created_by === request.employee.id)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "todo.complete" } });
|
||||
}
|
||||
const updated = await fastify.prisma.todoItem.update({
|
||||
where: { id: todoId },
|
||||
data: { status: "done", completed_at: new Date() },
|
||||
include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } },
|
||||
});
|
||||
return sendData(reply, request, { item: mapTodo(updated) });
|
||||
});
|
||||
|
||||
fastify.post("/todos/:id/cancel", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const todoId = parseUuid(request.params?.id);
|
||||
const current = await fastify.prisma.todoItem.findUnique({ where: { id: todoId } });
|
||||
if (!current) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (!(canManageTeamTodos(fastify, request.employee) || current.assignee_employee_id === request.employee.id || current.created_by === request.employee.id)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "todo.cancel" } });
|
||||
}
|
||||
const updated = await fastify.prisma.todoItem.update({
|
||||
where: { id: todoId },
|
||||
data: { status: "cancelled", completed_at: null },
|
||||
include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } },
|
||||
});
|
||||
return sendData(reply, request, { item: mapTodo(updated) });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = todosRoutes;
|
||||
@@ -0,0 +1,213 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
|
||||
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const QUERY_SCHEMA = z.object({ weekStart: DATE_SCHEMA.optional() });
|
||||
const WRITE_SCHEMA = z
|
||||
.object({
|
||||
weekStart: DATE_SCHEMA.optional(),
|
||||
highlightsText: z.string().max(5000).optional(),
|
||||
blockersText: z.string().max(5000).optional(),
|
||||
nextWeekText: z.string().max(5000).optional(),
|
||||
needsHelp: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
|
||||
function parseBody(schema, body) {
|
||||
const parsed = schema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function normalizeDateString(value) {
|
||||
return new Date(`${value}T00:00:00.000Z`).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function resolveWeekStart(rawValue, timeZone) {
|
||||
const base = rawValue ? normalizeDateString(rawValue) : currentDateString(timeZone ?? "Asia/Shanghai");
|
||||
const date = parseDateOnlyString(base);
|
||||
const day = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() - (day - 1));
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function ensureFeatureEnabled(fastify, request) {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.weeklyReport,
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
if (!gate.enabled) {
|
||||
throw buildValidationError({ featureFlag: "weekly_report_v1_disabled", reason: gate.reason }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
function mapReport(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
employeeId: row.employee_id,
|
||||
weekStart: row.week_start?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
status: row.status,
|
||||
highlightsText: row.highlights_text,
|
||||
blockersText: row.blockers_text,
|
||||
nextWeekText: row.next_week_text,
|
||||
needsHelp: row.needs_help,
|
||||
submittedAt: row.submitted_at?.toISOString?.() ?? null,
|
||||
createdAt: row.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureReportDraft(prisma, employeeId, weekStart) {
|
||||
const existing = await prisma.weeklyReport.findUnique({
|
||||
where: {
|
||||
employee_id_week_start: {
|
||||
employee_id: employeeId,
|
||||
week_start: parseDateOnlyString(weekStart),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
return prisma.weeklyReport.create({
|
||||
data: {
|
||||
employee_id: employeeId,
|
||||
week_start: parseDateOnlyString(weekStart),
|
||||
status: "draft",
|
||||
highlights_text: "",
|
||||
blockers_text: "",
|
||||
next_week_text: "",
|
||||
needs_help: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function weeklyReportsRoutes(fastify) {
|
||||
fastify.get("/weekly-reports/me", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
const weekStart = resolveWeekStart(parsed.data.weekStart, fastify.config.appTimezone);
|
||||
const report = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart);
|
||||
return sendData(reply, request, { item: mapReport(report) });
|
||||
});
|
||||
|
||||
fastify.put("/weekly-reports/me", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const body = parseBody(WRITE_SCHEMA, request.body);
|
||||
const weekStart = resolveWeekStart(body.weekStart, fastify.config.appTimezone);
|
||||
const current = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart);
|
||||
if (current.status === "submitted") {
|
||||
throw buildValidationError({ status: "submitted_readonly" }, 409);
|
||||
}
|
||||
const updated = await fastify.prisma.weeklyReport.update({
|
||||
where: { id: current.id },
|
||||
data: {
|
||||
...(body.highlightsText !== undefined ? { highlights_text: body.highlightsText } : {}),
|
||||
...(body.blockersText !== undefined ? { blockers_text: body.blockersText } : {}),
|
||||
...(body.nextWeekText !== undefined ? { next_week_text: body.nextWeekText } : {}),
|
||||
...(body.needsHelp !== undefined ? { needs_help: body.needsHelp } : {}),
|
||||
},
|
||||
});
|
||||
return sendData(reply, request, { item: mapReport(updated) });
|
||||
});
|
||||
|
||||
fastify.post("/weekly-reports/me/submit", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const body = parseBody(QUERY_SCHEMA, request.body);
|
||||
const weekStart = resolveWeekStart(body.weekStart, fastify.config.appTimezone);
|
||||
const current = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart);
|
||||
const updated = await fastify.prisma.weeklyReport.update({
|
||||
where: { id: current.id },
|
||||
data: {
|
||||
status: "submitted",
|
||||
submitted_at: new Date(),
|
||||
},
|
||||
});
|
||||
return sendData(reply, request, { item: mapReport(updated) });
|
||||
});
|
||||
|
||||
fastify.get("/weekly-reports/team", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!(fastify.hasPermission(request.employee, "report.dashboard.view") || fastify.hasPermission(request.employee, "system.users"))) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "report.dashboard.view" } });
|
||||
}
|
||||
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
const weekStart = resolveWeekStart(parsed.data.weekStart, fastify.config.appTimezone);
|
||||
const [employees, reports] = await Promise.all([
|
||||
fastify.prisma.employee.findMany({
|
||||
where: { status: "active" },
|
||||
orderBy: { employee_no: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
title: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
fastify.prisma.weeklyReport.findMany({
|
||||
where: { week_start: parseDateOnlyString(weekStart) },
|
||||
select: {
|
||||
id: true,
|
||||
employee_id: true,
|
||||
week_start: true,
|
||||
status: true,
|
||||
highlights_text: true,
|
||||
blockers_text: true,
|
||||
next_week_text: true,
|
||||
needs_help: true,
|
||||
submitted_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const reportMap = new Map(reports.map((item) => [item.employee_id, item]));
|
||||
const items = employees.map((employee) => {
|
||||
const report = reportMap.get(employee.id) ?? null;
|
||||
return {
|
||||
employee: {
|
||||
id: employee.id,
|
||||
employeeNo: employee.employee_no,
|
||||
name: employee.name,
|
||||
title: employee.title,
|
||||
department: employee.department ? { id: employee.department.id, name: employee.department.name } : null,
|
||||
},
|
||||
report: report ? mapReport(report) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const summary = {
|
||||
totalEmployees: employees.length,
|
||||
submittedCount: items.filter((item) => item.report?.status === "submitted").length,
|
||||
draftCount: items.filter((item) => item.report?.status === "draft").length,
|
||||
missingCount: items.filter((item) => !item.report).length,
|
||||
needsHelpCount: items.filter((item) => item.report?.needsHelp).length,
|
||||
};
|
||||
|
||||
return sendData(reply, request, { weekStart, summary, items });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = weeklyReportsRoutes;
|
||||
@@ -10,6 +10,10 @@ const FEATURE_FLAG_KEYS = {
|
||||
announcements: "announcements_v1",
|
||||
workflowDefinitionAdmin: "workflow_definition_admin_v1",
|
||||
cutoverConsole: "cutover_console_v1",
|
||||
presenceBoard: "presence_board_v1",
|
||||
weeklyReport: "weekly_report_v1",
|
||||
employeeLifecycle: "employee_lifecycle_v1",
|
||||
todoBoard: "todo_board_v1",
|
||||
};
|
||||
|
||||
const FEATURE_FLAG_KEY_LIST = Object.freeze(Object.values(FEATURE_FLAG_KEYS));
|
||||
@@ -107,6 +111,22 @@ function resolveEnvGate(config, flagKey) {
|
||||
return Boolean(config.cutoverConsoleEnabled);
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.presenceBoard) {
|
||||
return Boolean(config.presenceBoardEnabled);
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.weeklyReport) {
|
||||
return Boolean(config.weeklyReportEnabled);
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.employeeLifecycle) {
|
||||
return Boolean(config.employeeLifecycleEnabled);
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.todoBoard) {
|
||||
return Boolean(config.todoBoardEnabled);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("./featureFlags");
|
||||
|
||||
const WORKSPACE_PROFILES = Object.freeze({
|
||||
smallTeamInternal: "small_team_internal",
|
||||
});
|
||||
|
||||
const MODULE_VISIBILITY_MODES = Object.freeze(["main", "admin", "hidden"]);
|
||||
|
||||
const NAVIGATION_REGISTRY = Object.freeze({
|
||||
portal: { key: "portal", label: "Portal", icon: "home_app_logo", href: "/portal", anyOf: [] },
|
||||
team: { key: "team", label: "团队", icon: "groups", href: "/team", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.presenceBoard },
|
||||
leaveRequests: { key: "leaveRequests", label: "请假", icon: "event_available", href: "/leave-requests", anyOf: ["leave.view_own", "leave.view_all", "leave.manage"] },
|
||||
reimbursements: { key: "reimbursements", label: "报销", icon: "request_quote", href: "/reimbursements", anyOf: ["expense.view_own", "expense.view_all"] },
|
||||
assets: { key: "assets", label: "资产", icon: "inventory_2", href: "/assets", anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"] },
|
||||
weeklyReports: { key: "weeklyReports", label: "周报", icon: "article", href: "/weekly-reports", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.weeklyReport },
|
||||
todos: { key: "todos", label: "待办", icon: "check_circle", href: "/todos", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.todoBoard },
|
||||
announcements: { key: "announcements", label: "公告", icon: "campaign", href: "/announcements", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.announcements },
|
||||
|
||||
workspace: { key: "workspace", label: "工作区设置", icon: "tune", href: "/system/workspace", anyOf: ["system.settings"] },
|
||||
people: { key: "people", label: "人员管理", icon: "group", href: "/people", anyOf: ["system.users", "report.dashboard.view"], featureFlagKey: FEATURE_FLAG_KEYS.employeeLifecycle },
|
||||
workflowDefinitions: { key: "workflowDefinitions", label: "流程定义", icon: "schema", href: "/system/workflow-definitions", anyOf: ["system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.workflowDefinitionAdmin },
|
||||
systemSettings: { key: "systemSettings", label: "系统设置", icon: "settings", href: "/system/settings", anyOf: ["system.settings", "system.users", "masterdata.view", "masterdata.manage", "notification.channel.manage", "integration.manage"] },
|
||||
featureFlags: { key: "featureFlags", label: "功能开关", icon: "toggle_on", href: "/system/feature-flags", anyOf: ["system.settings"] },
|
||||
auditLogs: { key: "auditLogs", label: "审计日志", icon: "manage_search", href: "/audit-logs", anyOf: ["system.audit.view"] },
|
||||
|
||||
approvalRules: { key: "approvalRules", label: "审批规则", icon: "rule", href: "/system/approval-rules", anyOf: ["system.settings"] },
|
||||
userManagement: { key: "userManagement", label: "用户管理", icon: "badge", href: "/system/users", anyOf: ["system.users"] },
|
||||
departments: { key: "departments", label: "部门管理", icon: "account_tree", href: "/system/departments", anyOf: ["system.users"] },
|
||||
masterData: { key: "masterData", label: "主数据", icon: "dataset", href: "/system/master-data", anyOf: ["masterdata.view", "masterdata.manage"] },
|
||||
notificationChannels: { key: "notificationChannels", label: "通知渠道", icon: "notifications_active", href: "/system/notification-channels", anyOf: ["notification.channel.manage"] },
|
||||
workflowTasks: { key: "workflowTasks", label: "审批待办", icon: "fact_check", href: "/workflow/tasks", anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"] },
|
||||
dashboard: { key: "dashboard", label: "仪表盘", icon: "dashboard", href: "/dashboard", anyOf: ["report.dashboard.view"] },
|
||||
purchaseRequests: { key: "purchaseRequests", label: "采购申请", icon: "shopping_cart", href: "/purchase-requests", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
|
||||
travelRequests: { key: "travelRequests", label: "差旅申请", icon: "flight", href: "/travel-requests", anyOf: ["travel.create", "travel.view_own", "travel.view_all", "travel.manage", "travel.payment.manage", "integration.manage"] },
|
||||
budgets: { key: "budgets", label: "预算管理", icon: "account_balance", href: "/budgets", anyOf: ["budget.view", "budget.manage"] },
|
||||
paymentBatches: { key: "paymentBatches", label: "支付批次", icon: "payments", href: "/payment-batches", anyOf: ["payment.batch.manage", "risk.review.manage"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch },
|
||||
apOperations: { key: "apOperations", label: "AP 运营", icon: "account_balance_wallet", href: "/ap-operations", anyOf: ["payment.batch.manage", "risk.review.manage"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch },
|
||||
invoicePool: { key: "invoicePool", label: "票据中心", icon: "receipt_long", href: "/invoice-pool", anyOf: ["invoice.upload", "invoice.view_own", "invoice.manage"] },
|
||||
inventoryChecks: { key: "inventoryChecks", label: "资产盘点", icon: "inventory", href: "/inventory-checks", anyOf: ["asset.inventory"] },
|
||||
cutover: { key: "cutover", label: "切换控制台", icon: "swap_calls", href: "/system/cutover", anyOf: ["system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.cutoverConsole },
|
||||
erpIntegrations: { key: "erpIntegrations", label: "ERP 集成", icon: "hub", href: "/system/integrations/erp", anyOf: ["integration.manage"] },
|
||||
profile: { key: "profile", label: "个人设置", icon: "manage_accounts", href: "/settings/profile", anyOf: [] },
|
||||
});
|
||||
|
||||
const DEFAULT_MODULE_VISIBILITY = Object.freeze({
|
||||
portal: "main",
|
||||
team: "main",
|
||||
leaveRequests: "main",
|
||||
reimbursements: "main",
|
||||
assets: "main",
|
||||
weeklyReports: "main",
|
||||
todos: "main",
|
||||
announcements: "main",
|
||||
|
||||
workspace: "admin",
|
||||
people: "admin",
|
||||
workflowDefinitions: "admin",
|
||||
systemSettings: "admin",
|
||||
featureFlags: "admin",
|
||||
auditLogs: "admin",
|
||||
|
||||
approvalRules: "admin",
|
||||
userManagement: "admin",
|
||||
departments: "admin",
|
||||
masterData: "admin",
|
||||
notificationChannels: "admin",
|
||||
workflowTasks: "admin",
|
||||
dashboard: "hidden",
|
||||
purchaseRequests: "admin",
|
||||
travelRequests: "admin",
|
||||
budgets: "admin",
|
||||
paymentBatches: "admin",
|
||||
apOperations: "admin",
|
||||
invoicePool: "admin",
|
||||
inventoryChecks: "admin",
|
||||
cutover: "admin",
|
||||
erpIntegrations: "admin",
|
||||
profile: "hidden",
|
||||
});
|
||||
|
||||
function normalizeModuleVisibilityJson(input) {
|
||||
const result = { ...DEFAULT_MODULE_VISIBILITY };
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const [key, rawValue] of Object.entries(input)) {
|
||||
if (!(key in DEFAULT_MODULE_VISIBILITY)) {
|
||||
continue;
|
||||
}
|
||||
const value = String(rawValue ?? "").trim();
|
||||
if (MODULE_VISIBILITY_MODES.includes(value)) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildDefaultWorkspaceSetting() {
|
||||
return {
|
||||
id: null,
|
||||
persisted: false,
|
||||
profile: WORKSPACE_PROFILES.smallTeamInternal,
|
||||
orgMode: "single_company",
|
||||
primaryEntryMode: "password",
|
||||
notificationStrategy: "in_app",
|
||||
devicePriority: "desktop",
|
||||
moduleVisibility: { ...DEFAULT_MODULE_VISIBILITY },
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapWorkspaceSetting(record) {
|
||||
if (!record) {
|
||||
return buildDefaultWorkspaceSetting();
|
||||
}
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
persisted: true,
|
||||
profile: record.profile,
|
||||
orgMode: record.org_mode,
|
||||
primaryEntryMode: record.primary_entry_mode,
|
||||
notificationStrategy: record.notification_strategy,
|
||||
devicePriority: record.device_priority,
|
||||
moduleVisibility: normalizeModuleVisibilityJson(record.module_visibility_json),
|
||||
createdAt: record.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
||||
updatedBy: record.updated_by ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getWorkspaceSettingRecord(prisma) {
|
||||
return prisma.workspaceSetting.findFirst({ orderBy: { created_at: "asc" } });
|
||||
}
|
||||
|
||||
async function getWorkspaceSetting(prisma) {
|
||||
const record = await getWorkspaceSettingRecord(prisma);
|
||||
return mapWorkspaceSetting(record);
|
||||
}
|
||||
|
||||
async function upsertWorkspaceSetting(prisma, actorId, input = {}) {
|
||||
const current = await getWorkspaceSettingRecord(prisma);
|
||||
const data = {
|
||||
profile: WORKSPACE_PROFILES.smallTeamInternal,
|
||||
org_mode: "single_company",
|
||||
primary_entry_mode: "password",
|
||||
notification_strategy: "in_app",
|
||||
device_priority: "desktop",
|
||||
module_visibility_json: normalizeModuleVisibilityJson(input.moduleVisibility ?? input.module_visibility_json ?? current?.module_visibility_json ?? null),
|
||||
updated_by: actorId ?? null,
|
||||
};
|
||||
|
||||
const record = current
|
||||
? await prisma.workspaceSetting.update({ where: { id: current.id }, data })
|
||||
: await prisma.workspaceSetting.create({ data });
|
||||
|
||||
return mapWorkspaceSetting(record);
|
||||
}
|
||||
|
||||
function hasAnyPermission(hasPermission, employee, anyOf = []) {
|
||||
if (!anyOf.length) {
|
||||
return true;
|
||||
}
|
||||
return anyOf.some((permissionCode) => hasPermission(employee, permissionCode));
|
||||
}
|
||||
|
||||
async function resolveModuleGate({ prisma, config, employee, moduleKey, registryItem }) {
|
||||
if (!registryItem?.featureFlagKey) {
|
||||
return { enabled: true, reason: "no_feature_gate" };
|
||||
}
|
||||
|
||||
return evaluateFeatureFlag({
|
||||
prisma,
|
||||
config,
|
||||
flagKey: registryItem.featureFlagKey,
|
||||
departmentId: employee?.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function getSystemNavigation({ prisma, config, employee, hasPermission }) {
|
||||
const workspace = await getWorkspaceSetting(prisma);
|
||||
const sections = { main: [], admin: [], backup: [] };
|
||||
|
||||
for (const [moduleKey, registryItem] of Object.entries(NAVIGATION_REGISTRY)) {
|
||||
const placement = workspace.moduleVisibility[moduleKey] ?? DEFAULT_MODULE_VISIBILITY[moduleKey] ?? "hidden";
|
||||
if (placement === "hidden") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasAnyPermission(hasPermission, employee, registryItem.anyOf)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const gate = await resolveModuleGate({ prisma, config, employee, moduleKey, registryItem });
|
||||
if (!gate.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry = {
|
||||
key: registryItem.key,
|
||||
label: registryItem.label,
|
||||
icon: registryItem.icon,
|
||||
href: registryItem.href,
|
||||
placement,
|
||||
permissionCodes: registryItem.anyOf,
|
||||
featureFlagKey: registryItem.featureFlagKey ?? null,
|
||||
};
|
||||
|
||||
if (["purchaseRequests", "travelRequests", "budgets", "paymentBatches", "apOperations", "invoicePool", "inventoryChecks", "cutover", "erpIntegrations", "approvalRules", "userManagement", "departments", "masterData", "notificationChannels", "workflowTasks", "dashboard"].includes(moduleKey)) {
|
||||
sections.backup.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (placement === "admin") {
|
||||
sections.admin.push(entry);
|
||||
} else {
|
||||
sections.main.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
workspace,
|
||||
sections,
|
||||
recommendedRolePresets: [
|
||||
{ key: "owner", label: "老板", roleCodes: ["ceo"], description: "查看团队状态、审批汇总与关键管理入口。" },
|
||||
{ key: "admin", label: "管理员", roleCodes: ["admin"], description: "管理工作区、人员、流程、开关与审计。" },
|
||||
{ key: "employee", label: "员工", roleCodes: ["employee"], description: "日常使用 Portal、请假、报销、周报、待办。" },
|
||||
{ key: "manager", label: "主管", roleCodes: ["manager"], description: "处理审批、查看团队周报与在岗状态。" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WORKSPACE_PROFILES,
|
||||
NAVIGATION_REGISTRY,
|
||||
DEFAULT_MODULE_VISIBILITY,
|
||||
normalizeModuleVisibilityJson,
|
||||
buildDefaultWorkspaceSetting,
|
||||
mapWorkspaceSetting,
|
||||
getWorkspaceSetting,
|
||||
upsertWorkspaceSetting,
|
||||
getSystemNavigation,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户