Merge remote-tracking branch 'origin/main' into codex/release-bom-costing-main-20260804
This commit is contained in:
commit
608da97538
@ -17,7 +17,12 @@ import {
|
||||
Sparkles,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { pickCurrentOverdueStageMetric } from '@/lib/oa-board-overtime'
|
||||
import {
|
||||
getOaBoardFollowupDeadlineStatus,
|
||||
listHistoricalOverdueStageMetrics,
|
||||
OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS,
|
||||
pickCurrentOverdueStageMetric,
|
||||
} from '@/lib/oa-board-overtime'
|
||||
import { OA_BOARD_DEFAULT_TRACKED_STATUSES } from '@/server/services/oa-board.constants'
|
||||
import type {
|
||||
OaBoardDashboardDTO,
|
||||
@ -139,6 +144,16 @@ type OvertimeStage = {
|
||||
overtimeDays: number
|
||||
}
|
||||
|
||||
type HistoricalOvertimeRecord = {
|
||||
id: string
|
||||
boardKey: string
|
||||
openingCode: string | null
|
||||
spu: string | null
|
||||
stage: OvertimeStage
|
||||
followup: OaBoardTimeoutFollowup | null
|
||||
item: OaBoardProjectListItemDTO | null
|
||||
}
|
||||
|
||||
type TimeoutFollowupSaveEntry = {
|
||||
stage: OvertimeStage
|
||||
draft: TimeoutFollowupDraft
|
||||
@ -752,6 +767,23 @@ function getOvertimeStages(item: OaBoardProjectListItemDTO): OvertimeStage[] {
|
||||
}]
|
||||
}
|
||||
|
||||
function getHistoricalOvertimeStages(item: OaBoardProjectListItemDTO): OvertimeStage[] {
|
||||
const currentStageNames = splitStageText(
|
||||
item.formSummary.approvalStageDisplay || item.currentApprovalStageName,
|
||||
)
|
||||
|
||||
return listHistoricalOverdueStageMetrics(currentStageNames, item.timelineMetrics).map((stage) => ({
|
||||
stageKey: stage.metricKey,
|
||||
stageLabel: stage.metricLabel,
|
||||
stageContextLabel: '历史超时',
|
||||
nodeName: '-',
|
||||
ownerLabel: '-',
|
||||
targetDays: stage.targetDays,
|
||||
actualDays: stage.actualDays,
|
||||
overtimeDays: stage.overtimeDays,
|
||||
}))
|
||||
}
|
||||
|
||||
function resolveTimeoutFollowupOwner(item: OaBoardProjectListItemDTO, stageKey: string) {
|
||||
const stage = getOvertimeStages(item).find((entry) => entry.stageKey === stageKey)
|
||||
if (stage?.ownerLabel?.trim()) return stage.ownerLabel.trim()
|
||||
@ -763,6 +795,20 @@ function getTimeoutFollowupStatusLabel(status: TimeoutFollowupStatus) {
|
||||
return TIMEOUT_FOLLOWUP_STATUS_OPTIONS.find((option) => option.value === status)?.label ?? '待填写'
|
||||
}
|
||||
|
||||
function getFollowupDeadlineStatus(followup: OaBoardTimeoutFollowup | null | undefined) {
|
||||
if (!followup) return null
|
||||
|
||||
return getOaBoardFollowupDeadlineStatus(
|
||||
followup.expectedResolveDate,
|
||||
followup.status === 'RESOLVED' || followup.status === 'NOT_REQUIRED',
|
||||
)
|
||||
}
|
||||
|
||||
function getFollowupDeadlineLabel(status: ReturnType<typeof getOaBoardFollowupDeadlineStatus>) {
|
||||
if (!status) return null
|
||||
return status.state === 'DUE_TODAY' ? '今日到期' : `已逾期 ${status.overdueDays} 天`
|
||||
}
|
||||
|
||||
function createTimeoutFollowupDraft(
|
||||
followup: OaBoardTimeoutFollowup | undefined,
|
||||
defaultOwner: string,
|
||||
@ -791,22 +837,103 @@ function buildHistoricalFollowupStage(followup: OaBoardTimeoutFollowup): Overtim
|
||||
|
||||
function getFollowupDisplayStages(
|
||||
currentOvertimeStages: OvertimeStage[],
|
||||
historicalOvertimeStages: OvertimeStage[],
|
||||
followups: OaBoardTimeoutFollowup[],
|
||||
) {
|
||||
const currentStageKeys = new Set(currentOvertimeStages.map((stage) => stage.stageKey))
|
||||
const historicalStages = followups
|
||||
.filter((followup) => !currentStageKeys.has(followup.stageKey))
|
||||
.map(buildHistoricalFollowupStage)
|
||||
const stagesByKey = new Map(currentOvertimeStages.map((stage) => [stage.stageKey, stage]))
|
||||
|
||||
return [...currentOvertimeStages, ...historicalStages]
|
||||
historicalOvertimeStages.forEach((stage) => {
|
||||
if (!stagesByKey.has(stage.stageKey)) stagesByKey.set(stage.stageKey, stage)
|
||||
})
|
||||
|
||||
followups.forEach((followup) => {
|
||||
if (!stagesByKey.has(followup.stageKey)) {
|
||||
stagesByKey.set(followup.stageKey, buildHistoricalFollowupStage(followup))
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(stagesByKey.values())
|
||||
}
|
||||
|
||||
function buildHistoricalOvertimeRecords(
|
||||
items: OaBoardProjectListItemDTO[],
|
||||
followups: OaBoardTimeoutFollowup[],
|
||||
): HistoricalOvertimeRecord[] {
|
||||
const itemByBoardKey = new Map(items.map((item) => [item.boardKey, item]))
|
||||
const followupByStageKey = new Map(
|
||||
followups.map((followup) => [`${followup.boardKey}:${followup.stageKey}`, followup]),
|
||||
)
|
||||
const records = new Map<string, HistoricalOvertimeRecord>()
|
||||
|
||||
items.forEach((item) => {
|
||||
getHistoricalOvertimeStages(item).forEach((stage) => {
|
||||
const id = `${item.boardKey}:${stage.stageKey}`
|
||||
records.set(id, {
|
||||
id,
|
||||
boardKey: item.boardKey,
|
||||
openingCode: item.formSummary.openingCode || item.openingCode || null,
|
||||
spu: item.formSummary.spu || item.spu || null,
|
||||
stage,
|
||||
followup: followupByStageKey.get(id) ?? null,
|
||||
item,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
followups.forEach((followup) => {
|
||||
const id = `${followup.boardKey}:${followup.stageKey}`
|
||||
if (records.has(id)) return
|
||||
|
||||
const item = itemByBoardKey.get(followup.boardKey)
|
||||
const currentMetricKeys = new Set(
|
||||
splitStageText(item?.formSummary.approvalStageDisplay || item?.currentApprovalStageName)
|
||||
.map((stageName) => OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS[stageName])
|
||||
.filter((metricKey): metricKey is string => Boolean(metricKey)),
|
||||
)
|
||||
const currentStageKeys = new Set(item ? getOvertimeStages(item).map((stage) => stage.stageKey) : [])
|
||||
if (currentStageKeys.has(followup.stageKey) || currentMetricKeys.has(followup.stageKey)) return
|
||||
|
||||
records.set(id, {
|
||||
id,
|
||||
boardKey: followup.boardKey,
|
||||
openingCode: followup.openingCode,
|
||||
spu: followup.spu,
|
||||
stage: buildHistoricalFollowupStage(followup),
|
||||
followup,
|
||||
item: item ?? null,
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(records.values()).sort((left, right) => {
|
||||
const leftUpdatedAt = left.followup?.updatedAt ?? ''
|
||||
const rightUpdatedAt = right.followup?.updatedAt ?? ''
|
||||
if (leftUpdatedAt !== rightUpdatedAt) return rightUpdatedAt.localeCompare(leftUpdatedAt)
|
||||
return right.stage.overtimeDays - left.stage.overtimeDays
|
||||
})
|
||||
}
|
||||
|
||||
function getTimeoutFollowupSummary(
|
||||
overtimeStages: OvertimeStage[],
|
||||
followups: OaBoardTimeoutFollowup[],
|
||||
) {
|
||||
const relevantFollowups =
|
||||
overtimeStages.length === 0
|
||||
? followups
|
||||
: followups.filter((followup) =>
|
||||
overtimeStages.some((stage) => stage.stageKey === followup.stageKey),
|
||||
)
|
||||
const allFollowupDeadlineStatuses = relevantFollowups
|
||||
.map((followup) => getFollowupDeadlineStatus(followup))
|
||||
.filter((status): status is NonNullable<typeof status> => Boolean(status))
|
||||
|
||||
if (overtimeStages.length === 0) {
|
||||
if (followups.length === 0) return null
|
||||
if (allFollowupDeadlineStatuses.length > 0) {
|
||||
return {
|
||||
label: `${allFollowupDeadlineStatuses.length} 项跟进到期`,
|
||||
tone: 'danger' as const,
|
||||
}
|
||||
}
|
||||
return { label: `${followups.length} 项历史跟进`, tone: 'default' as const }
|
||||
}
|
||||
|
||||
@ -818,6 +945,12 @@ function getTimeoutFollowupSummary(
|
||||
(record) => record.status === 'RESOLVED' || record.status === 'NOT_REQUIRED',
|
||||
).length
|
||||
|
||||
if (allFollowupDeadlineStatuses.length > 0) {
|
||||
return {
|
||||
label: `${allFollowupDeadlineStatuses.length} 项跟进到期`,
|
||||
tone: 'danger' as const,
|
||||
}
|
||||
}
|
||||
if (pending > 0) return { label: `${pending} 项待填写`, tone: 'danger' as const }
|
||||
if (inProgress > 0) return { label: `${inProgress} 项处理中`, tone: 'warning' as const }
|
||||
if (resolved === overtimeStages.length) return { label: '已完成跟进', tone: 'success' as const }
|
||||
@ -1263,57 +1396,71 @@ function HistoricalTimeoutAnalysis({
|
||||
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 historicalRecords = useMemo(
|
||||
() => buildHistoricalOvertimeRecords(items, followups),
|
||||
[followups, items],
|
||||
)
|
||||
const stageOptions = useMemo(
|
||||
() => buildOptionList(followups.map((followup) => followup.stageLabel), '全部历史阶段'),
|
||||
[followups],
|
||||
() => buildOptionList(historicalRecords.map((record) => record.stage.stageLabel), '全部历史阶段'),
|
||||
[historicalRecords],
|
||||
)
|
||||
const ownerOptions = useMemo(
|
||||
() => buildOptionList(followups.map((followup) => followup.followUpOwner), '全部跟进人'),
|
||||
[followups],
|
||||
() => buildOptionList(historicalRecords.map((record) => record.followup?.followUpOwner), '全部跟进人'),
|
||||
[historicalRecords],
|
||||
)
|
||||
const statusOptions = useMemo(
|
||||
() => [{ value: '', label: '全部跟进状态' }, ...TIMEOUT_FOLLOWUP_STATUS_OPTIONS],
|
||||
() => [
|
||||
{ value: '', label: '全部跟进状态' },
|
||||
{ value: 'UNRECORDED', label: '未填写跟进' },
|
||||
{ value: 'DEADLINE_DUE', label: '跟进已到期' },
|
||||
...TIMEOUT_FOLLOWUP_STATUS_OPTIONS,
|
||||
],
|
||||
[],
|
||||
)
|
||||
const filteredFollowups = useMemo(
|
||||
const filteredRecords = 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
|
||||
historicalRecords.filter((record) => {
|
||||
const updatedDate = String(record.followup?.updatedAt ?? '').slice(0, 10)
|
||||
if (statusFilter === 'UNRECORDED' && record.followup) return false
|
||||
if (statusFilter === 'DEADLINE_DUE' && !getFollowupDeadlineStatus(record.followup)) return false
|
||||
if (
|
||||
statusFilter &&
|
||||
statusFilter !== 'UNRECORDED' &&
|
||||
statusFilter !== 'DEADLINE_DUE' &&
|
||||
record.followup?.status !== statusFilter
|
||||
) return false
|
||||
if (stageFilter && record.stage.stageLabel !== stageFilter) return false
|
||||
if (ownerFilter && record.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],
|
||||
[historicalRecords, ownerFilter, stageFilter, statusFilter, updatedDateEnd, updatedDateStart],
|
||||
)
|
||||
const historySummary = useMemo(() => {
|
||||
const totalOvertimeDays = filteredFollowups.reduce(
|
||||
(total, followup) => total + followup.overtimeDays,
|
||||
const totalOvertimeDays = filteredRecords.reduce(
|
||||
(total, record) => total + record.stage.overtimeDays,
|
||||
0,
|
||||
)
|
||||
const pending = filteredFollowups.filter(
|
||||
(followup) => followup.status === 'PENDING' || followup.status === 'IN_PROGRESS',
|
||||
const pending = filteredRecords.filter(
|
||||
(record) => !record.followup || record.followup.status === 'PENDING' || record.followup.status === 'IN_PROGRESS',
|
||||
).length
|
||||
const resolved = filteredFollowups.filter(
|
||||
(followup) => followup.status === 'RESOLVED' || followup.status === 'NOT_REQUIRED',
|
||||
const resolved = filteredRecords.filter(
|
||||
(record) => record.followup?.status === 'RESOLVED' || record.followup?.status === 'NOT_REQUIRED',
|
||||
).length
|
||||
const due = filteredRecords.filter((record) => Boolean(getFollowupDeadlineStatus(record.followup))).length
|
||||
|
||||
return {
|
||||
total: filteredFollowups.length,
|
||||
total: filteredRecords.length,
|
||||
pending,
|
||||
resolved,
|
||||
due,
|
||||
averageOvertimeDays:
|
||||
filteredFollowups.length > 0
|
||||
? Number((totalOvertimeDays / filteredFollowups.length).toFixed(1))
|
||||
filteredRecords.length > 0
|
||||
? Number((totalOvertimeDays / filteredRecords.length).toFixed(1))
|
||||
: 0,
|
||||
}
|
||||
}, [filteredFollowups])
|
||||
}, [filteredRecords])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto pb-1">
|
||||
@ -1327,9 +1474,9 @@ function HistoricalTimeoutAnalysis({
|
||||
<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="grid divide-y divide-apple-separator sm:grid-cols-2 sm:divide-x sm:divide-y-0 lg:grid-cols-5">
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-[11px] text-apple-label-secondary">历史跟进项</p>
|
||||
<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">
|
||||
@ -1340,6 +1487,10 @@ function HistoricalTimeoutAnalysis({
|
||||
<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-rose-700">{historySummary.due}</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">
|
||||
@ -1353,11 +1504,11 @@ function HistoricalTimeoutAnalysis({
|
||||
<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>
|
||||
<p className="mt-1 text-xs text-apple-label-secondary">自动保留已发生的超时;仅当前阶段参与当前超时统计和钉钉待办发送。</p>
|
||||
</div>
|
||||
<span className="text-xs text-apple-label-secondary">{filteredFollowups.length} 条记录</span>
|
||||
<span className="text-xs text-apple-label-secondary">{filteredRecords.length} 条记录</span>
|
||||
</header>
|
||||
{filteredFollowups.length === 0 ? (
|
||||
{filteredRecords.length === 0 ? (
|
||||
<div className="px-5 py-10 text-center text-sm text-apple-label-secondary">当前筛选范围内没有历史跟进记录</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
@ -1377,23 +1528,38 @@ function HistoricalTimeoutAnalysis({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredFollowups.map((followup) => {
|
||||
const item = itemByBoardKey.get(followup.boardKey)
|
||||
{filteredRecords.map((record) => {
|
||||
const followup = record.followup
|
||||
const deadlineStatus = getFollowupDeadlineStatus(followup)
|
||||
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>
|
||||
<tr key={record.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(record.openingCode)}</td>
|
||||
<td className="px-5 py-3 text-apple-label-primary">{record.stage.stageLabel}</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">
|
||||
查看记录
|
||||
<div>
|
||||
<StatusBadge label={followup ? getTimeoutFollowupStatusLabel(followup.status) : '未填写跟进'} tone={followup?.status === 'PENDING' ? 'danger' : followup?.status === 'IN_PROGRESS' ? 'warning' : followup ? 'success' : 'default'} />
|
||||
</div>
|
||||
{deadlineStatus ? (
|
||||
<span className="mt-1 block text-[10px] font-apple-semibold text-rose-700">
|
||||
{getFollowupDeadlineLabel(deadlineStatus)}
|
||||
</span>
|
||||
) : null}
|
||||
</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">{record.stage.targetDays} 天</td>
|
||||
<td className="px-5 py-3 text-apple-label-primary">{record.stage.actualDays} 天</td>
|
||||
<td className="px-5 py-3 text-rose-700">{record.stage.overtimeDays} 天</td>
|
||||
<td className={`px-5 py-3 ${deadlineStatus ? 'font-apple-semibold text-rose-700' : 'text-apple-label-primary'}`}>
|
||||
{formatCellText(followup?.expectedResolveDate)}
|
||||
{deadlineStatus ? (
|
||||
<span className="mt-1 block text-[10px]">{getFollowupDeadlineLabel(deadlineStatus)}</span>
|
||||
) : null}
|
||||
</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">
|
||||
{record.item ? (
|
||||
<button type="button" onClick={() => onOpenFollowup(record.item as OaBoardProjectListItemDTO)} 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>
|
||||
@ -1455,7 +1621,12 @@ function TimeoutFollowupInspector({
|
||||
onSave: (entries: TimeoutFollowupSaveEntry[]) => Promise<boolean>
|
||||
}) {
|
||||
const currentOvertimeStages = getOvertimeStages(item)
|
||||
const overtimeStages = getFollowupDisplayStages(currentOvertimeStages, followups)
|
||||
const historicalOvertimeStages = getHistoricalOvertimeStages(item)
|
||||
const overtimeStages = getFollowupDisplayStages(
|
||||
currentOvertimeStages,
|
||||
historicalOvertimeStages,
|
||||
followups,
|
||||
)
|
||||
const currentOvertimeStageKeys = new Set(currentOvertimeStages.map((stage) => stage.stageKey))
|
||||
const [selectedStageKey, setSelectedStageKey] = useState(overtimeStages[0]?.stageKey ?? '')
|
||||
const selectedStage =
|
||||
@ -1463,6 +1634,7 @@ function TimeoutFollowupInspector({
|
||||
const selectedFollowup = selectedStage
|
||||
? followups.find((followup) => followup.stageKey === selectedStage.stageKey)
|
||||
: undefined
|
||||
const selectedFollowupDeadlineStatus = getFollowupDeadlineStatus(selectedFollowup)
|
||||
const [draftsByStageKey, setDraftsByStageKey] = useState<Record<string, TimeoutFollowupDraft>>(() =>
|
||||
Object.fromEntries(
|
||||
overtimeStages.map((stage) => [
|
||||
@ -1555,6 +1727,7 @@ function TimeoutFollowupInspector({
|
||||
<div className="flex w-max gap-2">
|
||||
{overtimeStages.map((stage) => {
|
||||
const record = followups.find((followup) => followup.stageKey === stage.stageKey)
|
||||
const deadlineStatus = getFollowupDeadlineStatus(record)
|
||||
const active = selectedStage.stageKey === stage.stageKey
|
||||
const isDirty = dirtyStageKeys.has(stage.stageKey)
|
||||
const isHistorical = !currentOvertimeStageKeys.has(stage.stageKey)
|
||||
@ -1575,6 +1748,11 @@ function TimeoutFollowupInspector({
|
||||
<span className={`mt-1 text-[10px] ${active ? 'text-white/70' : 'text-apple-label-secondary'}`}>
|
||||
{isHistorical ? '历史记录' : isDirty ? '待保存' : record ? getTimeoutFollowupStatusLabel(record.status) : '待跟进'}
|
||||
</span>
|
||||
{deadlineStatus ? (
|
||||
<span className={`mt-1 text-[10px] font-apple-semibold ${active ? 'text-rose-200' : 'text-rose-700'}`}>
|
||||
{getFollowupDeadlineLabel(deadlineStatus)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@ -1599,7 +1777,10 @@ function TimeoutFollowupInspector({
|
||||
|
||||
{isHistoricalStage ? (
|
||||
<section className="mt-6 border border-apple-separator bg-[#fafbfc] p-4">
|
||||
<p className="text-xs text-apple-label-secondary">该节点已不在当前超时阶段,以下为已保存的历史跟进内容。</p>
|
||||
<p className="text-xs text-apple-label-secondary">
|
||||
该阶段已离开当前节点,因此只作为历史超时保留,不会再触发钉钉待办。
|
||||
{selectedFollowup ? ' 以下为已保存的历史跟进内容。' : ' 该阶段尚未填写历史跟进记录。'}
|
||||
</p>
|
||||
<dl className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-[11px] font-apple-medium text-apple-label-secondary">跟进状态</dt>
|
||||
@ -1611,7 +1792,12 @@ function TimeoutFollowupInspector({
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[11px] font-apple-medium text-apple-label-secondary">预计完成日期</dt>
|
||||
<dd className="mt-1 text-sm text-apple-label-primary">{formatCellText(selectedFollowup?.expectedResolveDate)}</dd>
|
||||
<dd className={`mt-1 text-sm ${selectedFollowupDeadlineStatus ? 'font-apple-semibold text-rose-700' : 'text-apple-label-primary'}`}>
|
||||
{formatCellText(selectedFollowup?.expectedResolveDate)}
|
||||
{selectedFollowupDeadlineStatus ? (
|
||||
<span className="ml-2 text-xs">{getFollowupDeadlineLabel(selectedFollowupDeadlineStatus)}</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="mt-5 border-t border-apple-separator pt-4">
|
||||
@ -1656,6 +1842,11 @@ function TimeoutFollowupInspector({
|
||||
onChange={(value) => updateDraft((current) => ({ ...current, expectedResolveDate: value }))}
|
||||
/>
|
||||
</div>
|
||||
{selectedFollowupDeadlineStatus ? (
|
||||
<div className="mt-3 border border-rose-200 bg-rose-50 px-3 py-2 text-xs font-apple-semibold text-rose-700">
|
||||
{getFollowupDeadlineLabel(selectedFollowupDeadlineStatus)},请更新跟进状态或调整预计完成日期。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="mt-5 flex flex-col gap-1.5">
|
||||
<span className="text-[11px] font-apple-medium text-apple-label-secondary">超时原因说明</span>
|
||||
|
||||
51
furniture-3d-system/src/lib/oa-board-overtime.test.ts
Normal file
51
furniture-3d-system/src/lib/oa-board-overtime.test.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import {
|
||||
getOaBoardFollowupDeadlineStatus,
|
||||
listHistoricalOverdueStageMetrics,
|
||||
pickCurrentOverdueStageMetric,
|
||||
} from './oa-board-overtime'
|
||||
|
||||
describe('OA 看板时效归类', () => {
|
||||
const timelineMetrics = [
|
||||
{ key: 'exterior', label: '外观', targetDays: 3, actualDays: 3.98 },
|
||||
{ key: 'sample_prepare', label: '打样', targetDays: 8, actualDays: 11.04 },
|
||||
]
|
||||
|
||||
it('将已离开的超时阶段保留为历史记录', () => {
|
||||
expect(listHistoricalOverdueStageMetrics(['产品打样'], timelineMetrics)).toEqual([
|
||||
{
|
||||
stageName: '外观',
|
||||
metricKey: 'exterior',
|
||||
metricLabel: '外观',
|
||||
targetDays: 3,
|
||||
actualDays: 3.98,
|
||||
overtimeDays: 0.98,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('只将当前阶段用于当前超时判断', () => {
|
||||
expect(pickCurrentOverdueStageMetric(['产品打样'], timelineMetrics)).toEqual({
|
||||
stageName: '产品打样',
|
||||
metricKey: 'sample_prepare',
|
||||
metricLabel: '打样',
|
||||
targetDays: 8,
|
||||
actualDays: 11.04,
|
||||
overtimeDays: 3.04,
|
||||
})
|
||||
})
|
||||
|
||||
it('仅标记计划日期已到且尚未完成的跟进', () => {
|
||||
const today = new Date(2026, 7, 4)
|
||||
|
||||
expect(getOaBoardFollowupDeadlineStatus('2026-08-03', false, today)).toEqual({
|
||||
state: 'OVERDUE',
|
||||
overdueDays: 1,
|
||||
})
|
||||
expect(getOaBoardFollowupDeadlineStatus('2026-08-04', false, today)).toEqual({
|
||||
state: 'DUE_TODAY',
|
||||
overdueDays: 0,
|
||||
})
|
||||
expect(getOaBoardFollowupDeadlineStatus('2026-08-04', true, today)).toBeNull()
|
||||
expect(getOaBoardFollowupDeadlineStatus('2026-02-30', false, today)).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -14,7 +14,57 @@ export type OaBoardCurrentStageOvertime = {
|
||||
overtimeDays: number
|
||||
}
|
||||
|
||||
const APPROVAL_STAGE_TIMELINE_METRIC_KEYS: Record<string, string> = {
|
||||
export type OaBoardFollowupDeadlineStatus = {
|
||||
state: 'DUE_TODAY' | 'OVERDUE'
|
||||
overdueDays: number
|
||||
}
|
||||
|
||||
function toDateTimestamp(value: string) {
|
||||
const matched = value.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
||||
if (!matched) return null
|
||||
|
||||
const [, year, month, day] = matched
|
||||
const normalizedYear = Number(year)
|
||||
const normalizedMonth = Number(month)
|
||||
const normalizedDay = Number(day)
|
||||
const timestamp = Date.UTC(normalizedYear, normalizedMonth - 1, normalizedDay)
|
||||
const parsedDate = new Date(timestamp)
|
||||
|
||||
if (
|
||||
!Number.isFinite(timestamp) ||
|
||||
parsedDate.getUTCFullYear() !== normalizedYear ||
|
||||
parsedDate.getUTCMonth() !== normalizedMonth - 1 ||
|
||||
parsedDate.getUTCDate() !== normalizedDay
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return timestamp
|
||||
}
|
||||
|
||||
export function getOaBoardFollowupDeadlineStatus(
|
||||
expectedResolveDate: string | null | undefined,
|
||||
isCompleted: boolean,
|
||||
referenceDate = new Date(),
|
||||
): OaBoardFollowupDeadlineStatus | null {
|
||||
const expectedTimestamp = toDateTimestamp(String(expectedResolveDate ?? '').trim())
|
||||
if (isCompleted || expectedTimestamp === null) return null
|
||||
|
||||
const todayTimestamp = Date.UTC(
|
||||
referenceDate.getFullYear(),
|
||||
referenceDate.getMonth(),
|
||||
referenceDate.getDate(),
|
||||
)
|
||||
const overdueDays = Math.round((todayTimestamp - expectedTimestamp) / 86400000)
|
||||
if (overdueDays < 0) return null
|
||||
|
||||
return {
|
||||
state: overdueDays === 0 ? 'DUE_TODAY' : 'OVERDUE',
|
||||
overdueDays,
|
||||
}
|
||||
}
|
||||
|
||||
export const OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS: Record<string, string> = {
|
||||
'产品设计-外观': 'exterior',
|
||||
'产品设计-结构': 'structure',
|
||||
产品打样: 'sample_prepare',
|
||||
@ -36,13 +86,48 @@ const APPROVAL_STAGE_TIMELINE_METRIC_KEYS: Record<string, string> = {
|
||||
运营上架: 'listing',
|
||||
}
|
||||
|
||||
export function listHistoricalOverdueStageMetrics(
|
||||
currentStageNames: readonly string[],
|
||||
timelineMetrics: readonly OaBoardTimelineMetricLike[],
|
||||
): OaBoardCurrentStageOvertime[] {
|
||||
const currentMetricKeys = new Set(
|
||||
currentStageNames
|
||||
.map((stageName) => OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS[stageName])
|
||||
.filter((metricKey): metricKey is string => Boolean(metricKey)),
|
||||
)
|
||||
const trackedMetricKeys = new Set(Object.values(OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS))
|
||||
|
||||
return timelineMetrics.flatMap((metric) => {
|
||||
if (
|
||||
!trackedMetricKeys.has(metric.key) ||
|
||||
currentMetricKeys.has(metric.key) ||
|
||||
metric.targetDays === null ||
|
||||
metric.actualDays === null
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const overtimeDays = Number((metric.actualDays - metric.targetDays).toFixed(2))
|
||||
if (overtimeDays <= 0) return []
|
||||
|
||||
return [{
|
||||
stageName: metric.label,
|
||||
metricKey: metric.key,
|
||||
metricLabel: metric.label,
|
||||
targetDays: metric.targetDays,
|
||||
actualDays: metric.actualDays,
|
||||
overtimeDays,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
export function pickCurrentOverdueStageMetric(
|
||||
stageNames: readonly string[],
|
||||
timelineMetrics: readonly OaBoardTimelineMetricLike[],
|
||||
): OaBoardCurrentStageOvertime | null {
|
||||
const metricsByKey = new Map(timelineMetrics.map((metric) => [metric.key, metric]))
|
||||
const candidates = stageNames.flatMap((stageName) => {
|
||||
const metricKey = APPROVAL_STAGE_TIMELINE_METRIC_KEYS[stageName]
|
||||
const metricKey = OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS[stageName]
|
||||
const metric = metricKey ? metricsByKey.get(metricKey) : null
|
||||
if (!metric || metric.targetDays === null || metric.actualDays === null) return []
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user