R40: 完成档案编辑扩展与员工导出
这个提交包含在:
@@ -137,6 +137,7 @@ export async function patchEmployeeProfile(
|
||||
departmentId: string;
|
||||
directManagerId: string | null;
|
||||
title: string;
|
||||
hireDate: string | null;
|
||||
probationEndDate: string | null;
|
||||
lastWorkingDate: string | null;
|
||||
workLocation: string | null;
|
||||
|
||||
@@ -56,4 +56,21 @@ export async function exportDashboardReimbursements(params: {
|
||||
return apiRequestBlob(`/api/exports/dashboard/reimbursements?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export async function exportEmployees(params: {
|
||||
format: "csv" | "xlsx";
|
||||
status?: "active" | "archived";
|
||||
departmentId?: string;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("format", params.format);
|
||||
if (params.status) qs.set("status", params.status);
|
||||
if (params.departmentId) qs.set("departmentId", params.departmentId);
|
||||
if (params.q) qs.set("q", params.q);
|
||||
if (typeof params.limit === "number") qs.set("limit", String(params.limit));
|
||||
|
||||
return apiRequestBlob(`/api/exports/employees?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export type { ApiBlobResponse };
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
startEmployeeOffboarding,
|
||||
transferEmployee,
|
||||
} from "../api/employees";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
@@ -29,6 +31,7 @@ function errorMessage(error: unknown, fallback: string) {
|
||||
|
||||
export function EmployeeProfilePage() {
|
||||
const { employeeId = "" } = useParams();
|
||||
const { permissions, me } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const profileQuery = useQuery({
|
||||
enabled: Boolean(employeeId),
|
||||
@@ -37,7 +40,15 @@ export function EmployeeProfilePage() {
|
||||
});
|
||||
|
||||
const profile = profileQuery.data;
|
||||
const canManageEmployeeProfile = hasPermission(permissions, "system.users");
|
||||
const canEditSelfProfile = me?.id === employeeId;
|
||||
const canEditBaseFields = canManageEmployeeProfile || canEditSelfProfile;
|
||||
const canEditExtendedFields = canManageEmployeeProfile;
|
||||
const [form, setForm] = useState({
|
||||
title: "",
|
||||
hireDate: "",
|
||||
probationEndDate: "",
|
||||
lastWorkingDate: "",
|
||||
workLocation: "",
|
||||
emergencyContactName: "",
|
||||
emergencyContactPhone: "",
|
||||
@@ -47,6 +58,10 @@ export function EmployeeProfilePage() {
|
||||
useEffect(() => {
|
||||
if (!profile) return;
|
||||
setForm({
|
||||
title: profile.employee.title ?? "",
|
||||
hireDate: profile.employee.hireDate ?? "",
|
||||
probationEndDate: profile.employee.probationEndDate ?? "",
|
||||
lastWorkingDate: profile.employee.lastWorkingDate ?? "",
|
||||
workLocation: profile.employee.workLocation ?? "",
|
||||
emergencyContactName: profile.employee.emergencyContactName ?? "",
|
||||
emergencyContactPhone: profile.employee.emergencyContactPhone ?? "",
|
||||
@@ -55,7 +70,25 @@ export function EmployeeProfilePage() {
|
||||
}, [profile]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => patchEmployeeProfile(employeeId, { ...form }),
|
||||
mutationFn: () =>
|
||||
patchEmployeeProfile(employeeId, {
|
||||
...(canEditExtendedFields
|
||||
? {
|
||||
...(form.title.trim() ? { title: form.title.trim() } : {}),
|
||||
hireDate: form.hireDate || null,
|
||||
probationEndDate: form.probationEndDate || null,
|
||||
lastWorkingDate: form.lastWorkingDate || null,
|
||||
}
|
||||
: {}),
|
||||
...(canEditBaseFields
|
||||
? {
|
||||
workLocation: form.workLocation,
|
||||
emergencyContactName: form.emergencyContactName,
|
||||
emergencyContactPhone: form.emergencyContactPhone,
|
||||
notes: form.notes,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-profile", employeeId] });
|
||||
toast.success("档案已保存");
|
||||
@@ -161,29 +194,76 @@ export function EmployeeProfilePage() {
|
||||
</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 }))} />
|
||||
{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>
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存档案</Button>
|
||||
</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">
|
||||
|
||||
@@ -7,21 +7,29 @@ import {
|
||||
startEmployeeOffboarding,
|
||||
startEmployeeOnboarding,
|
||||
} from "../api/employees";
|
||||
import { exportEmployees } from "../api/exports";
|
||||
import { fetchDepartments } from "../api/departments";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { downloadBlob } from "../lib/download";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||
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 { permissions } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [q, setQ] = useState("");
|
||||
const [status, setStatus] = useState<"" | "active" | "archived">("");
|
||||
const [departmentId, setDepartmentId] = useState("");
|
||||
const [exportError, setExportError] = useState("");
|
||||
const canExportEmployees = hasPermission(permissions, "system.users");
|
||||
|
||||
const departmentsQuery = useQuery({
|
||||
queryKey: ["departments", "people-page", "active"],
|
||||
@@ -88,6 +96,30 @@ export function PeoplePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: (format: "csv" | "xlsx") =>
|
||||
exportEmployees({
|
||||
format,
|
||||
q: q || undefined,
|
||||
status: status || undefined,
|
||||
departmentId: departmentId || undefined,
|
||||
}),
|
||||
onSuccess: (response, format) => {
|
||||
downloadBlob(response.blob, response.filename ?? `employees.${format}`);
|
||||
setExportError("");
|
||||
toast.success("导出完成");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = isApiError(error)
|
||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "导出失败";
|
||||
setExportError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const items = employeesQuery.data?.items ?? [];
|
||||
const departments = departmentsQuery.data?.items ?? [];
|
||||
|
||||
@@ -98,6 +130,23 @@ export function PeoplePage() {
|
||||
subtitle="小团队用这里管入职、转正、离职与档案入口。"
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
{canExportEmployees ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" type="button" disabled={exportMutation.isPending}>
|
||||
{exportMutation.isPending ? "导出中..." : "导出"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => exportMutation.mutate("csv")}>
|
||||
导出 CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => exportMutation.mutate("xlsx")}>
|
||||
导出 Excel
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/system/users">先新建账号</Link>
|
||||
</Button>
|
||||
@@ -137,6 +186,11 @@ export function PeoplePage() {
|
||||
</div>
|
||||
|
||||
<div className="card-std p-6">
|
||||
{exportError ? (
|
||||
<p className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{exportError}
|
||||
</p>
|
||||
) : null}
|
||||
{employeesQuery.isError ? (
|
||||
<p className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{isApiError(employeesQuery.error)
|
||||
|
||||
在新工单中引用
屏蔽一个用户