gongxiangp2/components/department-dashboard/TeamWorkloadInsights.tsx

121 lines
7.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
)
}