235 lines
18 KiB
TypeScript
235 lines
18 KiB
TypeScript
'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>
|
||
}
|