import React, { useEffect, useState } from 'react';
import {
ArrowDown,
ArrowRight,
ArrowUp,
Building2,
ClipboardList,
DraftingCompass,
Edit3,
Layers3,
Map,
Mountain,
Plus,
Route,
Save,
ScanLine,
Trash2,
Waypoints,
X
} from 'lucide-react';
const cheonjiinFlow = [
{
id: '1-1',
title: '범위입력',
icon: ScanLine,
feature: '직접선택, 도면 불러오기',
note: '사업 대상지와 원본 도면 범위를 먼저 확정합니다.'
},
{
id: '1-2',
title: '선택/검토',
icon: Layers3,
feature: '등고선, 레이어 선택',
note: '해석에 필요한 도면 요소와 레이어만 정리합니다.'
},
{
id: '1-3',
title: '현장조사',
icon: Mountain,
feature: '지표, 지층, 상세측량\n파일 불러오기\n지형, 지층 보정 모델',
note: '실측 자료를 반영해 지형과 지층 정보를 보정합니다.'
},
{
id: '1-4',
title: '구조물편집',
icon: Building2,
feature: '도로, 건물, 하천, 지형\n수치지형도 v2 다운로드 시 적용\n구조물 포함 모델',
note: '구조물과 지형 요소를 편집해 통합 모델을 만듭니다.'
},
{
id: '1-5',
title: '유역면적',
icon: Map,
feature: '유역 구분 및 면적 산정',
note: '정리된 공간 정보를 바탕으로 유역면적 결과를 산정합니다.'
}
];
const wayPrimalFlow = [
{
id: 'wp-1',
title: '설계조건',
icon: ClipboardList,
feature: '전용조건 입력(도로, 포장 등)\n정온시설 위치 입력',
note: '도로와 주변 조건을 설계 입력값으로 등록합니다.'
},
{
id: 'wp-2',
title: '노폭계획',
icon: Route,
feature: '선형계획, 노즈, 편경사,\n구조물, 시설계획 입력',
note: '노선의 폭, 선형, 구조물 조건을 계획합니다.'
},
{
id: 'wp-3',
title: '기본계획',
icon: Waypoints,
feature: '길어깨편경사, 비탈면계획,\n부체도로, 횡단등도',
note: '기본 단면과 주변 계획 조건을 정리합니다.'
},
{
id: 'wp-4',
title: '기본설계',
icon: DraftingCompass,
feature: '배수공, 포장공, 부대공 자동설치\n횡단면도 생성, 도공계산서 내보내기\n평면도, 횡단면도, 종평면도, 기본설계모델',
note: '자동 설계 성과물과 기본설계 모델을 생성합니다.'
}
];
const cheonjiinDeliverables = [
'지형도',
'수치지형도',
'정사영상',
'지형,지층 보정 모델'
];
const detailSteps = [
{
id: '1-1',
title: '범위 입력',
feature: '직접 선택 · 도면 불러오기',
detail: '대상 유역 영역의 좌표 체계와 경계를 지정하고 원천 도면 파일을 등록합니다.',
output: '활성 작업 영역 레이어'
},
{
id: '1-2',
title: '선택·검토',
feature: '등고선 · 적용 레이어 선택',
detail: '설계 연산에 사용할 등고선 데이터와 속성별 물리 레이어를 선별합니다.',
output: '정제된 등고선 추출본'
},
{
id: '1-3',
title: '현장조사',
feature: '지표 · 지층 · 상세측량 자료 입력',
detail: 'GPS 고도 측량치와 시추 분석 데이터를 입력해 지형과 지층을 보정합니다.',
output: '3차원 지표·지층 보정 모델'
},
{
id: '1-4',
title: '구조물 편집',
feature: '도로 · 건물 · 하천 · 지형 편집',
detail: '도로 중심선, 건물 폴리곤, 하천 경계 등 구조물 정보를 지형면에 정합합니다.',
output: '구조물 포함 통합 3D GIS 모델'
},
{
id: '1-5',
title: '유역면적 산정',
feature: '유역 구분 · 면적 산정 · 결과 검토',
detail: '정립된 지형과 구조물 모델을 분석해 유역 경계를 구분하고 면적을 계산합니다.',
output: '유역면적 최종 산정 결과'
}
];
const reviewGates = [
{
key: 'gate1',
stepId: '1-1',
question: '범위 또는 원본 도면 변경?',
yes: '예: 1-1 재진입',
no: '아니오: 다음 검토'
},
{
key: 'gate2',
stepId: '1-2',
question: '등고선 또는 적용 레이어 변경?',
yes: '예: 1-2 재진입',
no: '아니오: 다음 검토'
},
{
key: 'gate3',
stepId: '1-3',
question: '지표·지층 또는 상세측량 정보 변경?',
yes: '예: 1-3 재진입',
no: '아니오: 다음 검토'
},
{
key: 'gate4',
stepId: '1-4',
question: '도로·건물·하천 또는 지형 정보 변경?',
yes: '예: 1-4 재진입',
no: '아니오: 변경사항 없음'
}
];
const initialGates = {
gate1: 'no',
gate2: 'no',
gate3: 'no',
gate4: 'no'
};
const channelName = 'program-flow-state';
const contentStorageKey = 'program-flow-content';
const programStateStorageKey = 'program-flow-gates';
const serverStateEndpoint = '/api/state';
const disabledFlowStep = '__disabled__';
const defaultContent = {
cheonjiin: {
name: '천지인',
description: '공간정보 입력·검토·보정·유역면적 산정',
steps: cheonjiinFlow.map(({ title, feature, note }) => ({ title, feature, note })),
deliverables: cheonjiinDeliverables,
format: 'glb',
predecessors: [],
successors: []
},
wayPrimal: {
name: 'WayPrimal',
description: '설계조건 입력부터 기본설계 성과물 생성',
steps: wayPrimalFlow.map(({ title, feature, note }) => ({ title, feature, note })),
format: '',
deliverables: ['기본설계 모델'],
predecessors: [],
successors: [],
linkLabel: '천지인 산출 모델을 WayPrimal 설계 입력으로 연계'
},
extraPrograms: []
};
function normalizeStoredContent(parsed) {
if (!parsed) return defaultContent;
return {
cheonjiin: {
...defaultContent.cheonjiin,
...(parsed.cheonjiin ?? {}),
predecessors: parsed.cheonjiin?.predecessors ?? [],
successors: parsed.cheonjiin?.successors ?? [],
format:
!parsed.cheonjiin?.format || parsed.cheonjiin.format === '예: DXF, SHP, GeoTIFF, 수치지형도 v2 등'
? defaultContent.cheonjiin.format
: parsed.cheonjiin.format
},
wayPrimal: {
...defaultContent.wayPrimal,
...(parsed.wayPrimal ?? {}),
predecessors: parsed.wayPrimal?.predecessors ?? [],
successors: parsed.wayPrimal?.successors ?? [],
format:
parsed.wayPrimal?.format === '예: DWG, LandXML, XLSX, 도공계산서, 기본설계 모델 등'
? defaultContent.wayPrimal.format
: (parsed.wayPrimal?.format ?? defaultContent.wayPrimal.format)
},
extraPrograms: (parsed.extraPrograms ?? []).map((program, index, programs) => ({
...program,
predecessors: program.predecessors ?? [],
successors: program.successors ?? [],
linkLabel: program.linkLabel ?? `이전 프로그램 산출물을 ${program.name} 입력으로 연계`
}))
};
}
function readStoredContent() {
try {
const stored = window.localStorage.getItem(contentStorageKey);
if (!stored) return defaultContent;
return normalizeStoredContent(JSON.parse(stored));
} catch {
return defaultContent;
}
}
function mergeStepContent(flow, storedSteps = []) {
const mergedBaseSteps = flow.map((step, index) => ({
...step,
...(storedSteps[index] ?? {})
}));
const addedSteps = storedSteps.slice(flow.length).map((step, index) => ({
...step,
id: step.id ?? `added-step-${flow.length + index + 1}`,
icon: step.icon ?? pickStepIcon(step)
}));
return [...mergedBaseSteps, ...addedSteps];
}
function pickStepIcon(step) {
const text = `${step.title ?? ''} ${step.feature ?? ''} ${step.note ?? ''}`.toLowerCase();
if (/모델|3d|glb|ifc|wbpifc|grasshopper|rhino|객체|형상/.test(text)) return Layers3;
if (/포맷|파일|저장|출력|내보내기|생성|변환/.test(text)) return ClipboardList;
if (/속성|정보|입력|데이터|plugin|플러그인/.test(text)) return Waypoints;
if (/편집|수정|보정|변경|정합/.test(text)) return DraftingCompass;
if (/도로|노선|선형|중심선|경로/.test(text)) return Route;
if (/지도|수치지형도|유역|면적|범위|도면/.test(text)) return Map;
if (/측량|지형|지층|고도|산/.test(text)) return Mountain;
if (/스캔|영상|정사영상|레이어/.test(text)) return ScanLine;
if (/구조물|건물|하천/.test(text)) return Building2;
return ClipboardList;
}
function normalizeProgramSteps(program) {
return program.steps.map((step, index) => ({
id: step.id ?? `${program.id}-step-${index + 1}`,
icon: pickStepIcon(step),
title: step.title,
feature: step.feature,
note: step.note
}));
}
function createInitialGates(steps) {
return steps.slice(0, Math.max(1, steps.length - 1)).reduce((gates, step, index) => ({
...gates,
[`gate${index + 1}`]: 'no'
}), {});
}
function buildReviewItems(programId, steps, detailGates) {
const hasStoredDetailGates = Array.isArray(detailGates);
const storedDetailGates = hasStoredDetailGates ? detailGates : [];
if (programId === 'cheonjiin') {
const count = hasStoredDetailGates ? Math.max(1, storedDetailGates.length) : reviewGates.length;
return Array.from({ length: count }, (_, index) => {
const baseGate = reviewGates[index] ?? {
key: `gate${index + 1}`,
stepId: steps[Math.min(index, Math.max(0, steps.length - 2))]?.id ?? steps[0]?.id,
question: `${index + 1}-${index + 1} 변경?`,
yes: `예: ${index + 1}-${index + 1} 재진입`,
no: index === count - 1 ? '아니오: 변경사항 없음' : '아니오: 다음 검토'
};
return {
...baseGate,
...(storedDetailGates[index] ?? {}),
targetProgramId: storedDetailGates[index] ? storedDetailGates[index].targetProgramId : programId,
key: storedDetailGates[index]?.key ?? baseGate.key ?? `gate${index + 1}`
};
});
}
const defaultCount = Math.max(1, steps.length - 1);
const count = hasStoredDetailGates ? Math.max(1, storedDetailGates.length) : defaultCount;
return Array.from({ length: count }, (_, index) => {
const step = steps[Math.min(index, Math.max(0, steps.length - 2))] ?? steps[0];
return {
key: storedDetailGates[index]?.key ?? `gate${index + 1}`,
targetProgramId: storedDetailGates[index] ? storedDetailGates[index].targetProgramId : programId,
stepId: storedDetailGates[index]?.stepId ?? step?.id,
question: storedDetailGates[index]?.question ?? `${step?.title ?? `${index + 1}단계`} 변경?`,
yes: storedDetailGates[index]?.yes ?? `예: ${step?.title ?? `${index + 1}단계`} 재진입`,
no: storedDetailGates[index]?.no ?? (index === count - 1 ? '아니오: 변경사항 없음' : '아니오: 다음 검토')
};
});
}
function readStoredProgramStates() {
try {
const stored = window.localStorage.getItem(programStateStorageKey);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}
function publishProgramStates(states) {
try {
window.localStorage.setItem(programStateStorageKey, JSON.stringify(states));
patchServerState({ programStates: states });
const channel = new BroadcastChannel(channelName);
channel.postMessage({ states });
channel.close();
} catch {
window.localStorage.setItem(programStateStorageKey, JSON.stringify(states));
patchServerState({ programStates: states });
}
}
function saveServerState(payload, method = 'PUT') {
return fetch(serverStateEndpoint, {
method,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
}
function patchServerState(payload) {
return saveServerState(payload, 'PATCH').catch(() => undefined);
}
function getProgramGates(states, programId, steps) {
return {
...createInitialGates(steps),
...(states[programId] ?? {})
};
}
function getActiveGate(gates, reviewItems) {
return reviewItems.find((gate) => gates[gate.key] === 'yes');
}
function getActiveStep(gates, reviewItems) {
return getActiveGate(gates, reviewItems)?.stepId ?? 'completed';
}
function getReentryContext(programs, programStates, getProgramReviewItems) {
return programs
.map((sourceProgram) => {
const reviewItems = getProgramReviewItems(sourceProgram);
const gates = getProgramGates(programStates, sourceProgram.id, sourceProgram.steps);
const activeGate = getActiveGate(gates, reviewItems);
if (!activeGate) return null;
const targetProgramId = resolveGateTargetProgramId(activeGate, sourceProgram.id, programs);
const targetProgramIndex = programs.findIndex((program) => program.id === targetProgramId);
if (targetProgramIndex < 0) return null;
return {
sourceProgramId: sourceProgram.id,
targetProgramId,
targetProgramIndex,
targetStepId: activeGate.stepId
};
})
.find(Boolean);
}
function resolveGateTargetProgramId(gate, sourceProgramId, allPrograms) {
if (gate?.targetProgramId) return gate.targetProgramId;
return allPrograms.find((program) => program.steps.some((step) => step.id === gate?.stepId))?.id ?? sourceProgramId;
}
function getStepLabel(program, stepId) {
return program?.steps.find((step) => step.id === stepId)?.title ?? stepId ?? '';
}
function moveItem(items, fromIndex, toIndex) {
if (toIndex < 0 || toIndex >= items.length) return items;
const nextItems = [...items];
const [movedItem] = nextItems.splice(fromIndex, 1);
nextItems.splice(toIndex, 0, movedItem);
return nextItems;
}
function splitDeliverableText(item) {
return String(item ?? '')
.split(', ')
.map((value) => value.trim())
.filter(Boolean);
}
function FlowCard({ step, index, total, accent, status, isEditing, onChange }) {
const Icon = step.icon ?? pickStepIcon(step);
return (
{status === 'active' ? '재진입' : status === 'completed' ? '통과' : `${String(index + 1).padStart(2, '0')} / ${String(total).padStart(2, '0')}`}
{isEditing ? (
onChange(index, 'title', event.target.value)}
className="w-full rounded-md border border-slate-200 bg-white px-2 py-1.5 text-sm font-extrabold text-slate-950 outline-none focus:border-teal-400"
/>
) : (
<>
{step.title}
{step.feature}
{step.note}
>
)}
);
}
function FlowRow({
label,
description,
steps,
accent,
onLabelClick,
activeStep,
isEditing,
onStepChange,
format,
onFormatChange,
deliverables = [],
onDeliverableChange,
onLabelChange,
onDescriptionChange,
onAddStep,
onRemoveStep,
onMoveStep,
onProgramDelete
}) {
const activeIndex = steps.findIndex((step) => step.id === activeStep);
const getStatus = (index) => {
if (activeStep === disabledFlowStep) return 'disabled';
if (!activeStep || activeStep === 'completed') return activeStep === 'completed' ? 'completed' : 'idle';
if (index === activeIndex) return 'active';
if (index < activeIndex) return 'completed';
return 'disabled';
};
const isRowClickable = Boolean(onLabelClick) && !isEditing;
const isWrappedStepLayout = steps.length > 5;
const isSingleStepLayout = steps.length === 1;
const stepRows = isWrappedStepLayout
? steps.reduce((rows, step, index) => {
const rowIndex = Math.floor(index / 4);
rows[rowIndex] = [...(rows[rowIndex] ?? []), { step, index }];
return rows;
}, [])
: [];
const wrappedCardColumns = ['xl:col-start-1', 'xl:col-start-3', 'xl:col-start-5', 'xl:col-start-7'];
const wrappedArrowColumns = ['xl:col-start-2', 'xl:col-start-4', 'xl:col-start-6'];
const renderStepCard = (step, index, className) => (
{isEditing && (onMoveStep || onRemoveStep) && (
{onMoveStep && (
<>
>
)}
{onRemoveStep && steps.length > 1 && (
)}
)}
);
return (
{onFormatChange && (
포맷
{isEditing ? (
onFormatChange(event.target.value)}
placeholder="예: glb"
className="h-8 w-28 rounded-full border border-slate-200 bg-white/85 px-3 text-[12px] font-extrabold text-slate-700 outline-none focus:border-teal-400"
/>
) : (
{format || '-'}
)}
)}
{(deliverables.length > 0 || isEditing) && (
성과물
{(deliverables.length > 0 ? deliverables : ['']).map((item, index) => (
isEditing ? (
onDeliverableChange?.(index, event.target.value)}
placeholder="성과물"
className="h-7 w-28 rounded-full border border-slate-200 bg-white/85 px-3 text-[11px] font-extrabold text-slate-700 outline-none focus:border-teal-400"
/>
) : splitDeliverableText(item).map((deliverable, deliverableIndex) => (
{deliverable}
))
))}
)}
{isEditing && onAddStep && (
)}
{isWrappedStepLayout ? (
{stepRows.map((row, rowIndex) => (
{row.map(({ step, index }, itemIndex) => (
{renderStepCard(
step,
index,
`min-w-0 ${wrappedCardColumns[itemIndex]}`
)}
{itemIndex < row.length - 1 && (
)}
))}
))}
) : (
{steps.map((step, index) => (
{renderStepCard(step, index, isSingleStepLayout ? 'w-full max-w-[320px]' : 'min-w-0 flex-1')}
{index < steps.length - 1 && (
)}
))}
)}
);
}
function DetailPopup({
program,
allPrograms,
gates,
setGates,
selectedStep,
setSelectedStep,
reviewItems,
onReviewItemChange,
onAddReviewItem,
onRemoveReviewItem,
onMoveReviewItem,
onClose,
standalone = false
}) {
const [position, setPosition] = useState({ x: 24, y: 72 });
const [dragStart, setDragStart] = useState(null);
const [isDetailEditing, setIsDetailEditing] = useState(false);
const [changeSearch, setChangeSearch] = useState('');
const normalizedGates = reviewItems.reduce((nextGates, item) => ({
...nextGates,
[item.key]: gates[item.key] ?? 'no'
}), {});
const activeStep = getActiveStep(normalizedGates, reviewItems);
const getTargetProgram = (gate) => allPrograms.find((item) => item.id === resolveGateTargetProgramId(gate, program.id, allPrograms)) ?? program;
const getTargetLabel = (gate) => {
const targetProgram = getTargetProgram(gate);
return `${targetProgram.name} · ${getStepLabel(targetProgram, gate.stepId)}`;
};
const normalizedSearch = changeSearch.trim().toLowerCase();
const isSearchMatched = (gate) => Boolean(normalizedSearch) && [
gate.question,
gate.yes,
gate.no,
getTargetLabel(gate)
].join(' ').toLowerCase().includes(normalizedSearch);
const searchMatchCount = normalizedSearch
? reviewItems.filter((gate) => isSearchMatched(gate)).length
: 0;
const setGate = (key, value) => {
const nextGates = { ...normalizedGates, [key]: value };
setGates(nextGates);
const gate = reviewItems.find((item) => item.key === key);
if (value === 'yes' && gate) setSelectedStep(gate.stepId);
if (value === 'no' && key === reviewItems[reviewItems.length - 1]?.key) setSelectedStep('completed');
};
useEffect(() => {
if (!dragStart) return undefined;
const movePopup = (event) => {
const nextX = dragStart.originX + event.clientX - dragStart.startX;
const nextY = dragStart.originY + event.clientY - dragStart.startY;
const maxX = Math.max(12, window.innerWidth - 360);
const maxY = Math.max(12, window.innerHeight - 96);
setPosition({
x: Math.min(Math.max(12, nextX), maxX),
y: Math.min(Math.max(12, nextY), maxY)
});
};
const stopDrag = () => setDragStart(null);
window.addEventListener('pointermove', movePopup);
window.addEventListener('pointerup', stopDrag);
window.addEventListener('pointercancel', stopDrag);
document.body.style.userSelect = 'none';
return () => {
window.removeEventListener('pointermove', movePopup);
window.removeEventListener('pointerup', stopDrag);
window.removeEventListener('pointercancel', stopDrag);
document.body.style.userSelect = '';
};
}, [dragStart]);
const startDrag = (event) => {
if (event.target.closest('button')) return;
setDragStart({
startX: event.clientX,
startY: event.clientY,
originX: position.x,
originY: position.y
});
};
const header = (
{program.name} 상세 플로우
마지막 단계 완료 후 변경사항 검토 피드백 엔진
);
const content = (
<>
{header}
{normalizedSearch && (
`{changeSearch}` 매칭 스텝 {searchMatchCount}개
)}
{reviewItems.map((gate, index) => {
const blocked = reviewItems.slice(0, index).some((item) => normalizedGates[item.key] === 'yes');
const active = normalizedGates[gate.key] === 'yes';
const passed = normalizedGates[gate.key] === 'no' && !blocked;
const matched = isSearchMatched(gate);
return (
{isDetailEditing ? (
) : (
{gate.yes}
대상: {getTargetLabel(gate)}
)}
{isDetailEditing ? (
) : (
{gate.question}
)}
{isDetailEditing ? (
onReviewItemChange(index, 'no', event.target.value)}
className="w-full rounded border border-emerald-200 bg-white px-2 py-1 text-[11px] font-extrabold text-emerald-700 outline-none"
/>
) : (
{gate.no}
)}
{index < reviewItems.length - 1 && (
)}
);
})}
>
);
if (standalone) {
return {content};
}
return (
);
}
function RelationPopup({ programs, onToggleRelation, onClose }) {
const [isRelationEditorOpen, setIsRelationEditorOpen] = useState(false);
const relations = programs.flatMap((program) =>
(program.successors ?? [])
.map((successorId) => ({
from: program,
to: programs.find((item) => item.id === successorId)
}))
.filter((relation) => relation.to)
);
const connectedProgramIds = new Set(relations.flatMap((relation) => [relation.from.id, relation.to.id]));
const isolatedPrograms = programs.filter((program) => !connectedProgramIds.has(program.id));
const levelMap = programs.reduce((levels, program) => ({ ...levels, [program.id]: 0 }), {});
for (let pass = 0; pass < programs.length; pass += 1) {
relations.forEach((relation) => {
levelMap[relation.to.id] = Math.max(levelMap[relation.to.id], levelMap[relation.from.id] + 1);
});
}
const graphPrograms = programs.filter((program) => connectedProgramIds.has(program.id));
const graphLevels = graphPrograms.reduce((levels, program) => {
const level = levelMap[program.id] ?? 0;
levels[level] = [...(levels[level] ?? []), program];
return levels;
}, []);
const nodeWidth = 160;
const nodeHeight = 48;
const columnGap = 42;
const rowGap = 132;
const graphPadding = 54;
const sideLaneWidth = 240;
const maxLevelCount = Math.max(1, ...graphLevels.map((level) => level?.length ?? 0));
const graphWidth = Math.max(560, graphPadding * 2 + sideLaneWidth + maxLevelCount * nodeWidth + Math.max(0, maxLevelCount - 1) * columnGap);
const graphHeight = Math.max(620, graphPadding * 2 + graphLevels.length * nodeHeight + Math.max(0, graphLevels.length - 1) * rowGap);
const nodePositions = Object.fromEntries(
graphLevels.flatMap((levelPrograms = [], levelIndex) => {
const rowWidth = levelPrograms.length * nodeWidth + Math.max(0, levelPrograms.length - 1) * columnGap;
const startX = (graphWidth - rowWidth) / 2;
const y = graphPadding + levelIndex * (nodeHeight + rowGap);
return levelPrograms.map((program, rowIndex) => [
program.id,
{
x: startX + rowIndex * (nodeWidth + columnGap),
y
}
]);
})
);
return (
프로그램 연관관계
전체 프로그램 연결도 및 관계 수정
연결도
입력한 선행/후행 관계를 화살표로 표시합니다
{relations.length}개 연결
{relations.length > 0 ? (
{graphPrograms.map((program) => {
const position = nodePositions[program.id];
if (!position) return null;
return (
);
})}
) : (
아직 연결된 프로그램이 없습니다. 아래에서 선행/후행을 선택하세요.
)}
{isolatedPrograms.length > 0 && (
미연결
{isolatedPrograms.map((program) => (
{program.name}
))}
)}
{isRelationEditorOpen && (
프로그램 연결 수정
각 프로그램의 선행/후행을 선택하면 연결도가 바로 반영됩니다
{programs.map((program) => {
const candidates = programs.filter((item) => item.id !== program.id);
return (
);
})}
)}
);
}
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);
const indent = Math.min(depth, 5) * 13;
return (
{depth > 0 && (
)}
{hasChildren && !isRepeated ? (
) : (
)}
{hasMultipleInputs && (
다중
)}
{hasChildren && isExpanded && !isRepeated && (
{successors.map((successor) => renderNode(successor, depth + 1, nextVisitedIds))}
)}
);
};
return (
);
}
export default function App() {
const urlParams = new URLSearchParams(window.location.search);
const isDetailWindow = urlParams.get('view') === 'program-detail' || urlParams.get('view') === 'cheonjiin-detail';
const detailProgramId = urlParams.get('program') ?? 'cheonjiin';
const [programStates, setProgramStates] = useState(readStoredProgramStates);
const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(readStoredContent);
const [isRelationPopupOpen, setIsRelationPopupOpen] = useState(false);
const [isServerLoaded, setIsServerLoaded] = useState(false);
const editableCheonjiinFlow = mergeStepContent(cheonjiinFlow, content.cheonjiin.steps);
const editableWayPrimalFlow = mergeStepContent(wayPrimalFlow, content.wayPrimal.steps);
const programs = [
{
id: 'cheonjiin',
name: content.cheonjiin.name,
description: content.cheonjiin.description,
steps: editableCheonjiinFlow,
format: content.cheonjiin.format,
predecessors: content.cheonjiin.predecessors ?? [],
successors: content.cheonjiin.successors ?? [],
accent: {
iconBg: 'bg-teal-50',
iconText: 'text-teal-700',
labelText: 'text-teal-700',
arrowText: 'text-teal-600'
}
},
{
id: 'wayPrimal',
name: content.wayPrimal.name,
description: content.wayPrimal.description,
steps: editableWayPrimalFlow,
format: content.wayPrimal.format,
predecessors: content.wayPrimal.predecessors ?? [],
successors: content.wayPrimal.successors ?? [],
accent: {
iconBg: 'bg-indigo-50',
iconText: 'text-indigo-700',
labelText: 'text-indigo-700',
arrowText: 'text-indigo-600'
}
},
...content.extraPrograms.map((program) => ({
id: program.id,
name: program.name,
description: program.description,
steps: normalizeProgramSteps(program),
format: program.format,
predecessors: program.predecessors ?? [],
successors: program.successors ?? [],
accent: {
iconBg: 'bg-blue-50',
iconText: 'text-blue-700',
labelText: 'text-blue-700',
arrowText: 'text-blue-600'
}
}))
];
const getStoredProgram = (programId) => (
programId === 'cheonjiin'
? content.cheonjiin
: programId === 'wayPrimal'
? content.wayPrimal
: content.extraPrograms.find((program) => program.id === programId)
);
const getProgramReviewItems = (program) => buildReviewItems(
program.id,
program.steps,
getStoredProgram(program.id)?.detailGates
);
const reentryContext = getReentryContext(programs, programStates, getProgramReviewItems);
const getMainActiveStep = (programId) => {
if (!reentryContext) return 'completed';
const programIndex = programs.findIndex((program) => program.id === programId);
if (programId === reentryContext.targetProgramId) return reentryContext.targetStepId;
if (programIndex > reentryContext.targetProgramIndex) return disabledFlowStep;
return 'completed';
};
useEffect(() => {
const channel = new BroadcastChannel(channelName);
channel.onmessage = (event) => {
if (!event.data?.states) return;
setProgramStates(event.data.states);
};
return () => channel.close();
}, []);
useEffect(() => {
let isMounted = true;
const loadServerState = async () => {
try {
const response = await fetch(serverStateEndpoint);
if (response.status === 404) {
await saveServerState({
content,
programStates
});
if (isMounted) setIsServerLoaded(true);
return;
}
if (!response.ok) throw new Error('Failed to load server state');
const serverState = await response.json();
if (!isMounted) return;
if (serverState.content) {
const nextContent = normalizeStoredContent(serverState.content);
setContent(nextContent);
window.localStorage.setItem(contentStorageKey, JSON.stringify(nextContent));
}
if (serverState.programStates) {
setProgramStates(serverState.programStates);
window.localStorage.setItem(programStateStorageKey, JSON.stringify(serverState.programStates));
}
setIsServerLoaded(true);
} catch {
if (isMounted) setIsServerLoaded(true);
}
};
loadServerState();
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
window.localStorage.setItem(contentStorageKey, JSON.stringify(content));
}, [content]);
useEffect(() => {
if (!isServerLoaded) return;
const saveTimer = window.setTimeout(() => {
saveServerState({
content,
programStates
}).catch(() => undefined);
}, 250);
return () => window.clearTimeout(saveTimer);
}, [content, programStates, isServerLoaded]);
useEffect(() => {
const syncStoredContent = (event) => {
if (event.key === contentStorageKey) {
setContent(readStoredContent());
}
if (event.key === programStateStorageKey) {
setProgramStates(readStoredProgramStates());
}
};
window.addEventListener('storage', syncStoredContent);
return () => window.removeEventListener('storage', syncStoredContent);
}, []);
useEffect(() => {
window.localStorage.setItem(programStateStorageKey, JSON.stringify(programStates));
}, [programStates]);
useEffect(() => {
if (!isDetailWindow) return undefined;
const resetMainFlowState = () => {
publishProgramStates({});
};
window.addEventListener('beforeunload', resetMainFlowState);
return () => window.removeEventListener('beforeunload', resetMainFlowState);
}, [isDetailWindow]);
const updateStep = (program, index, field, value) => {
setContent((current) => ({
...current,
[program]: {
...current[program],
steps: current[program].steps.map((step, stepIndex) =>
stepIndex === index ? { ...step, [field]: value } : step
)
}
}));
};
const createStep = (programId, nextIndex) => ({
id: `${programId}-${Date.now()}-${nextIndex}`,
title: `${nextIndex}단계`,
feature: '주요 기능 입력',
note: '단계 설명을 입력합니다.'
});
const addStep = (programId) => {
setContent((current) => {
if (programId === 'cheonjiin' || programId === 'wayPrimal') {
const steps = current[programId].steps ?? [];
return {
...current,
[programId]: {
...current[programId],
steps: [...steps, createStep(programId, steps.length + 1)]
}
};
}
return {
...current,
extraPrograms: current.extraPrograms.map((program) =>
program.id === programId
? {
...program,
steps: [...program.steps, createStep(program.id, program.steps.length + 1)]
}
: program
)
};
});
};
const removeStep = (programId, stepIndex) => {
setContent((current) => {
if (programId === 'cheonjiin' || programId === 'wayPrimal') {
const steps = current[programId].steps ?? [];
if (steps.length <= 1) return current;
return {
...current,
[programId]: {
...current[programId],
steps: steps.filter((_, index) => index !== stepIndex)
}
};
}
return {
...current,
extraPrograms: current.extraPrograms.map((program) =>
program.id === programId && program.steps.length > 1
? { ...program, steps: program.steps.filter((_, index) => index !== stepIndex) }
: program
)
};
});
};
const moveStep = (programId, stepIndex, nextIndex) => {
setContent((current) => {
if (programId === 'cheonjiin' || programId === 'wayPrimal') {
return {
...current,
[programId]: {
...current[programId],
steps: moveItem(current[programId].steps ?? [], stepIndex, nextIndex)
}
};
}
return {
...current,
extraPrograms: current.extraPrograms.map((program) =>
program.id === programId
? { ...program, steps: moveItem(program.steps, stepIndex, nextIndex) }
: program
)
};
});
};
const updateFormat = (program, value) => {
setContent((current) => ({
...current,
[program]: {
...current[program],
format: value
}
}));
};
const updateDeliverable = (index, value) => {
setContent((current) => ({
...current,
cheonjiin: {
...current.cheonjiin,
deliverables: current.cheonjiin.deliverables.map((item, itemIndex) =>
itemIndex === index ? value : item
)
}
}));
};
const updateProgramDeliverable = (programId, itemIndex, value) => {
if (programId === 'cheonjiin') {
updateDeliverable(itemIndex, value);
return;
}
if (programId === 'wayPrimal') {
setContent((current) => ({
...current,
wayPrimal: {
...current.wayPrimal,
deliverables: (current.wayPrimal.deliverables ?? ['']).map((item, index) =>
index === itemIndex ? value : item
)
}
}));
return;
}
setContent((current) => ({
...current,
extraPrograms: current.extraPrograms.map((program) =>
program.id === programId
? {
...program,
deliverables: (program.deliverables ?? ['']).map((item, index) =>
index === itemIndex ? value : item
)
}
: program
)
}));
};
const updateProgramLinkLabel = (programId, value) => {
if (programId === 'wayPrimal') {
setContent((current) => ({
...current,
wayPrimal: {
...current.wayPrimal,
linkLabel: value
}
}));
return;
}
setContent((current) => ({
...current,
extraPrograms: current.extraPrograms.map((program) =>
program.id === programId
? { ...program, linkLabel: value }
: program
)
}));
};
const updateProgramRecord = (current, programId, updater) => {
if (programId === 'cheonjiin' || programId === 'wayPrimal') {
return {
...current,
[programId]: updater(current[programId])
};
}
return {
...current,
extraPrograms: current.extraPrograms.map((program) =>
program.id === programId ? updater(program) : program
)
};
};
const toggleProgramRelation = (programId, field, relatedProgramId, checked) => {
const oppositeField = field === 'predecessors' ? 'successors' : 'predecessors';
const updateList = (list = [], value, shouldAdd) => (
shouldAdd
? [...new Set([...list, value])]
: list.filter((item) => item !== value)
);
setContent((current) => {
const nextContent = updateProgramRecord(current, programId, (program) => ({
...program,
[field]: updateList(program[field], relatedProgramId, checked)
}));
return updateProgramRecord(nextContent, relatedProgramId, (program) => ({
...program,
[oppositeField]: updateList(program[oppositeField], programId, checked)
}));
});
};
const updateProgramTitle = (programId, field, value) => {
if (programId === 'cheonjiin' || programId === 'wayPrimal') {
const target = programId === 'cheonjiin' ? 'cheonjiin' : 'wayPrimal';
setContent((current) => ({
...current,
[target]: {
...current[target],
[field]: value
}
}));
return;
}
setContent((current) => ({
...current,
extraPrograms: current.extraPrograms.map((program) =>
program.id === programId
? { ...program, [field]: value }
: program
)
}));
};
const addProgram = () => {
const id = `program-${Date.now()}`;
setContent((current) => ({
...current,
extraPrograms: [
...current.extraPrograms,
{
id,
name: '새 프로그램',
description: '새 프로그램 업무 플로우',
steps: [
{
id: `${id}-1`,
title: '1단계',
feature: '주요 기능 입력',
note: '단계 설명을 입력합니다.'
},
{
id: `${id}-2`,
title: '2단계',
feature: '주요 기능 입력',
note: '단계 설명을 입력합니다.'
}
],
format: '',
deliverables: ['성과물'],
predecessors: [],
successors: [],
linkLabel: '이전 프로그램 산출물을 새 프로그램 입력으로 연계'
}
]
}));
setIsEditing(true);
};
const toggleProgramEdit = () => {
setIsEditing((current) => !current);
};
const updateProgram = (programIndex, field, value) => {
setContent((current) => ({
...current,
extraPrograms: current.extraPrograms.map((program, index) =>
index === programIndex ? { ...program, [field]: value } : program
)
}));
};
const updateProgramStep = (programIndex, stepIndex, field, value) => {
setContent((current) => ({
...current,
extraPrograms: current.extraPrograms.map((program, index) =>
index === programIndex
? {
...program,
steps: program.steps.map((step, currentStepIndex) =>
currentStepIndex === stepIndex ? { ...step, [field]: value } : step
)
}
: program
)
}));
};
const removeProgram = (programIndex) => {
setContent((current) => ({
...current,
extraPrograms: current.extraPrograms.filter((_, index) => index !== programIndex)
}));
};
const moveProgram = (programIndex, nextIndex) => {
setContent((current) => ({
...current,
extraPrograms: moveItem(current.extraPrograms, programIndex, nextIndex)
}));
};
const updateProgramGates = (programId, gates) => {
setProgramStates((current) => {
const nextStates = { ...current, [programId]: gates };
publishProgramStates(nextStates);
return nextStates;
});
};
const updateDetailGate = (programId, gateIndex, field, value) => {
setContent((current) => {
if (programId === 'cheonjiin' || programId === 'wayPrimal') {
const target = programId === 'cheonjiin' ? 'cheonjiin' : 'wayPrimal';
const nextDetailGates = [
...(current[target].detailGates ?? buildReviewItems(programId, programs.find((program) => program.id === programId)?.steps ?? []))
];
nextDetailGates[gateIndex] = { ...nextDetailGates[gateIndex], [field]: value };
return {
...current,
[target]: {
...current[target],
detailGates: nextDetailGates
}
};
}
return {
...current,
extraPrograms: current.extraPrograms.map((program) => {
if (program.id !== programId) return program;
const baseSteps = normalizeProgramSteps(program);
const nextDetailGates = [
...(program.detailGates ?? buildReviewItems(program.id, baseSteps))
];
nextDetailGates[gateIndex] = { ...nextDetailGates[gateIndex], [field]: value };
return { ...program, detailGates: nextDetailGates };
})
};
});
};
const updateDetailGates = (programId, getNextDetailGates) => {
setContent((current) => {
if (programId === 'cheonjiin' || programId === 'wayPrimal') {
const target = programId === 'cheonjiin' ? 'cheonjiin' : 'wayPrimal';
const currentProgram = programs.find((program) => program.id === programId);
const currentGates = current[target].detailGates ?? buildReviewItems(programId, currentProgram?.steps ?? []);
return {
...current,
[target]: {
...current[target],
detailGates: getNextDetailGates(currentGates, currentProgram?.steps ?? [])
}
};
}
return {
...current,
extraPrograms: current.extraPrograms.map((program) => {
if (program.id !== programId) return program;
const baseSteps = normalizeProgramSteps(program);
const currentGates = program.detailGates ?? buildReviewItems(program.id, baseSteps);
return {
...program,
detailGates: getNextDetailGates(currentGates, baseSteps)
};
})
};
});
};
const addDetailGate = (programId) => {
updateDetailGates(programId, (currentGates, steps) => {
const nextIndex = currentGates.length;
const targetStep = steps[Math.min(nextIndex, Math.max(0, steps.length - 2))] ?? steps[0];
return [
...currentGates.map((gate, index) => ({
...gate,
no: index === currentGates.length - 1 ? '아니오: 다음 검토' : gate.no
})),
{
key: `gate${nextIndex + 1}-${Date.now()}`,
targetProgramId: programId,
stepId: targetStep?.id,
question: `${targetStep?.title ?? `${nextIndex + 1}단계`} 변경?`,
yes: `예: ${targetStep?.title ?? `${nextIndex + 1}단계`} 재진입`,
no: '아니오: 변경사항 없음'
}
];
});
};
const removeDetailGate = (programId, gateIndex) => {
updateDetailGates(programId, (currentGates) => {
const nextGates = currentGates.filter((_, index) => index !== gateIndex);
return nextGates.map((gate, index) => ({
...gate,
no: index === nextGates.length - 1 ? '아니오: 변경사항 없음' : gate.no
}));
});
};
const moveDetailGate = (programId, gateIndex, nextIndex) => {
updateDetailGates(programId, (currentGates) => {
const nextGates = moveItem(currentGates, gateIndex, nextIndex);
return nextGates.map((gate, index) => ({
...gate,
no: index === nextGates.length - 1 ? '아니오: 변경사항 없음' : gate.no
}));
});
};
const resetContent = () => {
setContent(defaultContent);
window.localStorage.removeItem(contentStorageKey);
setProgramStates({});
window.localStorage.removeItem(programStateStorageKey);
};
const openProgramWindow = (programId) => {
publishProgramStates(programStates);
window.open(
`/?view=program-detail&program=${encodeURIComponent(programId)}`,
'program-detail-flow',
'popup=yes,width=620,height=990,left=80,top=40,resizable=yes,scrollbars=yes'
);
};
if (isDetailWindow) {
const detailProgram = programs.find((program) => program.id === detailProgramId) ?? programs[0];
const storedProgram = getStoredProgram(detailProgram.id);
const reviewItems = buildReviewItems(detailProgram.id, detailProgram.steps, storedProgram?.detailGates);
const gates = getProgramGates(programStates, detailProgram.id, detailProgram.steps);
return (
updateProgramGates(detailProgram.id, nextGates)}
selectedStep={getActiveStep(gates, reviewItems)}
setSelectedStep={() => {}}
reviewItems={reviewItems}
onReviewItemChange={(index, field, value) => updateDetailGate(detailProgram.id, index, field, value)}
onAddReviewItem={() => addDetailGate(detailProgram.id)}
onRemoveReviewItem={(index) => removeDetailGate(detailProgram.id, index)}
onMoveReviewItem={(index, nextIndex) => moveDetailGate(detailProgram.id, index, nextIndex)}
onClose={() => {
publishProgramStates({});
window.close();
}}
standalone
/>
);
}
return (
setIsRelationPopupOpen(true)}
/>
{isEditing && (
프로그램 편집
프로그램을 추가하거나 기존 내용을 수정합니다
)}
openProgramWindow('cheonjiin')}
activeStep={getMainActiveStep('cheonjiin')}
isEditing={isEditing}
onStepChange={(index, field, value) => updateStep('cheonjiin', index, field, value)}
onLabelChange={(value) => updateProgramTitle('cheonjiin', 'name', value)}
onDescriptionChange={(value) => updateProgramTitle('cheonjiin', 'description', value)}
format={content.cheonjiin.format}
onFormatChange={(value) => updateFormat('cheonjiin', value)}
deliverables={content.cheonjiin.deliverables}
onDeliverableChange={(index, value) => updateProgramDeliverable('cheonjiin', index, value)}
onAddStep={() => addStep('cheonjiin')}
onRemoveStep={(index) => removeStep('cheonjiin', index)}
onMoveStep={(index, nextIndex) => moveStep('cheonjiin', index, nextIndex)}
accent={{
iconBg: 'bg-teal-50',
iconText: 'text-teal-700',
labelText: 'text-teal-700',
arrowText: 'text-teal-600'
}}
/>
{isEditing ? (
updateProgramLinkLabel('wayPrimal', event.target.value)}
className="min-w-[360px] rounded-full border border-slate-200 bg-white/85 px-3 py-2 text-sm font-bold text-slate-700 outline-none focus:border-indigo-400"
/>
) : (
{content.wayPrimal.linkLabel}
)}
openProgramWindow('wayPrimal')}
activeStep={getMainActiveStep('wayPrimal')}
isEditing={isEditing}
onStepChange={(index, field, value) => updateStep('wayPrimal', index, field, value)}
onLabelChange={(value) => updateProgramTitle('wayPrimal', 'name', value)}
onDescriptionChange={(value) => updateProgramTitle('wayPrimal', 'description', value)}
format={content.wayPrimal.format}
onFormatChange={(value) => updateFormat('wayPrimal', value)}
deliverables={content.wayPrimal.deliverables ?? []}
onDeliverableChange={(index, value) => updateProgramDeliverable('wayPrimal', index, value)}
onAddStep={() => addStep('wayPrimal')}
onRemoveStep={(index) => removeStep('wayPrimal', index)}
onMoveStep={(index, nextIndex) => moveStep('wayPrimal', index, nextIndex)}
accent={{
iconBg: 'bg-indigo-50',
iconText: 'text-indigo-700',
labelText: 'text-indigo-700',
arrowText: 'text-indigo-600'
}}
/>
{content.extraPrograms.map((program, programIndex) => (
{isEditing ? (
updateProgramLinkLabel(program.id, event.target.value)}
className="min-w-[360px] rounded-full border border-slate-200 bg-white/85 px-3 py-2 text-sm font-bold text-slate-700 outline-none focus:border-blue-400"
/>
) : (
{program.linkLabel ?? `이전 프로그램 산출물을 ${program.name} 입력으로 연계`}
)}
{isEditing && (
)}
openProgramWindow(program.id)}
activeStep={getMainActiveStep(program.id)}
isEditing={isEditing}
onStepChange={(index, field, value) => updateProgramStep(programIndex, index, field, value)}
onLabelChange={(value) => updateProgramTitle(program.id, 'name', value)}
onDescriptionChange={(value) => updateProgramTitle(program.id, 'description', value)}
format={program.format}
onFormatChange={(value) => updateProgram(programIndex, 'format', value)}
deliverables={program.deliverables ?? []}
onDeliverableChange={(index, value) => updateProgramDeliverable(program.id, index, value)}
onAddStep={() => addStep(program.id)}
onRemoveStep={(index) => removeStep(program.id, index)}
onMoveStep={(index, nextIndex) => moveStep(program.id, index, nextIndex)}
onProgramDelete={() => removeProgram(programIndex)}
accent={{
iconBg: 'bg-blue-50',
iconText: 'text-blue-700',
labelText: 'text-blue-700',
arrowText: 'text-blue-600'
}}
/>
))}
{isRelationPopupOpen && (
setIsRelationPopupOpen(false)}
/>
)}
);
}