gongxiangp2/components/department-dashboard/TeamCapacityDashboard.tsx

235 lines
18 KiB
TypeScript
Raw 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 Link from 'next/link'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
ArrowLeft,
CalendarRange,
CheckCircle2,
CircleDot,
Database,
RefreshCw,
UsersRound,
} from 'lucide-react'
import DepartmentShell from './DepartmentShell'
import { TEAM_CAPACITY_DEPARTMENT_ORDER } from './team-capacity'
import type {
TeamCapacityDepartment,
TeamCapacityInsights,
TeamCapacityWeek,
TeamCapacityWorkflowItem,
} from './team-capacity'
type ViewState =
| { status: 'loading'; data: null; error: null }
| { status: 'success'; data: TeamCapacityInsights; error: null }
| { status: 'error'; data: null; error: string }
function formatDate(value: string | null, includeTime = false) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit', day: '2-digit',
...(includeTime ? { hour: '2-digit', minute: '2-digit', hour12: false } : {}),
timeZone: 'Asia/Shanghai',
}).format(date)
}
function personnelCountLabel(value: number | null) {
return value === null ? '暂无历史快照' : `${value}人`
}
function rosterHistoryLabel(insights: TeamCapacityInsights) {
const history = insights.summary.rosterHistory
if (history.source !== 'historical_snapshot') {
return '尚未采集到员工组织历史快照;历史周人数暂不展示,避免用当前花名册冒充过去。'
}
return `已覆盖 ${history.coveredWeeks}/${history.totalWeeks} 周;每周人数取该周结束前最近一次完整快照。`
}
function statusLabel(value: string) {
const status = value.trim().toUpperCase()
if (status === 'COMPLETED') return '已完结'
if (status === 'TERMINATED' || status === 'CANCELED') return '已终止'
return '进行中'
}
function syncLabel(insights: TeamCapacityInsights) {
if (insights.summary.lastSyncedAt) return `每小时增量同步 · ${formatDate(insights.summary.lastSyncedAt, true)}`
if (insights.summary.syncStatus === 'error') return '同步异常,当前展示本地已有快照'
if (insights.summary.syncStatus === 'syncing') return '正在同步'
return '等待首轮同步'
}
function WeekDetail({ week, department }: { week: TeamCapacityWeek; department: TeamCapacityDepartment }) {
const items = week.items
return (
<section aria-labelledby="team-capacity-week-detail" className="mt-6 overflow-hidden rounded-lg border border-slate-200 bg-white">
<header className="flex flex-col gap-2 border-b border-slate-200 px-4 py-4 sm:flex-row sm:items-end sm:justify-between sm:px-6">
<div>
<h3 id="team-capacity-week-detail" className="font-bold text-slate-950">{week.label} · {department.name}明细</h3>
<p className="mt-1 text-xs text-slate-500">本周收到和完结的流程,同一流程只列一次。</p>
</div>
<p className="text-sm font-semibold tabular-nums text-slate-600">共 {items.length} 条</p>
</header>
{items.length === 0 ? (
<div role="status" className="px-5 py-12 text-center">
<Database className="mx-auto h-6 w-6 text-slate-400" aria-hidden="true" />
<p className="mt-3 text-sm font-semibold text-slate-700">本周暂无流程明细</p>
</div>
) : (
<>
<div className="hidden overflow-x-auto md:block" tabIndex={0} aria-label={`${department.name}${week.label}流程明细表`}>
<table className="w-full min-w-[920px] table-fixed text-left text-sm">
<caption className="sr-only">{department.name}{week.label}团队产能流程明细</caption>
<thead className="bg-slate-50 text-xs font-bold text-slate-500">
<tr>
<th scope="col" className="w-[300px] px-4 py-3">流程</th>
<th scope="col" className="w-[130px] px-4 py-3">本周动作</th>
<th scope="col" className="w-[180px] px-4 py-3">负责人</th>
<th scope="col" className="w-[110px] px-4 py-3">状态</th>
<th scope="col" className="w-[140px] px-4 py-3">发起时间</th>
<th scope="col" className="w-[140px] px-4 py-3">完结时间</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{items.map((item) => <WorkflowRow key={item.id} item={item} />)}
</tbody>
</table>
</div>
<ul className="divide-y divide-slate-200 md:hidden" aria-label={`${department.name}${week.label}流程明细列表`}>
{items.map((item) => <WorkflowListItem key={item.id} item={item} />)}
</ul>
</>
)}
</section>
)
}
function WorkflowRow({ item }: { item: TeamCapacityWorkflowItem }) {
return (
<tr className="align-top hover:bg-slate-50">
<td className="px-4 py-3.5"><p className="line-clamp-2 font-bold text-slate-950">{item.title}</p><p className="mt-1 text-xs text-slate-500">审批编号 {item.businessId || item.id}</p></td>
<td className="px-4 py-3.5 text-xs font-bold"><span className="text-[#176fc1]">{item.receivedInWeek ? '收到' : ''}</span>{item.receivedInWeek && item.completedInWeek ? ' · ' : ''}<span className="text-[#168252]">{item.completedInWeek ? '完结' : ''}</span></td>
<td className="px-4 py-3.5 font-semibold text-slate-800">{item.assigneeNames.join('、') || '—'}</td>
<td className="px-4 py-3.5 font-semibold text-slate-700">{statusLabel(item.status)}</td>
<td className="px-4 py-3.5 tabular-nums text-slate-600">{formatDate(item.sourceCreatedAt, true)}</td>
<td className="px-4 py-3.5 tabular-nums text-slate-600">{formatDate(item.sourceFinishedAt, true)}</td>
</tr>
)
}
function WorkflowListItem({ item }: { item: TeamCapacityWorkflowItem }) {
return (
<li className="p-4">
<div className="flex items-start justify-between gap-3"><h4 className="min-w-0 break-words font-bold text-slate-950">{item.title}</h4><span className="shrink-0 text-xs font-bold text-slate-600">{statusLabel(item.status)}</span></div>
<p className="mt-1 break-all text-xs text-slate-500">审批编号 {item.businessId || item.id}</p>
<p className="mt-3 text-xs font-bold"><span className="text-[#176fc1]">{item.receivedInWeek ? '本周收到' : ''}</span>{item.receivedInWeek && item.completedInWeek ? ' · ' : ''}<span className="text-[#168252]">{item.completedInWeek ? '本周完结' : ''}</span></p>
<dl className="mt-3 grid grid-cols-2 gap-x-3 gap-y-3 text-sm">
<div><dt className="text-xs text-slate-500">负责人</dt><dd className="mt-1 font-semibold text-slate-800">{item.assigneeNames.join('、') || '—'}</dd></div>
<div><dt className="text-xs text-slate-500">发起时间</dt><dd className="mt-1 tabular-nums text-slate-700">{formatDate(item.sourceCreatedAt, true)}</dd></div>
<div><dt className="text-xs text-slate-500">完结时间</dt><dd className="mt-1 tabular-nums text-slate-700">{formatDate(item.sourceFinishedAt, true)}</dd></div>
</dl>
</li>
)
}
function LoadingView() {
return <div role="status" className="border border-slate-200 bg-white px-5 py-14 text-center"><RefreshCw className="mx-auto h-7 w-7 animate-spin text-[#1677ff]" aria-hidden="true" /><p className="mt-4 text-sm font-semibold text-slate-700">正在读取近 8 周团队产能</p></div>
}
export default function TeamCapacityDashboard() {
const [selectedDepartmentKey, setSelectedDepartmentKey] = useState('')
const [selectedWeekKey, setSelectedWeekKey] = useState('')
const [state, setState] = useState<ViewState>({ status: 'loading', data: null, error: null })
const load = useCallback(async () => {
setState({ status: 'loading', data: null, error: null })
try {
const response = await fetch('/api/department-dashboard/team-capacity', { cache: 'no-store' })
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success || !payload.data) throw new Error(payload?.error || '团队产能读取失败')
setState({ status: 'success', data: payload.data, error: null })
} catch (error) {
setState({ status: 'error', data: null, error: (error as Error).message || '团队产能读取失败' })
}
}, [])
useEffect(() => { void load() }, [load])
const capacityData = state.status === 'success' ? state.data : null
const departments = useMemo(() => {
if (!capacityData) return []
return TEAM_CAPACITY_DEPARTMENT_ORDER.flatMap((name) => {
const department = capacityData.departments.find((candidate) => candidate.name === name)
return department ? [department] : []
})
}, [capacityData])
const selectedDepartment = useMemo(() => {
return departments.find((department) => department.name === selectedDepartmentKey) || departments[0]
}, [departments, selectedDepartmentKey])
const weeks = selectedDepartment?.weeks || []
const selectedWeek = weeks.find((week) => week.key === selectedWeekKey) || weeks.at(-1)
const weekMaxValue = Math.max(1, ...weeks.flatMap((week) => [week.receivedCount, week.completedCount]))
useEffect(() => {
if (state.status === 'success') {
if (!selectedDepartmentKey && departments[0]) setSelectedDepartmentKey(departments[0].name)
if (!selectedWeekKey && departments[0]?.weeks.at(-1)) setSelectedWeekKey(departments[0].weeks.at(-1)!.key)
}
}, [departments, selectedDepartmentKey, selectedWeekKey, state.status])
return (
<DepartmentShell>
<main className="mx-auto max-w-[1600px] px-4 pb-16 pt-7 sm:px-6 lg:px-8">
<header className="flex flex-col gap-4 border-b border-[#dfe4ea] pb-6 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-[-0.03em] text-[#101828] sm:text-4xl">团队产能</h1>
<p className="mt-3 max-w-3xl text-base leading-7 text-[#66758b]">按二级部门查看团队近 8 周收到与完结的流程,点击周次展开任务明细。</p>
</div>
{state.status === 'success' && <p className="inline-flex items-center gap-2 self-start text-xs font-semibold text-slate-500 lg:self-auto"><CircleDot className={`h-3.5 w-3.5 ${state.data.summary.syncStatus === 'error' ? 'text-amber-600' : 'text-emerald-600'}`} aria-hidden="true" />{syncLabel(state.data)}</p>}
</header>
{state.status === 'loading' && <div className="mt-6"><LoadingView /></div>}
{state.status === 'error' && <div role="alert" className="mt-6 border border-slate-200 bg-white px-5 py-14 text-center"><Database className="mx-auto h-7 w-7 text-slate-400" aria-hidden="true" /><h2 className="mt-4 font-bold text-slate-950">团队产能暂时无法读取</h2><p className="mt-2 text-sm text-slate-600">{state.error}</p><button type="button" onClick={() => void load()} className="mt-5 inline-flex min-h-10 items-center gap-2 rounded-lg bg-[#1677ff] px-4 text-sm font-bold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><RefreshCw className="h-4 w-4" aria-hidden="true" />重新读取</button></div>}
{state.status === 'success' && departments.length === 0 && <div role="status" className="mt-6 border border-slate-200 bg-white px-5 py-14 text-center"><UsersRound className="mx-auto h-7 w-7 text-slate-400" aria-hidden="true" /><p className="mt-4 text-sm font-semibold text-slate-700">当前没有可展示的部门产能数据</p></div>}
{state.status === 'success' && departments.length > 0 && selectedDepartment && (
<div className="mt-6 overflow-hidden rounded-lg border border-slate-200 bg-white lg:grid lg:grid-cols-[208px_minmax(0,1fr)]">
<aside aria-label="团队产能二级部门导航" className="border-b border-slate-200 bg-slate-50 lg:border-b-0 lg:border-r">
<div className="hidden border-b border-slate-200 px-4 py-4 lg:block"><p className="text-xs font-bold text-slate-500">部门</p><p className="mt-1 text-sm font-bold text-slate-900">团队产能</p></div>
<nav className="flex gap-1 overflow-x-auto p-2 lg:block lg:space-y-1 lg:overflow-visible" aria-label="选择二级部门">
{departments.map((department) => <button key={department.name} type="button" aria-label={department.name} aria-current={selectedDepartment.name === department.name ? 'page' : undefined} onClick={() => setSelectedDepartmentKey(department.name)} className={`flex min-h-10 shrink-0 items-center justify-between gap-3 rounded-lg px-4 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] lg:w-full lg:text-left ${selectedDepartment.name === department.name ? 'bg-[#1677ff] text-white' : 'text-slate-600 hover:bg-slate-200/70 hover:text-slate-950'}`}><span className="truncate">{department.name}</span><span aria-hidden="true" className={`shrink-0 text-xs font-semibold ${selectedDepartment.name === department.name ? 'text-white/75' : 'text-slate-400'}`}>({department.personnelCount}人)</span></button>)}
</nav>
</aside>
<section className="min-w-0">
<header className="flex flex-col gap-4 border-b border-slate-200 px-4 py-5 sm:px-6 lg:flex-row lg:items-end lg:justify-between">
<div><p className="text-xs font-bold text-[#1677ff]">当前部门</p><h2 className="mt-1 text-xl font-bold text-slate-950">{selectedDepartment.name}</h2><p className="mt-1 text-sm text-slate-500">按部门架构归属统计 · 当前 {selectedDepartment.people.length} 名在职人员</p></div>
<dl className="flex flex-wrap gap-x-6 gap-y-2 text-sm tabular-nums"><div><dt className="inline text-slate-500">当前在职人数 </dt><dd className="inline font-bold text-slate-950">{selectedDepartment.personnelCount}</dd></div><div><dt className="inline text-slate-500">近8周收到 </dt><dd className="inline font-bold text-slate-950">{weeks.reduce((sum, week) => sum + week.receivedCount, 0)}</dd></div><div><dt className="inline text-slate-500">近8周完结 </dt><dd className="inline font-bold text-[#176fc1]">{weeks.reduce((sum, week) => sum + week.completedCount, 0)}</dd></div></dl>
</header>
<div className="border-b border-slate-200 bg-slate-50/70 px-4 py-5 sm:px-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"><div className="inline-flex items-center gap-2 text-xs font-semibold text-slate-500"><CalendarRange className="h-4 w-4" aria-hidden="true" />近 8 个自然周</div><div className="flex items-center gap-4 text-xs font-semibold text-slate-500" aria-label="图例"><span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-[#89b9f4]" aria-hidden="true" />收到</span><span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-[#22a06b]" aria-hidden="true" />完结</span></div></div>
<div role="note" className="mt-4 flex items-start gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-xs leading-5 text-slate-600"><Database className="mt-0.5 h-4 w-4 shrink-0 text-[#1677ff]" aria-hidden="true" /><p><span className="font-bold text-slate-800">历史人员数:</span>{rosterHistoryLabel(state.data)} 历史快照从首次成功采集后开始累计。</p></div>
<div className="mt-4 overflow-x-auto" tabIndex={0} aria-label="团队产能近8周数量横向滚动区"><div className="grid min-w-[760px] grid-cols-8 gap-2">{weeks.map((week) => <WeekButton key={week.key} week={week} maxValue={weekMaxValue} selected={week.key === selectedWeek?.key} onSelect={() => setSelectedWeekKey(week.key)} />)}</div></div>
</div>
{selectedWeek && <WeekDetail week={selectedWeek} department={selectedDepartment} />}
</section>
</div>
)}
<div className="mt-8 flex flex-col gap-3 border-t border-slate-200 pt-5 sm:flex-row sm:items-center sm:justify-between"><p className="inline-flex items-center gap-2 text-xs text-slate-500"><CheckCircle2 className="h-4 w-4 text-emerald-600" aria-hidden="true" />流程与员工组织快照每小时增量同步</p><Link href="/" className="inline-flex min-h-10 items-center gap-2 self-start rounded-lg px-3 text-sm font-bold text-[#176fc1] hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><ArrowLeft className="h-4 w-4" aria-hidden="true" />返回部门看板</Link></div>
</main>
</DepartmentShell>
)
}
function WeekButton({ week, maxValue, selected, onSelect }: { week: TeamCapacityWeek; maxValue: number; selected: boolean; onSelect: () => void }) {
const receivedHeight = week.receivedCount ? Math.max(10, Math.round((week.receivedCount / maxValue) * 88)) : 0
const completedHeight = week.completedCount ? Math.max(10, Math.round((week.completedCount / maxValue) * 88)) : 0
return <button type="button" aria-expanded={selected} aria-controls="team-capacity-week-detail" aria-label={`${week.label},${personnelCountLabel(week.personnelCount)},收到${week.receivedCount}个,完结${week.completedCount}个`} onClick={onSelect} className={`group min-w-0 rounded-lg border px-2 pb-3 pt-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] ${selected ? 'border-[#1677ff] bg-white' : 'border-transparent hover:border-slate-300 hover:bg-white'}`}><div className="flex h-24 items-end justify-center gap-2" aria-hidden="true"><span className="w-4 rounded-t-sm bg-[#89b9f4]" style={{ height: `${receivedHeight}%` }} /><span className="w-4 rounded-t-sm bg-[#22a06b]" style={{ height: `${completedHeight}%` }} /></div><p className="mt-2 truncate text-center text-xs font-semibold text-slate-600">{week.label}</p><p className="mt-1 text-center text-xs tabular-nums text-slate-500">人员 {personnelCountLabel(week.personnelCount)} · 参与 {week.involvedPersonnelCount}</p><p className="mt-1 text-center text-xs tabular-nums text-slate-500"><span className="font-bold text-[#176fc1]">{week.receivedCount}</span><span className="px-1">/</span><span className="font-bold text-[#168252]">{week.completedCount}</span></p></button>
}