feat(supply-chain): drill into department overtime flows
This commit is contained in:
parent
90e58ecaf7
commit
d507aa08f7
@ -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"
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user