Add sidebar program relation tree
This commit is contained in:
132
src/App.jsx
132
src/App.jsx
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user