232 lines
12 KiB
TypeScript
232 lines
12 KiB
TypeScript
'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>
|
||
)
|
||
}
|