feat(supply-chain): refine timing hover summaries
This commit is contained in:
parent
94f754c80b
commit
1f9293cc42
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user