feat: 完成 R13/R14/R15/R16 小团队管理核心能力

这个提交包含在:
2026-03-21 20:31:34 +08:00
父节点 0826693a8f
当前提交 1e52f1c225
共修改 47 个文件,包含 4298 行新增和 306 行删除
+160
查看文件
@@ -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 -1
查看文件
@@ -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";
+21
查看文件
@@ -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() {
+67
查看文件
@@ -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;
+79
查看文件
@@ -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({}),
});
}
+68
查看文件
@@ -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}`);
}
+62
查看文件
@@ -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");
}
+16 -3
查看文件
@@ -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>
+74 -170
查看文件
@@ -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;
}
return hasAnyPermission(permissions, item.anyOf);
});
function renderSection(
title: string,
items: Array<{ key?: string; href: string; label: string; icon: string }>,
onNavigate?: () => void,
) {
if (!items.length) {
return null;
}
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>
);
}
+5 -5
查看文件
@@ -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>
+2 -1
查看文件
@@ -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) {
+24
查看文件
@@ -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;
+247
查看文件
@@ -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;
+4
查看文件
@@ -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;
}
+140
查看文件
@@ -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;
+58 -19
查看文件
@@ -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>
<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>
</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>
<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>
</Link>
) : (
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">当前没有开放风险项</div>
)}
{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>
) : null}
</div>
</div>
</div>
</div>
);
}
export default PortalHomePage;
+29
查看文件
@@ -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;
+130
查看文件
@@ -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;
+124
查看文件
@@ -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;
+156
查看文件
@@ -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;
+147
查看文件
@@ -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,7 +64,16 @@ export function UserRoleDialog({
</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap gap-3 text-sm">
<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}>
<input
@@ -79,6 +95,7 @@ export function UserRoleDialog({
<span>{item.label}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2">
+41
查看文件
@@ -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";
+1
查看文件
@@ -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";
+1
查看文件
@@ -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";
+8
查看文件
@@ -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"));