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

2215 lines
88 KiB
JavaScript

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 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)_36px_minmax(0,1fr)_36px_minmax(0,1fr)_36px_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, 'min-w-[230px] flex-1')}
{index < steps.length - 1 && (
<div className="flex items-center justify-center xl:w-9">
<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>
);
}
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);
const appPath = window.location.pathname.startsWith('/8093') ? '/8093' : window.location.pathname || '/8093';
window.open(
`${appPath}?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-[1600px] space-y-5 px-6 py-6">
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setIsRelationPopupOpen(true)}
className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 text-lg font-black text-amber-700 shadow-sm ring-1 ring-amber-200 hover:bg-amber-200"
title="프로그램 연결도 및 관계 수정"
aria-label="프로그램 연결도 및 관계 수정"
>
!
</button>
<button
type="button"
onClick={toggleProgramEdit}
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>
{isEditing && (
<section className="flex items-center justify-between rounded-[24px] border border-white/70 bg-white/70 px-5 py-4 shadow-sm backdrop-blur">
<div>
<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>
</section>
)}
<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>
))}
{isRelationPopupOpen && (
<RelationPopup
programs={programs}
onToggleRelation={toggleProgramRelation}
onClose={() => setIsRelationPopupOpen(false)}
/>
)}
</div>
</main>
);
}