2327 lines
93 KiB
JavaScript
2327 lines
93 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 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>
|
||
);
|
||
}
|