feat(supply-chain): add completed timing department metrics

This commit is contained in:
jingchuan 2026-08-18 18:49:20 +08:00
parent f0730ec03d
commit 90e58ecaf7
3 changed files with 283 additions and 0 deletions

View File

@ -25,6 +25,10 @@ import {
OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS,
getOaBoardTimelineMetricOvertimeDays,
} from "@/lib/oa-board-overtime";
import {
summarizeCompletedDepartmentTimings,
type CompletedDepartmentTimingSummary,
} from "@/lib/new-product-department-timing";
import type {
OaBoardDashboardDTO,
OaBoardProjectListItemDTO,
@ -109,6 +113,7 @@ type TimingDetailRow = {
type TimingOverview = {
completedAverageDays: number;
completedDepartments: CompletedDepartmentTimingSummary[];
completedOvertimeCount: number;
completedAssessedCount: number;
completedOnTimeCount: number;
@ -1342,6 +1347,11 @@ function buildTimingOverview(
.map((row) => row.totalActualDays)
.filter((value): value is number => value !== null),
),
completedDepartments:
summarizeCompletedDepartmentTimings(
completedRows,
completedOvertimeRows,
),
completedOvertimeCount: completedOvertimeRows.length,
completedAssessedCount: completedAssessedRows.length,
completedOnTimeCount: completedOnTimeRows.length,
@ -1879,6 +1889,7 @@ function EfficiencyCard({
benchmark,
note,
tone,
departmentItems,
onClick,
}: {
label: string;
@ -1887,6 +1898,13 @@ function EfficiencyCard({
benchmark: string;
note: string;
tone: "neutral" | "good" | "warning" | "danger";
departmentItems?: Array<{
label: string;
value: string;
tooltipTitle: string;
tooltipDetail: string;
tone: "good" | "danger";
}>;
onClick?: () => void;
}) {
const toneClass = {
@ -1918,6 +1936,48 @@ function EfficiencyCard({
{note}
</span>
</div>
{departmentItems?.length ? (
<div className="mt-2 grid grid-cols-4 gap-1 border-t border-black/[0.06] pt-2">
{departmentItems.map((item, index) => {
const tooltipAlignment =
index === 0
? "left-0"
: index === departmentItems.length - 1
? "right-0"
: "left-1/2 -translate-x-1/2";
const itemTone =
item.tone === "danger"
? "border-[#efd3cf] bg-[#fff8f7] text-[#a84741]"
: "border-[#cfe2dd] bg-[#f4faf8] text-[#28705a]";
return (
<span
key={item.label}
tabIndex={0}
aria-label={`${item.tooltipTitle}。${item.tooltipDetail}`}
className={`group/dept relative flex min-w-0 flex-col items-center rounded border px-1 py-1 outline-none focus-visible:ring-2 focus-visible:ring-[#245f5a]/30 ${itemTone}`}
>
<span className="text-[9px] leading-none opacity-75">
{item.label}
</span>
<span className="mt-1 whitespace-nowrap text-[10px] font-semibold leading-none">
{item.value}
</span>
<span
role="tooltip"
className={`pointer-events-none absolute bottom-[calc(100%+7px)] z-40 hidden w-[min(240px,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>
<span className="mt-1 block text-[10px] font-normal leading-4 text-white/70">
{item.tooltipDetail}
</span>
</span>
</span>
);
})}
</div>
) : null}
</>
);
const className = `group flex min-h-[104px] flex-col justify-between rounded-lg border border-l-[3px] border-black/[0.08] bg-white px-4 py-3 text-left ${onClick ? "transition hover:bg-[#fbfcfc] hover:shadow-[0_8px_20px_rgba(30,40,46,0.06)]" : ""} ${toneClass}`;
@ -3074,6 +3134,22 @@ export default function NewProductManagementDashboard({
benchmark={`已评估 ${timingOverview.completedAssessedCount} 个流程`}
note="已完成流程平均用时"
tone="good"
departmentItems={timingOverview.completedDepartments.map(
(department) => ({
label: department.label,
value:
department.averageDays === null
? "-"
: `${department.averageDays}天`,
tooltipTitle: `${department.label}模块平均用时:${
department.averageDays === null
? "暂无数据"
: `${department.averageDays} 天`
}`,
tooltipDetail: `${department.definition};纳入 ${department.assessedCount} 个已完成流程`,
tone: "good" as const,
}),
)}
/>
<EfficiencyCard
label="周期超期任务"
@ -3088,6 +3164,20 @@ export default function NewProductManagementDashboard({
tone={
timingOverview.completedOvertimeCount ? "danger" : "good"
}
departmentItems={timingOverview.completedDepartments.map(
(department) => ({
label: department.label,
value: `${department.overtimeCount}个`,
tooltipTitle: `${department.label}超时流程:${department.overtimeCount} 个`,
tooltipDetail: `在 ${timingOverview.completedOvertimeCount} 个周期超期流程中,${department.overtimeParts
.map((part) => `${part.label} ${part.count} 个`)
.join(";")};同一流程不重复计数`,
tone:
department.overtimeCount > 0
? ("danger" as const)
: ("good" as const),
}),
)}
/>
<EfficiencyCard
label="按期完成率"

View File

@ -0,0 +1,88 @@
import {
summarizeCompletedDepartmentTimings,
type DepartmentTimingRow,
} from "@/lib/new-product-department-timing";
function timing(actual: number | null, overtime: number | null) {
return { actual, overtime };
}
const rows: DepartmentTimingRow[] = [
{
requirementReview: timing(2, 1),
pricing: timing(1, 0.5),
exterior: timing(3, 0),
structure: timing(4, 2),
procurement: timing(8, 3),
visual: timing(5, 0),
},
{
requirementReview: timing(4, 0),
pricing: timing(1, 0),
exterior: timing(2, 1),
structure: timing(2, 0),
procurement: timing(6, 0),
visual: timing(7, 2),
},
{
requirementReview: timing(null, null),
pricing: timing(2, 1),
exterior: timing(1, 0),
structure: timing(null, null),
procurement: timing(4, 0),
visual: timing(null, null),
},
];
describe("summarizeCompletedDepartmentTimings", () => {
it("calculates department averages only from complete department timings", () => {
const summaries = summarizeCompletedDepartmentTimings(rows);
expect(
summaries.map(({ key, averageDays, assessedCount }) => ({
key,
averageDays,
assessedCount,
})),
).toEqual([
{ key: "planning", averageDays: 4, assessedCount: 2 },
{ key: "product", averageDays: 5.5, assessedCount: 2 },
{ key: "procurement", averageDays: 6, assessedCount: 3 },
{ key: "visual", averageDays: 6, assessedCount: 2 },
]);
});
it("counts each overtime process once and preserves stage breakdowns", () => {
const summaries = summarizeCompletedDepartmentTimings(rows);
const planning = summaries.find((item) => item.key === "planning");
const product = summaries.find((item) => item.key === "product");
expect(planning).toMatchObject({
overtimeCount: 2,
overtimeParts: [
{ label: "需求评审", count: 1 },
{ label: "定价", count: 2 },
],
});
expect(product).toMatchObject({
overtimeCount: 2,
overtimeParts: [
{ label: "外观", count: 1 },
{ label: "结构", count: 1 },
],
});
});
it("limits department overtime counts to the supplied cycle-overtime rows", () => {
const summaries = summarizeCompletedDepartmentTimings(rows, [rows[0]]);
expect(
summaries.map(({ key, overtimeCount }) => ({ key, overtimeCount })),
).toEqual([
{ key: "planning", overtimeCount: 1 },
{ key: "product", overtimeCount: 1 },
{ key: "procurement", overtimeCount: 1 },
{ key: "visual", overtimeCount: 0 },
]);
});
});

View File

@ -0,0 +1,105 @@
export type DepartmentTimingStageKey =
| "requirementReview"
| "exterior"
| "structure"
| "procurement"
| "visual"
| "pricing";
type DepartmentTimingStageValue = {
actual: number | null;
overtime: number | null;
};
export type DepartmentTimingRow = Record<
DepartmentTimingStageKey,
DepartmentTimingStageValue
>;
export type CompletedDepartmentTimingSummary = {
key: "planning" | "product" | "procurement" | "visual";
label: string;
definition: string;
averageDays: number | null;
assessedCount: number;
overtimeCount: number;
overtimeParts: Array<{ label: string; count: number }>;
};
const COMPLETED_DEPARTMENT_CONFIG: Array<{
key: CompletedDepartmentTimingSummary["key"];
label: string;
stages: Array<{ key: DepartmentTimingStageKey; label: string }>;
}> = [
{
key: "planning",
label: "企划",
stages: [
{ key: "requirementReview", label: "需求评审" },
{ key: "pricing", label: "定价" },
],
},
{
key: "product",
label: "产品",
stages: [
{ key: "exterior", label: "外观" },
{ key: "structure", label: "结构" },
],
},
{
key: "procurement",
label: "采购",
stages: [{ key: "procurement", label: "采购链路" }],
},
{
key: "visual",
label: "视觉",
stages: [{ key: "visual", label: "视觉" }],
},
];
function roundOneDecimal(value: number) {
return Number(value.toFixed(1));
}
export function summarizeCompletedDepartmentTimings(
rows: DepartmentTimingRow[],
overtimeRows: DepartmentTimingRow[] = rows,
): CompletedDepartmentTimingSummary[] {
return COMPLETED_DEPARTMENT_CONFIG.map((department) => {
const actualDays: number[] = rows.flatMap((row) => {
const stageValues = department.stages.map(
(stage) => row[stage.key].actual,
);
const completeValues = stageValues.filter(
(value): value is number => value !== null,
);
if (completeValues.length !== stageValues.length) return [];
return [completeValues.reduce((sum, value) => sum + value, 0)];
});
const overtimeParts = department.stages.map((stage) => ({
label: stage.label,
count: overtimeRows.filter((row) => (row[stage.key].overtime ?? 0) > 0)
.length,
}));
return {
key: department.key,
label: department.label,
definition: department.stages.map((stage) => stage.label).join(" + "),
averageDays:
actualDays.length > 0
? roundOneDecimal(
actualDays.reduce((sum, value) => sum + value, 0) /
actualDays.length,
)
: null,
assessedCount: actualDays.length,
overtimeCount: overtimeRows.filter((row) =>
department.stages.some((stage) => (row[stage.key].overtime ?? 0) > 0),
).length,
overtimeParts,
};
});
}