gongxiangp2/components/department-dashboard/OnTimeDeliveryInsights.tsx
2026-08-28 14:12:02 +08:00

232 lines
12 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 { useMemo, useState } from 'react'
import { Database } from 'lucide-react'
import type {
OnTimeDeliveryInsights,
OnTimeDeliveryWorkflowItem,
} from '@/lib/ding-workflow-sync'
const ART_PROCESS_CODE = 'PROC-3571C915-0506-4490-A91A-8AA7EA33F838'
const CONTENT_PROCESS_CODE = 'PROC-AE9FF3E1-A442-4E6A-85CF-0F2B86409673'
type ProcessFilter = 'all' | 'art' | 'content'
const PROCESS_FILTERS: Array<{ key: ProcessFilter; label: string }> = [
{ key: 'all', label: '全部流程' },
{ key: 'art', label: '美术设计流程' },
{ key: 'content', label: '内容视频流程' },
]
function formatDate(value: string | null) {
if (!value) return '—'
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'Asia/Shanghai',
}).format(new Date(value))
}
function formatRate(rate: number | null) {
if (rate === null) return '—'
return `${Number.isInteger(rate) ? rate.toFixed(0) : rate.toFixed(1)}%`
}
function formatDuration(value: number | null, unit: 'day' | 'hour') {
if (value === null) return '—'
const amount = Number.isInteger(value) ? value.toFixed(0) : value.toFixed(2)
return `${amount} ${unit === 'day' ? '天' : '小时'}`
}
function matchesProcessFilter(item: OnTimeDeliveryWorkflowItem, filter: ProcessFilter) {
if (filter === 'art') return item.processCode === ART_PROCESS_CODE
if (filter === 'content') return item.processCode === CONTENT_PROCESS_CODE
return true
}
function isInRange(value: string | null, rangeStart: string, rangeEnd: string) {
if (!value) return false
const timestamp = Date.parse(value)
const start = Date.parse(rangeStart)
const end = Date.parse(rangeEnd)
return Number.isFinite(timestamp) && Number.isFinite(start) && Number.isFinite(end)
&& timestamp >= start
&& timestamp < end
}
/**
* The delivery denominator is the set of workflows whose timing can be
* judged and whose applicable review node was completed during this month.
* Pending assessments and source-level completion timestamps are deliberately
* excluded: the metric follows the actual review node used by each process.
*/
function isCountableThisMonth(item: OnTimeDeliveryWorkflowItem, monthStart: string, monthEnd: string) {
return item.assessment.status !== 'pending'
&& isInRange(item.assessment.finishedAt, monthStart, monthEnd)
}
function summarize(items: OnTimeDeliveryWorkflowItem[]) {
const onTime = items.filter((item) => item.assessment.status === 'on_time').length
const overtime = items.filter((item) => item.assessment.status === 'overtime').length
const pending = items.filter((item) => item.assessment.status === 'pending').length
const total = items.length
return {
onTime,
overtime,
pending,
total,
rate: total > 0 ? Math.round((onTime / total) * 1_000) / 10 : null,
}
}
function DeliveryStatusBadge({ status }: { status: 'on_time' | 'overtime' | 'pending' }) {
const styles = status === 'on_time'
? 'bg-emerald-50 text-emerald-800'
: status === 'overtime'
? 'bg-red-50 text-red-700'
: 'bg-amber-50 text-amber-800'
const label = status === 'on_time' ? '未超时' : status === 'overtime' ? '已超时' : '待计算'
return <span className={`inline-flex min-h-7 items-center rounded-lg px-2.5 text-xs font-bold ${styles}`}>{label}</span>
}
export default function OnTimeDeliveryInsightsView({ insights }: { insights: OnTimeDeliveryInsights }) {
const [activeFilter, setActiveFilter] = useState<ProcessFilter>('all')
const { items, summary } = insights
const countableItems = useMemo(
() => items.filter((item) => isCountableThisMonth(item, summary.monthStart, summary.monthEnd)),
[items, summary.monthEnd, summary.monthStart],
)
const countableSummary = useMemo(() => summarize(countableItems), [countableItems])
const counts = useMemo(() => ({
all: countableSummary.total,
art: countableItems.filter((item) => matchesProcessFilter(item, 'art')).length,
content: countableItems.filter((item) => matchesProcessFilter(item, 'content')).length,
}), [countableItems, countableSummary.total])
const filteredItems = useMemo(
() => countableItems.filter((item) => matchesProcessFilter(item, activeFilter)),
[activeFilter, countableItems],
)
const filteredSummary = activeFilter === 'all' ? countableSummary : summarize(filteredItems)
const activeLabel = PROCESS_FILTERS.find((filter) => filter.key === activeFilter)?.label || '全部流程'
return (
<section aria-labelledby="on-time-delivery-list-title" className="mt-10">
<div className="rounded-lg border border-[#e2e8f0] bg-white px-5 py-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-xs font-bold text-slate-500">统计公式</p>
<p className="mt-1 text-sm font-bold text-slate-900">
本月按时交付率 = 本月未超时审核节点完成流程数 ÷ 本月审核节点完成流程数 × 100%
</p>
</div>
<div className="flex flex-wrap gap-2" role="tablist" aria-label="流程类型筛选">
{PROCESS_FILTERS.map((filter) => {
const selected = filter.key === activeFilter
return (
<button
key={filter.key}
type="button"
role="tab"
aria-selected={selected}
aria-controls="on-time-delivery-results"
aria-label={`筛选${filter.label},共${counts[filter.key]}条`}
onClick={() => setActiveFilter(filter.key)}
className={`inline-flex min-h-11 items-center gap-2 rounded-lg border px-3.5 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9] focus-visible:ring-offset-2 ${selected
? 'border-[#9fcaf2] bg-[#e7f3ff] text-[#176fc1]'
: 'border-slate-200 bg-white text-slate-700 hover:bg-slate-100'
}`}
>
<span>{filter.label}</span>
<span className={`rounded-md px-1.5 py-0.5 text-xs tabular-nums ${selected ? 'bg-white/80 text-[#176fc1]' : 'bg-slate-100 text-slate-500'}`}>
{counts[filter.key]}
</span>
</button>
)
})}
</div>
</div>
<p className="mt-3 text-xs tabular-nums text-slate-500" aria-live="polite">
当前口径:{filteredSummary.onTime} ÷ {filteredSummary.total} = {formatRate(filteredSummary.rate)} · 超时 {filteredSummary.overtime}
</p>
</div>
<div className="mt-8 flex flex-wrap items-end justify-between gap-3">
<div>
<h2 id="on-time-delivery-list-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">
仅统计对应审核节点在本月完成、审批状态为已完结且未被拒绝的流程;待计算流程不计入分母。美术按“品牌策划主管分配任务”至“品牌策划主管审核”计算,内容按外部看板规则计至“编导审核”或“内容主管审核”,并应用排队顺延后的起算时间。
</p>
</div>
<p className="text-sm font-semibold tabular-nums text-slate-600" aria-live="polite">{activeLabel} · 共 {filteredSummary.total} 条</p>
</div>
<div id="on-time-delivery-results" role="tabpanel">
{filteredItems.length === 0 ? (
<div role="status" className="mt-6 rounded-lg border border-[#e2e8f0] bg-white px-5 py-14 text-center">
<Database className="mx-auto h-8 w-8 text-slate-400" aria-hidden="true" />
<h3 className="mt-4 text-base font-bold text-slate-900">{activeLabel}本月暂无符合口径的已判定流程</h3>
<p className="mt-2 text-sm text-slate-600">仅当对应审核节点在本月完成,并且标准时效与实际用时均可计算时,流程才会进入本月交付时效统计。</p>
</div>
) : (
<div className="mt-6 overflow-x-auto rounded-lg border border-[#e2e8f0] bg-white">
<table aria-label={`${activeLabel}本月审核节点完成时效明细`} className="min-w-[1180px] w-full text-left text-sm">
<thead className="bg-slate-50 text-xs font-bold text-slate-500">
<tr>
<th scope="col" className="w-[25%] px-5 py-3.5">流程</th>
<th scope="col" className="w-[14%] px-4 py-3.5">发起人 / 部门</th>
<th scope="col" className="w-[22%] px-4 py-3.5">计时节点</th>
<th scope="col" className="w-[13%] px-4 py-3.5">标准时效</th>
<th scope="col" className="w-[13%] px-4 py-3.5">实际用时</th>
<th scope="col" className="w-[13%] px-5 py-3.5">判定</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{filteredItems.map((item) => (
<tr key={item.id} className="align-top transition-colors hover:bg-slate-50">
<td className="px-5 py-4">
<p className="font-bold text-slate-950">{item.title}</p>
<p className="mt-1 text-xs tabular-nums text-slate-500">{item.businessId || item.id}</p>
<p className="mt-1 text-xs font-semibold text-[#176fc1]">{item.processCode === CONTENT_PROCESS_CODE ? '内容视频流程' : item.projectType}</p>
</td>
<td className="px-4 py-4">
<p className="font-bold text-slate-900">{item.initiatorName}</p>
<p className="mt-1 leading-5 text-slate-500">{item.initiatorDepartment}</p>
</td>
<td className="px-4 py-4">
<p className="font-semibold text-slate-800">{item.assessment.startNodeLabel}</p>
<p className="mt-1 tabular-nums text-slate-500">{formatDate(item.assessment.startedAt)}</p>
<p className="mt-3 font-semibold text-slate-800">{item.assessment.endNodeLabel}</p>
<p className="mt-1 tabular-nums text-slate-500">{formatDate(item.assessment.finishedAt)}</p>
{item.assessment.queueDeferred && (
<p className="mt-2 text-xs font-semibold text-amber-700">
已按排队规则顺延 {item.assessment.queueShiftDays || 0} 天
</p>
)}
</td>
<td className="px-4 py-4 font-bold tabular-nums text-slate-900">
{formatDuration(item.assessment.standardDuration, item.assessment.durationUnit)}
</td>
<td className="px-4 py-4 font-bold tabular-nums text-slate-900">
{formatDuration(item.assessment.actualDuration, item.assessment.durationUnit)}
</td>
<td className="px-5 py-4">
<DeliveryStatusBadge status={item.assessment.status} />
{item.assessment.status === 'pending' && (
<p className="mt-2 max-w-40 text-xs leading-5 text-slate-500">缺少标准配置或关键节点时间</p>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
)
}