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() {
|
export default function App() {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const isDetailWindow = urlParams.get('view') === 'program-detail' || urlParams.get('view') === 'cheonjiin-detail';
|
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">
|
<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="mx-auto max-w-[1600px] space-y-5 px-6 py-6">
|
||||||
<div className="flex justify-end gap-2">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleProgramEdit}
|
onClick={toggleProgramEdit}
|
||||||
@@ -2019,8 +2118,16 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{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>
|
<div>
|
||||||
<p className="text-[12px] font-extrabold uppercase tracking-wide text-blue-700">
|
<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" />
|
<Plus className="h-4 w-4" />
|
||||||
프로그램 추가
|
프로그램 추가
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FlowRow
|
<FlowRow
|
||||||
@@ -2200,6 +2307,9 @@ export default function App() {
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isRelationPopupOpen && (
|
{isRelationPopupOpen && (
|
||||||
<RelationPopup
|
<RelationPopup
|
||||||
programs={programs}
|
programs={programs}
|
||||||
|
|||||||
Reference in New Issue
Block a user