feat(supply-chain): drill into department overtime flows

This commit is contained in:
jingchuan 2026-08-18 19:02:08 +08:00
parent 90e58ecaf7
commit d507aa08f7
3 changed files with 121 additions and 19 deletions

View File

@ -1904,6 +1904,9 @@ function EfficiencyCard({
tooltipTitle: string;
tooltipDetail: string;
tone: "good" | "danger";
previewRows?: Array<{ openingCode: string; productName: string }>;
actionLabel?: string;
onSelect?: () => void;
}>;
onClick?: () => void;
}) {
@ -1949,13 +1952,9 @@ function EfficiencyCard({
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}`}
>
const itemClassName = `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 ${item.onSelect ? "cursor-pointer hover:shadow-sm" : ""} ${itemTone}`;
const itemContent = (
<>
<span className="text-[9px] leading-none opacity-75">
{item.label}
</span>
@ -1964,7 +1963,7 @@ function EfficiencyCard({
</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}`}
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}
@ -1972,7 +1971,49 @@ function EfficiencyCard({
<span className="mt-1 block text-[10px] font-normal leading-4 text-white/70">
{item.tooltipDetail}
</span>
{item.previewRows?.length ? (
<span className="mt-2 block border-t border-white/15 pt-1.5">
{item.previewRows.map((row) => (
<span
key={row.openingCode}
className="flex items-center gap-2 py-0.5 text-[10px] leading-4"
>
<span className="shrink-0 font-semibold text-white">
{row.openingCode}
</span>
<span className="min-w-0 truncate text-white/65">
{row.productName}
</span>
</span>
))}
</span>
) : null}
{item.actionLabel ? (
<span className="mt-1.5 block text-[10px] font-semibold text-[#8fd4c2]">
{item.actionLabel}
</span>
) : null}
</span>
</>
);
return item.onSelect ? (
<button
key={item.label}
type="button"
aria-label={`${item.tooltipTitle}。${item.tooltipDetail}。${item.actionLabel ?? ""}`}
onClick={item.onSelect}
className={itemClassName}
>
{itemContent}
</button>
) : (
<span
key={item.label}
tabIndex={0}
aria-label={`${item.tooltipTitle}。${item.tooltipDetail}`}
className={itemClassName}
>
{itemContent}
</span>
);
})}
@ -2106,11 +2147,13 @@ function TimingOvertimeCell({ value }: { value: TimingStageValue }) {
function TimingDetailTable({
mode,
rows,
filterLabel,
onClose,
onSelectRow,
}: {
mode: "completed" | "running";
rows: TimingDetailRow[];
filterLabel?: string;
onClose: () => void;
onSelectRow: (row: SampleRow) => void;
}) {
@ -2124,8 +2167,8 @@ function TimingDetailTable({
<>
<section className="overflow-hidden rounded-lg border border-[#b7cfcb] bg-white shadow-[0_8px_24px_rgba(25,65,62,0.08)]">
<SectionHeader
title={`${isCompleted ? "已完成" : "进行中"}时效`}
eyebrow={`仅统计${isCompleted ? "已完成" : "进行中"}流程 · 按开款编号去重 · 点击一行查看完整节点`}
title={`${isCompleted ? "已完成" : "进行中"}时效${filterLabel ? ` · ${filterLabel}` : ""}`}
eyebrow={`仅统计${isCompleted ? "已完成" : "进行中"}流程 · 按开款编号去重${filterLabel ? ` · 当前 ${rows.length} 个流程` : ""} · 点击一行查看完整节点`}
icon={<BarChart3 className="h-4 w-4 text-[#245f5a]" />}
detail={
<button
@ -2853,6 +2896,9 @@ export default function NewProductManagementDashboard({
const [timingDetailMode, setTimingDetailMode] = useState<
"completed" | "running" | null
>(null);
const [completedDepartmentFilter, setCompletedDepartmentFilter] = useState<
CompletedDepartmentTimingSummary["key"] | null
>(null);
const periodRange = useMemo(
() => getPeriodRange(period, anchorDate),
[anchorDate, period],
@ -2899,6 +2945,18 @@ export default function NewProductManagementDashboard({
() => buildTimingOverview(timingDetails.completed, timingDetails.running),
[timingDetails],
);
const selectedCompletedDepartment = useMemo(
() =>
timingOverview.completedDepartments.find(
(department) => department.key === completedDepartmentFilter,
) ?? null,
[completedDepartmentFilter, timingOverview.completedDepartments],
);
const completedTimingDetailRows = useMemo(() => {
if (!selectedCompletedDepartment) return timingDetails.completed;
const rowIds = new Set(selectedCompletedDepartment.overtimeRowIds);
return timingDetails.completed.filter((row) => rowIds.has(row.id));
}, [selectedCompletedDepartment, timingDetails.completed]);
const stageData: StageDatum[] = useMemo(
() => (dashboard && liveItems ? buildLiveStageData(liveItems) : STAGE_DATA),
[dashboard, liveItems],
@ -3111,11 +3169,12 @@ export default function NewProductManagementDashboard({
<button
type="button"
aria-expanded={timingDetailMode === "completed"}
onClick={() =>
onClick={() => {
setCompletedDepartmentFilter(null);
setTimingDetailMode((current) =>
current === "completed" ? null : "completed",
)
}
);
}}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-black/[0.09] bg-white px-3 text-[11px] font-medium text-[#334044] hover:bg-[#f3f6f6]"
>
{timingDetailMode === "completed"
@ -3176,6 +3235,30 @@ export default function NewProductManagementDashboard({
department.overtimeCount > 0
? ("danger" as const)
: ("good" as const),
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,
},
]
: [];
}),
actionLabel: department.overtimeCount
? `点击查看全部 ${department.overtimeCount} 个流程`
: undefined,
onSelect: department.overtimeCount
? () => {
setCompletedDepartmentFilter(department.key);
setTimingDetailMode("completed");
}
: undefined,
}),
)}
/>
@ -3216,8 +3299,16 @@ export default function NewProductManagementDashboard({
{timingDetailMode === "completed" ? (
<TimingDetailTable
mode="completed"
rows={timingDetails.completed}
onClose={() => setTimingDetailMode(null)}
rows={completedTimingDetailRows}
filterLabel={
selectedCompletedDepartment
? `${selectedCompletedDepartment.label}超时流程`
: undefined
}
onClose={() => {
setTimingDetailMode(null);
setCompletedDepartmentFilter(null);
}}
onSelectRow={setSelectedRow}
/>
) : null}
@ -3236,11 +3327,12 @@ export default function NewProductManagementDashboard({
<button
type="button"
aria-expanded={timingDetailMode === "running"}
onClick={() =>
onClick={() => {
setCompletedDepartmentFilter(null);
setTimingDetailMode((current) =>
current === "running" ? null : "running",
)
}
);
}}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-black/[0.09] bg-white px-3 text-[11px] font-medium text-[#334044] hover:bg-[#f3f6f6]"
>
{timingDetailMode === "running"

View File

@ -9,6 +9,7 @@ function timing(actual: number | null, overtime: number | null) {
const rows: DepartmentTimingRow[] = [
{
id: "row-1",
requirementReview: timing(2, 1),
pricing: timing(1, 0.5),
exterior: timing(3, 0),
@ -17,6 +18,7 @@ const rows: DepartmentTimingRow[] = [
visual: timing(5, 0),
},
{
id: "row-2",
requirementReview: timing(4, 0),
pricing: timing(1, 0),
exterior: timing(2, 1),
@ -25,6 +27,7 @@ const rows: DepartmentTimingRow[] = [
visual: timing(7, 2),
},
{
id: "row-3",
requirementReview: timing(null, null),
pricing: timing(2, 1),
exterior: timing(1, 0),
@ -59,6 +62,7 @@ describe("summarizeCompletedDepartmentTimings", () => {
expect(planning).toMatchObject({
overtimeCount: 2,
overtimeRowIds: ["row-1", "row-3"],
overtimeParts: [
{ label: "需求评审", count: 1 },
{ label: "定价", count: 2 },

View File

@ -11,7 +11,7 @@ type DepartmentTimingStageValue = {
overtime: number | null;
};
export type DepartmentTimingRow = Record<
export type DepartmentTimingRow = { id: string } & Record<
DepartmentTimingStageKey,
DepartmentTimingStageValue
>;
@ -23,6 +23,7 @@ export type CompletedDepartmentTimingSummary = {
averageDays: number | null;
assessedCount: number;
overtimeCount: number;
overtimeRowIds: string[];
overtimeParts: Array<{ label: string; count: number }>;
};
@ -99,6 +100,11 @@ export function summarizeCompletedDepartmentTimings(
overtimeCount: overtimeRows.filter((row) =>
department.stages.some((stage) => (row[stage.key].overtime ?? 0) > 0),
).length,
overtimeRowIds: overtimeRows
.filter((row) =>
department.stages.some((stage) => (row[stage.key].overtime ?? 0) > 0),
)
.map((row) => row.id),
overtimeParts,
};
});