feat(supply-chain): improve timing detail drilldown

This commit is contained in:
jingchuan 2026-08-18 19:37:17 +08:00
parent 641a162675
commit ef6392be81

View File

@ -128,6 +128,17 @@ type TimingOverview = {
runningLongestTargetDays: number | null;
};
type CompletedTimingDetailFilter =
| {
kind: "department";
key: CompletedDepartmentTimingSummary["key"];
}
| {
kind: "max-overtime";
rowId: string;
}
| null;
type BaseAnalysisDatum = (typeof ANALYSIS_DATA)[Exclude<
AnalysisView,
"overview"
@ -1890,6 +1901,8 @@ function EfficiencyCard({
note,
tone,
departmentItems,
hoverDetail,
active = false,
onClick,
}: {
label: string;
@ -1906,8 +1919,16 @@ function EfficiencyCard({
tone: "good" | "danger";
previewRows?: Array<{ openingCode: string; productName: string }>;
actionLabel?: string;
active?: boolean;
onSelect?: () => void;
}>;
hoverDetail?: {
title: string;
detail: string;
lines?: string[];
actionLabel?: string;
};
active?: boolean;
onClick?: () => void;
}) {
const toneClass = {
@ -1922,7 +1943,7 @@ function EfficiencyCard({
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-medium text-[#647176]">{label}</span>
{onClick ? (
<ChevronRight className="h-3.5 w-3.5 text-[#a3adaf] transition group-hover:translate-x-0.5" />
<ChevronRight className="h-3.5 w-3.5 text-[#a3adaf] transition group-hover/card:translate-x-0.5" />
) : null}
</div>
<div className="flex items-end justify-between gap-3">
@ -1949,7 +1970,9 @@ function EfficiencyCard({
? "right-0"
: "left-1/2 -translate-x-1/2";
const itemTone =
item.tone === "danger"
item.active
? "border-[#245f5a] bg-[#eaf4f1] text-[#245f5a] ring-1 ring-[#245f5a]/20"
: item.tone === "danger"
? "border-[#efd3cf] bg-[#fff8f7] text-[#a84741]"
: "border-[#cfe2dd] bg-[#f4faf8] text-[#28705a]";
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}`;
@ -2000,6 +2023,7 @@ function EfficiencyCard({
<button
key={item.label}
type="button"
aria-pressed={item.active}
aria-label={`${item.tooltipTitle}。${item.tooltipDetail}。${item.actionLabel ?? ""}`}
onClick={item.onSelect}
className={itemClassName}
@ -2019,11 +2043,46 @@ function EfficiencyCard({
})}
</div>
) : null}
{hoverDetail ? (
<span
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>
<span className="mt-1 block text-[10px] font-normal leading-4 text-white/70">
{hoverDetail.detail}
</span>
{hoverDetail.lines?.length ? (
<span className="mt-2 block border-t border-white/15 pt-1.5">
{hoverDetail.lines.map((line) => (
<span
key={line}
className="block py-0.5 text-[10px] leading-4 text-white/75"
>
{line}
</span>
))}
</span>
) : null}
{hoverDetail.actionLabel ? (
<span className="mt-1.5 block text-[10px] font-semibold text-[#8fd4c2]">
{hoverDetail.actionLabel}
</span>
) : null}
</span>
) : 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}`;
const className = `group/card relative flex min-h-[104px] flex-col justify-between rounded-lg border border-l-[3px] bg-white px-4 py-3 text-left ${active ? "border-[#7aa69f] bg-[#f8fcfb] ring-2 ring-[#245f5a]/15" : "border-black/[0.08]"} ${onClick ? "transition hover:bg-[#fbfcfc] hover:shadow-[0_8px_20px_rgba(30,40,46,0.06)]" : ""} ${toneClass}`;
return onClick ? (
<button type="button" onClick={onClick} className={className}>
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={className}
>
{content}
</button>
) : (
@ -2174,10 +2233,11 @@ function TimingDetailTable({
<button
type="button"
onClick={onClose}
className="inline-flex items-center gap-1 text-[#647176] hover:text-[#182225]"
aria-label={`收起${filterLabel ? `当前${filterLabel}` : isCompleted ? "已完成详细时效" : "进行中详细时效"}`}
className="inline-flex h-7 items-center gap-1 rounded-md border border-black/[0.08] bg-white px-2 text-[10px] font-medium text-[#647176] hover:bg-[#f3f6f6] hover:text-[#182225]"
>
<ChevronLeft className="h-3.5 w-3.5" />
{isCompleted ? "收起已完成详细时效" : "收起进行中详细时效"}
<X className="h-3.5 w-3.5" />
{filterLabel ? "收起当前明细" : "收起详细时效"}
</button>
}
/>
@ -2896,9 +2956,8 @@ export default function NewProductManagementDashboard({
const [timingDetailMode, setTimingDetailMode] = useState<
"completed" | "running" | null
>(null);
const [completedDepartmentFilter, setCompletedDepartmentFilter] = useState<
CompletedDepartmentTimingSummary["key"] | null
>(null);
const [completedTimingFilter, setCompletedTimingFilter] =
useState<CompletedTimingDetailFilter>(null);
const periodRange = useMemo(
() => getPeriodRange(period, anchorDate),
[anchorDate, period],
@ -2947,16 +3006,50 @@ export default function NewProductManagementDashboard({
);
const selectedCompletedDepartment = useMemo(
() =>
timingOverview.completedDepartments.find(
(department) => department.key === completedDepartmentFilter,
) ?? null,
[completedDepartmentFilter, timingOverview.completedDepartments],
completedTimingFilter?.kind === "department"
? (timingOverview.completedDepartments.find(
(department) =>
department.key === completedTimingFilter.key,
) ?? null)
: null,
[completedTimingFilter, timingOverview.completedDepartments],
);
const maxCompletedOvertimeRow = useMemo(
() =>
timingDetails.completed.reduce<TimingDetailRow | null>(
(maxRow, row) => {
if (!maxRow) return row;
return (row.totalOvertimeDays ?? -1) >
(maxRow.totalOvertimeDays ?? -1)
? row
: maxRow;
},
null,
),
[timingDetails.completed],
);
const completedTimingDetailRows = useMemo(() => {
if (!selectedCompletedDepartment) return timingDetails.completed;
if (completedTimingFilter?.kind === "max-overtime") {
return timingDetails.completed.filter(
(row) => row.id === completedTimingFilter.rowId,
);
}
if (selectedCompletedDepartment) {
const rowIds = new Set(selectedCompletedDepartment.overtimeRowIds);
return timingDetails.completed.filter((row) => rowIds.has(row.id));
}, [selectedCompletedDepartment, timingDetails.completed]);
}
return timingDetails.completed;
}, [
completedTimingFilter,
selectedCompletedDepartment,
timingDetails.completed,
]);
const completedTimingFilterLabel =
selectedCompletedDepartment !== null
? `${selectedCompletedDepartment.label}超时流程`
: completedTimingFilter?.kind === "max-overtime"
? "最大周期超期任务"
: undefined;
const stageData: StageDatum[] = useMemo(
() => (dashboard && liveItems ? buildLiveStageData(liveItems) : STAGE_DATA),
[dashboard, liveItems],
@ -3170,7 +3263,7 @@ export default function NewProductManagementDashboard({
type="button"
aria-expanded={timingDetailMode === "completed"}
onClick={() => {
setCompletedDepartmentFilter(null);
setCompletedTimingFilter(null);
setTimingDetailMode((current) =>
current === "completed" ? null : "completed",
);
@ -3235,6 +3328,10 @@ export default function NewProductManagementDashboard({
department.overtimeCount > 0
? ("danger" as const)
: ("good" as const),
active:
timingDetailMode === "completed" &&
completedTimingFilter?.kind === "department" &&
completedTimingFilter.key === department.key,
previewRows: department.overtimeRowIds
.slice(0, 4)
.flatMap((rowId) => {
@ -3251,12 +3348,29 @@ export default function NewProductManagementDashboard({
: [];
}),
actionLabel: department.overtimeCount
? `点击查看全部 ${department.overtimeCount} 个流程`
? timingDetailMode === "completed" &&
completedTimingFilter?.kind === "department" &&
completedTimingFilter.key === department.key
? "已展开,再次点击收起"
: `点击查看全部 ${department.overtimeCount} 个流程`
: undefined,
onSelect: department.overtimeCount
? () => {
setCompletedDepartmentFilter(department.key);
setTimingDetailMode("completed");
const isActive =
timingDetailMode === "completed" &&
completedTimingFilter?.kind === "department" &&
completedTimingFilter.key === department.key;
setCompletedTimingFilter(
isActive
? null
: {
kind: "department",
key: department.key,
},
);
setTimingDetailMode(
isActive ? null : "completed",
);
}
: undefined,
}),
@ -3294,20 +3408,59 @@ export default function NewProductManagementDashboard({
tone={
timingOverview.completedMaxOvertimeDays ? "danger" : "good"
}
active={
timingDetailMode === "completed" &&
completedTimingFilter?.kind === "max-overtime"
}
hoverDetail={
maxCompletedOvertimeRow &&
timingOverview.completedMaxOvertimeDays > 0
? {
title: `最大周期超期:${timingOverview.completedMaxOvertimeDays} 天`,
detail: `${maxCompletedOvertimeRow.openingCode} · ${maxCompletedOvertimeRow.productName}`,
lines: [
`最终时效 ${maxCompletedOvertimeRow.totalActualDays ?? "-"} 天 / 标准 ${maxCompletedOvertimeRow.standardDays ?? "-"} 天`,
`完成日期 ${maxCompletedOvertimeRow.finishedDate || "-"}`,
],
actionLabel:
timingDetailMode === "completed" &&
completedTimingFilter?.kind === "max-overtime"
? "已展开,再次点击收起"
: "点击查看该款完整节点时效",
}
: undefined
}
onClick={
maxCompletedOvertimeRow &&
timingOverview.completedMaxOvertimeDays > 0
? () => {
const isActive =
timingDetailMode === "completed" &&
completedTimingFilter?.kind === "max-overtime";
setCompletedTimingFilter(
isActive
? null
: {
kind: "max-overtime",
rowId: maxCompletedOvertimeRow.id,
},
);
setTimingDetailMode(
isActive ? null : "completed",
);
}
: undefined
}
/>
</div>
{timingDetailMode === "completed" ? (
<TimingDetailTable
mode="completed"
rows={completedTimingDetailRows}
filterLabel={
selectedCompletedDepartment
? `${selectedCompletedDepartment.label}超时流程`
: undefined
}
filterLabel={completedTimingFilterLabel}
onClose={() => {
setTimingDetailMode(null);
setCompletedDepartmentFilter(null);
setCompletedTimingFilter(null);
}}
onSelectRow={setSelectedRow}
/>
@ -3328,7 +3481,7 @@ export default function NewProductManagementDashboard({
type="button"
aria-expanded={timingDetailMode === "running"}
onClick={() => {
setCompletedDepartmentFilter(null);
setCompletedTimingFilter(null);
setTimingDetailMode((current) =>
current === "running" ? null : "running",
);