feat(oa-board): split current and historical timeout analysis

This commit is contained in:
jingchuan 2026-08-01 09:22:22 +08:00
parent d9672a9231
commit 3289809a95

View File

@ -98,6 +98,8 @@ type CurrentOwnerInsight = {
type TimeoutFollowupStatus = 'PENDING' | 'IN_PROGRESS' | 'RESOLVED' | 'NOT_REQUIRED'
type TimeoutAnalysisView = 'current' | 'history'
type OaBoardTimeoutFollowup = {
id: number
boardKey: string
@ -849,7 +851,7 @@ function buildTimeoutAnalysis(items: OaBoardProjectListItemDTO[]) {
let totalOvertimeNodes = 0
let maxOvertimeDays = 0
items.forEach((item) => {
items.filter((item) => item.overallStatus === 'RUNNING').forEach((item) => {
getOvertimeStages(item).forEach((stage) => {
overdueProcessKeys.add(item.boardKey)
totalOvertimeNodes += 1
@ -1118,7 +1120,7 @@ function StatusBadge({
)
}
function TimeoutAnalysis({ items }: { items: OaBoardProjectListItemDTO[] }) {
function CurrentTimeoutAnalysis({ items }: { items: OaBoardProjectListItemDTO[] }) {
const analysis = buildTimeoutAnalysis(items)
const maxStageCount = analysis.stages[0]?.count ?? 0
@ -1127,8 +1129,8 @@ function TimeoutAnalysis({ items }: { items: OaBoardProjectListItemDTO[] }) {
<section className="shrink-0 overflow-hidden rounded-apple-xl border border-apple-separator bg-white shadow-apple-xs">
<header className="flex items-center justify-between border-b border-apple-separator px-5 py-3">
<div>
<h2 className="text-lg font-apple-semibold text-apple-label-primary">超时概览</h2>
<p className="mt-1 text-xs text-apple-label-secondary">基于当前流程快照与已应用筛选条件</p>
<h2 className="text-lg font-apple-semibold text-apple-label-primary">当前超时概览</h2>
<p className="mt-1 text-xs text-apple-label-secondary">仅统计进行中流程的当前节点或当前审批阶段</p>
</div>
<AlertTriangle className="h-4 w-4 text-rose-700" />
</header>
@ -1247,6 +1249,196 @@ function TimeoutAnalysis({ items }: { items: OaBoardProjectListItemDTO[] }) {
)
}
function HistoricalTimeoutAnalysis({
items,
followups,
onOpenFollowup,
}: {
items: OaBoardProjectListItemDTO[]
followups: OaBoardTimeoutFollowup[]
onOpenFollowup: (item: OaBoardProjectListItemDTO) => void
}) {
const [statusFilter, setStatusFilter] = useState('')
const [stageFilter, setStageFilter] = useState('')
const [ownerFilter, setOwnerFilter] = useState('')
const [updatedDateStart, setUpdatedDateStart] = useState('')
const [updatedDateEnd, setUpdatedDateEnd] = useState('')
const itemByBoardKey = useMemo(
() => new Map(items.map((item) => [item.boardKey, item])),
[items],
)
const stageOptions = useMemo(
() => buildOptionList(followups.map((followup) => followup.stageLabel), '全部历史阶段'),
[followups],
)
const ownerOptions = useMemo(
() => buildOptionList(followups.map((followup) => followup.followUpOwner), '全部跟进人'),
[followups],
)
const statusOptions = useMemo(
() => [{ value: '', label: '全部跟进状态' }, ...TIMEOUT_FOLLOWUP_STATUS_OPTIONS],
[],
)
const filteredFollowups = useMemo(
() =>
followups.filter((followup) => {
const updatedDate = String(followup.updatedAt ?? '').slice(0, 10)
if (statusFilter && followup.status !== statusFilter) return false
if (stageFilter && followup.stageLabel !== stageFilter) return false
if (ownerFilter && followup.followUpOwner !== ownerFilter) return false
if (updatedDateStart && updatedDate < updatedDateStart) return false
if (updatedDateEnd && updatedDate > updatedDateEnd) return false
return true
}),
[followups, ownerFilter, stageFilter, statusFilter, updatedDateEnd, updatedDateStart],
)
const historySummary = useMemo(() => {
const totalOvertimeDays = filteredFollowups.reduce(
(total, followup) => total + followup.overtimeDays,
0,
)
const pending = filteredFollowups.filter(
(followup) => followup.status === 'PENDING' || followup.status === 'IN_PROGRESS',
).length
const resolved = filteredFollowups.filter(
(followup) => followup.status === 'RESOLVED' || followup.status === 'NOT_REQUIRED',
).length
return {
total: filteredFollowups.length,
pending,
resolved,
averageOvertimeDays:
filteredFollowups.length > 0
? Number((totalOvertimeDays / filteredFollowups.length).toFixed(1))
: 0,
}
}, [filteredFollowups])
return (
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto pb-1">
<section className="shrink-0 overflow-hidden rounded-apple-xl border border-apple-separator bg-white shadow-apple-xs">
<header className="border-b border-apple-separator px-5 py-3">
<div className="grid gap-3 lg:grid-cols-5">
<FilterSelect label="跟进状态" value={statusFilter} options={statusOptions} onChange={setStatusFilter} />
<FilterSelect label="历史阶段" value={stageFilter} options={stageOptions} onChange={setStageFilter} />
<FilterSelect label="跟进人" value={ownerFilter} options={ownerOptions} onChange={setOwnerFilter} />
<FilterInput label="更新开始日期" type="date" value={updatedDateStart} placeholder="开始日期" onChange={setUpdatedDateStart} />
<FilterInput label="更新结束日期" type="date" value={updatedDateEnd} placeholder="结束日期" onChange={setUpdatedDateEnd} />
</div>
</header>
<div className="grid divide-y divide-apple-separator sm:grid-cols-2 sm:divide-x sm:divide-y-0 lg:grid-cols-4">
<div className="px-5 py-4">
<p className="text-[11px] text-apple-label-secondary">历史跟进项</p>
<p className="mt-1 text-2xl font-apple-semibold text-apple-label-primary">{historySummary.total}</p>
</div>
<div className="px-5 py-4">
<p className="text-[11px] text-apple-label-secondary">待处理</p>
<p className="mt-1 text-2xl font-apple-semibold text-amber-700">{historySummary.pending}</p>
</div>
<div className="px-5 py-4">
<p className="text-[11px] text-apple-label-secondary">已完成</p>
<p className="mt-1 text-2xl font-apple-semibold text-emerald-700">{historySummary.resolved}</p>
</div>
<div className="px-5 py-4">
<p className="text-[11px] text-apple-label-secondary">平均超时</p>
<p className="mt-1 text-2xl font-apple-semibold text-apple-label-primary">
{historySummary.averageOvertimeDays} <span className="text-sm font-apple-medium">天</span>
</p>
</div>
</div>
</section>
<section className="min-h-0 overflow-hidden rounded-apple-xl border border-apple-separator bg-white shadow-apple-xs">
<header className="flex items-center justify-between border-b border-apple-separator px-5 py-3">
<div>
<h2 className="text-lg font-apple-semibold text-apple-label-primary">历史超时跟进</h2>
<p className="mt-1 text-xs text-apple-label-secondary">记录来源于已保存的超时跟进,不参与当前超时统计和钉钉待办发送。</p>
</div>
<span className="text-xs text-apple-label-secondary">{filteredFollowups.length} 条记录</span>
</header>
{filteredFollowups.length === 0 ? (
<div className="px-5 py-10 text-center text-sm text-apple-label-secondary">当前筛选范围内没有历史跟进记录</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[1120px] text-left text-xs">
<thead className="bg-[#fafbfc] text-apple-label-secondary">
<tr className="border-b border-apple-separator">
<th className="px-5 py-3 font-apple-semibold">开款编号</th>
<th className="px-5 py-3 font-apple-semibold">历史阶段</th>
<th className="px-5 py-3 font-apple-semibold">跟进状态</th>
<th className="px-5 py-3 font-apple-semibold">跟进人</th>
<th className="px-5 py-3 font-apple-semibold">标准时效</th>
<th className="px-5 py-3 font-apple-semibold">实际用时</th>
<th className="px-5 py-3 font-apple-semibold">超时用时</th>
<th className="px-5 py-3 font-apple-semibold">预计完成</th>
<th className="px-5 py-3 font-apple-semibold">最近更新</th>
<th className="px-5 py-3 font-apple-semibold">查看</th>
</tr>
</thead>
<tbody>
{filteredFollowups.map((followup) => {
const item = itemByBoardKey.get(followup.boardKey)
return (
<tr key={followup.id} className="border-b border-apple-separator last:border-b-0">
<td className="px-5 py-3 font-apple-medium text-apple-label-primary">{formatCellText(followup.openingCode || item?.formSummary.openingCode || item?.openingCode)}</td>
<td className="px-5 py-3 text-apple-label-primary">{followup.stageLabel}</td>
<td className="px-5 py-3"><StatusBadge label={getTimeoutFollowupStatusLabel(followup.status)} tone={followup.status === 'PENDING' ? 'danger' : followup.status === 'IN_PROGRESS' ? 'warning' : 'success'} /></td>
<td className="px-5 py-3 text-apple-label-primary">{formatCellText(followup.followUpOwner)}</td>
<td className="px-5 py-3 text-apple-label-primary">{followup.targetDays} 天</td>
<td className="px-5 py-3 text-apple-label-primary">{followup.actualDays} 天</td>
<td className="px-5 py-3 text-rose-700">{followup.overtimeDays} 天</td>
<td className="px-5 py-3 text-apple-label-primary">{formatCellText(followup.expectedResolveDate)}</td>
<td className="px-5 py-3 text-apple-label-secondary">{formatCellText(followup.updatedAt?.replace('T', ' ').slice(0, 16))}</td>
<td className="px-5 py-3">
{item ? (
<button type="button" onClick={() => onOpenFollowup(item)} className="rounded-apple-md border border-apple-separator bg-white px-2.5 py-1.5 text-[11px] font-apple-medium text-apple-label-primary transition hover:bg-apple-bg-secondary">
查看记录
</button>
) : '-'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</section>
</div>
)
}
function TimeoutAnalysis({
items,
followups,
onOpenFollowup,
}: {
items: OaBoardProjectListItemDTO[]
followups: OaBoardTimeoutFollowup[]
onOpenFollowup: (item: OaBoardProjectListItemDTO) => void
}) {
const [view, setView] = useState<TimeoutAnalysisView>('current')
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<div className="inline-flex w-fit shrink-0 rounded-apple-md border border-apple-separator bg-white p-1 shadow-apple-xs" role="tablist" aria-label="超时分析视图">
<button type="button" role="tab" aria-selected={view === 'current'} onClick={() => setView('current')} className={`rounded-apple-sm px-4 py-2 text-xs font-apple-semibold transition ${view === 'current' ? 'bg-zinc-950 text-white' : 'text-apple-label-secondary hover:bg-apple-bg-secondary'}`}>
当前超时分析
</button>
<button type="button" role="tab" aria-selected={view === 'history'} onClick={() => setView('history')} className={`rounded-apple-sm px-4 py-2 text-xs font-apple-semibold transition ${view === 'history' ? 'bg-zinc-950 text-white' : 'text-apple-label-secondary hover:bg-apple-bg-secondary'}`}>
历史超时复盘
</button>
</div>
{view === 'current' ? (
<CurrentTimeoutAnalysis items={items} />
) : (
<HistoricalTimeoutAnalysis items={items} followups={followups} onOpenFollowup={onOpenFollowup} />
)}
</div>
)
}
function TimeoutFollowupInspector({
item,
followups,
@ -2486,7 +2678,7 @@ export default function OaBoardClient({
: activeTab === 'brand-planning'
? '新品品牌策划看板'
: activeTab === 'timeout-analysis'
? '超时分析'
? '当前超时分析'
: activeTab === 'shipment'
? '新品出货进度表'
: '新品上架数据'
@ -2531,7 +2723,7 @@ export default function OaBoardClient({
},
{
key: 'timeout-analysis',
label: '超时分析',
label: '当前超时分析',
active: activeTab === 'timeout-analysis',
disabled: false,
onClick: () => switchTab('timeout-analysis'),
@ -2935,7 +3127,7 @@ export default function OaBoardClient({
)
useEffect(() => {
const boardKeys = Array.from(new Set(pagedItems.map((item) => item.boardKey).filter(Boolean)))
const boardKeys = Array.from(new Set(items.map((item) => item.boardKey).filter(Boolean))).slice(0, 500)
if (boardKeys.length === 0) {
setTimeoutFollowups([])
return
@ -2974,7 +3166,7 @@ export default function OaBoardClient({
return () => {
cancelled = true
}
}, [pagedItems])
}, [items])
const metricCardClass = (isActive: boolean) =>
[
'group flex h-[72px] items-center justify-between rounded-md border px-3.5 text-left transition',
@ -3488,7 +3680,11 @@ export default function OaBoardClient({
) : activeTab === 'brand-planning' ? (
<BrandPlanningBoardPanel />
) : activeTab === 'timeout-analysis' ? (
<TimeoutAnalysis items={items} />
<TimeoutAnalysis
items={items}
followups={timeoutFollowups}
onOpenFollowup={setSelectedTimeoutFollowupItem}
/>
) : (
<section className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-white shadow-apple-xs">
<header className="flex shrink-0 items-center justify-between border-b border-apple-separator px-5 py-3">