import React, { useEffect, useState } from 'react';
import {
ArrowDown,
ArrowRight,
ArrowUp,
Building2,
ClipboardList,
DraftingCompass,
Edit3,
Layers3,
Map,
Maximize2,
Minimize2,
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 programTypeOptions = {
internal: {
label: '사내프로그램',
shortLabel: '사내',
rowClass: 'border-emerald-100 bg-emerald-50/55',
badgeClass: 'bg-emerald-100 text-emerald-700 ring-emerald-200',
dotClass: 'bg-emerald-500'
},
commercial: {
label: '상용프로그램',
shortLabel: '상용',
rowClass: 'border-violet-100 bg-violet-50/55',
badgeClass: 'bg-violet-100 text-violet-700 ring-violet-200',
dotClass: 'bg-violet-500'
}
};
const getProgramType = (type) => (type === 'commercial' ? 'commercial' : 'internal');
const getProgramTypeMeta = (type) => programTypeOptions[getProgramType(type)];
const defaultContent = {
cheonjiin: {
name: '천지인',
description: '공간정보 입력·검토·보정·유역면적 산정',
steps: cheonjiinFlow.map(({ title, feature, note }) => ({ title, feature, note })),
deliverables: cheonjiinDeliverables,
format: 'glb',
programType: 'internal',
predecessors: [],
successors: []
},
wayPrimal: {
name: 'WayPrimal',
description: '설계조건 입력부터 기본설계 성과물 생성',
steps: wayPrimalFlow.map(({ title, feature, note }) => ({ title, feature, note })),
format: '',
deliverables: ['기본설계 모델'],
programType: 'internal',
predecessors: [],
successors: [],
linkLabel: '천지인 산출 모델을 WayPrimal 설계 입력으로 연계'
},
comparisons: [],
extraPrograms: []
};
function normalizeStoredContent(parsed) {
if (!parsed) return defaultContent;
return {
cheonjiin: {
...defaultContent.cheonjiin,
...(parsed.cheonjiin ?? {}),
programType: getProgramType(parsed.cheonjiin?.programType ?? defaultContent.cheonjiin.programType),
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 ?? {}),
programType: getProgramType(parsed.wayPrimal?.programType ?? defaultContent.wayPrimal.programType),
predecessors: parsed.wayPrimal?.predecessors ?? [],
successors: parsed.wayPrimal?.successors ?? [],
format:
parsed.wayPrimal?.format === '예: DWG, LandXML, XLSX, 도공계산서, 기본설계 모델 등'
? defaultContent.wayPrimal.format
: (parsed.wayPrimal?.format ?? defaultContent.wayPrimal.format)
},
comparisons: parsed.comparisons ?? [],
extraPrograms: (parsed.extraPrograms ?? []).map((program, index, programs) => ({
...program,
programType: getProgramType(program.programType),
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,
programType,
onProgramTypeChange,
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 programTypeKey = getProgramType(programType);
const programTypeMeta = getProgramTypeMeta(programTypeKey);
const maxVisibleSteps = 4;
const [stepWindowStart, setStepWindowStart] = useState(0);
const maxWindowStart = Math.max(0, steps.length - maxVisibleSteps);
const visibleStepItems = steps
.map((step, index) => ({ step, index }))
.slice(stepWindowStart, stepWindowStart + maxVisibleSteps);
const placeholderCount = Math.max(0, maxVisibleSteps - visibleStepItems.length);
const canMovePrev = stepWindowStart > 0;
const canMoveNext = stepWindowStart < maxWindowStart;
useEffect(() => {
setStepWindowStart((current) => Math.min(current, maxWindowStart));
}, [maxWindowStart]);
const moveStepWindow = (direction) => {
setStepWindowStart((current) => Math.min(maxWindowStart, Math.max(0, current + direction)));
};
const renderStepCard = (step, index, className) => (
{isEditing && (onMoveStep || onRemoveStep) && (
{onMoveStep && (
<>
>
)}
{onRemoveStep && steps.length > 1 && (
)}
)}
);
return (
{isEditing && onLabelChange ? (
onLabelChange(event.target.value)}
className={`w-44 rounded-xl border border-slate-200 bg-white/85 px-3 py-2 text-xl font-extrabold tracking-tight outline-none focus:border-teal-400 ${accent.labelText}`}
/>
) : (
)}
{isEditing && onProgramDelete && (
)}
{isEditing && onProgramTypeChange ? (
{Object.entries(programTypeOptions).map(([typeKey, typeMeta]) => (
))}
) : (
{programTypeMeta.label}
)}
{isEditing && onDescriptionChange ? (
{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 && (
)}
{steps.length > maxVisibleSteps && (
<>
>
)}
{visibleStepItems.map(({ step, index }, itemIndex) => (
{renderStepCard(step, index, 'min-w-0')}
{itemIndex < visibleStepItems.length - 1 && (
)}
))}
{Array.from({ length: placeholderCount }).map((_, index) => (
))}
{visibleStepItems.slice(0, -1).map((item, index) => (
))}
{steps.length > 1 && (
{steps.map((step, index) => {
const isVisible = index >= stepWindowStart && index < stepWindowStart + maxVisibleSteps;
return (
)}
);
}
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 }) {
return (
프로그램 연결 수정
각 프로그램의 선행/후행을 선택하면 메인 연결도에 바로 반영됩니다.
{programs.map((program) => {
const candidates = programs.filter((item) => item.id !== program.id);
return (
);
})}
);
}
function ProgramComparePopup({ programs, comparisons, onComparisonChange, onClose }) {
const internalProgram = programs.find((program) => getProgramType(program.programType) === 'internal');
const commercialProgram = programs.find((program) => getProgramType(program.programType) === 'commercial');
const savedComparisons = comparisons.filter(
(comparison) =>
programs.some((program) => program.id === comparison.leftProgramId) &&
programs.some((program) => program.id === comparison.rightProgramId) &&
((comparison.stepMatches ?? []).some((match) => match.reason?.trim()) || comparison.note?.trim())
).sort((left, right) => (right.updatedAt ?? '').localeCompare(left.updatedAt ?? ''));
const [leftProgramId, setLeftProgramId] = useState(savedComparisons[0]?.leftProgramId ?? internalProgram?.id ?? programs[0]?.id ?? '');
const [rightProgramId, setRightProgramId] = useState(
savedComparisons[0]?.rightProgramId ??
commercialProgram?.id ??
programs.find((program) => program.id !== (internalProgram?.id ?? programs[0]?.id))?.id ??
''
);
const leftProgram = programs.find((program) => program.id === leftProgramId);
const rightProgram = programs.find((program) => program.id === rightProgramId);
const comparison =
comparisons.find((item) => item.leftProgramId === leftProgramId && item.rightProgramId === rightProgramId) ?? {};
const hasSavedComparison = Boolean((comparison.stepMatches ?? []).some((match) => match.reason?.trim()) || comparison.note?.trim());
const leftSteps = leftProgram?.steps ?? [];
const rightSteps = rightProgram?.steps ?? [];
const stepMatches = comparison.stepMatches ?? [];
const maxStepCount = Math.max(leftSteps.length, rightSteps.length);
const updateComparison = (field, value) => {
if (!leftProgramId || !rightProgramId || leftProgramId === rightProgramId) return;
onComparisonChange(leftProgramId, rightProgramId, field, value);
};
const getStepMatch = (stepIndex) =>
stepMatches.find(
(match) => String(match.leftStepIndex) === String(stepIndex) && String(match.rightStepIndex) === String(stepIndex)
) ?? {};
const updateStepReason = (stepIndex, value) => {
const existingIndex = stepMatches.findIndex(
(match) => String(match.leftStepIndex) === String(stepIndex) && String(match.rightStepIndex) === String(stepIndex)
);
if (!value.trim() && existingIndex >= 0) {
updateComparison('stepMatches', stepMatches.filter((_, index) => index !== existingIndex));
return;
}
if (!value.trim()) return;
const nextMatch = {
...(existingIndex >= 0 ? stepMatches[existingIndex] : {}),
id: `step-${stepIndex}`,
leftStepIndex: String(stepIndex),
rightStepIndex: String(stepIndex),
reason: value
};
updateComparison(
'stepMatches',
existingIndex >= 0
? stepMatches.map((match, index) => (index === existingIndex ? nextMatch : match))
: [...stepMatches, nextMatch]
);
};
const getStepLabel = (step, index) => `${index + 1}. ${step?.title ?? '-'}`;
const getProgramName = (programId) => programs.find((program) => program.id === programId)?.name ?? programId;
const getComparisonMemoCount = (savedComparison) =>
(savedComparison.stepMatches ?? []).filter((match) => match.reason?.trim()).length;
const renderProgramSummary = (program) => {
if (!program) return null;
const typeMeta = getProgramTypeMeta(program.programType);
return (
{program.description}
{(program.deliverables ?? []).map((deliverable) => (
{deliverable}
))}
포맷: {program.format || '-'}
);
};
return (
1:1 프로그램 비교
사내 한계와 상용 활용 기능 정리
{savedComparisons.length > 0 && (
저장된 비교
{savedComparisons.map((savedComparison) => {
const active =
savedComparison.leftProgramId === leftProgramId &&
savedComparison.rightProgramId === rightProgramId;
const memoCount = getComparisonMemoCount(savedComparison);
return (
);
})}
)}
{renderProgramSummary(leftProgram)}
{renderProgramSummary(rightProgram)}
전체 스텝 1:1 비교
두 프로그램의 스텝을 전부 펼친 뒤, 가운데에 해당 스텝에서 상용 프로그램을 쓰는 이유를 적습니다.
{hasSavedComparison ? ' 저장된 비교 내용을 불러왔습니다.' : ''}
{maxStepCount === 0 ? (
비교할 스텝이 없습니다.
) : (
Array.from({ length: maxStepCount }).map((_, stepIndex) => {
const leftStep = leftSteps[stepIndex];
const rightStep = rightSteps[stepIndex];
const match = getStepMatch(stepIndex);
return (
{leftProgram?.name ?? 'A'} 스텝
{leftStep ? getStepLabel(leftStep, stepIndex) : `${stepIndex + 1}. -`}
{leftStep?.feature || '해당 스텝 없음'}
{leftStep?.note && (
{leftStep.note}
)}
{rightProgram?.name ?? 'B'} 스텝
{rightStep ? getStepLabel(rightStep, stepIndex) : `${stepIndex + 1}. -`}
{rightStep?.feature || '해당 스텝 없음'}
{rightStep?.note && (
{rightStep.note}
)}
);
})
)}
);
}
function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup, sidebarWidth, onSidebarWidthChange }) {
const [expandedProgramIds, setExpandedProgramIds] = useState(() => new Set(programs.map((program) => program.id)));
const programMap = Object.fromEntries(programs.map((program) => [program.id, program]));
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 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 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 miniNodeWidth = 128;
const miniNodeHeight = 38;
const miniColumnGap = 52;
const miniRowGap = 96;
const miniPadding = 34;
const miniSideLaneWidth = 96;
const sidebarSizes = [280, 420, 560];
const resolvedSidebarWidth = sidebarWidth ?? sidebarSizes[0];
const miniViewportWidth = Math.max(230, resolvedSidebarWidth - 32);
const miniMaxLevelCount = Math.max(1, ...graphLevels.map((level) => level?.length ?? 0));
const miniGraphWidth = Math.max(
560,
miniPadding * 2 + miniSideLaneWidth + miniMaxLevelCount * miniNodeWidth + Math.max(0, miniMaxLevelCount - 1) * miniColumnGap
);
const miniGraphHeight = Math.max(
660,
miniPadding * 2 + graphLevels.length * miniNodeHeight + Math.max(0, graphLevels.length - 1) * miniRowGap
);
const miniNodePositions = Object.fromEntries(
graphLevels.flatMap((levelPrograms = [], levelIndex) => {
const rowWidth = levelPrograms.length * miniNodeWidth + Math.max(0, levelPrograms.length - 1) * miniColumnGap;
const startX = (miniGraphWidth - rowWidth) / 2;
const y = miniPadding + levelIndex * (miniNodeHeight + miniRowGap);
return levelPrograms.map((program, rowIndex) => [
program.id,
{
x: startX + rowIndex * (miniNodeWidth + miniColumnGap),
y
}
]);
})
);
const mapOffset = Math.max(0, Math.round((miniGraphWidth - miniViewportWidth) / 2));
const canShrinkSidebar = resolvedSidebarWidth > sidebarSizes[0];
const canGrowSidebar = resolvedSidebarWidth < sidebarSizes[sidebarSizes.length - 1];
const setSidebarSize = (size) => {
onSidebarWidthChange?.(size);
};
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 typeMeta = getProgramTypeMeta(program.programType);
const nextVisitedIds = new Set(visitedIds);
nextVisitedIds.add(program.id);
const indent = Math.min(depth, 5) * 13;
return (
{depth > 0 && (
)}
{hasChildren && !isRepeated ? (
) : (
)}
{typeMeta.shortLabel}
{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 [isComparePopupOpen, setIsComparePopupOpen] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(280);
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,
programType: getProgramType(content.cheonjiin.programType),
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,
programType: getProgramType(content.wayPrimal.programType),
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,
programType: getProgramType(program.programType),
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 updateComparison = (leftProgramId, rightProgramId, field, value) => {
setContent((current) => {
const comparisons = current.comparisons ?? [];
const comparisonIndex = comparisons.findIndex(
(comparison) => comparison.leftProgramId === leftProgramId && comparison.rightProgramId === rightProgramId
);
const nextComparison = {
...(comparisonIndex >= 0 ? comparisons[comparisonIndex] : {}),
leftProgramId,
rightProgramId,
updatedAt: new Date().toISOString(),
[field]: value
};
const hasComparisonContent =
(nextComparison.stepMatches ?? []).some((match) => match.reason?.trim()) ||
nextComparison.note?.trim();
return {
...current,
comparisons:
!hasComparisonContent
? comparisons.filter((_, index) => index !== comparisonIndex)
:
comparisonIndex >= 0
? comparisons.map((comparison, index) => (index === comparisonIndex ? nextComparison : comparison))
: [...comparisons, nextComparison]
};
});
};
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: ['성과물'],
programType: 'internal',
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)}
sidebarWidth={sidebarWidth}
onSidebarWidthChange={setSidebarWidth}
/>
{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)}
programType={content.cheonjiin.programType}
onProgramTypeChange={(value) => updateProgramTitle('cheonjiin', 'programType', 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)}
programType={content.wayPrimal.programType}
onProgramTypeChange={(value) => updateProgramTitle('wayPrimal', 'programType', 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)}
programType={program.programType}
onProgramTypeChange={(value) => updateProgram(programIndex, 'programType', 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)}
/>
)}
{isComparePopupOpen && (
setIsComparePopupOpen(false)}
/>
)}
);
}