Files
HM-Program/src/App.jsx
2026-06-24 08:58:42 +09:00

2327 lines
93 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="relative z-10 h-full w-full">
<div
className={`relative h-full overflow-hidden rounded-2xl border px-4 py-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md ${
status === 'active'
? 'border-amber-200 bg-gradient-to-br from-amber-50 to-white shadow-amber-100'
: status === 'disabled'
? 'border-slate-100 bg-white/45 opacity-45 shadow-none'
: status === 'completed'
? 'border-emerald-100 bg-gradient-to-br from-emerald-50 to-white'
: 'border-white/80 bg-white/80'
}`}
>
<div
className={`absolute inset-x-0 top-0 h-1 ${
status === 'active'
? 'bg-amber-400'
: status === 'completed'
? 'bg-emerald-400'
: 'bg-slate-200'
}`}
/>
<div className="mb-3 flex items-center justify-between">
<div className={`flex h-9 w-9 items-center justify-center rounded-2xl ${accent.iconBg}`}>
<Icon className={`h-4 w-4 ${accent.iconText}`} />
</div>
<span
className={`text-[11px] font-bold ${
status === 'active' ? 'text-amber-700' : status === 'completed' ? 'text-emerald-700' : 'text-slate-400'
}`}
>
{status === 'active' ? '재진입' : status === 'completed' ? '통과' : `${String(index + 1).padStart(2, '0')} / ${String(total).padStart(2, '0')}`}
</span>
</div>
{isEditing ? (
<div className="space-y-2">
<input
value={step.title}
onChange={(event) => 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"
/>
<textarea
value={step.feature}
onChange={(event) => onChange(index, 'feature', event.target.value)}
rows={3}
className="w-full resize-none rounded-md border border-slate-200 bg-white px-2 py-1.5 text-[13px] font-semibold leading-5 text-slate-700 outline-none focus:border-teal-400"
/>
<textarea
value={step.note}
onChange={(event) => onChange(index, 'note', event.target.value)}
rows={3}
className="w-full resize-none rounded-md border border-slate-200 bg-white px-2 py-1.5 text-[12px] leading-5 text-slate-500 outline-none focus:border-teal-400"
/>
</div>
) : (
<>
<h3 className="text-base font-extrabold text-slate-950">{step.title}</h3>
<p className="mt-2 min-h-[72px] whitespace-pre-line text-[13px] font-semibold leading-5 text-slate-700">
{step.feature}
</p>
<p className="mt-4 whitespace-pre-line border-t border-dashed border-slate-200 pt-3 text-[12px] leading-5 text-slate-500">
{step.note}
</p>
</>
)}
</div>
</div>
);
}
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) => (
<div className={`relative z-10 flex flex-col gap-2 ${className}`}>
<FlowCard
step={step}
index={index}
total={steps.length}
accent={accent}
status={getStatus(index)}
isEditing={isEditing}
onChange={onStepChange}
/>
{isEditing && (onMoveStep || onRemoveStep) && (
<div className="flex flex-wrap justify-center gap-1.5">
{onMoveStep && (
<>
<button
type="button"
disabled={index === 0}
onClick={() => onMoveStep(index, index - 1)}
className="rounded-full bg-white px-2.5 py-1 text-[10px] font-extrabold text-slate-600 ring-1 ring-slate-200 hover:bg-slate-50 disabled:opacity-35"
>
</button>
<button
type="button"
disabled={index === steps.length - 1}
onClick={() => onMoveStep(index, index + 1)}
className="rounded-full bg-white px-2.5 py-1 text-[10px] font-extrabold text-slate-600 ring-1 ring-slate-200 hover:bg-slate-50 disabled:opacity-35"
>
아래
</button>
</>
)}
{onRemoveStep && steps.length > 1 && (
<button
type="button"
onClick={() => onRemoveStep(index)}
className="rounded-full bg-rose-50 px-2.5 py-1 text-[10px] font-extrabold text-rose-600 hover:bg-rose-100"
>
삭제
</button>
)}
</div>
)}
</div>
);
return (
<section
onClick={isRowClickable ? onLabelClick : undefined}
className={`rounded-[28px] border border-white/70 bg-white/75 p-5 shadow-[0_18px_50px_rgba(15,23,42,0.08)] backdrop-blur ${
isRowClickable ? 'cursor-pointer transition hover:-translate-y-0.5 hover:shadow-[0_22px_60px_rgba(15,23,42,0.12)]' : ''
}`}
>
<div className="mb-5 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
{isEditing && onLabelChange ? (
<input
value={label}
onChange={(event) => 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}`}
/>
) : (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onLabelClick?.();
}}
className={`rounded-xl px-1 py-0.5 text-left text-xl font-extrabold tracking-tight ${accent.labelText} ${
onLabelClick ? 'cursor-pointer hover:bg-white/70' : ''
}`}
>
{label}
</button>
)}
{isEditing && onProgramDelete && (
<button
type="button"
onClick={onProgramDelete}
className="flex items-center gap-1 rounded-full bg-rose-50 px-2.5 py-1 text-[11px] font-extrabold text-rose-700 hover:bg-rose-100"
>
<Trash2 className="h-3.5 w-3.5" />
프로그램 삭제
</button>
)}
</div>
{isEditing && onDescriptionChange ? (
<textarea
value={description}
onChange={(event) => onDescriptionChange(event.target.value)}
rows={2}
className="mt-1 w-full min-w-[360px] resize-none rounded-xl border border-slate-200 bg-white/85 px-3 py-1.5 text-sm font-bold tracking-tight text-slate-800 outline-none focus:border-teal-400"
/>
) : (
<h2 className="mt-1 whitespace-pre-line text-sm font-bold tracking-tight text-slate-800">{description}</h2>
)}
</div>
<div className="flex flex-col items-start gap-2 md:items-end">
{onFormatChange && (
<div className="flex items-center gap-2">
<span className="text-[11px] font-extrabold text-slate-400">포맷</span>
{isEditing ? (
<input
value={format}
onChange={(event) => 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"
/>
) : (
<span className="min-w-16 rounded-full bg-slate-100 px-3 py-1.5 text-center text-[12px] font-extrabold text-slate-700">
{format || '-'}
</span>
)}
</div>
)}
{(deliverables.length > 0 || isEditing) && (
<div className="flex max-w-[720px] flex-wrap items-center justify-start gap-1.5 md:justify-end">
<span className={`mr-1 text-[11px] font-extrabold ${accent.labelText}`}>성과물</span>
{(deliverables.length > 0 ? deliverables : ['']).map((item, index) => (
isEditing ? (
<input
key={index}
value={item}
onChange={(event) => 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) => (
<span
key={`${deliverable}-${index}-${deliverableIndex}`}
className="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-extrabold text-slate-700"
>
{deliverable}
</span>
))
))}
</div>
)}
{isEditing && onAddStep && (
<button
type="button"
onClick={onAddStep}
className="flex items-center gap-1 rounded-full bg-blue-50 px-3 py-2 text-[12px] font-extrabold text-blue-700 hover:bg-blue-100"
>
<Plus className="h-3.5 w-3.5" />
단계 추가
</button>
)}
</div>
</div>
{isWrappedStepLayout ? (
<div className="space-y-5">
{stepRows.map((row, rowIndex) => (
<div
key={rowIndex}
className="relative flex flex-col gap-3 xl:grid xl:grid-cols-[minmax(0,1fr)_28px_minmax(0,1fr)_28px_minmax(0,1fr)_28px_minmax(0,1fr)] xl:gap-0 xl:items-stretch"
>
{row.map(({ step, index }, itemIndex) => (
<React.Fragment key={step.id}>
{renderStepCard(
step,
index,
`min-w-0 ${wrappedCardColumns[itemIndex]}`
)}
{itemIndex < row.length - 1 && (
<div className={`flex items-center justify-center ${wrappedArrowColumns[itemIndex]}`}>
<ArrowRight className={`hidden h-5 w-5 xl:block ${accent.arrowText}`} />
<ArrowDown className={`h-5 w-5 xl:hidden ${accent.arrowText}`} />
</div>
)}
</React.Fragment>
))}
</div>
))}
</div>
) : (
<div className="relative flex flex-col gap-3 xl:flex-row xl:items-stretch">
{steps.map((step, index) => (
<React.Fragment key={step.id}>
{renderStepCard(step, index, isSingleStepLayout ? 'w-full max-w-[320px]' : 'min-w-0 flex-1')}
{index < steps.length - 1 && (
<div className="flex items-center justify-center xl:w-7">
<ArrowRight className={`hidden h-5 w-5 xl:block ${accent.arrowText}`} />
<ArrowDown className={`h-5 w-5 xl:hidden ${accent.arrowText}`} />
</div>
)}
</React.Fragment>
))}
</div>
)}
</section>
);
}
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 = (
<div
onPointerDown={standalone ? undefined : startDrag}
className={`flex items-center justify-between border-b border-white/10 bg-gradient-to-r from-slate-950 via-slate-900 to-teal-950 px-4 py-3 text-white ${
standalone ? '' : 'cursor-move'
}`}
>
<div>
<h2 className="text-sm font-extrabold">{program.name} 상세 플로우</h2>
<p className="mt-0.5 text-[11px] font-medium text-slate-300">
마지막 단계 완료 변경사항 검토 피드백 엔진
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
setIsDetailEditing((current) => !current);
}}
className="rounded-full bg-white/10 px-3 py-1.5 text-[11px] font-extrabold text-white hover:bg-white/20"
>
{isDetailEditing ? '수정 완료' : '내용 수정'}
</button>
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onClose();
}}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
aria-label="상세 플로우 닫기"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
const content = (
<>
{header}
<div className="overflow-auto bg-[radial-gradient(circle_at_top_left,#eefdfa_0,#fcfbf8_42%,#f8fafc_100%)] p-4">
<section>
<div className="rounded-2xl border border-white/80 bg-white/75 px-4 py-4 shadow-sm backdrop-blur">
<div className="mb-5 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<h3 className="text-base font-extrabold text-slate-950">변경사항 검토 재진입 흐름</h3>
<div className="flex flex-wrap items-center gap-2">
<input
value={changeSearch}
onChange={(event) => setChangeSearch(event.target.value)}
placeholder="변경사항 검색 예: 노선변경"
className="h-8 w-56 rounded-full border border-slate-200 bg-white/90 px-3 text-[12px] font-bold text-slate-700 outline-none focus:border-teal-400"
/>
{changeSearch && (
<button
type="button"
onClick={() => setChangeSearch('')}
className="rounded-full bg-slate-100 px-3 py-1.5 text-[11px] font-extrabold text-slate-500 hover:bg-slate-200"
>
검색 해제
</button>
)}
{isDetailEditing && (
<button
type="button"
onClick={onAddReviewItem}
className="rounded-full bg-slate-900 px-3 py-1.5 text-[11px] font-extrabold text-white hover:bg-slate-700"
>
스텝 추가
</button>
)}
</div>
</div>
<div className="space-y-4">
{normalizedSearch && (
<p className="text-center text-[12px] font-bold text-slate-500">
`{changeSearch}` 매칭 스텝 {searchMatchCount}
</p>
)}
{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 (
<div key={gate.key} className={`grid grid-cols-[0.85fr_minmax(220px,1.25fr)_0.85fr] items-center gap-2.5 ${blocked ? 'opacity-35' : ''} ${matched ? 'rounded-3xl bg-yellow-50/60 p-2 ring-2 ring-yellow-300' : ''}`}>
<div className={`rounded-2xl px-3 py-3.5 ${matched ? 'bg-yellow-100/90 ring-2 ring-yellow-300' : active ? 'bg-amber-100/90 ring-2 ring-amber-300' : 'bg-amber-50/80 ring-1 ring-amber-100'}`}>
{isDetailEditing ? (
<div className="space-y-1.5">
<input
value={gate.yes}
onChange={(event) => onReviewItemChange(index, 'yes', event.target.value)}
className="w-full rounded border border-amber-200 bg-white px-2 py-1 text-[11px] font-extrabold text-amber-700 outline-none"
/>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
disabled={index === 0}
onClick={() => onMoveReviewItem(index, index - 1)}
className="rounded-full bg-white px-2 py-1 text-[10px] font-extrabold text-slate-600 ring-1 ring-amber-100 hover:bg-amber-50 disabled:opacity-35"
>
</button>
<button
type="button"
disabled={index === reviewItems.length - 1}
onClick={() => onMoveReviewItem(index, index + 1)}
className="rounded-full bg-white px-2 py-1 text-[10px] font-extrabold text-slate-600 ring-1 ring-amber-100 hover:bg-amber-50 disabled:opacity-35"
>
아래
</button>
{reviewItems.length > 1 && (
<button
type="button"
onClick={() => onRemoveReviewItem(index)}
className="rounded-full bg-rose-50 px-2 py-1 text-[10px] font-extrabold text-rose-600 hover:bg-rose-100"
>
삭제
</button>
)}
</div>
</div>
) : (
<div>
<p className="text-[12px] font-extrabold text-amber-700">{gate.yes}</p>
<p className="mt-1 text-[10px] font-bold text-amber-500">대상: {getTargetLabel(gate)}</p>
</div>
)}
</div>
<div className={`rounded-2xl bg-white/95 px-4 py-4 text-center shadow-sm ring-1 ${matched ? 'ring-yellow-300' : active ? 'ring-amber-300' : passed ? 'ring-emerald-300' : 'ring-slate-200'}`}>
{isDetailEditing ? (
<div className="space-y-1.5">
<textarea
value={gate.question}
onChange={(event) => onReviewItemChange(index, 'question', event.target.value)}
rows={3}
className="w-full resize-none rounded border border-slate-200 bg-slate-50 px-2 py-1 text-center text-[11px] font-extrabold text-slate-800 outline-none"
/>
<select
value={`${resolveGateTargetProgramId(gate, program.id, allPrograms)}::${gate.stepId}`}
onChange={(event) => {
const [targetProgramId, stepId] = event.target.value.split('::');
onReviewItemChange(index, 'targetProgramId', targetProgramId);
onReviewItemChange(index, 'stepId', stepId);
}}
className="w-full rounded border border-slate-200 bg-white px-1 py-1 text-[10px] font-bold text-slate-700 outline-none"
>
{allPrograms.map((targetProgram) => (
<optgroup key={targetProgram.id} label={targetProgram.name}>
{targetProgram.steps.map((step) => (
<option key={`${targetProgram.id}-${step.id}`} value={`${targetProgram.id}::${step.id}`}>
{step.title}
</option>
))}
</optgroup>
))}
</select>
</div>
) : (
<p className="whitespace-pre-line text-[13px] font-extrabold leading-5 text-slate-800">{gate.question}</p>
)}
<div className="mt-2 flex justify-center gap-1.5">
<button
type="button"
disabled={blocked}
onClick={() => setGate(gate.key, 'yes')}
className={`rounded-full px-2 py-1 text-[10px] font-extrabold disabled:cursor-not-allowed ${
active ? 'bg-amber-500 text-white' : 'bg-slate-100 text-slate-600 hover:bg-amber-100'
}`}
>
</button>
<button
type="button"
disabled={blocked}
onClick={() => setGate(gate.key, 'no')}
className={`rounded-full px-2 py-1 text-[10px] font-extrabold disabled:cursor-not-allowed ${
passed ? 'bg-emerald-500 text-white' : 'bg-slate-100 text-slate-600 hover:bg-emerald-100'
}`}
>
아니오
</button>
</div>
</div>
<div className={`rounded-2xl px-3 py-3.5 ${matched ? 'bg-yellow-100/90 ring-2 ring-yellow-300' : passed ? 'bg-emerald-100/90 ring-2 ring-emerald-300' : 'bg-emerald-50/80 ring-1 ring-emerald-100'}`}>
{isDetailEditing ? (
<input
value={gate.no}
onChange={(event) => 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"
/>
) : (
<p className="text-[12px] font-extrabold text-emerald-700">{gate.no}</p>
)}
</div>
{index < reviewItems.length - 1 && (
<div className="hidden md:col-start-2 md:flex md:justify-center">
<ArrowDown className="h-4 w-4 text-slate-400" />
</div>
)}
</div>
);
})}
</div>
</div>
</section>
</div>
</>
);
if (standalone) {
return <main className="min-h-screen bg-[radial-gradient(circle_at_top_left,#eefdfa_0,#fcfbf8_42%,#f8fafc_100%)]">{content}</main>;
}
return (
<div className="fixed inset-0 z-50 bg-slate-950/20 backdrop-blur-sm">
<div
className="fixed flex max-h-[calc(100vh-32px)] w-[min(1320px,calc(100vw-24px))] flex-col overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl"
style={{ left: position.x, top: position.y }}
>
{content}
</div>
</div>
);
}
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 (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-slate-950/30 px-4 backdrop-blur-sm">
<section className="relative flex max-h-[calc(100vh-48px)] w-full max-w-4xl flex-col overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl">
<div className="flex items-center justify-between bg-slate-950 px-5 py-4 text-white">
<div>
<p className="text-[11px] font-extrabold uppercase tracking-wide text-amber-300">
프로그램 연관관계
</p>
<h2 className="mt-1 text-lg font-extrabold">전체 프로그램 연결도 관계 수정</h2>
</div>
<button
type="button"
onClick={onClose}
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
aria-label="관계 설정 닫기"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="overflow-auto p-5">
<div className="rounded-3xl border border-blue-100 bg-gradient-to-br from-blue-50 to-white p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-extrabold text-slate-950">연결도</h3>
<p className="mt-1 text-[12px] font-bold text-slate-500">
입력한 선행/후행 관계를 화살표로 표시합니다
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-white px-3 py-1.5 text-[12px] font-extrabold text-blue-700 ring-1 ring-blue-100">
{relations.length} 연결
</span>
<button
type="button"
onClick={() => setIsRelationEditorOpen((current) => !current)}
className="rounded-full bg-slate-950 px-4 py-2 text-[12px] font-extrabold text-white shadow-sm hover:bg-slate-800"
>
{isRelationEditorOpen ? '연결 수정 닫기' : '프로그램 연결 수정하기'}
</button>
</div>
</div>
<div className="mt-5 space-y-3">
{relations.length > 0 ? (
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-blue-100">
<div className="relative h-[780px] w-full">
<svg className="absolute inset-0 h-full w-full overflow-visible" viewBox={`0 0 ${graphWidth} ${graphHeight}`} preserveAspectRatio="none">
<defs>
<marker id="relation-arrow" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2563eb" />
</marker>
<marker id="relation-arrow-skip" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#7c3aed" />
</marker>
</defs>
{relations.map((relation) => {
const from = nodePositions[relation.from.id];
const to = nodePositions[relation.to.id];
if (!from || !to) return null;
const fromLevel = levelMap[relation.from.id] ?? 0;
const toLevel = levelMap[relation.to.id] ?? 0;
const isSkipEdge = toLevel - fromLevel > 1;
const normalOutgoing = relations.filter(
(item) =>
item.from.id === relation.from.id &&
(levelMap[item.to.id] ?? 0) - (levelMap[item.from.id] ?? 0) === 1
);
const normalIncoming = relations.filter(
(item) =>
item.to.id === relation.to.id &&
(levelMap[item.to.id] ?? 0) - (levelMap[item.from.id] ?? 0) === 1
);
const outgoingIndex = normalOutgoing.findIndex((item) => item.to.id === relation.to.id);
const incomingIndex = normalIncoming.findIndex((item) => item.from.id === relation.from.id);
const fromCenterX = from.x + nodeWidth / 2;
const toCenterX = to.x + nodeWidth / 2;
const startY = from.y + nodeHeight;
const endY = to.y;
const isCenterAligned = Math.abs(fromCenterX - toCenterX) < 6;
const useStraightLine = !isSkipEdge && isCenterAligned;
const branchDirection = Math.sign(toCenterX - fromCenterX);
const mergeDirection = Math.sign(fromCenterX - toCenterX);
const startOffset = useStraightLine
? 0
: branchDirection !== 0 && normalOutgoing.length > 1
? branchDirection * 28
: (outgoingIndex - (normalOutgoing.length - 1) / 2) * 22;
const endOffset = useStraightLine
? 0
: mergeDirection !== 0 && normalIncoming.length > 1
? mergeDirection * 20
: (incomingIndex - (normalIncoming.length - 1) / 2) * 18;
const startX = fromCenterX + startOffset;
const endX = toCenterX + endOffset;
const midY = startY + Math.max(38, (endY - startY) / 2);
const skipEdges = relations.filter((item) => (levelMap[item.to.id] ?? 0) - (levelMap[item.from.id] ?? 0) > 1);
const skipFromLevels = [...new Set(skipEdges.map((item) => levelMap[item.from.id] ?? 0))].sort((a, b) => a - b);
const skipFromLevelIndex = skipFromLevels.indexOf(fromLevel);
const leftMostNodeX = Math.min(...Object.values(nodePositions).map((position) => position.x));
const outerLaneX = 24;
const innerLaneX = Math.max(outerLaneX + 42, leftMostNodeX - 86);
const getSkipLaneX = (level) => {
const levelIndex = Math.max(0, skipFromLevels.indexOf(level));
const laneRatio = skipFromLevels.length <= 1 ? 1 : levelIndex / (skipFromLevels.length - 1);
return outerLaneX + (innerLaneX - outerLaneX) * laneRatio;
};
const skipIncoming = skipEdges.filter((item) => item.to.id === relation.to.id);
const innermostSkipLevel = Math.max(...skipIncoming.map((item) => levelMap[item.from.id] ?? 0));
const isInnermostSkipEdge = fromLevel === innermostSkipLevel;
const innermostLaneX = getSkipLaneX(innermostSkipLevel);
const laneX = getSkipLaneX(fromLevel);
const skipStartX = from.x;
const skipStartY = from.y + nodeHeight / 2;
const skipEndX = to.x;
const skipEndY = to.y + nodeHeight / 2;
const pathD = isSkipEdge
? `M ${skipStartX} ${skipStartY} H ${laneX} V ${skipEndY} H ${isInnermostSkipEdge ? skipEndX - 10 : innermostLaneX}`
: useStraightLine
? `M ${fromCenterX} ${startY} L ${toCenterX} ${endY - 10}`
: `M ${startX} ${startY} C ${startX} ${midY}, ${endX} ${midY}, ${endX} ${endY - 10}`;
return (
<g key={`${relation.from.id}-${relation.to.id}`}>
<path
d={pathD}
fill="none"
stroke={isSkipEdge ? '#7c3aed' : '#2563eb'}
strokeWidth={isSkipEdge ? '2.5' : '2'}
strokeDasharray={isSkipEdge ? '7 6' : undefined}
opacity={isSkipEdge ? '0.9' : '1'}
markerEnd={
isSkipEdge
? isInnermostSkipEdge
? 'url(#relation-arrow-skip)'
: undefined
: 'url(#relation-arrow)'
}
/>
</g>
);
})}
</svg>
{graphPrograms.map((program) => {
const position = nodePositions[program.id];
if (!position) return null;
return (
<div
key={program.id}
className="absolute flex flex-col items-center justify-center rounded-2xl border border-blue-100 bg-gradient-to-br from-white to-blue-50 px-3 text-center shadow-sm"
style={{
left: `${(position.x / graphWidth) * 100}%`,
top: `${(position.y / graphHeight) * 100}%`,
width: `${(nodeWidth / graphWidth) * 100}%`,
height: `${(nodeHeight / graphHeight) * 100}%`
}}
>
<p className="text-sm font-extrabold text-slate-950">{program.name}</p>
</div>
);
})}
</div>
</div>
) : (
<div className="rounded-2xl bg-white px-4 py-6 text-center text-sm font-bold text-slate-500 ring-1 ring-blue-100">
아직 연결된 프로그램이 없습니다. 아래에서 선행/후행을 선택하세요.
</div>
)}
{isolatedPrograms.length > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-2xl bg-white/70 px-4 py-3 text-[12px] font-bold text-slate-500 ring-1 ring-blue-50">
<span className="font-extrabold text-slate-700">미연결</span>
{isolatedPrograms.map((program) => (
<span key={program.id} className="rounded-full bg-slate-100 px-2.5 py-1 text-slate-600">
{program.name}
</span>
))}
</div>
)}
</div>
</div>
{isRelationEditorOpen && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-slate-950/30 px-5 backdrop-blur-sm">
<div className="max-h-[calc(100%-48px)] w-full max-w-3xl overflow-auto rounded-3xl border border-white/80 bg-slate-50 p-5 shadow-2xl">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-extrabold text-slate-950">프로그램 연결 수정</h3>
<p className="mt-1 text-[12px] font-bold text-slate-500">
프로그램의 선행/후행을 선택하면 연결도가 바로 반영됩니다
</p>
</div>
<button
type="button"
onClick={() => setIsRelationEditorOpen(false)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-900 text-white hover:bg-slate-700"
aria-label="프로그램 연결 수정 닫기"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-4 space-y-3">
{programs.map((program) => {
const candidates = programs.filter((item) => item.id !== program.id);
return (
<div key={program.id} className="grid gap-3 rounded-2xl bg-white p-4 ring-1 ring-slate-100 lg:grid-cols-[180px_1fr_1fr]">
<div>
<p className="text-[11px] font-extrabold uppercase tracking-wide text-slate-400">프로그램</p>
<h4 className="mt-1 text-base font-extrabold text-slate-950">{program.name}</h4>
</div>
<div>
<p className="mb-2 text-[12px] font-extrabold text-amber-700">선행</p>
<div className="flex flex-wrap gap-1.5">
{candidates.map((candidate) => (
<label key={candidate.id} className="flex cursor-pointer items-center gap-1.5 rounded-full bg-amber-50 px-2.5 py-1.5 text-[11px] font-extrabold text-amber-800 ring-1 ring-amber-100">
<input
type="checkbox"
checked={(program.predecessors ?? []).includes(candidate.id)}
onChange={(event) => onToggleRelation(program.id, 'predecessors', candidate.id, event.target.checked)}
className="h-3.5 w-3.5 accent-amber-500"
/>
{candidate.name}
</label>
))}
</div>
</div>
<div>
<p className="mb-2 text-[12px] font-extrabold text-blue-700">후행</p>
<div className="flex flex-wrap gap-1.5">
{candidates.map((candidate) => (
<label key={candidate.id} className="flex cursor-pointer items-center gap-1.5 rounded-full bg-blue-50 px-2.5 py-1.5 text-[11px] font-extrabold text-blue-800 ring-1 ring-blue-100">
<input
type="checkbox"
checked={(program.successors ?? []).includes(candidate.id)}
onChange={(event) => onToggleRelation(program.id, 'successors', candidate.id, event.target.checked)}
className="h-3.5 w-3.5 accent-blue-500"
/>
{candidate.name}
</label>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
</section>
</div>
);
}
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 (
<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: `${indent}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 overflow-hidden 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 overflow-x-hidden rounded-[26px] border border-white/75 bg-white/75 p-3.5 shadow-sm backdrop-blur">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<p className="text-[11px] font-black uppercase tracking-wide text-blue-700">Program Map</p>
<h2 className="mt-1 text-lg font-black text-slate-950">프로그램 연결</h2>
<p className="mt-1 text-xs font-semibold leading-5 text-slate-500">
선행·후행 관계를 세로 트리로 봅니다.
</p>
</div>
<button
type="button"
onClick={onOpenRelationPopup}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-base font-black text-amber-700 ring-1 ring-amber-200 hover:bg-amber-200"
title="프로그램 연결도 및 관계 수정"
aria-label="프로그램 연결도 및 관계 수정"
>
!
</button>
</div>
<div className="space-y-1">
{visibleRoots.map((program) => renderNode(program))}
</div>
</aside>
);
}
export default function App() {
const urlParams = new URLSearchParams(window.location.search);
const isDetailWindow = urlParams.get('view') === 'program-detail' || urlParams.get('view') === 'cheonjiin-detail';
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 (
<DetailPopup
program={detailProgram}
allPrograms={programs}
gates={gates}
setGates={(nextGates) => 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 (
<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-[1760px] space-y-4 px-3 py-4">
<div className="flex justify-end gap-2">
<button
type="button"
onClick={toggleProgramEdit}
className={`flex items-center gap-2 rounded-full px-4 py-2 text-sm font-bold shadow-sm ${
isEditing
? 'bg-emerald-600 text-white hover:bg-emerald-700'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{isEditing ? <Save className="h-4 w-4" /> : <Edit3 className="h-4 w-4" />}
{isEditing ? '수정 완료' : '프로그램 추가 및 수정'}
</button>
</div>
<div className="grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)] xl:items-start">
<RelationTreePanel
programs={programs}
onProgramClick={openProgramWindow}
onOpenRelationPopup={() => setIsRelationPopupOpen(true)}
/>
<section className="min-w-0 space-y-5">
{isEditing && (
<div className="flex items-center justify-between rounded-[24px] border border-white/70 bg-white/70 px-5 py-4 shadow-sm backdrop-blur">
<div>
<p className="text-[12px] font-extrabold uppercase tracking-wide text-blue-700">
프로그램 편집
</p>
<h2 className="mt-1 text-lg font-extrabold text-slate-950">
프로그램을 추가하거나 기존 내용을 수정합니다
</h2>
</div>
<button
type="button"
onClick={addProgram}
className="flex items-center gap-2 rounded-full bg-blue-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-blue-700"
>
<Plus className="h-4 w-4" />
프로그램 추가
</button>
</div>
)}
<FlowRow
label={content.cheonjiin.name}
description={content.cheonjiin.description}
steps={editableCheonjiinFlow}
onLabelClick={() => 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'
}}
/>
<div className="flex items-center gap-4 px-3 py-1">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-white/85 text-slate-500 shadow-sm ring-1 ring-white">
<ArrowDown className="h-5 w-5" />
</div>
{isEditing ? (
<input
value={content.wayPrimal.linkLabel}
onChange={(event) => 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"
/>
) : (
<div className="text-sm font-bold text-slate-600">
{content.wayPrimal.linkLabel}
</div>
)}
</div>
<FlowRow
label={content.wayPrimal.name}
description={content.wayPrimal.description}
steps={editableWayPrimalFlow}
onLabelClick={() => 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) => (
<React.Fragment key={program.id}>
<div className="flex items-center gap-4 px-3 py-1">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-white/85 text-slate-500 shadow-sm ring-1 ring-white">
<ArrowDown className="h-5 w-5" />
</div>
{isEditing ? (
<input
value={program.linkLabel ?? `이전 프로그램 산출물을 ${program.name} 입력으로 연계`}
onChange={(event) => 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"
/>
) : (
<div className="text-sm font-bold text-slate-600">
{program.linkLabel ?? `이전 프로그램 산출물을 ${program.name} 입력으로 연계`}
</div>
)}
</div>
{isEditing && (
<section className="rounded-[24px] border border-white/70 bg-white/70 px-5 py-4 shadow-sm backdrop-blur">
<div className="grid gap-3 lg:grid-cols-[220px_1fr_auto] lg:items-start">
<div>
<p className="text-[12px] font-extrabold uppercase tracking-wide text-blue-700">
추가 프로그램
</p>
<h2 className="mt-1 text-lg font-extrabold text-slate-950">프로그램 정보</h2>
</div>
<div className="grid gap-2 md:grid-cols-2">
<input
value={program.name}
onChange={(event) => updateProgram(programIndex, 'name', event.target.value)}
className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-sm font-extrabold text-slate-900 outline-none focus:border-blue-400"
/>
<input
value={program.description}
onChange={(event) => updateProgram(programIndex, 'description', event.target.value)}
className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-sm font-bold text-slate-700 outline-none focus:border-blue-400"
/>
</div>
<div className="flex gap-2">
<button
type="button"
disabled={programIndex === 0}
onClick={() => moveProgram(programIndex, programIndex - 1)}
className="flex items-center gap-1 rounded-full bg-white px-3 py-2 text-[12px] font-extrabold text-slate-600 ring-1 ring-slate-200 hover:bg-slate-50 disabled:opacity-35"
>
<ArrowUp className="h-3.5 w-3.5" />
</button>
<button
type="button"
disabled={programIndex === content.extraPrograms.length - 1}
onClick={() => moveProgram(programIndex, programIndex + 1)}
className="flex items-center gap-1 rounded-full bg-white px-3 py-2 text-[12px] font-extrabold text-slate-600 ring-1 ring-slate-200 hover:bg-slate-50 disabled:opacity-35"
>
<ArrowDown className="h-3.5 w-3.5" />
아래
</button>
</div>
</div>
</section>
)}
<FlowRow
label={program.name}
description={program.description}
steps={normalizeProgramSteps(program)}
onLabelClick={() => 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'
}}
/>
</React.Fragment>
))}
</section>
</div>
{isRelationPopupOpen && (
<RelationPopup
programs={programs}
onToggleRelation={toggleProgramRelation}
onClose={() => setIsRelationPopupOpen(false)}
/>
)}
</div>
</main>
);
}