import React, { useEffect, useState } from 'react'; import { ArrowDown, ArrowRight, ArrowUp, Building2, ClipboardList, DraftingCompass, Edit3, Layers3, Map, Mountain, Plus, Route, Save, ScanLine, Trash2, Waypoints, X } from 'lucide-react'; const cheonjiinFlow = [ { id: '1-1', title: '범위입력', icon: ScanLine, feature: '직접선택, 도면 불러오기', note: '사업 대상지와 원본 도면 범위를 먼저 확정합니다.' }, { id: '1-2', title: '선택/검토', icon: Layers3, feature: '등고선, 레이어 선택', note: '해석에 필요한 도면 요소와 레이어만 정리합니다.' }, { id: '1-3', title: '현장조사', icon: Mountain, feature: '지표, 지층, 상세측량\n파일 불러오기\n지형, 지층 보정 모델', note: '실측 자료를 반영해 지형과 지층 정보를 보정합니다.' }, { id: '1-4', title: '구조물편집', icon: Building2, feature: '도로, 건물, 하천, 지형\n수치지형도 v2 다운로드 시 적용\n구조물 포함 모델', note: '구조물과 지형 요소를 편집해 통합 모델을 만듭니다.' }, { id: '1-5', title: '유역면적', icon: Map, feature: '유역 구분 및 면적 산정', note: '정리된 공간 정보를 바탕으로 유역면적 결과를 산정합니다.' } ]; const wayPrimalFlow = [ { id: 'wp-1', title: '설계조건', icon: ClipboardList, feature: '전용조건 입력(도로, 포장 등)\n정온시설 위치 입력', note: '도로와 주변 조건을 설계 입력값으로 등록합니다.' }, { id: 'wp-2', title: '노폭계획', icon: Route, feature: '선형계획, 노즈, 편경사,\n구조물, 시설계획 입력', note: '노선의 폭, 선형, 구조물 조건을 계획합니다.' }, { id: 'wp-3', title: '기본계획', icon: Waypoints, feature: '길어깨편경사, 비탈면계획,\n부체도로, 횡단등도', note: '기본 단면과 주변 계획 조건을 정리합니다.' }, { id: 'wp-4', title: '기본설계', icon: DraftingCompass, feature: '배수공, 포장공, 부대공 자동설치\n횡단면도 생성, 도공계산서 내보내기\n평면도, 횡단면도, 종평면도, 기본설계모델', note: '자동 설계 성과물과 기본설계 모델을 생성합니다.' } ]; const cheonjiinDeliverables = [ '지형도', '수치지형도', '정사영상', '지형,지층 보정 모델' ]; const detailSteps = [ { id: '1-1', title: '범위 입력', feature: '직접 선택 · 도면 불러오기', detail: '대상 유역 영역의 좌표 체계와 경계를 지정하고 원천 도면 파일을 등록합니다.', output: '활성 작업 영역 레이어' }, { id: '1-2', title: '선택·검토', feature: '등고선 · 적용 레이어 선택', detail: '설계 연산에 사용할 등고선 데이터와 속성별 물리 레이어를 선별합니다.', output: '정제된 등고선 추출본' }, { id: '1-3', title: '현장조사', feature: '지표 · 지층 · 상세측량 자료 입력', detail: 'GPS 고도 측량치와 시추 분석 데이터를 입력해 지형과 지층을 보정합니다.', output: '3차원 지표·지층 보정 모델' }, { id: '1-4', title: '구조물 편집', feature: '도로 · 건물 · 하천 · 지형 편집', detail: '도로 중심선, 건물 폴리곤, 하천 경계 등 구조물 정보를 지형면에 정합합니다.', output: '구조물 포함 통합 3D GIS 모델' }, { id: '1-5', title: '유역면적 산정', feature: '유역 구분 · 면적 산정 · 결과 검토', detail: '정립된 지형과 구조물 모델을 분석해 유역 경계를 구분하고 면적을 계산합니다.', output: '유역면적 최종 산정 결과' } ]; const reviewGates = [ { key: 'gate1', stepId: '1-1', question: '범위 또는 원본 도면 변경?', yes: '예: 1-1 재진입', no: '아니오: 다음 검토' }, { key: 'gate2', stepId: '1-2', question: '등고선 또는 적용 레이어 변경?', yes: '예: 1-2 재진입', no: '아니오: 다음 검토' }, { key: 'gate3', stepId: '1-3', question: '지표·지층 또는 상세측량 정보 변경?', yes: '예: 1-3 재진입', no: '아니오: 다음 검토' }, { key: 'gate4', stepId: '1-4', question: '도로·건물·하천 또는 지형 정보 변경?', yes: '예: 1-4 재진입', no: '아니오: 변경사항 없음' } ]; const initialGates = { gate1: 'no', gate2: 'no', gate3: 'no', gate4: 'no' }; const channelName = 'program-flow-state'; const contentStorageKey = 'program-flow-content'; const programStateStorageKey = 'program-flow-gates'; const serverStateEndpoint = '/api/state'; const disabledFlowStep = '__disabled__'; const defaultContent = { cheonjiin: { name: '천지인', description: '공간정보 입력·검토·보정·유역면적 산정', steps: cheonjiinFlow.map(({ title, feature, note }) => ({ title, feature, note })), deliverables: cheonjiinDeliverables, format: 'glb', predecessors: [], successors: [] }, wayPrimal: { name: 'WayPrimal', description: '설계조건 입력부터 기본설계 성과물 생성', steps: wayPrimalFlow.map(({ title, feature, note }) => ({ title, feature, note })), format: '', deliverables: ['기본설계 모델'], predecessors: [], successors: [], linkLabel: '천지인 산출 모델을 WayPrimal 설계 입력으로 연계' }, extraPrograms: [] }; function normalizeStoredContent(parsed) { if (!parsed) return defaultContent; return { cheonjiin: { ...defaultContent.cheonjiin, ...(parsed.cheonjiin ?? {}), predecessors: parsed.cheonjiin?.predecessors ?? [], successors: parsed.cheonjiin?.successors ?? [], format: !parsed.cheonjiin?.format || parsed.cheonjiin.format === '예: DXF, SHP, GeoTIFF, 수치지형도 v2 등' ? defaultContent.cheonjiin.format : parsed.cheonjiin.format }, wayPrimal: { ...defaultContent.wayPrimal, ...(parsed.wayPrimal ?? {}), predecessors: parsed.wayPrimal?.predecessors ?? [], successors: parsed.wayPrimal?.successors ?? [], format: parsed.wayPrimal?.format === '예: DWG, LandXML, XLSX, 도공계산서, 기본설계 모델 등' ? defaultContent.wayPrimal.format : (parsed.wayPrimal?.format ?? defaultContent.wayPrimal.format) }, extraPrograms: (parsed.extraPrograms ?? []).map((program, index, programs) => ({ ...program, predecessors: program.predecessors ?? [], successors: program.successors ?? [], linkLabel: program.linkLabel ?? `이전 프로그램 산출물을 ${program.name} 입력으로 연계` })) }; } function readStoredContent() { try { const stored = window.localStorage.getItem(contentStorageKey); if (!stored) return defaultContent; return normalizeStoredContent(JSON.parse(stored)); } catch { return defaultContent; } } function mergeStepContent(flow, storedSteps = []) { const mergedBaseSteps = flow.map((step, index) => ({ ...step, ...(storedSteps[index] ?? {}) })); const addedSteps = storedSteps.slice(flow.length).map((step, index) => ({ ...step, id: step.id ?? `added-step-${flow.length + index + 1}`, icon: step.icon ?? pickStepIcon(step) })); return [...mergedBaseSteps, ...addedSteps]; } function pickStepIcon(step) { const text = `${step.title ?? ''} ${step.feature ?? ''} ${step.note ?? ''}`.toLowerCase(); if (/모델|3d|glb|ifc|wbpifc|grasshopper|rhino|객체|형상/.test(text)) return Layers3; if (/포맷|파일|저장|출력|내보내기|생성|변환/.test(text)) return ClipboardList; if (/속성|정보|입력|데이터|plugin|플러그인/.test(text)) return Waypoints; if (/편집|수정|보정|변경|정합/.test(text)) return DraftingCompass; if (/도로|노선|선형|중심선|경로/.test(text)) return Route; if (/지도|수치지형도|유역|면적|범위|도면/.test(text)) return Map; if (/측량|지형|지층|고도|산/.test(text)) return Mountain; if (/스캔|영상|정사영상|레이어/.test(text)) return ScanLine; if (/구조물|건물|하천/.test(text)) return Building2; return ClipboardList; } function normalizeProgramSteps(program) { return program.steps.map((step, index) => ({ id: step.id ?? `${program.id}-step-${index + 1}`, icon: pickStepIcon(step), title: step.title, feature: step.feature, note: step.note })); } function createInitialGates(steps) { return steps.slice(0, Math.max(1, steps.length - 1)).reduce((gates, step, index) => ({ ...gates, [`gate${index + 1}`]: 'no' }), {}); } function buildReviewItems(programId, steps, detailGates) { const hasStoredDetailGates = Array.isArray(detailGates); const storedDetailGates = hasStoredDetailGates ? detailGates : []; if (programId === 'cheonjiin') { const count = hasStoredDetailGates ? Math.max(1, storedDetailGates.length) : reviewGates.length; return Array.from({ length: count }, (_, index) => { const baseGate = reviewGates[index] ?? { key: `gate${index + 1}`, stepId: steps[Math.min(index, Math.max(0, steps.length - 2))]?.id ?? steps[0]?.id, question: `${index + 1}-${index + 1} 변경?`, yes: `예: ${index + 1}-${index + 1} 재진입`, no: index === count - 1 ? '아니오: 변경사항 없음' : '아니오: 다음 검토' }; return { ...baseGate, ...(storedDetailGates[index] ?? {}), targetProgramId: storedDetailGates[index] ? storedDetailGates[index].targetProgramId : programId, key: storedDetailGates[index]?.key ?? baseGate.key ?? `gate${index + 1}` }; }); } const defaultCount = Math.max(1, steps.length - 1); const count = hasStoredDetailGates ? Math.max(1, storedDetailGates.length) : defaultCount; return Array.from({ length: count }, (_, index) => { const step = steps[Math.min(index, Math.max(0, steps.length - 2))] ?? steps[0]; return { key: storedDetailGates[index]?.key ?? `gate${index + 1}`, targetProgramId: storedDetailGates[index] ? storedDetailGates[index].targetProgramId : programId, stepId: storedDetailGates[index]?.stepId ?? step?.id, question: storedDetailGates[index]?.question ?? `${step?.title ?? `${index + 1}단계`} 변경?`, yes: storedDetailGates[index]?.yes ?? `예: ${step?.title ?? `${index + 1}단계`} 재진입`, no: storedDetailGates[index]?.no ?? (index === count - 1 ? '아니오: 변경사항 없음' : '아니오: 다음 검토') }; }); } function readStoredProgramStates() { try { const stored = window.localStorage.getItem(programStateStorageKey); return stored ? JSON.parse(stored) : {}; } catch { return {}; } } function publishProgramStates(states) { try { window.localStorage.setItem(programStateStorageKey, JSON.stringify(states)); patchServerState({ programStates: states }); const channel = new BroadcastChannel(channelName); channel.postMessage({ states }); channel.close(); } catch { window.localStorage.setItem(programStateStorageKey, JSON.stringify(states)); patchServerState({ programStates: states }); } } function saveServerState(payload, method = 'PUT') { return fetch(serverStateEndpoint, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); } function patchServerState(payload) { return saveServerState(payload, 'PATCH').catch(() => undefined); } function getProgramGates(states, programId, steps) { return { ...createInitialGates(steps), ...(states[programId] ?? {}) }; } function getActiveGate(gates, reviewItems) { return reviewItems.find((gate) => gates[gate.key] === 'yes'); } function getActiveStep(gates, reviewItems) { return getActiveGate(gates, reviewItems)?.stepId ?? 'completed'; } function getReentryContext(programs, programStates, getProgramReviewItems) { return programs .map((sourceProgram) => { const reviewItems = getProgramReviewItems(sourceProgram); const gates = getProgramGates(programStates, sourceProgram.id, sourceProgram.steps); const activeGate = getActiveGate(gates, reviewItems); if (!activeGate) return null; const targetProgramId = resolveGateTargetProgramId(activeGate, sourceProgram.id, programs); const targetProgramIndex = programs.findIndex((program) => program.id === targetProgramId); if (targetProgramIndex < 0) return null; return { sourceProgramId: sourceProgram.id, targetProgramId, targetProgramIndex, targetStepId: activeGate.stepId }; }) .find(Boolean); } function resolveGateTargetProgramId(gate, sourceProgramId, allPrograms) { if (gate?.targetProgramId) return gate.targetProgramId; return allPrograms.find((program) => program.steps.some((step) => step.id === gate?.stepId))?.id ?? sourceProgramId; } function getStepLabel(program, stepId) { return program?.steps.find((step) => step.id === stepId)?.title ?? stepId ?? ''; } function moveItem(items, fromIndex, toIndex) { if (toIndex < 0 || toIndex >= items.length) return items; const nextItems = [...items]; const [movedItem] = nextItems.splice(fromIndex, 1); nextItems.splice(toIndex, 0, movedItem); return nextItems; } function splitDeliverableText(item) { return String(item ?? '') .split(', ') .map((value) => value.trim()) .filter(Boolean); } function FlowCard({ step, index, total, accent, status, isEditing, onChange }) { const Icon = step.icon ?? pickStepIcon(step); return (
{status === 'active' ? '재진입' : status === 'completed' ? '통과' : `${String(index + 1).padStart(2, '0')} / ${String(total).padStart(2, '0')}`}
{isEditing ? (
onChange(index, 'title', event.target.value)} className="w-full rounded-md border border-slate-200 bg-white px-2 py-1.5 text-sm font-extrabold text-slate-950 outline-none focus:border-teal-400" />