Add sidebar program relation tree

This commit is contained in:
2026-06-24 08:49:57 +09:00
parent 3ec4aec98b
commit a721ebf286

View File

@@ -1350,6 +1350,114 @@ function RelationPopup({ programs, onToggleRelation, onClose }) {
);
}
function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup }) {
const [expandedProgramIds, setExpandedProgramIds] = useState(() => new Set(programs.map((program) => program.id)));
const programMap = Object.fromEntries(programs.map((program) => [program.id, program]));
const linkedChildIds = new Set(programs.flatMap((program) => program.successors ?? []));
const rootPrograms = programs.filter((program) => !linkedChildIds.has(program.id));
const visibleRoots = rootPrograms.length ? rootPrograms : programs.slice(0, 1);
const toggleExpanded = (programId) => {
setExpandedProgramIds((current) => {
const next = new Set(current);
if (next.has(programId)) {
next.delete(programId);
} else {
next.add(programId);
}
return next;
});
};
const renderNode = (program, depth = 0, visitedIds = new Set()) => {
const successors = (program.successors ?? [])
.map((successorId) => programMap[successorId])
.filter(Boolean);
const hasChildren = successors.length > 0;
const isExpanded = expandedProgramIds.has(program.id);
const isRepeated = visitedIds.has(program.id);
const hasMultipleInputs = (program.predecessors ?? []).length > 1;
const nextVisitedIds = new Set(visitedIds);
nextVisitedIds.add(program.id);
return (
<div key={`${program.id}-${depth}-${[...visitedIds].join('-')}`} className="relative">
{depth > 0 && (
<span
className="absolute left-[11px] top-0 h-full border-l border-dashed border-slate-200"
aria-hidden="true"
/>
)}
<div
className="relative flex items-center gap-2 py-1.5"
style={{ paddingLeft: `${depth * 18}px` }}
>
{hasChildren && !isRepeated ? (
<button
type="button"
onClick={() => toggleExpanded(program.id)}
className="relative z-10 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-white text-[11px] font-black text-slate-500 ring-1 ring-slate-200 hover:bg-slate-50"
aria-label={`${program.name} 하위 프로그램 ${isExpanded ? '접기' : '펼치기'}`}
>
{isExpanded ? '' : '+'}
</button>
) : (
<span className="relative z-10 h-5 w-5 shrink-0 rounded-full bg-slate-100 ring-1 ring-slate-200" />
)}
<button
type="button"
onClick={() => onProgramClick(program.id)}
className="min-w-0 flex-1 rounded-xl bg-white/85 px-3 py-2 text-left shadow-sm ring-1 ring-slate-100 transition hover:-translate-y-0.5 hover:bg-white hover:shadow"
>
<span className="block truncate text-[13px] font-black text-slate-900">
{program.name}
</span>
<span className="mt-0.5 block truncate text-[11px] font-semibold text-slate-500">
{program.description}
</span>
</button>
{hasMultipleInputs && (
<span className="rounded-full bg-violet-50 px-2 py-1 text-[10px] font-black text-violet-600 ring-1 ring-violet-100">
다중
</span>
)}
</div>
{hasChildren && isExpanded && !isRepeated && (
<div className="ml-2">
{successors.map((successor) => renderNode(successor, depth + 1, nextVisitedIds))}
</div>
)}
</div>
);
};
return (
<aside className="sticky top-5 max-h-[calc(100vh-40px)] overflow-y-auto rounded-[28px] border border-white/75 bg-white/75 p-4 shadow-sm backdrop-blur">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<p className="text-[11px] font-black uppercase tracking-wide text-blue-700">Program Map</p>
<h2 className="mt-1 text-lg font-black text-slate-950">프로그램 연결</h2>
<p className="mt-1 text-xs font-semibold leading-5 text-slate-500">
선행·후행 관계를 세로 트리로 봅니다.
</p>
</div>
<button
type="button"
onClick={onOpenRelationPopup}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-base font-black text-amber-700 ring-1 ring-amber-200 hover:bg-amber-200"
title="프로그램 연결도 및 관계 수정"
aria-label="프로그램 연결도 및 관계 수정"
>
!
</button>
</div>
<div className="space-y-1">
{visibleRoots.map((program) => renderNode(program))}
</div>
</aside>
);
}
export default function App() {
const urlParams = new URLSearchParams(window.location.search);
const isDetailWindow = urlParams.get('view') === 'program-detail' || urlParams.get('view') === 'cheonjiin-detail';
@@ -1996,15 +2104,6 @@ export default function App() {
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,#dffcf4_0,#f7f8fa_34%,#eef2ff_100%)] text-slate-900">
<div className="mx-auto max-w-[1600px] space-y-5 px-6 py-6">
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setIsRelationPopupOpen(true)}
className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 text-lg font-black text-amber-700 shadow-sm ring-1 ring-amber-200 hover:bg-amber-200"
title="프로그램 연결도 및 관계 수정"
aria-label="프로그램 연결도 및 관계 수정"
>
!
</button>
<button
type="button"
onClick={toggleProgramEdit}
@@ -2019,8 +2118,16 @@ export default function App() {
</button>
</div>
<div className="grid gap-5 xl:grid-cols-[310px_minmax(0,1fr)] xl:items-start">
<RelationTreePanel
programs={programs}
onProgramClick={openProgramWindow}
onOpenRelationPopup={() => setIsRelationPopupOpen(true)}
/>
<section className="min-w-0 space-y-5">
{isEditing && (
<section className="flex items-center justify-between rounded-[24px] border border-white/70 bg-white/70 px-5 py-4 shadow-sm backdrop-blur">
<div className="flex items-center justify-between rounded-[24px] border border-white/70 bg-white/70 px-5 py-4 shadow-sm backdrop-blur">
<div>
<p className="text-[12px] font-extrabold uppercase tracking-wide text-blue-700">
프로그램 편집
@@ -2037,7 +2144,7 @@ export default function App() {
<Plus className="h-4 w-4" />
프로그램 추가
</button>
</section>
</div>
)}
<FlowRow
@@ -2200,6 +2307,9 @@ export default function App() {
</React.Fragment>
))}
</section>
</div>
{isRelationPopupOpen && (
<RelationPopup
programs={programs}