diff --git a/furniture-3d-system/src/app/intake/new-product-management/NewProductManagementDashboard.tsx b/furniture-3d-system/src/app/intake/new-product-management/NewProductManagementDashboard.tsx index f897b8f3..0e923d58 100644 --- a/furniture-3d-system/src/app/intake/new-product-management/NewProductManagementDashboard.tsx +++ b/furniture-3d-system/src/app/intake/new-product-management/NewProductManagementDashboard.tsx @@ -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}`} > - - {item.tooltipTitle} + + + {item.tooltipTitle} + + {item.previewRows && item.previewRows.length > 1 ? ( + + TOP 3 + + ) : null} - + {item.tooltipDetail} {item.previewRows?.length ? ( + + 开款 / 产品 + 主要卡点 + 时效 + {item.previewRows.map((row) => ( - - {row.openingCode} + + + {row.openingCode} + + + {row.productName} + - - {row.productName} + + {row.stage ?? "-"} + + + {row.metric ?? "-"} ))} ) : null} {item.actionLabel ? ( - - {item.actionLabel} + + {item.actionLabel} + ) : null} @@ -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" > - - {hoverDetail.title} + + + {hoverDetail.title} + + {hoverDetail.previewRows && hoverDetail.previewRows.length > 1 ? ( + + TOP 3 + + ) : null} - + {hoverDetail.detail} {hoverDetail.lines?.length ? ( @@ -2105,9 +2257,40 @@ function EfficiencyCard({ ))} ) : null} + {hoverDetail.previewRows?.length ? ( + + + 开款 / 产品 + 当前环节 + 时效 + + {hoverDetail.previewRows.map((row) => ( + + + + {row.openingCode} + + + {row.productName} + + + + {row.stage ?? "-"} + + + {row.metric ?? "-"} + + + ))} + + ) : null} {hoverDetail.actionLabel ? ( - - {hoverDetail.actionLabel} + + {hoverDetail.actionLabel} + ) : null} @@ -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"