feat(supply-chain): refine timing hover summaries

This commit is contained in:
jingchuan 2026-08-19 13:03:50 +08:00
parent 94f754c80b
commit 1f9293cc42

View File

@ -29,6 +29,7 @@ import {
calculateProcurementActualDays,
summarizeCompletedDepartmentTimings,
type CompletedDepartmentTimingSummary,
type DepartmentTimingStageKey,
} from "@/lib/new-product-department-timing";
import type {
OaBoardDashboardDTO,
@ -112,6 +113,13 @@ type TimingDetailRow = {
pricing: TimingStageValue;
};
type TimingHoverPreviewRow = {
openingCode: string;
productName: string;
stage?: string;
metric?: string;
};
type TimingOverview = {
completedAverageDays: number;
completedDepartments: CompletedDepartmentTimingSummary[];
@ -157,6 +165,121 @@ type RunningTimingDetailFilter =
}
| null;
const DEPARTMENT_TIMING_STAGES: Record<
CompletedDepartmentTimingSummary["key"],
Array<{ key: DepartmentTimingStageKey; label: string }>
> = {
planning: [
{ key: "requirementReview", label: "需求评审" },
{ key: "pricing", label: "定价" },
],
product: [
{ key: "exterior", label: "外观" },
{ key: "structure", label: "结构" },
],
procurement: [{ key: "procurement", label: "采购链路" }],
visual: [{ key: "visual", label: "视觉" }],
};
function getDepartmentOvertimePreview(
row: TimingDetailRow,
departmentKey: CompletedDepartmentTimingSummary["key"],
) {
const bottleneck = DEPARTMENT_TIMING_STAGES[departmentKey]
.map((stage) => ({ label: stage.label, value: row[stage.key] }))
.filter((stage) => (stage.value.overtime ?? 0) > 0)
.sort(
(left, right) =>
(right.value.overtime ?? 0) - (left.value.overtime ?? 0),
)[0];
return {
stage: bottleneck?.label ?? "部门链路",
metric: bottleneck ? `超 ${bottleneck.value.overtime} 天` : "已超时",
overtimeDays: bottleneck?.value.overtime ?? 0,
};
}
function getPrimaryOvertimePreview(row: TimingDetailRow) {
const bottleneck = Object.values(DEPARTMENT_TIMING_STAGES)
.flat()
.map((stage) => ({ label: stage.label, value: row[stage.key] }))
.filter((stage) => (stage.value.overtime ?? 0) > 0)
.sort(
(left, right) =>
(right.value.overtime ?? 0) - (left.value.overtime ?? 0),
)[0];
return {
stage: bottleneck?.label ?? "总周期",
metric: bottleneck ? `超 ${bottleneck.value.overtime} 天` : "已超时",
};
}
function buildDepartmentHoverPreviewRows(
rows: TimingDetailRow[],
department: CompletedDepartmentTimingSummary,
): TimingHoverPreviewRow[] {
return department.overtimeRowIds
.flatMap((rowId) => {
const row = rows.find((candidate) => candidate.id === rowId);
if (!row) return [];
return [
{
row,
...getDepartmentOvertimePreview(row, department.key),
},
];
})
.sort((left, right) => right.overtimeDays - left.overtimeDays)
.slice(0, 3)
.map(({ row, stage, metric }) => ({
openingCode: row.openingCode,
productName: row.productName,
stage,
metric,
}));
}
function getDepartmentHoverConclusion(
department: CompletedDepartmentTimingSummary,
) {
const primaryPart = [...department.overtimeParts].sort(
(left, right) => right.count - left.count,
)[0];
return primaryPart?.count
? `主要卡点:${primaryPart.label} ${primaryPart.count} 款 · 统计范围:${department.definition}`
: `统计范围:${department.definition} · 暂无超时`;
}
function buildNearOvertimeHoverPreviewRows(
rows: TimingDetailRow[],
rowIds: string[],
): TimingHoverPreviewRow[] {
return rowIds
.flatMap((rowId) => {
const row = rows.find((candidate) => candidate.id === rowId);
if (!row) return [];
return [
{
row,
remaining: Math.max(
0,
(row.standardDays ?? 0) - (row.totalActualDays ?? 0),
),
},
];
})
.sort((left, right) => left.remaining - right.remaining)
.slice(0, 3)
.map(({ row, remaining }) => ({
openingCode: row.openingCode,
productName: row.productName,
stage: "总周期",
metric: `剩 ${remaining} 天`,
}));
}
type BaseAnalysisDatum = (typeof ANALYSIS_DATA)[Exclude<
AnalysisView,
"overview"
@ -1956,7 +2079,7 @@ function EfficiencyCard({
tooltipTitle: string;
tooltipDetail: string;
tone: "good" | "danger";
previewRows?: Array<{ openingCode: string; productName: string }>;
previewRows?: TimingHoverPreviewRow[];
actionLabel?: string;
active?: boolean;
onSelect?: () => void;
@ -1965,6 +2088,7 @@ function EfficiencyCard({
title: string;
detail: string;
lines?: string[];
previewRows?: TimingHoverPreviewRow[];
actionLabel?: string;
};
active?: boolean;
@ -2027,32 +2151,53 @@ function EfficiencyCard({
role="tooltip"
className={`pointer-events-none absolute bottom-[calc(100%+7px)] z-40 hidden w-[min(300px,calc(100vw-32px))] rounded-md border border-black/[0.08] bg-[#182225] px-3 py-2.5 text-left text-white shadow-[0_10px_28px_rgba(16,28,31,0.22)] group-hover/dept:block group-focus/dept:block ${tooltipAlignment}`}
>
<span className="block text-[11px] font-semibold leading-4">
{item.tooltipTitle}
<span className="flex items-start justify-between gap-3">
<span className="block text-[11px] font-semibold leading-4">
{item.tooltipTitle}
</span>
{item.previewRows && item.previewRows.length > 1 ? (
<span className="shrink-0 rounded bg-white/10 px-1.5 py-0.5 text-[9px] font-medium text-white/65">
TOP 3
</span>
) : null}
</span>
<span className="mt-1 block text-[10px] font-normal leading-4 text-white/70">
<span className="mt-1.5 block rounded bg-white/[0.06] px-2 py-1.5 text-[10px] font-normal leading-4 text-white/75">
{item.tooltipDetail}
</span>
{item.previewRows?.length ? (
<span className="mt-2 block border-t border-white/15 pt-1.5">
<span className="grid grid-cols-[minmax(0,1fr)_58px_48px] gap-2 pb-1 text-[9px] text-white/40">
<span>开款 / 产品</span>
<span>主要卡点</span>
<span className="text-right">时效</span>
</span>
{item.previewRows.map((row) => (
<span
key={row.openingCode}
className="flex items-center gap-2 py-0.5 text-[10px] leading-4"
className="grid grid-cols-[minmax(0,1fr)_58px_48px] items-center gap-2 border-t border-white/[0.07] py-1.5 text-[10px] leading-4"
>
<span className="shrink-0 font-semibold text-white">
{row.openingCode}
<span className="min-w-0">
<span className="block truncate font-semibold text-white">
{row.openingCode}
</span>
<span className="block truncate text-[9px] text-white/45">
{row.productName}
</span>
</span>
<span className="min-w-0 truncate text-white/65">
{row.productName}
<span className="truncate text-white/65">
{row.stage ?? "-"}
</span>
<span className="text-right font-semibold text-[#ffb5aa]">
{row.metric ?? "-"}
</span>
</span>
))}
</span>
) : null}
{item.actionLabel ? (
<span className="mt-1.5 block text-[10px] font-semibold text-[#8fd4c2]">
{item.actionLabel}
<span className="mt-2 flex items-center justify-between border-t border-white/15 pt-2 text-[10px] font-semibold text-[#8fd4c2]">
<span>{item.actionLabel}</span>
<ArrowUpRight className="h-3 w-3" />
</span>
) : null}
</span>
@ -2087,10 +2232,17 @@ function EfficiencyCard({
role="tooltip"
className="pointer-events-none absolute bottom-[calc(100%+8px)] right-0 z-50 hidden w-[min(320px,calc(100vw-32px))] rounded-md border border-black/[0.08] bg-[#182225] px-3 py-2.5 text-left text-white shadow-[0_10px_28px_rgba(16,28,31,0.22)] group-hover/card:block group-focus/card:block"
>
<span className="block text-[11px] font-semibold leading-4">
{hoverDetail.title}
<span className="flex items-start justify-between gap-3">
<span className="block text-[11px] font-semibold leading-4">
{hoverDetail.title}
</span>
{hoverDetail.previewRows && hoverDetail.previewRows.length > 1 ? (
<span className="shrink-0 rounded bg-white/10 px-1.5 py-0.5 text-[9px] font-medium text-white/65">
TOP 3
</span>
) : null}
</span>
<span className="mt-1 block text-[10px] font-normal leading-4 text-white/70">
<span className="mt-1.5 block rounded bg-white/[0.06] px-2 py-1.5 text-[10px] font-normal leading-4 text-white/75">
{hoverDetail.detail}
</span>
{hoverDetail.lines?.length ? (
@ -2105,9 +2257,40 @@ function EfficiencyCard({
))}
</span>
) : null}
{hoverDetail.previewRows?.length ? (
<span className="mt-2 block border-t border-white/15 pt-1.5">
<span className="grid grid-cols-[minmax(0,1fr)_58px_48px] gap-2 pb-1 text-[9px] text-white/40">
<span>开款 / 产品</span>
<span>当前环节</span>
<span className="text-right">时效</span>
</span>
{hoverDetail.previewRows.map((row) => (
<span
key={`${row.openingCode}-${row.stage ?? ""}`}
className="grid grid-cols-[minmax(0,1fr)_58px_48px] items-center gap-2 border-t border-white/[0.07] py-1.5 text-[10px] leading-4"
>
<span className="min-w-0">
<span className="block truncate font-semibold text-white">
{row.openingCode}
</span>
<span className="block truncate text-[9px] text-white/45">
{row.productName}
</span>
</span>
<span className="truncate text-white/65">
{row.stage ?? "-"}
</span>
<span className="text-right font-semibold text-[#ffcf87]">
{row.metric ?? "-"}
</span>
</span>
))}
</span>
) : null}
{hoverDetail.actionLabel ? (
<span className="mt-1.5 block text-[10px] font-semibold text-[#8fd4c2]">
{hoverDetail.actionLabel}
<span className="mt-2 flex items-center justify-between border-t border-white/15 pt-2 text-[10px] font-semibold text-[#8fd4c2]">
<span>{hoverDetail.actionLabel}</span>
<ArrowUpRight className="h-3 w-3" />
</span>
) : null}
</span>
@ -3400,10 +3583,8 @@ export default function NewProductManagementDashboard({
(department) => ({
label: department.label,
value: `${department.overtimeCount}个`,
tooltipTitle: `${department.label}超时流程:${department.overtimeCount} 个`,
tooltipDetail: `在 ${timingOverview.completedOvertimeCount} 个周期超期流程中,${department.overtimeParts
.map((part) => `${part.label} ${part.count} 个`)
.join(";")};同一流程不重复计数`,
tooltipTitle: `${department.label} · ${department.overtimeCount} 款超时`,
tooltipDetail: getDepartmentHoverConclusion(department),
tone:
department.overtimeCount > 0
? ("danger" as const)
@ -3412,27 +3593,16 @@ export default function NewProductManagementDashboard({
timingDetailMode === "completed" &&
completedTimingFilter?.kind === "department" &&
completedTimingFilter.key === department.key,
previewRows: department.overtimeRowIds
.slice(0, 4)
.flatMap((rowId) => {
const row = timingDetails.completed.find(
(candidate) => candidate.id === rowId,
);
return row
? [
{
openingCode: row.openingCode,
productName: row.productName,
},
]
: [];
}),
previewRows: buildDepartmentHoverPreviewRows(
timingDetails.completed,
department,
),
actionLabel: department.overtimeCount
? timingDetailMode === "completed" &&
completedTimingFilter?.kind === "department" &&
completedTimingFilter.key === department.key
? "已展开,再次点击收起"
: `点击查看全部 ${department.overtimeCount} 个流程`
: `查看全部 ${department.overtimeCount} 款`
: undefined,
onSelect: department.overtimeCount
? () => {
@ -3496,11 +3666,19 @@ export default function NewProductManagementDashboard({
maxCompletedOvertimeRow &&
timingOverview.completedMaxOvertimeDays > 0
? {
title: `最大周期超期:${timingOverview.completedMaxOvertimeDays} 天`,
detail: `${maxCompletedOvertimeRow.openingCode} · ${maxCompletedOvertimeRow.productName}`,
title: `最大周期超期 · ${timingOverview.completedMaxOvertimeDays} 天`,
detail: `最终用时 ${maxCompletedOvertimeRow.totalActualDays ?? "-"} 天 · 标准 ${maxCompletedOvertimeRow.standardDays ?? "-"} 天 · ${maxCompletedOvertimeRow.finishedDate || "完成日期待补充"}`,
lines: [
`最终时效 ${maxCompletedOvertimeRow.totalActualDays ?? "-"} 天 / 标准 ${maxCompletedOvertimeRow.standardDays ?? "-"} 天`,
`完成日期 ${maxCompletedOvertimeRow.finishedDate || "-"}`,
"该款为当前已完成任务中周期超期最严重的一款",
],
previewRows: [
{
openingCode: maxCompletedOvertimeRow.openingCode,
productName: maxCompletedOvertimeRow.productName,
...getPrimaryOvertimePreview(
maxCompletedOvertimeRow,
),
},
],
actionLabel:
timingDetailMode === "completed" &&
@ -3600,10 +3778,8 @@ export default function NewProductManagementDashboard({
(department) => ({
label: department.label,
value: `${department.overtimeCount}个`,
tooltipTitle: `${department.label}超时流程:${department.overtimeCount} 个`,
tooltipDetail: `进行中流程中,${department.overtimeParts
.map((part) => `${part.label} ${part.count} 个`)
.join(";")};同一流程不重复计数`,
tooltipTitle: `${department.label} · ${department.overtimeCount} 款超时`,
tooltipDetail: getDepartmentHoverConclusion(department),
tone:
department.overtimeCount > 0
? ("danger" as const)
@ -3612,27 +3788,16 @@ export default function NewProductManagementDashboard({
timingDetailMode === "running" &&
runningTimingFilter?.kind === "department" &&
runningTimingFilter.key === department.key,
previewRows: department.overtimeRowIds
.slice(0, 4)
.flatMap((rowId) => {
const row = timingDetails.running.find(
(candidate) => candidate.id === rowId,
);
return row
? [
{
openingCode: row.openingCode,
productName: row.productName,
},
]
: [];
}),
previewRows: buildDepartmentHoverPreviewRows(
timingDetails.running,
department,
),
actionLabel: department.overtimeCount
? timingDetailMode === "running" &&
runningTimingFilter?.kind === "department" &&
runningTimingFilter.key === department.key
? "已展开,再次点击收起"
: `点击查看全部 ${department.overtimeCount} 个流程`
: `查看全部 ${department.overtimeCount} 款`
: undefined,
onSelect: department.overtimeCount
? () => {
@ -3667,28 +3832,17 @@ export default function NewProductManagementDashboard({
hoverDetail={
timingOverview.runningNearOvertimeCount > 0
? {
title: `临近超期任务:${timingOverview.runningNearOvertimeCount} 个`,
detail: `距该款总标准剩余 ${NEAR_OVERTIME_WINDOW_DAYS} 天内`,
lines: timingOverview.runningNearOvertimeRowIds
.slice(0, 4)
.flatMap((rowId) => {
const row = timingDetails.running.find(
(candidate) => candidate.id === rowId,
);
if (!row) return [];
const remaining =
(row.standardDays ?? 0) -
(row.totalActualDays ?? 0);
return [
`${row.openingCode} · ${row.productName}`,
`已用 ${row.totalActualDays ?? "-"} 天,剩余 ${Math.max(0, remaining)} 天`,
];
}),
title: `临近超期 · ${timingOverview.runningNearOvertimeCount} 款`,
detail: `按剩余时间由短到长排列 · 均在 ${NEAR_OVERTIME_WINDOW_DAYS} 天预警窗口内`,
previewRows: buildNearOvertimeHoverPreviewRows(
timingDetails.running,
timingOverview.runningNearOvertimeRowIds,
),
actionLabel:
timingDetailMode === "running" &&
runningTimingFilter?.kind === "near-overtime"
? "已展开,再次点击收起"
: "点击查看临近超期明细",
: `查看全部 ${timingOverview.runningNearOvertimeCount} 款`,
}
: undefined
}
@ -3732,20 +3886,31 @@ export default function NewProductManagementDashboard({
hoverDetail={
timingOverview.runningLongestRowId
? {
title: `最长已用时:${timingOverview.runningLongestDays} 天`,
detail: (() => {
title: `最长已用时 · ${timingOverview.runningLongestDays} 天`,
detail: `已用 ${timingOverview.runningLongestDays} 天 · 标准 ${timingOverview.runningLongestTargetDays ?? "-"} 天${timingOverview.runningLongestTargetDays !== null && timingOverview.runningLongestDays > timingOverview.runningLongestTargetDays ? ` · 已超 ${roundTimingValue(timingOverview.runningLongestDays - timingOverview.runningLongestTargetDays)} 天` : ""}`,
previewRows: (() => {
const row = timingDetails.running.find(
(candidate) =>
candidate.id ===
timingOverview.runningLongestRowId,
);
return row
? `${row.openingCode} · ${row.productName}`
: timingOverview.runningLongestTask;
? [
{
openingCode: row.openingCode,
productName: row.productName,
stage: "总周期",
metric:
timingOverview.runningLongestTargetDays !==
null &&
timingOverview.runningLongestDays >
timingOverview.runningLongestTargetDays
? `超 ${roundTimingValue(timingOverview.runningLongestDays - timingOverview.runningLongestTargetDays)} 天`
: `已用 ${timingOverview.runningLongestDays} 天`,
},
]
: [];
})(),
lines: [
`当前已用时 ${timingOverview.runningLongestDays} 天 / 标准 ${timingOverview.runningLongestTargetDays ?? "-"} 天`,
],
actionLabel:
timingDetailMode === "running" &&
runningTimingFilter?.kind === "longest"