feat(oa-board): split current and historical timeout analysis
This commit is contained in:
parent
d9672a9231
commit
3289809a95
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user