A1/A3/A5/A7/A8 完成员工头像前端、组织架构页与QA回填
这个提交包含在:
+104
-3
@@ -1,6 +1,13 @@
|
|||||||
import { apiRequestData } from "./http";
|
import { apiRequestData } from "./http";
|
||||||
|
|
||||||
export type EmployeeStatus = "active" | "archived";
|
export type EmployeeStatus = "active" | "archived";
|
||||||
|
export type EmployeeContractType = "labor" | "nda" | "non_compete" | "internship";
|
||||||
|
export type EmployeeContractStatus = "active" | "expired" | "terminated";
|
||||||
|
|
||||||
|
export type EmployeeDepartmentSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type EmployeeSummary = {
|
export type EmployeeSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,6 +19,7 @@ export type EmployeeSummary = {
|
|||||||
departmentId: string;
|
departmentId: string;
|
||||||
directManagerId: string | null;
|
directManagerId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
roleCodes: string[];
|
roleCodes: string[];
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
@@ -61,7 +69,46 @@ export type EmployeeOrganizationNode = {
|
|||||||
name: string;
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: EmployeeStatus;
|
status: EmployeeStatus;
|
||||||
department: { id: string; name: string } | null;
|
avatarUrl: string | null;
|
||||||
|
department: EmployeeDepartmentSummary | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EmployeeContract = {
|
||||||
|
id: string;
|
||||||
|
employeeId: string;
|
||||||
|
contractNo: string;
|
||||||
|
contractType: EmployeeContractType;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
terminatedDate: string | null;
|
||||||
|
attachmentPath: string | null;
|
||||||
|
status: EmployeeContractStatus;
|
||||||
|
createdAt: string | null;
|
||||||
|
createdBy: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
updatedBy: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationTreeNode = {
|
||||||
|
id: string;
|
||||||
|
employeeNo: string;
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
status: EmployeeStatus;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
department: EmployeeDepartmentSummary | null;
|
||||||
|
externalManager: {
|
||||||
|
id: string;
|
||||||
|
employeeNo: string;
|
||||||
|
name: string;
|
||||||
|
department: EmployeeDepartmentSummary | null;
|
||||||
|
} | null;
|
||||||
|
children: OrganizationTreeNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationDepartmentSection = {
|
||||||
|
department: EmployeeDepartmentSummary | null;
|
||||||
|
roots: OrganizationTreeNode[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EmployeeProfile = {
|
export type EmployeeProfile = {
|
||||||
@@ -83,10 +130,11 @@ export type EmployeeProfile = {
|
|||||||
emergencyContactName: string | null;
|
emergencyContactName: string | null;
|
||||||
emergencyContactPhone: string | null;
|
emergencyContactPhone: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
roleCodes: string[];
|
roleCodes: string[];
|
||||||
department: { id: string; name: string } | null;
|
department: EmployeeDepartmentSummary | null;
|
||||||
directManager: { id: string; employeeNo: string; name: string } | null;
|
directManager: { id: string; employeeNo: string; name: string; avatarUrl: string | null } | null;
|
||||||
};
|
};
|
||||||
lifecycle: {
|
lifecycle: {
|
||||||
events: EmployeeLifecycleEvent[];
|
events: EmployeeLifecycleEvent[];
|
||||||
@@ -131,6 +179,16 @@ export async function fetchEmployeeProfile(employeeId: string) {
|
|||||||
return apiRequestData<EmployeeProfile>(`/api/employees/${employeeId}/profile`);
|
return apiRequestData<EmployeeProfile>(`/api/employees/${employeeId}/profile`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadEmployeeAvatar(employeeId: string, file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
return apiRequestData<{ employeeId: string; avatarUrl: string | null }>(`/api/employees/${employeeId}/avatar`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function patchEmployeeProfile(
|
export async function patchEmployeeProfile(
|
||||||
employeeId: string,
|
employeeId: string,
|
||||||
payload: Partial<{
|
payload: Partial<{
|
||||||
@@ -202,6 +260,49 @@ export async function fetchEmployeeLifecycle(employeeId: string) {
|
|||||||
return apiRequestData<EmployeeProfile["lifecycle"]>(`/api/employees/${employeeId}/lifecycle`);
|
return apiRequestData<EmployeeProfile["lifecycle"]>(`/api/employees/${employeeId}/lifecycle`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchEmployeeOrganization() {
|
||||||
|
return apiRequestData<{ items: OrganizationDepartmentSection[] }>("/api/employees/organization");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEmployeeContracts(employeeId: string) {
|
||||||
|
return apiRequestData<{ items: EmployeeContract[] }>(`/api/employees/${employeeId}/contracts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEmployeeContract(
|
||||||
|
employeeId: string,
|
||||||
|
payload: {
|
||||||
|
contractNo: string;
|
||||||
|
contractType: EmployeeContractType;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
terminatedDate?: string | null;
|
||||||
|
attachmentPath?: string | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return apiRequestData<EmployeeContract>(`/api/employees/${employeeId}/contracts`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchEmployeeContract(
|
||||||
|
employeeId: string,
|
||||||
|
contractId: string,
|
||||||
|
payload: Partial<{
|
||||||
|
contractNo: string;
|
||||||
|
contractType: EmployeeContractType;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
terminatedDate: string | null;
|
||||||
|
attachmentPath: string | null;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return apiRequestData<EmployeeContract>(`/api/employees/${employeeId}/contracts/${contractId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function completeEmployeeChecklistTask(employeeId: string, taskId: string) {
|
export async function completeEmployeeChecklistTask(employeeId: string, taskId: string) {
|
||||||
return apiRequestData<EmployeeProfile["lifecycle"]>(`/api/employees/${employeeId}/checklist-tasks/${taskId}/complete`, {
|
return apiRequestData<EmployeeProfile["lifecycle"]>(`/api/employees/${employeeId}/checklist-tasks/${taskId}/complete`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type PresenceEmployee = {
|
|||||||
employeeNo: string;
|
employeeNo: string;
|
||||||
name: string;
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
employmentStatus: string;
|
employmentStatus: string;
|
||||||
department: { id: string; name: string } | null;
|
department: { id: string; name: string } | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
type EmployeeAvatarSize = "sm" | "md" | "lg" | "xl";
|
||||||
|
|
||||||
|
const SIZE_CLASS_MAP: Record<EmployeeAvatarSize, string> = {
|
||||||
|
sm: "size-9 text-xs",
|
||||||
|
md: "size-11 text-sm",
|
||||||
|
lg: "size-16 text-lg",
|
||||||
|
xl: "size-24 text-2xl",
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildInitials(name: string) {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "员工";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[A-Za-z0-9 ]+$/.test(trimmed)) {
|
||||||
|
const segments = trimmed.split(/\s+/).filter(Boolean);
|
||||||
|
return segments.slice(0, 2).map((item) => item[0]?.toUpperCase() ?? "").join("") || trimmed.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmployeeAvatar({
|
||||||
|
name,
|
||||||
|
src,
|
||||||
|
size = "md",
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
src?: string | null;
|
||||||
|
size?: EmployeeAvatarSize;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [imageBroken, setImageBroken] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImageBroken(false);
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
const showImage = Boolean(src) && !imageBroken;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative shrink-0 overflow-hidden rounded-full border border-zinc-200 bg-gradient-to-br from-zinc-100 via-white to-zinc-200",
|
||||||
|
SIZE_CLASS_MAP[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{showImage ? (
|
||||||
|
<img
|
||||||
|
src={src ?? undefined}
|
||||||
|
alt=""
|
||||||
|
className="size-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setImageBroken(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex size-full items-center justify-center font-semibold text-zinc-600">
|
||||||
|
{buildInitials(name)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmployeeAvatar;
|
||||||
@@ -29,6 +29,7 @@ const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
|||||||
skipped: { label: "已跳过", className: "badge-neutral" },
|
skipped: { label: "已跳过", className: "badge-neutral" },
|
||||||
revoked: { label: "已撤销", className: "badge-neutral" },
|
revoked: { label: "已撤销", className: "badge-neutral" },
|
||||||
expired: { label: "已过期", className: "badge-neutral" },
|
expired: { label: "已过期", className: "badge-neutral" },
|
||||||
|
terminated: { label: "已终止", className: "badge-error" },
|
||||||
scheduled: { label: "已排期", className: "badge-primary" },
|
scheduled: { label: "已排期", className: "badge-primary" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] },
|
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] },
|
||||||
{ to: "/system/workspace", label: "工作区设置", icon: "tune", anyOf: ["system.settings"] },
|
{ to: "/system/workspace", label: "工作区设置", icon: "tune", anyOf: ["system.settings"] },
|
||||||
{ to: "/people", label: "人员管理", icon: "group", anyOf: ["system.users", "report.dashboard.view"] },
|
{ to: "/people", label: "人员管理", icon: "group", anyOf: ["system.users", "report.dashboard.view"] },
|
||||||
|
{ to: "/organization", label: "组织架构", icon: "account_tree", anyOf: ["system.users", "report.dashboard.view"] },
|
||||||
{ to: "/system/workflow-definitions", label: "流程定义", icon: "schema", anyOf: ["system.settings"] },
|
{ 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/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: "/system/feature-flags", label: "功能开关", icon: "toggle_on", anyOf: ["system.settings"] },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const MAIN_NAV_ORDER = ["/portal", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"];
|
export const MAIN_NAV_ORDER = ["/portal", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"];
|
||||||
export const ADMIN_NAV_ORDER = ["/system/workspace", "/people", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"];
|
export const ADMIN_NAV_ORDER = ["/system/workspace", "/people", "/organization", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"];
|
||||||
export const BACKUP_NAV_ORDER = ["/workflow/tasks", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"];
|
export const BACKUP_NAV_ORDER = ["/workflow/tasks", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"];
|
||||||
|
|
||||||
const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||||
@@ -19,6 +19,7 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
|||||||
{ path: "/settings/profile", title: "个人设置", group: "账号设置" },
|
{ path: "/settings/profile", title: "个人设置", group: "账号设置" },
|
||||||
{ path: "/system/workspace", title: "工作区设置", group: "管理入口" },
|
{ path: "/system/workspace", title: "工作区设置", group: "管理入口" },
|
||||||
{ path: "/people", title: "人员管理", group: "管理入口" },
|
{ path: "/people", title: "人员管理", group: "管理入口" },
|
||||||
|
{ path: "/organization", title: "组织架构", group: "管理入口" },
|
||||||
{ path: "/system/workflow-definitions", title: "流程定义", group: "管理入口" },
|
{ path: "/system/workflow-definitions", title: "流程定义", group: "管理入口" },
|
||||||
{ path: "/system/settings", title: "系统设置", group: "管理入口" },
|
{ path: "/system/settings", title: "系统设置", group: "管理入口" },
|
||||||
{ path: "/system/feature-flags", title: "功能开关", group: "管理入口" },
|
{ path: "/system/feature-flags", title: "功能开关", group: "管理入口" },
|
||||||
|
|||||||
+568
-216
@@ -3,24 +3,50 @@ import { useParams } from "react-router-dom";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
completeEmployeeChecklistTask,
|
completeEmployeeChecklistTask,
|
||||||
|
createEmployeeContract,
|
||||||
|
fetchEmployeeContracts,
|
||||||
fetchEmployeeProfile,
|
fetchEmployeeProfile,
|
||||||
|
patchEmployeeContract,
|
||||||
patchEmployeeProfile,
|
patchEmployeeProfile,
|
||||||
regularizeEmployee,
|
regularizeEmployee,
|
||||||
skipEmployeeChecklistTask,
|
skipEmployeeChecklistTask,
|
||||||
startEmployeeOffboarding,
|
startEmployeeOffboarding,
|
||||||
transferEmployee,
|
transferEmployee,
|
||||||
|
uploadEmployeeAvatar,
|
||||||
|
type EmployeeContract,
|
||||||
|
type EmployeeContractType,
|
||||||
} from "../api/employees";
|
} from "../api/employees";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
|
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { Textarea } from "../components/ui/textarea";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
import { StatusBadge } from "../components/common/StatusBadge";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||||
|
import { Textarea } from "../components/ui/textarea";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { isApiError } from "../api/types";
|
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import { isApiError } from "../api/types";
|
||||||
|
|
||||||
|
type ProfileTab = "profile" | "contracts";
|
||||||
|
|
||||||
|
type ContractFormState = {
|
||||||
|
contractNo: string;
|
||||||
|
contractType: EmployeeContractType;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
terminatedDate: string;
|
||||||
|
attachmentPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTRACT_TYPE_OPTIONS: Array<{ value: EmployeeContractType; label: string }> = [
|
||||||
|
{ value: "labor", label: "劳动合同" },
|
||||||
|
{ value: "nda", label: "保密协议" },
|
||||||
|
{ value: "non_compete", label: "竞业限制" },
|
||||||
|
{ value: "internship", label: "实习协议" },
|
||||||
|
];
|
||||||
|
|
||||||
function errorMessage(error: unknown, fallback: string) {
|
function errorMessage(error: unknown, fallback: string) {
|
||||||
if (isApiError(error)) {
|
if (isApiError(error)) {
|
||||||
@@ -29,6 +55,21 @@ function errorMessage(error: unknown, fallback: string) {
|
|||||||
return error instanceof Error ? error.message : fallback;
|
return error instanceof Error ? error.message : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildEmptyContractForm(): ContractFormState {
|
||||||
|
return {
|
||||||
|
contractNo: "",
|
||||||
|
contractType: "labor",
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
terminatedDate: "",
|
||||||
|
attachmentPath: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function contractTypeLabel(type: EmployeeContractType) {
|
||||||
|
return CONTRACT_TYPE_OPTIONS.find((item) => item.value === type)?.label ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
export function EmployeeProfilePage() {
|
export function EmployeeProfilePage() {
|
||||||
const { employeeId = "" } = useParams();
|
const { employeeId = "" } = useParams();
|
||||||
const { permissions, me } = useAuth();
|
const { permissions, me } = useAuth();
|
||||||
@@ -38,12 +79,16 @@ export function EmployeeProfilePage() {
|
|||||||
queryKey: ["employee-profile", employeeId],
|
queryKey: ["employee-profile", employeeId],
|
||||||
queryFn: () => fetchEmployeeProfile(employeeId),
|
queryFn: () => fetchEmployeeProfile(employeeId),
|
||||||
});
|
});
|
||||||
|
|
||||||
const profile = profileQuery.data;
|
|
||||||
const canManageEmployeeProfile = hasPermission(permissions, "system.users");
|
const canManageEmployeeProfile = hasPermission(permissions, "system.users");
|
||||||
const canEditSelfProfile = me?.id === employeeId;
|
const canEditSelfProfile = me?.id === employeeId;
|
||||||
const canEditBaseFields = canManageEmployeeProfile || canEditSelfProfile;
|
const canEditBaseFields = canManageEmployeeProfile || canEditSelfProfile;
|
||||||
const canEditExtendedFields = canManageEmployeeProfile;
|
const canEditExtendedFields = canManageEmployeeProfile;
|
||||||
|
const canUploadAvatar = canManageEmployeeProfile || canEditSelfProfile;
|
||||||
|
const [activeTab, setActiveTab] = useState<ProfileTab>("profile");
|
||||||
|
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||||
|
const [avatarInputKey, setAvatarInputKey] = useState(0);
|
||||||
|
const [editingContractId, setEditingContractId] = useState<string | null>(null);
|
||||||
|
const [contractForm, setContractForm] = useState<ContractFormState>(buildEmptyContractForm());
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
title: "",
|
title: "",
|
||||||
hireDate: "",
|
hireDate: "",
|
||||||
@@ -55,19 +100,41 @@ export function EmployeeProfilePage() {
|
|||||||
notes: "",
|
notes: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const contractsQuery = useQuery({
|
||||||
|
enabled: Boolean(employeeId) && canManageEmployeeProfile,
|
||||||
|
queryKey: ["employee-contracts", employeeId],
|
||||||
|
queryFn: () => fetchEmployeeContracts(employeeId),
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!profile) return;
|
if (!profileQuery.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setForm({
|
setForm({
|
||||||
title: profile.employee.title ?? "",
|
title: profileQuery.data.employee.title ?? "",
|
||||||
hireDate: profile.employee.hireDate ?? "",
|
hireDate: profileQuery.data.employee.hireDate ?? "",
|
||||||
probationEndDate: profile.employee.probationEndDate ?? "",
|
probationEndDate: profileQuery.data.employee.probationEndDate ?? "",
|
||||||
lastWorkingDate: profile.employee.lastWorkingDate ?? "",
|
lastWorkingDate: profileQuery.data.employee.lastWorkingDate ?? "",
|
||||||
workLocation: profile.employee.workLocation ?? "",
|
workLocation: profileQuery.data.employee.workLocation ?? "",
|
||||||
emergencyContactName: profile.employee.emergencyContactName ?? "",
|
emergencyContactName: profileQuery.data.employee.emergencyContactName ?? "",
|
||||||
emergencyContactPhone: profile.employee.emergencyContactPhone ?? "",
|
emergencyContactPhone: profileQuery.data.employee.emergencyContactPhone ?? "",
|
||||||
notes: profile.employee.notes ?? "",
|
notes: profileQuery.data.employee.notes ?? "",
|
||||||
});
|
});
|
||||||
}, [profile]);
|
}, [profileQuery.data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canManageEmployeeProfile || activeTab !== "contracts") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveTab("profile");
|
||||||
|
}, [activeTab, canManageEmployeeProfile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAvatarFile(null);
|
||||||
|
setAvatarInputKey((current) => current + 1);
|
||||||
|
setEditingContractId(null);
|
||||||
|
setContractForm(buildEmptyContractForm());
|
||||||
|
}, [employeeId]);
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
@@ -96,6 +163,27 @@ export function EmployeeProfilePage() {
|
|||||||
onError: (error) => toast.error(errorMessage(error, "保存失败")),
|
onError: (error) => toast.error(errorMessage(error, "保存失败")),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const avatarMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!avatarFile) {
|
||||||
|
throw new Error("请先选择头像文件");
|
||||||
|
}
|
||||||
|
return uploadEmployeeAvatar(employeeId, avatarFile);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
setAvatarFile(null);
|
||||||
|
setAvatarInputKey((current) => current + 1);
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["employees"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["presence"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["employee-organization"] }),
|
||||||
|
]);
|
||||||
|
toast.success("头像已更新");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(errorMessage(error, "头像上传失败")),
|
||||||
|
});
|
||||||
|
|
||||||
const taskMutation = useMutation({
|
const taskMutation = useMutation({
|
||||||
mutationFn: ({ taskId, action }: { taskId: string; action: "complete" | "skip" }) =>
|
mutationFn: ({ taskId, action }: { taskId: string; action: "complete" | "skip" }) =>
|
||||||
action === "complete"
|
action === "complete"
|
||||||
@@ -135,23 +223,72 @@ export function EmployeeProfilePage() {
|
|||||||
onError: (error) => toast.error(errorMessage(error, "离职失败")),
|
onError: (error) => toast.error(errorMessage(error, "离职失败")),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (profileQuery.isLoading || !profile) {
|
const contractMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const payload = {
|
||||||
|
contractNo: contractForm.contractNo.trim(),
|
||||||
|
contractType: contractForm.contractType,
|
||||||
|
startDate: contractForm.startDate,
|
||||||
|
endDate: contractForm.endDate,
|
||||||
|
terminatedDate: contractForm.terminatedDate || null,
|
||||||
|
attachmentPath: contractForm.attachmentPath.trim() ? contractForm.attachmentPath.trim() : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingContractId) {
|
||||||
|
return patchEmployeeContract(employeeId, editingContractId, payload);
|
||||||
|
}
|
||||||
|
return createEmployeeContract(employeeId, payload);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["employee-contracts", employeeId] });
|
||||||
|
setEditingContractId(null);
|
||||||
|
setContractForm(buildEmptyContractForm());
|
||||||
|
toast.success(editingContractId ? "合同已更新" : "合同已新增");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(errorMessage(error, editingContractId ? "更新合同失败" : "新增合同失败")),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (profileQuery.isLoading) {
|
||||||
return <div className="card-std p-6 text-sm text-zinc-500">加载员工档案中...</div>;
|
return <div className="card-std p-6 text-sm text-zinc-500">加载员工档案中...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (profileQuery.isError || !profileQuery.data) {
|
||||||
|
return <div className="card-std p-6 text-sm text-red-600">{errorMessage(profileQuery.error, "加载员工档案失败")}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = profileQuery.data;
|
||||||
const reportingChain = profile.organization.reportingChain.length
|
const reportingChain = profile.organization.reportingChain.length
|
||||||
? profile.organization.reportingChain
|
? profile.organization.reportingChain
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
id: profile.employee.id,
|
id: profile.employee.id,
|
||||||
employeeNo: profile.employee.employeeNo,
|
employeeNo: profile.employee.employeeNo,
|
||||||
name: profile.employee.name,
|
name: profile.employee.name,
|
||||||
title: profile.employee.title,
|
title: profile.employee.title,
|
||||||
status: profile.employee.status,
|
status: profile.employee.status,
|
||||||
department: profile.employee.department,
|
avatarUrl: profile.employee.avatarUrl,
|
||||||
},
|
department: profile.employee.department,
|
||||||
];
|
},
|
||||||
|
];
|
||||||
const directReports = profile.organization.directReports;
|
const directReports = profile.organization.directReports;
|
||||||
|
const contracts = contractsQuery.data?.items ?? [];
|
||||||
|
|
||||||
|
function resetContractEditor() {
|
||||||
|
setEditingContractId(null);
|
||||||
|
setContractForm(buildEmptyContractForm());
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditingContract(contract: EmployeeContract) {
|
||||||
|
setEditingContractId(contract.id);
|
||||||
|
setContractForm({
|
||||||
|
contractNo: contract.contractNo,
|
||||||
|
contractType: contract.contractType,
|
||||||
|
startDate: contract.startDate ?? "",
|
||||||
|
endDate: contract.endDate ?? "",
|
||||||
|
terminatedDate: contract.terminatedDate ?? "",
|
||||||
|
attachmentPath: contract.attachmentPath ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -159,218 +296,433 @@ export function EmployeeProfilePage() {
|
|||||||
title={`${profile.employee.name} 档案`}
|
title={`${profile.employee.name} 档案`}
|
||||||
subtitle={`${profile.employee.employeeNo} · ${profile.employee.title}`}
|
subtitle={`${profile.employee.employeeNo} · ${profile.employee.title}`}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex flex-wrap gap-2">
|
canManageEmployeeProfile ? (
|
||||||
<Button type="button" variant="secondary" onClick={() => regularizeMutation.mutate()} disabled={regularizeMutation.isPending}>
|
<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" variant="secondary" onClick={() => transferMutation.mutate()} disabled={transferMutation.isPending}>
|
||||||
</Button>
|
记录调岗
|
||||||
<Button type="button" onClick={() => offboardingMutation.mutate()} disabled={offboardingMutation.isPending}>
|
</Button>
|
||||||
发起离职
|
<Button type="button" onClick={() => offboardingMutation.mutate()} disabled={offboardingMutation.isPending}>
|
||||||
</Button>
|
发起离职
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-3">
|
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ProfileTab)}>
|
||||||
<div className="card-std p-6 space-y-4 xl:col-span-2">
|
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<TabsTrigger value="profile">档案</TabsTrigger>
|
||||||
<div>
|
{canManageEmployeeProfile ? <TabsTrigger value="contracts">合同</TabsTrigger> : null}
|
||||||
<div className="text-sm text-zinc-500">账号状态</div>
|
</TabsList>
|
||||||
<div className="mt-2 flex gap-2"><StatusBadge status={profile.employee.status} /><StatusBadge status={profile.employee.employmentStatus} /></div>
|
|
||||||
|
<TabsContent value="profile" className="space-y-6">
|
||||||
|
<div className="grid gap-6 xl:grid-cols-3">
|
||||||
|
<div className="card-std space-y-6 p-6 xl:col-span-2">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<EmployeeAvatar name={profile.employee.name} src={profile.employee.avatarUrl} size="xl" />
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-semibold text-zinc-900">{profile.employee.name}</div>
|
||||||
|
<div className="mt-1 text-sm text-zinc-500">
|
||||||
|
{profile.employee.employeeNo} · {profile.employee.email}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<StatusBadge status={profile.employee.status} />
|
||||||
|
<StatusBadge status={profile.employee.employmentStatus} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canUploadAvatar ? (
|
||||||
|
<div className="w-full max-w-sm space-y-2">
|
||||||
|
<label className="text-sm text-zinc-500">头像上传</label>
|
||||||
|
<Input
|
||||||
|
key={avatarInputKey}
|
||||||
|
type="file"
|
||||||
|
accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp"
|
||||||
|
onChange={(event) => setAvatarFile(event.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => avatarMutation.mutate()} disabled={!avatarFile || avatarMutation.isPending}>
|
||||||
|
{avatarMutation.isPending ? "上传中..." : "上传头像"}
|
||||||
|
</Button>
|
||||||
|
<div className="text-xs text-zinc-500">支持 jpg/png/webp,最大 2MB,自动裁剪为 200x200</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<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.phone} / {profile.employee.email}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{canEditExtendedFields ? (
|
||||||
|
<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.title} onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-sm text-zinc-500">入职日期</label>
|
||||||
|
<Input type="date" value={form.hireDate} onChange={(event) => setForm((prev) => ({ ...prev, hireDate: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-sm text-zinc-500">试用期截止</label>
|
||||||
|
<Input type="date" value={form.probationEndDate} onChange={(event) => setForm((prev) => ({ ...prev, probationEndDate: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-sm text-zinc-500">离职日期</label>
|
||||||
|
<Input type="date" value={form.lastWorkingDate} onChange={(event) => setForm((prev) => ({ ...prev, lastWorkingDate: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canEditBaseFields ? (
|
||||||
|
<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-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-zinc-500">办公地点</div>
|
||||||
|
<div className="mt-2 text-sm text-zinc-900">{profile.employee.workLocation ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-zinc-500">紧急联系人</div>
|
||||||
|
<div className="mt-2 text-sm text-zinc-900">{profile.employee.emergencyContactName ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-zinc-500">紧急联系电话</div>
|
||||||
|
<div className="mt-2 text-sm text-zinc-900">{profile.employee.emergencyContactPhone ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEditBaseFields ? (
|
||||||
|
<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>
|
||||||
|
<div className="text-sm text-zinc-500">备注</div>
|
||||||
|
<div className="mt-2 whitespace-pre-wrap text-sm text-zinc-900">{profile.employee.notes ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEditBaseFields ? (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="button" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
|
||||||
|
保存档案
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<div className="text-sm text-zinc-500">部门 / 上级</div>
|
<div className="space-y-6">
|
||||||
<div className="mt-2 text-sm text-zinc-900">{profile.employee.department?.name ?? "-"} / {profile.employee.directManager?.name ?? "-"}</div>
|
<div className="card-std space-y-4 p-6">
|
||||||
</div>
|
<h3 className="text-base font-semibold text-zinc-900">离职阻塞项</h3>
|
||||||
<div>
|
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||||
<div className="text-sm text-zinc-500">入职 / 试用期</div>
|
资产未归还:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.openAssetCount}</span>
|
||||||
<div className="mt-2 text-sm text-zinc-900">{profile.employee.hireDate ?? "-"} / {profile.employee.probationEndDate ?? "-"}</div>
|
</div>
|
||||||
</div>
|
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||||
<div>
|
待办审批任务:<span className="font-medium text-zinc-900">{profile.lifecycle.blockers.pendingWorkflowTaskCount}</span>
|
||||||
<div className="text-sm text-zinc-500">离职日期</div>
|
</div>
|
||||||
<div className="mt-2 text-sm text-zinc-900">{profile.employee.lastWorkingDate ?? "-"}</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 className="card-std p-6">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">汇报链</h3>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{reportingChain.map((item, index) => (
|
||||||
|
<div key={item.id} className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-zinc-900">{item.name}</div>
|
||||||
|
<div className="mt-1 text-zinc-500">
|
||||||
|
{item.employeeNo} · {item.title} · {item.department?.name ?? "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
</div>
|
||||||
|
{index === reportingChain.length - 1 ? <div className="mt-2 text-xs text-zinc-500">当前员工</div> : null}
|
||||||
|
</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">
|
||||||
|
{directReports.length ? (
|
||||||
|
directReports.map((item) => (
|
||||||
|
<div key={item.id} className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-zinc-900">{item.name}</div>
|
||||||
|
<div className="mt-1 text-zinc-500">
|
||||||
|
{item.employeeNo} · {item.title} · {item.department?.name ?? "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-zinc-500">暂无直属下级</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canEditExtendedFields ? (
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="card-std p-6">
|
||||||
<div className="grid gap-2">
|
<h3 className="text-base font-semibold text-zinc-900">生命周期事件</h3>
|
||||||
<label className="text-sm text-zinc-500">职位</label>
|
<div className="mt-4 space-y-3">
|
||||||
<Input value={form.title} onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))} />
|
{profile.lifecycle.events.length ? (
|
||||||
</div>
|
profile.lifecycle.events.map((item) => (
|
||||||
<div className="grid gap-2">
|
<div key={item.id} className="rounded-lg border border-zinc-200 p-3 text-sm">
|
||||||
<label className="text-sm text-zinc-500">入职日期</label>
|
<div className="font-medium text-zinc-900">{item.eventType}</div>
|
||||||
<Input type="date" value={form.hireDate} onChange={(event) => setForm((prev) => ({ ...prev, hireDate: event.target.value }))} />
|
<div className="mt-1 text-zinc-500">生效日期:{item.effectiveDate ?? "-"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
))
|
||||||
<label className="text-sm text-zinc-500">试用期截止</label>
|
) : (
|
||||||
<Input type="date" value={form.probationEndDate} onChange={(event) => setForm((prev) => ({ ...prev, probationEndDate: event.target.value }))} />
|
<div className="text-sm text-zinc-500">暂无生命周期事件</div>
|
||||||
</div>
|
)}
|
||||||
<div className="grid gap-2">
|
|
||||||
<label className="text-sm text-zinc-500">离职日期</label>
|
|
||||||
<Input type="date" value={form.lastWorkingDate} onChange={(event) => setForm((prev) => ({ ...prev, lastWorkingDate: event.target.value }))} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{canEditBaseFields ? (
|
<div className="card-std p-6">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<h3 className="text-base font-semibold text-zinc-900">名下资产</h3>
|
||||||
<div className="grid gap-2">
|
<div className="mt-4 space-y-3">
|
||||||
<label className="text-sm text-zinc-500">办公地点</label>
|
{profile.assignedAssets.length ? (
|
||||||
<Input value={form.workLocation} onChange={(event) => setForm((prev) => ({ ...prev, workLocation: event.target.value }))} />
|
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 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-4 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-zinc-500">办公地点</div>
|
|
||||||
<div className="mt-2 text-sm text-zinc-900">{profile.employee.workLocation ?? "-"}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-zinc-500">紧急联系人</div>
|
|
||||||
<div className="mt-2 text-sm text-zinc-900">{profile.employee.emergencyContactName ?? "-"}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-zinc-500">紧急联系电话</div>
|
|
||||||
<div className="mt-2 text-sm text-zinc-900">{profile.employee.emergencyContactPhone ?? "-"}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canEditBaseFields ? (
|
|
||||||
<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>
|
|
||||||
<div className="text-sm text-zinc-500">备注</div>
|
|
||||||
<div className="mt-2 whitespace-pre-wrap text-sm text-zinc-900">{profile.employee.notes ?? "-"}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canEditBaseFields ? (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button type="button" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存档案</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<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>
|
||||||
|
|
||||||
<div className="card-std p-6">
|
<div className="card-std p-6">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">汇报链</h3>
|
<h3 className="text-base font-semibold text-zinc-900">Checklist</h3>
|
||||||
<div className="mt-4 space-y-3">
|
<Table>
|
||||||
{reportingChain.map((item, index) => (
|
<TableHeader>
|
||||||
<div key={item.id} className="rounded-lg border border-zinc-200 p-3 text-sm">
|
<TableRow>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<TableHead>场景</TableHead>
|
||||||
<div className="font-medium text-zinc-900">{item.name}</div>
|
<TableHead>任务</TableHead>
|
||||||
<StatusBadge status={item.status} />
|
<TableHead>状态</TableHead>
|
||||||
</div>
|
<TableHead>操作</TableHead>
|
||||||
<div className="mt-1 text-zinc-500">
|
</TableRow>
|
||||||
{item.employeeNo} · {item.title} · {item.department?.name ?? "-"}
|
</TableHeader>
|
||||||
</div>
|
<TableBody>
|
||||||
{index === reportingChain.length - 1 ? <div className="mt-1 text-xs text-zinc-500">当前员工</div> : null}
|
{profile.lifecycle.checklistTasks.map((item) => (
|
||||||
</div>
|
<TableRow key={item.id}>
|
||||||
))}
|
<TableCell>{item.scene}</TableCell>
|
||||||
</div>
|
<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>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<div className="card-std p-6">
|
{canManageEmployeeProfile ? (
|
||||||
<h3 className="text-base font-semibold text-zinc-900">直属下级</h3>
|
<TabsContent value="contracts" className="space-y-6">
|
||||||
<div className="mt-4 space-y-3">
|
{contractsQuery.isError ? (
|
||||||
{directReports.length ? directReports.map((item) => (
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
<div key={item.id} className="rounded-lg border border-zinc-200 p-3 text-sm">
|
{errorMessage(contractsQuery.error, "加载合同失败")}
|
||||||
<div className="flex items-center justify-between gap-3">
|
</div>
|
||||||
<div className="font-medium text-zinc-900">{item.name}</div>
|
) : null}
|
||||||
<StatusBadge status={item.status} />
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1.2fr,0.8fr]">
|
||||||
|
<div className="card-std p-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">合同列表</h3>
|
||||||
|
<div className="mt-1 text-sm text-zinc-500">状态由终止日期与结束日期自动推导,本轮仅支持新增与编辑。</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-zinc-500">
|
<div className="text-sm text-zinc-500">{contracts.length} 份合同</div>
|
||||||
{item.employeeNo} · {item.title} · {item.department?.name ?? "-"}
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>合同编号</TableHead>
|
||||||
|
<TableHead>类型</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>日期</TableHead>
|
||||||
|
<TableHead>附件路径</TableHead>
|
||||||
|
<TableHead>操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{contracts.length ? (
|
||||||
|
contracts.map((contract) => (
|
||||||
|
<TableRow key={contract.id}>
|
||||||
|
<TableCell>{contract.contractNo}</TableCell>
|
||||||
|
<TableCell>{contractTypeLabel(contract.contractType)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={contract.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>{contract.startDate ?? "-"} ~ {contract.endDate ?? "-"}</div>
|
||||||
|
<div className="text-xs text-zinc-500">终止:{contract.terminatedDate ?? "-"}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[220px] truncate">{contract.attachmentPath ?? "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button type="button" size="sm" variant="secondary" onClick={() => startEditingContract(contract)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="py-3 text-zinc-500" colSpan={6}>
|
||||||
|
{contractsQuery.isLoading ? "加载合同中..." : "暂无合同记录"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-std space-y-4 p-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">{editingContractId ? "编辑合同" : "新增合同"}</h3>
|
||||||
|
<div className="mt-1 text-sm text-zinc-500">附件本轮只保存路径字符串,不做上传。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-1">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-sm text-zinc-500">合同编号</label>
|
||||||
|
<Input value={contractForm.contractNo} onChange={(event) => setContractForm((prev) => ({ ...prev, contractNo: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-sm text-zinc-500">合同类型</label>
|
||||||
|
<select
|
||||||
|
className="select-std"
|
||||||
|
value={contractForm.contractType}
|
||||||
|
onChange={(event) => setContractForm((prev) => ({ ...prev, contractType: event.target.value as EmployeeContractType }))}
|
||||||
|
>
|
||||||
|
{CONTRACT_TYPE_OPTIONS.map((item) => (
|
||||||
|
<option key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-sm text-zinc-500">开始日期</label>
|
||||||
|
<Input type="date" value={contractForm.startDate} onChange={(event) => setContractForm((prev) => ({ ...prev, startDate: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-sm text-zinc-500">结束日期</label>
|
||||||
|
<Input type="date" value={contractForm.endDate} onChange={(event) => setContractForm((prev) => ({ ...prev, endDate: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-sm text-zinc-500">终止日期</label>
|
||||||
|
<Input type="date" value={contractForm.terminatedDate} onChange={(event) => setContractForm((prev) => ({ ...prev, terminatedDate: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-sm text-zinc-500">附件路径</label>
|
||||||
|
<Input value={contractForm.attachmentPath} onChange={(event) => setContractForm((prev) => ({ ...prev, attachmentPath: event.target.value }))} placeholder="例如:contracts/labor/E1001.pdf" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)) : <div className="text-sm text-zinc-500">暂无直属下级</div>}
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" onClick={() => contractMutation.mutate()} disabled={contractMutation.isPending}>
|
||||||
|
{contractMutation.isPending ? "保存中..." : editingContractId ? "保存修改" : "新增合同"}
|
||||||
|
</Button>
|
||||||
|
{editingContractId ? (
|
||||||
|
<Button type="button" variant="secondary" onClick={resetContractEditor}>
|
||||||
|
取消编辑
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsContent>
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
</Tabs>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
fetchEmployeeOrganization,
|
||||||
|
type OrganizationDepartmentSection,
|
||||||
|
type OrganizationTreeNode,
|
||||||
|
} from "../api/employees";
|
||||||
|
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||||
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
|
||||||
|
function countNodes(nodes: OrganizationTreeNode[]): number {
|
||||||
|
return nodes.reduce((total, node) => total + 1 + countNodes(node.children), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrganizationNodeCard({ node, depth = 0 }: { node: OrganizationTreeNode; depth?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm"
|
||||||
|
style={{ marginLeft: `${depth * 24}px` }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<EmployeeAvatar name={node.name} src={node.avatarUrl} size="lg" />
|
||||||
|
<div>
|
||||||
|
<Link to={`/people/${node.id}`} className="text-base font-semibold text-zinc-900 hover:text-zinc-700">
|
||||||
|
{node.name}
|
||||||
|
</Link>
|
||||||
|
<div className="mt-1 text-sm text-zinc-500">
|
||||||
|
{node.employeeNo} · {node.title || "未设置职位"} · {node.department?.name ?? "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{node.externalManager ? (
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||||
|
跨部门汇报:{node.externalManager.department?.name ?? "未知部门"} · {node.externalManager.name}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{node.children.length ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<OrganizationNodeCard key={child.id} node={child} depth={depth + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrganizationPage() {
|
||||||
|
const organizationQuery = useQuery({
|
||||||
|
queryKey: ["employee-organization"],
|
||||||
|
queryFn: fetchEmployeeOrganization,
|
||||||
|
});
|
||||||
|
const [expandedDepartments, setExpandedDepartments] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!organizationQuery.data?.items.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExpandedDepartments((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
for (const item of organizationQuery.data.items) {
|
||||||
|
const key = item.department?.id ?? item.department?.name ?? "unknown";
|
||||||
|
if (!(key in next)) {
|
||||||
|
next[key] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [organizationQuery.data?.items]);
|
||||||
|
|
||||||
|
if (organizationQuery.isLoading) {
|
||||||
|
return <div className="card-std p-6 text-sm text-zinc-500">加载组织架构中...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (organizationQuery.isError) {
|
||||||
|
return <div className="card-std p-6 text-sm text-red-600">加载组织架构失败</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = organizationQuery.data?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="组织架构"
|
||||||
|
subtitle="按部门查看当前 active 员工的汇报关系,跨部门上级会在节点右侧单独标注。"
|
||||||
|
actions={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedDepartments(
|
||||||
|
Object.fromEntries(
|
||||||
|
sections.map((item) => [item.department?.id ?? item.department?.name ?? "unknown", true]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
全部展开
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedDepartments(
|
||||||
|
Object.fromEntries(
|
||||||
|
sections.map((item) => [item.department?.id ?? item.department?.name ?? "unknown", false]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
全部折叠
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{sections.length ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{sections.map((section: OrganizationDepartmentSection) => {
|
||||||
|
const sectionKey = section.department?.id ?? section.department?.name ?? "unknown";
|
||||||
|
const expanded = expandedDepartments[sectionKey] ?? true;
|
||||||
|
const totalNodes = countNodes(section.roots);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section key={sectionKey} className="card-std p-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-zinc-900">{section.department?.name ?? "未分配部门"}</h3>
|
||||||
|
<div className="mt-1 text-sm text-zinc-500">{totalNodes} 名员工</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedDepartments((current) => ({
|
||||||
|
...current,
|
||||||
|
[sectionKey]: !expanded,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{expanded ? "折叠" : "展开"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded ? (
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
{section.roots.length ? (
|
||||||
|
section.roots.map((root) => <OrganizationNodeCard key={root.id} node={root} />)
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-zinc-500">当前部门暂无可展示节点</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card-std p-6 text-sm text-zinc-500">当前没有可展示的 active 员工。</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OrganizationPage;
|
||||||
@@ -14,6 +14,7 @@ import { mapApiErrorMessage } from "../api/errorMessages";
|
|||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
import { downloadBlob } from "../lib/download";
|
import { downloadBlob } from "../lib/download";
|
||||||
|
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||||
@@ -216,9 +217,14 @@ export function PeoplePage() {
|
|||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<TableRow key={item.id}>
|
<TableRow key={item.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="font-medium text-zinc-900">{item.name}</div>
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-xs text-zinc-500">
|
<EmployeeAvatar name={item.name} src={item.avatarUrl} />
|
||||||
{item.employeeNo} · {item.email}
|
<div>
|
||||||
|
<div className="font-medium text-zinc-900">{item.name}</div>
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
{item.employeeNo} · {item.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{item.roleCodes.join(" / ") || "-"}</TableCell>
|
<TableCell>{item.roleCodes.join(" / ") || "-"}</TableCell>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { mapApiErrorMessage } from "../api/errorMessages";
|
|||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
|
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -352,8 +353,13 @@ export function TeamPresencePage() {
|
|||||||
teamItems.map((item) => (
|
teamItems.map((item) => (
|
||||||
<TableRow key={item.employee.id}>
|
<TableRow key={item.employee.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="font-medium text-zinc-900">{item.employee.name}</div>
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-xs text-zinc-500">{item.employee.title}</div>
|
<EmployeeAvatar name={item.employee.name} src={item.employee.avatarUrl} />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-zinc-900">{item.employee.name}</div>
|
||||||
|
<div className="text-xs text-zinc-500">{item.employee.title}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{item.employee.department?.name ?? "-"}</TableCell>
|
<TableCell>{item.employee.department?.name ?? "-"}</TableCell>
|
||||||
<TableCell>{statusLabel(item.presence?.status ?? null)}</TableCell>
|
<TableCell>{statusLabel(item.presence?.status ?? null)}</TableCell>
|
||||||
@@ -524,8 +530,13 @@ export function TeamPresencePage() {
|
|||||||
<TableRow key={`${item.date}-${item.employee.id}`}>
|
<TableRow key={`${item.date}-${item.employee.id}`}>
|
||||||
<TableCell>{item.date}</TableCell>
|
<TableCell>{item.date}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="font-medium text-zinc-900">{item.employee.name}</div>
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-xs text-zinc-500">{item.employee.employeeNo} · {item.employee.title}</div>
|
<EmployeeAvatar name={item.employee.name} src={item.employee.avatarUrl} />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-zinc-900">{item.employee.name}</div>
|
||||||
|
<div className="text-xs text-zinc-500">{item.employee.employeeNo} · {item.employee.title}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{item.employee.department?.name ?? "-"}</TableCell>
|
<TableCell>{item.employee.department?.name ?? "-"}</TableCell>
|
||||||
<TableCell>{statusLabel(item.presence?.status ?? null)}</TableCell>
|
<TableCell>{statusLabel(item.presence?.status ?? null)}</TableCell>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
MobileCapturePage,
|
MobileCapturePage,
|
||||||
NotificationChannelsPage,
|
NotificationChannelsPage,
|
||||||
NotificationsPage,
|
NotificationsPage,
|
||||||
|
OrganizationPage,
|
||||||
PaymentBatchesPage,
|
PaymentBatchesPage,
|
||||||
PeoplePage,
|
PeoplePage,
|
||||||
PortalAnnouncementsPage,
|
PortalAnnouncementsPage,
|
||||||
@@ -302,6 +303,15 @@ export function AppRouter() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="organization"
|
||||||
|
element={
|
||||||
|
<RequirePermission anyOf={["system.users", "report.dashboard.view"]}>
|
||||||
|
{withRouteFallback(<OrganizationPage />)}
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="people"
|
path="people"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { OrganizationPage as default } from "../../pages/OrganizationPage";
|
||||||
@@ -21,6 +21,7 @@ export const NotificationsPage = lazy(() => import("./NotificationsPage"));
|
|||||||
export const AnnouncementsPage = lazy(() => import("./AnnouncementsPage"));
|
export const AnnouncementsPage = lazy(() => import("./AnnouncementsPage"));
|
||||||
export const PaymentBatchesPage = lazy(() => import("./PaymentBatchesPage"));
|
export const PaymentBatchesPage = lazy(() => import("./PaymentBatchesPage"));
|
||||||
export const PeoplePage = lazy(() => import("./PeoplePage"));
|
export const PeoplePage = lazy(() => import("./PeoplePage"));
|
||||||
|
export const OrganizationPage = lazy(() => import("./OrganizationPage"));
|
||||||
export const EmployeeProfilePage = lazy(() => import("./EmployeeProfilePage"));
|
export const EmployeeProfilePage = lazy(() => import("./EmployeeProfilePage"));
|
||||||
export const TeamPresencePage = lazy(() => import("./TeamPresencePage"));
|
export const TeamPresencePage = lazy(() => import("./TeamPresencePage"));
|
||||||
export const WeeklyReportsPage = lazy(() => import("./WeeklyReportsPage"));
|
export const WeeklyReportsPage = lazy(() => import("./WeeklyReportsPage"));
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
|
A1,R43合同建档与范围冻结,创建并冻结员工头像 组织架构页 员工合同管理三项能力的执行合同 且拆分为可独立验收的 A1-A8,Issue CSV 校验通过 且范围仅含头像 组织架构 合同管理 不扩散到附件上传 删除头像 删除合同 与额外权限模型调整,命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-29_r43-employee-enhancements.csv;业务流程:检查 CSV 含 A1-A8 全量条目 且 Dependencies Files Notes 均符合仓库规范,manual,DONE,DONE,DONE,issues/2026-03-29_r43-employee-enhancements.csv,[],none
|
||||||
|
A2,员工头像后端与数据模型,为 Employee 增加 avatar_path 并提供头像上传与读取能力 同时扩展员工列表 档案 团队状态所依赖的后端读模型返回 avatarUrl,本人可上传自己的头像 system.users 可为任意员工上传 仅允许 jpg/png/webp 且 2MB 内 服务端统一缩放并保存为 webp 列表 档案 团队页接口均返回 avatarUrl 公开头像路由可正确读取,业务流程:员工上传本人头像成功 再次上传可覆盖;管理员为他人上传成功;普通员工为他人上传返回 403;上传超过 2MB 或错误格式返回校验错误;访问 GET /api/avatars/:employeeId 可读取图片;命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/employees.js && node --check src/routes/users.js && node --check src/routes/presence.js && node --check src/services/presenceHistory.js,manual,DONE,DONE,DONE,server/package.json|server/package-lock.json|server/prisma/schema.prisma|server/prisma/migrations|server/src/routes/employees.js|server/src/routes/users.js|server/src/routes/presence.js|server/src/services/presenceHistory.js,"[""A1""]","新增 sharp 用于头像缩放与 webp 转换;存储复用 services/storage 本地 driver 实际写入 generated/storage/avatars/<employeeId>.webp;回滚路径:回滚代码与 avatar_path 字段 若需清理文件则删除 generated/storage/avatars/ 下对应文件"
|
||||||
|
A3,头像前端接入与展示,新增统一头像组件 并在人员列表 员工档案 团队状态页接入 avatarUrl 与上传交互,头像存在时展示图片 不存在时展示占位样式 管理员与本人可在档案页上传头像 上传成功后列表 档案 团队页展示一致,业务流程:本人在档案页上传头像后 PeoplePage EmployeeProfilePage TeamPresencePage 均显示新头像;管理员查看他人档案可上传头像;无头像员工显示占位;命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/api/employees.ts|client/src/api/presence.ts|client/src/pages/EmployeeProfilePage.tsx|client/src/pages/PeoplePage.tsx|client/src/pages/TeamPresencePage.tsx|client/src/components/common/EmployeeAvatar.tsx,"[""A2""]",不新增重型 UI 依赖 占位样式由轻量组件统一处理
|
||||||
|
A4,组织架构读模型与接口,新增按部门分组的组织架构读模型与 GET /api/employees/organization 接口 仅返回 active 员工 并标注跨部门上级摘要,接口按部门返回可构建组织树的数据 每个部门内根节点规则稳定 跨部门汇报节点带 externalManager 摘要 且权限沿用 /people,业务流程:具备 /people 权限的用户访问组织接口可拿到 active 员工部门分组树;部门内无上级或上级不在本部门者为根;跨部门汇报节点含 externalManager;无权限用户返回 403;命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/employees.js && node --check src/services/organization.js,manual,DONE,DONE,DONE,server/src/routes/employees.js|server/src/services/organization.js,"[""A2""]","只读接口 不新增权限码 沿用 system.users 与 report.dashboard.view;组织视图只展示 active 员工"
|
||||||
|
A5,组织架构页面与导航,新增 /organization 页面 并把导航与工作区入口补齐 前端以部门折叠 section + 缩进树展示组织架构节点,用户可进入 /organization 按部门折叠展开 查看头像 姓名 职位 部门 点击节点跳转到 /people/:employeeId 导航可见性与 /people 一致,业务流程:有权限用户进入 /organization 默认看到部门分组;展开部门后可递归查看下级;跨部门汇报节点显示汇报给某部门某人;点击节点跳到员工档案;无权限用户不可进入;命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/OrganizationPage.tsx|client/src/router/index.tsx|client/src/router/lazy/index.ts|client/src/router/lazy/OrganizationPage.ts|client/src/components/navigation/NavLinks.tsx|server/src/services/workspaceSettings.js,"[""A4""]",不引入重型图表库 使用现有卡片与递归缩进树实现
|
||||||
|
A6,员工合同后端模型与接口,新增 EmployeeContract 模型与员工合同接口 仅管理员可查看和维护 合同状态按 terminatedDate endDate 自动推导,管理员可新增与编辑合同 合同类型固定为 labor nda non_compete internship 返回状态固定为 active expired terminated 非管理员访问合同接口返回 403,业务流程:管理员为员工新增劳动合同 NDA 合同并保存成功;修改 terminatedDate 后状态变为 terminated;清空 terminatedDate 且 endDate 早于今天时状态变为 expired;普通员工访问合同接口返回 403;命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/employees.js,manual,DONE,DONE,DONE,server/prisma/schema.prisma|server/prisma/migrations|server/src/routes/employees.js|client/src/api/employees.ts,"[""A1""]","新增 EmployeeContract 与 EmployeeContractType;不复用 ContractRegistry;回滚路径:先删除 employee_contracts 数据 再回滚迁移与代码;合同附件本轮只存 attachment_path 字符串 不做上传"
|
||||||
|
A7,档案页合同标签与头像上传入口,将员工档案页改为 tabs 并新增仅管理员可见的 合同 标签 同时加入头像上传入口 维持原档案内容不回归,档案页默认保留现有档案信息 汇报链 资产 Checklist 管理员可见并维护合同标签 管理员与本人可上传头像 report.dashboard.view 与普通员工看不到合同标签,业务流程:管理员打开员工档案可在 档案 与 合同 两个标签间切换 并新增编辑合同;管理员或本人可上传头像;report.dashboard.view 用户与员工本人看不到合同标签;命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/EmployeeProfilePage.tsx|client/src/api/employees.ts,"[""A3"",""A6""]","当前档案页改为 tabs 但 档案 标签承载原有内容与行为 不改变现有生命周期与汇报链功能"
|
||||||
|
A8,Reviewer与QA收口,按 reviewer→qa 顺序完成迁移 公开头像读取接口 组织架构树与合同管理的独立审查与回归 并回填状态,reviewer 对 Prisma 迁移 头像公开读取边界 组织树口径与合同状态推导无阻塞意见 QA 跑通头像 组织架构 合同三条主线 且回填 CSV 状态,Review:独立审查 employees routes organization service avatar route 和合同迁移相关 diff;QA:执行各 Issue Test_Method 并补跑 export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/employees.js && node --check src/services/organization.js && cd ../client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,issues/2026-03-29_r43-employee-enhancements.csv|server/src/routes/employees.js|server/src/services/organization.js|client/src/pages/EmployeeProfilePage.tsx|client/src/pages/OrganizationPage.tsx,"[""A3"",""A5"",""A7""]","本轮 reviewer 必启用 因涉及 Prisma 迁移 与公开头像文件读取;若实现新增公开头像路由 需重点审查是否只暴露员工头像而非整个 storage 根目录;review 结论:未发现阻塞问题,残余风险为头像地址按方案保持公开,合同编号当前为同员工内唯一"
|
||||||
|
@@ -19,6 +19,7 @@ const NAVIGATION_REGISTRY = Object.freeze({
|
|||||||
|
|
||||||
workspace: { key: "workspace", label: "工作区设置", icon: "tune", href: "/system/workspace", anyOf: ["system.settings"] },
|
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 },
|
people: { key: "people", label: "人员管理", icon: "group", href: "/people", anyOf: ["system.users", "report.dashboard.view"], featureFlagKey: FEATURE_FLAG_KEYS.employeeLifecycle },
|
||||||
|
organization: { key: "organization", label: "组织架构", icon: "account_tree", href: "/organization", anyOf: ["system.users", "report.dashboard.view"], featureFlagKey: FEATURE_FLAG_KEYS.employeeLifecycle },
|
||||||
contractsRegistry: { key: "contractsRegistry", label: "合同台账", icon: "description", href: "/contracts-registry", anyOf: ["project.manage", "project.view_all", "system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.projectLinking },
|
contractsRegistry: { key: "contractsRegistry", label: "合同台账", icon: "description", href: "/contracts-registry", anyOf: ["project.manage", "project.view_all", "system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.projectLinking },
|
||||||
workflowDefinitions: { key: "workflowDefinitions", label: "流程定义", icon: "schema", href: "/system/workflow-definitions", anyOf: ["system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.workflowDefinitionAdmin },
|
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"] },
|
systemSettings: { key: "systemSettings", label: "系统设置", icon: "settings", href: "/system/settings", anyOf: ["system.settings", "system.users", "masterdata.view", "masterdata.manage", "notification.channel.manage", "integration.manage"] },
|
||||||
@@ -57,6 +58,7 @@ const DEFAULT_MODULE_VISIBILITY = Object.freeze({
|
|||||||
|
|
||||||
workspace: "admin",
|
workspace: "admin",
|
||||||
people: "admin",
|
people: "admin",
|
||||||
|
organization: "admin",
|
||||||
contractsRegistry: "admin",
|
contractsRegistry: "admin",
|
||||||
workflowDefinitions: "admin",
|
workflowDefinitions: "admin",
|
||||||
systemSettings: "admin",
|
systemSettings: "admin",
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户