206 行
9.9 KiB
TypeScript
206 行
9.9 KiB
TypeScript
import { Link } from "react-router-dom";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { fetchEmployeeStats, type EmployeeEmploymentStatus } from "../api/employees";
|
|
import { PageHeader } from "../components/common/PageHeader";
|
|
import { Button } from "../components/ui/button";
|
|
|
|
const EMPLOYMENT_STATUS_LABELS: Record<EmployeeEmploymentStatus, string> = {
|
|
onboarding: "待入职",
|
|
probation: "试用期",
|
|
active: "在职",
|
|
offboarding: "离职办理中",
|
|
inactive: "已离职",
|
|
};
|
|
|
|
function employmentStatusLabel(status: EmployeeEmploymentStatus) {
|
|
return EMPLOYMENT_STATUS_LABELS[status] ?? status;
|
|
}
|
|
|
|
function moneyText(value: number | null | undefined) {
|
|
return value == null ? "-" : value.toFixed(2);
|
|
}
|
|
|
|
export function PeopleStatsPage() {
|
|
const statsQuery = useQuery({
|
|
queryKey: ["employee-stats"],
|
|
queryFn: fetchEmployeeStats,
|
|
});
|
|
|
|
if (statsQuery.isLoading) {
|
|
return <div className="card-std p-6 text-sm text-zinc-500">加载员工统计中...</div>;
|
|
}
|
|
|
|
if (statsQuery.isError || !statsQuery.data) {
|
|
return <div className="card-std p-6 text-sm text-red-600">加载员工统计失败</div>;
|
|
}
|
|
|
|
const stats = statsQuery.data;
|
|
const salarySummary = stats.salarySummary;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
title="员工统计"
|
|
subtitle={`统计口径截至 ${stats.asOfDate},仅展示聚合数据。`}
|
|
actions={
|
|
<Button asChild variant="secondary" type="button">
|
|
<Link to="/people">返回人员管理</Link>
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
<section className="card-std p-5">
|
|
<div className="text-sm text-zinc-500">在职人数</div>
|
|
<div className="mt-2 text-3xl font-semibold text-zinc-900">{stats.employmentStatusSummary.total}</div>
|
|
<div className="mt-2 text-xs text-zinc-500">按 active 账号员工汇总</div>
|
|
</section>
|
|
<section className="card-std p-5">
|
|
<div className="text-sm text-zinc-500">近 30 天入职</div>
|
|
<div className="mt-2 text-3xl font-semibold text-zinc-900">{stats.recentMovements.hires}</div>
|
|
<div className="mt-2 text-xs text-zinc-500">
|
|
{stats.recentMovements.dateFrom} 至 {stats.recentMovements.dateTo}
|
|
</div>
|
|
</section>
|
|
<section className="card-std p-5">
|
|
<div className="text-sm text-zinc-500">近 30 天离职</div>
|
|
<div className="mt-2 text-3xl font-semibold text-zinc-900">{stats.recentMovements.departures}</div>
|
|
<div className="mt-2 text-xs text-zinc-500">
|
|
{stats.recentMovements.dateFrom} 至 {stats.recentMovements.dateTo}
|
|
</div>
|
|
</section>
|
|
<section className="card-std p-5">
|
|
<div className="text-sm text-zinc-500">30 天内合同到期</div>
|
|
<div className="mt-2 text-3xl font-semibold text-zinc-900">{stats.expiringContracts.count}</div>
|
|
<div className="mt-2 text-xs text-zinc-500">
|
|
{stats.expiringContracts.dateFrom} 至 {stats.expiringContracts.dateTo}
|
|
</div>
|
|
</section>
|
|
<section className="card-std p-5">
|
|
<div className="text-sm text-zinc-500">持有效证书人数</div>
|
|
<div className="mt-2 text-3xl font-semibold text-zinc-900">{stats.certificateSummary.validHolderCount}</div>
|
|
<div className="mt-2 text-xs text-zinc-500">按 active 员工去重统计有效证书持有人</div>
|
|
</section>
|
|
<section className="card-std p-5">
|
|
<div className="text-sm text-zinc-500">30 天内证书到期</div>
|
|
<div className="mt-2 text-3xl font-semibold text-zinc-900">{stats.certificateSummary.expiringSoonCount}</div>
|
|
<div className="mt-2 text-xs text-zinc-500">
|
|
{stats.certificateSummary.dateFrom} 至 {stats.certificateSummary.dateTo}
|
|
</div>
|
|
</section>
|
|
{salarySummary ? (
|
|
<section className="card-std p-5">
|
|
<div className="text-sm text-zinc-500">当月薪资总支出</div>
|
|
<div className="mt-2 text-3xl font-semibold text-zinc-900">{moneyText(salarySummary.currentMonth?.totalNetAmount)}</div>
|
|
<div className="mt-2 text-xs text-zinc-500">
|
|
{salarySummary.currentMonth ? `${salarySummary.currentMonth.month} · 已发放 ${salarySummary.currentMonth.issuedCount} 人` : "当月暂无已发放工资条"}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
{salarySummary ? (
|
|
<section className="card-std p-5">
|
|
<div className="text-sm text-zinc-500">当月人均薪资</div>
|
|
<div className="mt-2 text-3xl font-semibold text-zinc-900">{moneyText(salarySummary.currentMonth?.averageNetAmount)}</div>
|
|
<div className="mt-2 text-xs text-zinc-500">仅统计 issued 工资条实发金额</div>
|
|
</section>
|
|
) : null}
|
|
{salarySummary ? (
|
|
<section className="card-std p-5">
|
|
<div className="text-sm text-zinc-500">上月薪资总支出</div>
|
|
<div className="mt-2 text-3xl font-semibold text-zinc-900">{moneyText(salarySummary.previousMonth?.totalNetAmount)}</div>
|
|
<div className="mt-2 text-xs text-zinc-500">
|
|
{salarySummary.previousMonth ? `${salarySummary.previousMonth.month} · 已发放 ${salarySummary.previousMonth.issuedCount} 人` : "上月暂无已发放工资条"}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
{salarySummary ? (
|
|
<section className="card-std p-5">
|
|
<div className="text-sm text-zinc-500">上月人均薪资</div>
|
|
<div className="mt-2 text-3xl font-semibold text-zinc-900">{moneyText(salarySummary.previousMonth?.averageNetAmount)}</div>
|
|
<div className="mt-2 text-xs text-zinc-500">非 system.users 不展示任何薪资汇总</div>
|
|
</section>
|
|
) : null}
|
|
<section className="card-std p-5">
|
|
<div className="text-sm text-zinc-500">当前 / 最近周期平均分</div>
|
|
<div className="mt-2 text-3xl font-semibold text-zinc-900">
|
|
{stats.performanceSummary.averageScore == null ? "-" : stats.performanceSummary.averageScore.toFixed(1)}
|
|
</div>
|
|
<div className="mt-2 text-xs text-zinc-500">
|
|
{stats.performanceSummary.cycle
|
|
? `${stats.performanceSummary.cycle.name} · ${stats.performanceSummary.cycle.startDate} 至 ${stats.performanceSummary.cycle.endDate}`
|
|
: "暂无可展示的绩效周期"}
|
|
</div>
|
|
</section>
|
|
<section className="card-std p-5">
|
|
<div className="text-sm text-zinc-500">当前 / 最近周期已评分人数</div>
|
|
<div className="mt-2 text-3xl font-semibold text-zinc-900">{stats.performanceSummary.reviewedCount}</div>
|
|
<div className="mt-2 text-xs text-zinc-500">只统计当前 active 或最近 completed 周期</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
|
<section className="card-std p-6">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-zinc-900">雇用状态分布</h2>
|
|
<p className="mt-1 text-sm text-zinc-500">按当前 active 账号员工的 employment status 聚合。</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-6 space-y-3">
|
|
{stats.employmentStatusSummary.items.length ? (
|
|
stats.employmentStatusSummary.items.map((item) => (
|
|
<div key={item.status} className="flex items-center justify-between rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3">
|
|
<div className="text-sm font-medium text-zinc-800">{employmentStatusLabel(item.status)}</div>
|
|
<div className="text-lg font-semibold text-zinc-900">{item.count}</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-sm text-zinc-500">暂无雇用状态统计。</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="card-std p-6">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-zinc-900">部门人数分布</h2>
|
|
<p className="mt-1 text-sm text-zinc-500">按当前 active 账号员工的部门归属统计。</p>
|
|
</div>
|
|
<div className="mt-6 space-y-3">
|
|
{stats.departmentHeadcount.length ? (
|
|
stats.departmentHeadcount.map((item) => (
|
|
<div key={item.departmentId} className="flex items-center justify-between rounded-2xl border border-zinc-200 bg-white px-4 py-3">
|
|
<div className="text-sm font-medium text-zinc-800">{item.departmentName}</div>
|
|
<div className="text-lg font-semibold text-zinc-900">{item.count}</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-sm text-zinc-500">暂无部门人数统计。</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<section className="card-std p-6">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-zinc-900">绩效评分分布</h2>
|
|
<p className="mt-1 text-sm text-zinc-500">按当前 active 周期或最近 completed 周期的总分分布汇总。</p>
|
|
</div>
|
|
<div className="mt-6 grid gap-3 md:grid-cols-3 xl:grid-cols-5">
|
|
{stats.performanceSummary.cycle && stats.performanceSummary.distribution.length ? (
|
|
stats.performanceSummary.distribution.map((item) => (
|
|
<div key={item.score} className="rounded-2xl border border-zinc-200 bg-white px-4 py-3">
|
|
<div className="text-sm text-zinc-500">{item.score.toFixed(1)} 分</div>
|
|
<div className="mt-2 text-2xl font-semibold text-zinc-900">{item.count}</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-sm text-zinc-500">暂无绩效周期统计。</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default PeopleStatsPage;
|