feat(supply-chain): add completed timing department metrics
This commit is contained in:
parent
f0730ec03d
commit
90e58ecaf7
@ -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="按期完成率"
|
||||
|
||||
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
105
furniture-3d-system/src/lib/new-product-department-timing.ts
Normal file
105
furniture-3d-system/src/lib/new-product-department-timing.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user