Add report-style comparison and responsive relation map
This commit is contained in:
341
src/App.jsx
341
src/App.jsx
@@ -1178,7 +1178,7 @@ function RelationPopup({ programs, onToggleRelation, onMergeGroupChange, onClose
|
|||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-auto bg-slate-50 p-5">
|
<div className="overflow-auto bg-slate-50 p-5" onClick={() => setFormatMenu(null)}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-base font-extrabold text-slate-950">프로그램 연결 수정</h3>
|
<h3 className="text-base font-extrabold text-slate-950">프로그램 연결 수정</h3>
|
||||||
<p className="mt-1 text-[12px] font-bold text-slate-500">
|
<p className="mt-1 text-[12px] font-bold text-slate-500">
|
||||||
@@ -1296,6 +1296,12 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS
|
|||||||
const pairComparison = initialPair ? findSavedComparisonByPair(defaultLeftProgramId, defaultRightProgramId) : null;
|
const pairComparison = initialPair ? findSavedComparisonByPair(defaultLeftProgramId, defaultRightProgramId) : null;
|
||||||
return pairComparison?.id ?? (initialPair ? '' : savedComparisons[0]?.id ?? '');
|
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 leftProgram = programs.find((program) => program.id === draft.leftProgramId);
|
||||||
const rightProgram = programs.find((program) => program.id === draft.rightProgramId);
|
const rightProgram = programs.find((program) => program.id === draft.rightProgramId);
|
||||||
const leftSteps = leftProgram?.steps ?? [];
|
const leftSteps = leftProgram?.steps ?? [];
|
||||||
@@ -1303,6 +1309,10 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS
|
|||||||
const stepMatches = draft.stepMatches ?? [];
|
const stepMatches = draft.stepMatches ?? [];
|
||||||
const maxStepCount = Math.max(leftSteps.length, rightSteps.length);
|
const maxStepCount = Math.max(leftSteps.length, rightSteps.length);
|
||||||
const isSavedDraft = savedComparisons.some((comparison) => comparison.id === draft.id);
|
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) => {
|
const updateDraft = (field, value) => {
|
||||||
setDraft((current) => ({ ...current, [field]: value }));
|
setDraft((current) => ({ ...current, [field]: value }));
|
||||||
@@ -1311,6 +1321,7 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS
|
|||||||
const nextDraft = createEmptyDraft();
|
const nextDraft = createEmptyDraft();
|
||||||
setDraft(nextDraft);
|
setDraft(nextDraft);
|
||||||
setLoadTargetId('');
|
setLoadTargetId('');
|
||||||
|
setIsCompareEditing(true);
|
||||||
};
|
};
|
||||||
const loadSelectedComparison = () => {
|
const loadSelectedComparison = () => {
|
||||||
const selected = savedComparisons.find((comparison) => comparison.id === loadTargetId);
|
const selected = savedComparisons.find((comparison) => comparison.id === loadTargetId);
|
||||||
@@ -1321,6 +1332,7 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS
|
|||||||
note: selected.note ?? '',
|
note: selected.note ?? '',
|
||||||
title: selected.title ?? ''
|
title: selected.title ?? ''
|
||||||
});
|
});
|
||||||
|
setIsCompareEditing(false);
|
||||||
};
|
};
|
||||||
const saveDraft = () => {
|
const saveDraft = () => {
|
||||||
if (!draft.leftProgramId || !draft.rightProgramId || draft.leftProgramId === draft.rightProgramId) return;
|
if (!draft.leftProgramId || !draft.rightProgramId || draft.leftProgramId === draft.rightProgramId) return;
|
||||||
@@ -1333,6 +1345,7 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS
|
|||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
});
|
});
|
||||||
setLoadTargetId(draft.id);
|
setLoadTargetId(draft.id);
|
||||||
|
setIsCompareEditing(false);
|
||||||
};
|
};
|
||||||
const deleteDraft = () => {
|
const deleteDraft = () => {
|
||||||
if (isSavedDraft) onComparisonDelete(draft.id);
|
if (isSavedDraft) onComparisonDelete(draft.id);
|
||||||
@@ -1376,6 +1389,174 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS
|
|||||||
comparison.title?.trim() || `${getProgramName(comparison.leftProgramId)} ↔ ${getProgramName(comparison.rightProgramId)}`;
|
comparison.title?.trim() || `${getProgramName(comparison.leftProgramId)} ↔ ${getProgramName(comparison.rightProgramId)}`;
|
||||||
const getComparisonMemoCount = (comparison) =>
|
const getComparisonMemoCount = (comparison) =>
|
||||||
(comparison.stepMatches ?? []).filter((match) => match.reason?.trim()).length;
|
(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 <span className="text-slate-400">{emptyText}</span>;
|
||||||
|
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 <React.Fragment key={`text-${index}`}>{part.value}</React.Fragment>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span key={`${part.type}-${index}`} className={classMap[part.type]}>
|
||||||
|
{part.value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
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) => {
|
const renderProgramSummary = (program) => {
|
||||||
if (!program) return null;
|
if (!program) return null;
|
||||||
@@ -1414,6 +1595,23 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS
|
|||||||
<p className="text-[11px] font-extrabold uppercase tracking-wide text-blue-300">1:1 프로그램 비교</p>
|
<p className="text-[11px] font-extrabold uppercase tracking-wide text-blue-300">1:1 프로그램 비교</p>
|
||||||
<h2 className="mt-1 text-lg font-extrabold">사내 한계와 상용 활용 기능 정리</h2>
|
<h2 className="mt-1 text-lg font-extrabold">사내 한계와 상용 활용 기능 정리</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isCompareEditing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={generateComparisonConclusion}
|
||||||
|
className="rounded-full bg-blue-600 px-4 py-2 text-sm font-black text-white hover:bg-blue-500"
|
||||||
|
>
|
||||||
|
결론 자동작성
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCompareEditing((current) => !current)}
|
||||||
|
className="rounded-full bg-white/10 px-4 py-2 text-sm font-black text-white hover:bg-white/20"
|
||||||
|
>
|
||||||
|
{isCompareEditing ? '보고서 보기' : '편집'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -1423,7 +1621,9 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS
|
|||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="overflow-auto bg-slate-50 p-5">
|
<div className="overflow-auto bg-slate-50 p-5">
|
||||||
|
{isCompareEditing ? (
|
||||||
<div className="rounded-3xl bg-white p-4 ring-1 ring-slate-100">
|
<div className="rounded-3xl bg-white p-4 ring-1 ring-slate-100">
|
||||||
<div className="mb-3 grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto_auto] lg:items-end">
|
<div className="mb-3 grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto_auto] lg:items-end">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
@@ -1512,6 +1712,17 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-3xl bg-white p-5 ring-1 ring-slate-100">
|
||||||
|
<p className="text-[12px] font-black uppercase tracking-wide text-blue-700">비교 보고서</p>
|
||||||
|
<h3 className="mt-1 text-xl font-black text-slate-950">
|
||||||
|
{draft.title?.trim() || `${leftProgram?.name ?? '-'} ↔ ${rightProgram?.name ?? '-'} 비교`}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm font-bold text-slate-500">
|
||||||
|
두 프로그램의 동일/연계 스텝에서 사내 한계와 상용 프로그램 활용 사유를 정리합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||||
{renderProgramSummary(leftProgram)}
|
{renderProgramSummary(leftProgram)}
|
||||||
@@ -1553,16 +1764,33 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS
|
|||||||
<p className="mt-2 whitespace-pre-line text-[11px] font-bold leading-5 text-slate-500">{leftStep.note}</p>
|
<p className="mt-2 whitespace-pre-line text-[11px] font-bold leading-5 text-slate-500">{leftStep.note}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isCompareEditing ? (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-[11px] font-black text-slate-700">안 되는 기능 / 상용 사용 이유</span>
|
<span className="text-[11px] font-black text-slate-700">안 되는 기능 / 상용 사용 이유</span>
|
||||||
<textarea
|
<textarea
|
||||||
value={match.reason ?? ''}
|
value={match.reason ?? ''}
|
||||||
onChange={(event) => updateStepReason(stepIndex, event.target.value)}
|
onChange={(event) => updateStepReason(stepIndex, event.target.value)}
|
||||||
|
onSelect={(event) => rememberTextSelection(event, { type: 'reason', stepIndex })}
|
||||||
|
onMouseUp={(event) => rememberTextSelection(event, { type: 'reason', stepIndex })}
|
||||||
|
onKeyUp={(event) => rememberTextSelection(event, { type: 'reason', stepIndex })}
|
||||||
|
onPointerDown={(event) => openFormatMenuOnRightDown(event, { type: 'reason', stepIndex })}
|
||||||
|
onContextMenu={(event) => openFormatMenu(event, { type: 'reason', stepIndex })}
|
||||||
rows={5}
|
rows={5}
|
||||||
placeholder="예: 이 스텝에서 3D 형상 수정과 obj/ifc 변환이 안 돼서 오른쪽 프로그램의 기능을 사용"
|
placeholder="강조할 글자를 드래그한 뒤 우클릭하세요."
|
||||||
className="mt-1 h-[128px] w-full resize-y rounded-xl border border-slate-200 bg-white px-3 py-2 text-[12px] font-bold leading-5 text-slate-800 outline-none focus:border-blue-300"
|
className="mt-1 h-[128px] w-full resize-y rounded-xl border border-slate-200 bg-white px-3 py-2 text-[12px] font-bold leading-5 text-slate-800 outline-none focus:border-blue-300"
|
||||||
/>
|
/>
|
||||||
|
<span className="mt-1 block text-[10px] font-bold text-slate-400">
|
||||||
|
글자 선택 후 우클릭: 빨강 · 빨강볼드 · 파랑
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<span className="text-[11px] font-black text-slate-500">상용 활용 사유</span>
|
||||||
|
<p className="mt-2 whitespace-pre-line text-sm font-bold leading-6 text-slate-800">
|
||||||
|
{renderFormattedText(match.reason, '작성된 비교 내용이 없습니다.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="rounded-2xl bg-blue-50/70 p-3 ring-1 ring-blue-100">
|
<div className="rounded-2xl bg-blue-50/70 p-3 ring-1 ring-blue-100">
|
||||||
<span className="text-[11px] font-black text-blue-700">{rightProgram?.name ?? 'B'} 스텝</span>
|
<span className="text-[11px] font-black text-blue-700">{rightProgram?.name ?? 'B'} 스텝</span>
|
||||||
<h4 className="mt-1 text-sm font-black text-slate-950">
|
<h4 className="mt-1 text-sm font-black text-slate-950">
|
||||||
@@ -1581,15 +1809,34 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isCompareEditing ? (
|
||||||
|
<>
|
||||||
<label className="mt-4 block">
|
<label className="mt-4 block">
|
||||||
|
<span className="flex items-center justify-between gap-3">
|
||||||
<span className="text-[12px] font-black text-slate-500">비교 결론</span>
|
<span className="text-[12px] font-black text-slate-500">비교 결론</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={generateComparisonConclusion}
|
||||||
|
className="rounded-full bg-slate-900 px-3 py-1.5 text-[11px] font-black text-white shadow-sm hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
사유 분석해서 자동작성
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
<textarea
|
<textarea
|
||||||
value={draft.note ?? ''}
|
value={draft.note ?? ''}
|
||||||
onChange={(event) => updateDraft('note', event.target.value)}
|
onChange={(event) => updateDraft('note', event.target.value)}
|
||||||
|
onSelect={(event) => rememberTextSelection(event, { type: 'note' })}
|
||||||
|
onMouseUp={(event) => rememberTextSelection(event, { type: 'note' })}
|
||||||
|
onKeyUp={(event) => rememberTextSelection(event, { type: 'note' })}
|
||||||
|
onPointerDown={(event) => openFormatMenuOnRightDown(event, { type: 'note' })}
|
||||||
|
onContextMenu={(event) => openFormatMenu(event, { type: 'note' })}
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="예: 현재는 해당 스텝 보완을 위해 상용 프로그램 사용이 필요함."
|
placeholder="강조할 글자를 드래그한 뒤 우클릭하세요."
|
||||||
className="mt-1 w-full resize-y rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm font-bold leading-6 text-slate-700 outline-none focus:border-slate-400"
|
className="mt-1 w-full resize-y rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm font-bold leading-6 text-slate-700 outline-none focus:border-slate-400"
|
||||||
/>
|
/>
|
||||||
|
<span className="mt-1 block text-[10px] font-bold text-slate-400">
|
||||||
|
글자 선택 후 우클릭: 빨강 · 빨강볼드 · 파랑
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-4 flex justify-end">
|
<div className="mt-4 flex justify-end">
|
||||||
<button
|
<button
|
||||||
@@ -1600,8 +1847,50 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS
|
|||||||
저장하기
|
저장하기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<span className="text-[12px] font-black text-slate-500">비교 결론</span>
|
||||||
|
<p className="mt-2 whitespace-pre-line text-sm font-bold leading-6 text-slate-800">
|
||||||
|
{draft.note?.trim()
|
||||||
|
? renderFormattedText(draft.note)
|
||||||
|
: hasDraftContent
|
||||||
|
? '별도 결론이 작성되지 않았습니다.'
|
||||||
|
: '아직 작성된 비교 보고서가 없습니다. 편집을 눌러 내용을 입력하세요.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{formatMenu && (
|
||||||
|
<div
|
||||||
|
className="fixed z-[80] overflow-hidden rounded-2xl bg-white p-1 shadow-2xl ring-1 ring-slate-200"
|
||||||
|
style={{ left: formatMenu.x, top: formatMenu.y }}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyTextFormat('red')}
|
||||||
|
className="block w-full rounded-xl px-4 py-2 text-left text-sm font-black text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
빨강
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyTextFormat('redbold')}
|
||||||
|
className="block w-full rounded-xl px-4 py-2 text-left text-sm font-black text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
빨강볼드
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyTextFormat('blue')}
|
||||||
|
className="block w-full rounded-xl px-4 py-2 text-left text-sm font-black text-blue-600 hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
파랑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1621,6 +1910,10 @@ function RelationTreePanel({
|
|||||||
const [mapZoom, setMapZoom] = useState(1);
|
const [mapZoom, setMapZoom] = useState(1);
|
||||||
const [mapPan, setMapPan] = useState({ x: 0, y: 0 });
|
const [mapPan, setMapPan] = useState({ x: 0, y: 0 });
|
||||||
const [mapDrag, setMapDrag] = useState(null);
|
const [mapDrag, setMapDrag] = useState(null);
|
||||||
|
const [viewportSize, setViewportSize] = useState(() => ({
|
||||||
|
width: typeof window === 'undefined' ? 720 : window.innerWidth,
|
||||||
|
height: typeof window === 'undefined' ? 820 : window.innerHeight
|
||||||
|
}));
|
||||||
const mapViewportRef = useRef(null);
|
const mapViewportRef = useRef(null);
|
||||||
const programGroupKey = (program) => program.mergeGroup || program.id;
|
const programGroupKey = (program) => program.mergeGroup || program.id;
|
||||||
const groupMap = programs.reduce((groups, program) => {
|
const groupMap = programs.reduce((groups, program) => {
|
||||||
@@ -1704,10 +1997,10 @@ function RelationTreePanel({
|
|||||||
const miniSideLaneWidth = 96;
|
const miniSideLaneWidth = 96;
|
||||||
const sidebarSizes = [420, 560, 720];
|
const sidebarSizes = [420, 560, 720];
|
||||||
const resolvedSidebarWidth = sidebarWidth ?? sidebarSizes[0];
|
const resolvedSidebarWidth = sidebarWidth ?? sidebarSizes[0];
|
||||||
const miniViewportWidth = Math.max(290, fullPage ? 0 : resolvedSidebarWidth - 24);
|
const miniViewportWidth = Math.max(290, fullPage ? viewportSize.width - 64 : resolvedSidebarWidth - 24);
|
||||||
const miniMaxLevelCount = Math.max(1, ...graphLevels.map((level) => level?.length ?? 0));
|
const miniMaxLevelCount = Math.max(1, ...graphLevels.map((level) => level?.length ?? 0));
|
||||||
const miniGraphWidth = Math.max(
|
const miniGraphWidth = Math.max(
|
||||||
fullPage ? 1120 : 560,
|
fullPage ? 640 : 560,
|
||||||
miniPadding * 2 + miniSideLaneWidth + miniMaxLevelCount * miniNodeWidth + Math.max(0, miniMaxLevelCount - 1) * miniColumnGap
|
miniPadding * 2 + miniSideLaneWidth + miniMaxLevelCount * miniNodeWidth + Math.max(0, miniMaxLevelCount - 1) * miniColumnGap
|
||||||
);
|
);
|
||||||
const miniGraphHeight = Math.max(
|
const miniGraphHeight = Math.max(
|
||||||
@@ -1749,15 +2042,32 @@ function RelationTreePanel({
|
|||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const mergeGroupBoxMap = Object.fromEntries(mergeGroupBoxes.map((box) => [box.groupKey, box]));
|
const mergeGroupBoxMap = Object.fromEntries(mergeGroupBoxes.map((box) => [box.groupKey, box]));
|
||||||
const baseMiniGraphScale = fullPage ? 1 : 0.72;
|
const fullPageAvailableHeight = Math.max(420, viewportSize.height - 170);
|
||||||
|
const baseMiniGraphScale = fullPage
|
||||||
|
? Math.min(1, Math.max(0.68, Math.min((miniViewportWidth - 12) / miniGraphWidth, fullPageAvailableHeight / miniGraphHeight)))
|
||||||
|
: 0.72;
|
||||||
const miniGraphScale = baseMiniGraphScale * mapZoom;
|
const miniGraphScale = baseMiniGraphScale * mapZoom;
|
||||||
const miniScaledWidth = miniGraphWidth * miniGraphScale;
|
const miniScaledWidth = miniGraphWidth * miniGraphScale;
|
||||||
const mapOffset = fullPage ? 0 : Math.max(0, Math.round((miniScaledWidth - miniViewportWidth) / 2 / miniGraphScale));
|
const mapOffset = fullPage ? 0 : Math.max(0, Math.round((miniScaledWidth - miniViewportWidth) / 2 / miniGraphScale));
|
||||||
|
const mapTranslateX = fullPage ? Math.max(0, Math.round((miniViewportWidth - miniScaledWidth) / 2)) : -mapOffset;
|
||||||
const canShrinkSidebar = resolvedSidebarWidth > sidebarSizes[0];
|
const canShrinkSidebar = resolvedSidebarWidth > sidebarSizes[0];
|
||||||
const setSidebarSize = (size) => {
|
const setSidebarSize = (size) => {
|
||||||
onSidebarWidthChange?.(size);
|
onSidebarWidthChange?.(size);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fullPage) return undefined;
|
||||||
|
const updateViewportSize = () => {
|
||||||
|
setViewportSize({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight
|
||||||
|
});
|
||||||
|
};
|
||||||
|
updateViewportSize();
|
||||||
|
window.addEventListener('resize', updateViewportSize);
|
||||||
|
return () => window.removeEventListener('resize', updateViewportSize);
|
||||||
|
}, [fullPage]);
|
||||||
|
|
||||||
const handleMapWheel = useCallback((event) => {
|
const handleMapWheel = useCallback((event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -1890,7 +2200,7 @@ function RelationTreePanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`${fullPage ? 'min-h-screen overflow-auto rounded-none border-0 bg-white/80 p-5' : '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 transition-[width] duration-300`}>
|
<aside className={`${fullPage ? 'h-screen overflow-hidden rounded-none border-0 bg-white/80 p-3' : '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 transition-[width] duration-300`}>
|
||||||
<div className="mb-4 flex items-start justify-between gap-3">
|
<div className="mb-4 flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-black uppercase tracking-wide text-blue-700">Program Map</p>
|
<p className="text-[11px] font-black uppercase tracking-wide text-blue-700">Program Map</p>
|
||||||
@@ -1963,7 +2273,7 @@ function RelationTreePanel({
|
|||||||
style={{
|
style={{
|
||||||
width: miniGraphWidth,
|
width: miniGraphWidth,
|
||||||
height: miniGraphHeight,
|
height: miniGraphHeight,
|
||||||
transform: `translate(${mapPan.x - mapOffset}px, ${mapPan.y}px) scale(${miniGraphScale})`,
|
transform: `translate(${mapPan.x + mapTranslateX}px, ${mapPan.y}px) scale(${miniGraphScale})`,
|
||||||
transformOrigin: 'top left'
|
transformOrigin: 'top left'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -2813,7 +3123,7 @@ export default function App() {
|
|||||||
window.open(
|
window.open(
|
||||||
'/?view=relation-map',
|
'/?view=relation-map',
|
||||||
'program-relation-map',
|
'program-relation-map',
|
||||||
'popup=yes,width=1280,height=920,left=120,top=40,resizable=yes,scrollbars=yes'
|
'popup=yes,width=700,height=820,left=120,top=40,resizable=yes,scrollbars=yes'
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2856,6 +3166,19 @@ export default function App() {
|
|||||||
onSidebarWidthChange={() => {}}
|
onSidebarWidthChange={() => {}}
|
||||||
fullPage
|
fullPage
|
||||||
/>
|
/>
|
||||||
|
{isComparePopupOpen && (
|
||||||
|
<ProgramComparePopup
|
||||||
|
programs={programs}
|
||||||
|
comparisons={content.comparisons ?? []}
|
||||||
|
initialPair={compareInitialPair}
|
||||||
|
onComparisonSave={saveComparison}
|
||||||
|
onComparisonDelete={deleteComparison}
|
||||||
|
onClose={() => {
|
||||||
|
setIsComparePopupOpen(false);
|
||||||
|
setCompareInitialPair(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user