121 lines
7.0 KiB
TypeScript
121 lines
7.0 KiB
TypeScript
'use client'
|
||
|
||
import { useId, useState } from 'react'
|
||
import { ChevronDown, Clock3 } from 'lucide-react'
|
||
import type {
|
||
TeamWorkloadDepartment,
|
||
TeamWorkloadInsights as TeamWorkloadInsightsData,
|
||
TeamWorkloadPerson,
|
||
} from '@/lib/ding-workflow-sync'
|
||
|
||
function formatPercent(value: number | null) {
|
||
if (value === null) return '—'
|
||
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}%`
|
||
}
|
||
|
||
function personStatusLabel(person: TeamWorkloadPerson) {
|
||
if (person.status === 'idle') return '空闲'
|
||
if (person.status === 'available') return '1 个进行中 · 负荷 50%'
|
||
if (person.status === 'full') return '2 个进行中 · 满负荷 100%'
|
||
return `${person.activeProjectCount} 个进行中 · 负荷 ${person.loadPercent}%`
|
||
}
|
||
|
||
function personLoadBadgeClass(status: TeamWorkloadPerson['status']) {
|
||
if (status === 'idle') return 'bg-slate-200 text-slate-700'
|
||
if (status === 'overloaded') return 'bg-red-100 text-red-800'
|
||
return 'bg-blue-100 text-blue-800'
|
||
}
|
||
|
||
function DepartmentRow({ department }: { department: TeamWorkloadDepartment }) {
|
||
const [expanded, setExpanded] = useState(false)
|
||
const detailsId = useId()
|
||
return (
|
||
<div className="rounded-xl border border-slate-200 bg-white">
|
||
<button
|
||
type="button"
|
||
onClick={() => setExpanded((value) => !value)}
|
||
aria-expanded={expanded}
|
||
aria-controls={detailsId}
|
||
className="flex w-full items-center justify-between gap-4 p-5 text-left hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#5a9ce6]"
|
||
>
|
||
<span className="min-w-0">
|
||
<span className="block break-words text-base font-bold text-slate-950" title={department.name}>{department.name}</span>
|
||
<span className="mt-1 block text-xs text-slate-500">
|
||
{department.personnelCount} 人 · {department.busyPersonnel} 人有进行中 · {department.activeProjectAssignments} 个流程归属
|
||
</span>
|
||
</span>
|
||
<span className="flex shrink-0 items-center gap-3">
|
||
<span className="text-lg font-bold tabular-nums text-slate-950">{formatPercent(department.averageLoadPercent)}</span>
|
||
<ChevronDown className={`h-5 w-5 text-slate-500 transition-transform ${expanded ? 'rotate-180' : ''}`} aria-hidden="true" />
|
||
</span>
|
||
</button>
|
||
{expanded && (
|
||
<div id={detailsId} role="region" aria-label={`${department.name}人员与流程明细`} className="border-t border-slate-200 p-4">
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{department.people.map((person) => (
|
||
<article key={person.id} className="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<p className="break-words font-bold text-slate-950" title={person.name}>{person.name}</p>
|
||
<p className="mt-1 text-xs text-slate-500">{person.activeProjectCount} 个进行中流程</p>
|
||
</div>
|
||
<span className={`shrink-0 rounded-md px-2 py-1 text-xs font-bold ${personLoadBadgeClass(person.status)}`}>
|
||
{formatPercent(person.loadPercent)}
|
||
</span>
|
||
</div>
|
||
<p className="mt-3 text-xs font-semibold text-slate-600">{personStatusLabel(person)}</p>
|
||
{person.projects.length > 0 && (
|
||
<ul className="mt-3 space-y-2 border-t border-slate-200 pt-3">
|
||
{person.projects.map((project) => (
|
||
<li key={project.id} className="break-words text-xs leading-5 text-slate-700">
|
||
<span className="font-semibold text-slate-900" title={project.title}>{project.title}</span>
|
||
<span className="block text-slate-500">{project.projectType} · {project.nodeNames.join(' / ') || '当前处理节点'}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</article>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function TeamWorkloadInsights({ insights }: { insights: TeamWorkloadInsightsData }) {
|
||
const { summary, departments } = insights
|
||
return (
|
||
<section aria-labelledby="team-workload-detail-title" className="mt-10">
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||
<div>
|
||
<h2 id="team-workload-detail-title" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">团队人员负荷明细</h2>
|
||
<p className="mt-1 max-w-3xl text-sm leading-6 text-slate-600">
|
||
统计品牌中心在职人员当前手里的进行中流程;0 个为空闲,1 个为 50%,2 个为 100%,超过 2 个后每个流程继续增加 50%。点击部门可展开人员与流程明细。
|
||
</p>
|
||
</div>
|
||
<span className="inline-flex items-center gap-2 rounded-xl bg-white px-3 py-2 text-xs font-bold text-slate-600">
|
||
<Clock3 className="h-4 w-4" aria-hidden="true" />每小时增量同步
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
<div className="rounded-xl border border-slate-200 bg-white p-4"><p className="text-xs font-bold text-slate-500">品牌中心平均负荷</p><p className="mt-2 text-2xl font-bold tabular-nums text-slate-950">{formatPercent(summary.averageLoadPercent)}</p></div>
|
||
<div className="rounded-xl border border-slate-200 bg-white p-4"><p className="text-xs font-bold text-slate-500">统计人员</p><p className="mt-2 text-2xl font-bold tabular-nums text-slate-950">{summary.personnelCount} 人</p></div>
|
||
<div className="rounded-xl border border-slate-200 bg-white p-4"><p className="text-xs font-bold text-slate-500">有进行中流程</p><p className="mt-2 text-2xl font-bold tabular-nums text-slate-950">{summary.busyPersonnel} 人</p></div>
|
||
<div className="rounded-xl border border-slate-200 bg-white p-4"><p className="text-xs font-bold text-slate-500">进行中流程归属</p><p className="mt-2 text-2xl font-bold tabular-nums text-slate-950">{summary.activeProjectAssignments} 个</p></div>
|
||
</div>
|
||
|
||
<div className="mt-5 rounded-xl border border-blue-100 bg-blue-50 px-4 py-3 text-xs leading-5 text-blue-900">
|
||
已排除人员:{summary.excludedNames.join('、')};已排除部门:{summary.excludedDepartmentNames.join('、')}。进行中流程与“进行中项目”板块使用同一份钉钉流程快照;当前快照共 {summary.sourceOngoingProjects} 个流程。
|
||
</div>
|
||
|
||
<div className="mt-5 space-y-3">
|
||
{departments.length === 0 ? (
|
||
<div role="status" className="rounded-xl border border-slate-200 bg-white px-5 py-12 text-center text-sm text-slate-600">暂无匹配的品牌中心在职人员</div>
|
||
) : departments.map((department) => <DepartmentRow key={department.name} department={department} />)}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|