From c7b3273b26faba61ac6a3065ed4bbf4b06d06322 Mon Sep 17 00:00:00 2001 From: Hyein Date: Wed, 24 Jun 2026 14:39:54 +0900 Subject: [PATCH] Add report-style comparison and responsive relation map --- src/App.jsx | 411 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 367 insertions(+), 44 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index e373e85..341c7d9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1178,7 +1178,7 @@ function RelationPopup({ programs, onToggleRelation, onMergeGroupChange, onClose -
+
setFormatMenu(null)}>

프로그램 연결 수정

@@ -1296,6 +1296,12 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS const pairComparison = initialPair ? findSavedComparisonByPair(defaultLeftProgramId, defaultRightProgramId) : null; return pairComparison?.id ?? (initialPair ? '' : savedComparisons[0]?.id ?? ''); }); + const [isCompareEditing, setIsCompareEditing] = useState(() => { + const pairComparison = initialPair ? findSavedComparisonByPair(defaultLeftProgramId, defaultRightProgramId) : null; + return !pairComparison && !savedComparisons[0]; + }); + const [formatMenu, setFormatMenu] = useState(null); + const [lastTextSelection, setLastTextSelection] = useState(null); const leftProgram = programs.find((program) => program.id === draft.leftProgramId); const rightProgram = programs.find((program) => program.id === draft.rightProgramId); const leftSteps = leftProgram?.steps ?? []; @@ -1303,6 +1309,10 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS const stepMatches = draft.stepMatches ?? []; const maxStepCount = Math.max(leftSteps.length, rightSteps.length); const isSavedDraft = savedComparisons.some((comparison) => comparison.id === draft.id); + const hasDraftContent = + draft.title?.trim() || + (draft.stepMatches ?? []).some((match) => match.reason?.trim()) || + draft.note?.trim(); const updateDraft = (field, value) => { setDraft((current) => ({ ...current, [field]: value })); @@ -1311,6 +1321,7 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS const nextDraft = createEmptyDraft(); setDraft(nextDraft); setLoadTargetId(''); + setIsCompareEditing(true); }; const loadSelectedComparison = () => { const selected = savedComparisons.find((comparison) => comparison.id === loadTargetId); @@ -1321,6 +1332,7 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS note: selected.note ?? '', title: selected.title ?? '' }); + setIsCompareEditing(false); }; const saveDraft = () => { if (!draft.leftProgramId || !draft.rightProgramId || draft.leftProgramId === draft.rightProgramId) return; @@ -1333,6 +1345,7 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS updatedAt: new Date().toISOString() }); setLoadTargetId(draft.id); + setIsCompareEditing(false); }; const deleteDraft = () => { if (isSavedDraft) onComparisonDelete(draft.id); @@ -1376,6 +1389,174 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS comparison.title?.trim() || `${getProgramName(comparison.leftProgramId)} ↔ ${getProgramName(comparison.rightProgramId)}`; const getComparisonMemoCount = (comparison) => (comparison.stepMatches ?? []).filter((match) => match.reason?.trim()).length; + const stripFormatTags = (text = '') => text.replace(/\[(red|redbold|blue|bold|mark)\]([\s\S]*?)\[\/\1\]/g, '$2').trim(); + const generateComparisonConclusion = () => { + const reasonMatches = (draft.stepMatches ?? []) + .map((match) => { + const stepIndex = Number(match.leftStepIndex); + return { + ...match, + stepIndex, + reason: stripFormatTags(match.reason) + }; + }) + .filter((match) => match.reason); + + if (reasonMatches.length === 0) { + updateDraft('note', '상용 활용 사유가 아직 작성되지 않아 자동 결론을 생성할 수 없습니다.'); + return; + } + + const stepNames = reasonMatches + .map((match) => leftSteps[match.stepIndex]?.title || rightSteps[match.stepIndex]?.title) + .filter(Boolean); + const uniqueStepNames = [...new Set(stepNames)]; + const stepSummary = uniqueStepNames.length > 0 + ? uniqueStepNames.slice(0, 4).join(', ') + : '해당 비교 스텝'; + const overflowText = uniqueStepNames.length > 4 ? ` 외 ${uniqueStepNames.length - 4}개 스텝` : ''; + const keyReasons = reasonMatches + .slice(0, 3) + .map((match) => `- ${match.reason}`) + .join('\n'); + const conclusion = `${leftProgram?.name ?? '사내 프로그램'}은 ${stepSummary}${overflowText}에서 기능 구현 또는 자동화 범위에 한계가 있어 ${rightProgram?.name ?? '상용 프로그램'}의 기능 보완이 필요합니다.\n\n주요 사유\n${keyReasons}\n\n따라서 현재 업무 흐름에서는 해당 구간을 ${rightProgram?.name ?? '상용 프로그램'}으로 연계하여 결과 품질과 작업 연속성을 확보하는 것이 적절합니다.`; + updateDraft('note', conclusion); + }; + const renderFormattedText = (text, emptyText = '작성된 내용이 없습니다.') => { + const source = text?.trim(); + if (!source) return {emptyText}; + const pattern = /\[(red|redbold|blue|bold|mark)\]([\s\S]*?)\[\/\1\]/g; + const parts = []; + let lastIndex = 0; + let match; + while ((match = pattern.exec(source)) !== null) { + if (match.index > lastIndex) { + parts.push({ type: 'text', value: source.slice(lastIndex, match.index) }); + } + parts.push({ type: match[1], value: match[2] }); + lastIndex = pattern.lastIndex; + } + if (lastIndex < source.length) { + parts.push({ type: 'text', value: source.slice(lastIndex) }); + } + const classMap = { + red: 'font-black text-red-600', + redbold: 'font-black text-red-700', + blue: 'font-black text-blue-600', + bold: 'font-black text-slate-950', + mark: 'rounded bg-yellow-200/80 px-1 font-black text-slate-950' + }; + return parts.map((part, index) => { + if (part.type === 'text') { + return {part.value}; + } + return ( + + {part.value} + + ); + }); + }; + const openFormatMenu = (event, target) => { + const textarea = event.currentTarget; + event.preventDefault(); + const isSameTarget = + lastTextSelection?.target?.type === target.type && + String(lastTextSelection?.target?.stepIndex ?? '') === String(target.stepIndex ?? ''); + let selectionStart = textarea.selectionStart !== textarea.selectionEnd + ? textarea.selectionStart + : isSameTarget + ? lastTextSelection.selectionStart + : textarea.selectionStart; + let selectionEnd = textarea.selectionStart !== textarea.selectionEnd + ? textarea.selectionEnd + : isSameTarget + ? lastTextSelection.selectionEnd + : textarea.selectionEnd; + if (selectionStart === selectionEnd) { + const value = textarea.value ?? ''; + let wordStart = selectionStart; + let wordEnd = selectionEnd; + while (wordStart > 0 && !/\s/.test(value[wordStart - 1])) wordStart -= 1; + while (wordEnd < value.length && !/\s/.test(value[wordEnd])) wordEnd += 1; + selectionStart = wordStart; + selectionEnd = wordEnd; + if (selectionStart === selectionEnd && value.trim()) { + selectionStart = 0; + selectionEnd = value.length; + } + } + if (selectionStart === selectionEnd) return; + setFormatMenu({ + x: event.clientX, + y: event.clientY, + target, + selectionStart, + selectionEnd + }); + }; + const openFormatMenuOnRightDown = (event, target) => { + if (event.button !== 2) return; + openFormatMenu(event, target); + }; + const rememberTextSelection = (event, target) => { + const textarea = event.currentTarget; + if (textarea.selectionStart === textarea.selectionEnd) return; + setLastTextSelection({ + target, + selectionStart: textarea.selectionStart, + selectionEnd: textarea.selectionEnd + }); + }; + const applyTextFormat = (format) => { + if (!formatMenu) return; + const wrapText = (value = '') => { + const before = value.slice(0, formatMenu.selectionStart); + const selected = value.slice(formatMenu.selectionStart, formatMenu.selectionEnd); + const after = value.slice(formatMenu.selectionEnd); + return `${before}[${format}]${selected}[/${format}]${after}`; + }; + + if (formatMenu.target.type === 'reason') { + setDraft((current) => { + const currentMatches = current.stepMatches ?? []; + const existingIndex = currentMatches.findIndex( + (match) => + String(match.leftStepIndex) === String(formatMenu.target.stepIndex) && + String(match.rightStepIndex) === String(formatMenu.target.stepIndex) + ); + const currentMatch = + existingIndex >= 0 + ? currentMatches[existingIndex] + : { + id: `step-${formatMenu.target.stepIndex}`, + leftStepIndex: String(formatMenu.target.stepIndex), + rightStepIndex: String(formatMenu.target.stepIndex), + reason: '' + }; + const nextMatch = { + ...currentMatch, + reason: wrapText(currentMatch.reason ?? '') + }; + return { + ...current, + stepMatches: + existingIndex >= 0 + ? currentMatches.map((match, index) => (index === existingIndex ? nextMatch : match)) + : [...currentMatches, nextMatch] + }; + }); + } + + if (formatMenu.target.type === 'note') { + setDraft((current) => ({ + ...current, + note: wrapText(current.note ?? '') + })); + } + + setFormatMenu(null); + }; const renderProgramSummary = (program) => { if (!program) return null; @@ -1414,16 +1595,35 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS

1:1 프로그램 비교

사내 한계와 상용 활용 기능 정리

- +
+ {isCompareEditing && ( + + )} + + +
+ {isCompareEditing ? (
+ ) : ( +
+

비교 보고서

+

+ {draft.title?.trim() || `${leftProgram?.name ?? '-'} ↔ ${rightProgram?.name ?? '-'} 비교`} +

+

+ 두 프로그램의 동일/연계 스텝에서 사내 한계와 상용 프로그램 활용 사유를 정리합니다. +

+
+ )}
{renderProgramSummary(leftProgram)} @@ -1553,16 +1764,33 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS

{leftStep.note}

)}
-