- 두 프로그램의 스텝을 전부 펼친 뒤, 가운데에 해당 스텝에서 상용 프로그램을 쓰는 이유를 적습니다.
-
- {maxStepCount === 0 ? (
+ {comparisonRows.length === 0 ? (
- 비교할 스텝이 없습니다.
+ {isCompareEditing ? '비교 행을 추가해서 좌우 스텝을 선택하세요.' : '작성된 비교 행이 없습니다.'}
) : (
- Array.from({ length: maxStepCount }).map((_, stepIndex) => {
- const leftStep = leftSteps[stepIndex];
- const rightStep = rightSteps[stepIndex];
- const match = getStepMatch(stepIndex);
+ comparisonRows.map((match, rowIndex) => {
+ const leftStepIndex = match.leftStepIndex === '' ? -1 : Number(match.leftStepIndex);
+ const rightStepIndex = match.rightStepIndex === '' ? -1 : Number(match.rightStepIndex);
+ const leftStep = leftSteps[leftStepIndex];
+ const rightStep = rightSteps[rightStepIndex];
return (
-
{leftProgram?.name ?? 'A'} 스텝
-
- {leftStep ? getStepLabel(leftStep, stepIndex) : `${stepIndex + 1}. -`}
-
+
+ {leftProgram?.name ?? 'A'} 스텝
+
+ #{rowIndex + 1}
+
+
+ {isCompareEditing ? (
+
+ ) : (
+
+ {leftStep ? getStepLabel(leftStep, leftStepIndex) : '선택된 스텝 없음'}
+
+ )}
{leftStep?.feature || '해당 스텝 없음'}
@@ -1785,15 +1822,24 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS
{isCompareEditing ? (
{!fullPage && (
@@ -2336,6 +2413,9 @@ function RelationTreePanel({
+
{relations.map((relation) => {
const from = miniNodePositions[relation.from.id];
@@ -2344,6 +2424,7 @@ function RelationTreePanel({
const selectedToId = groupRepresentativeMap[relation.toGroupKey] ?? relation.to.id;
const relationId = `${relation.from.id}->${selectedToId}`;
const isSelectedRelation = selectedRelationId === relationId;
+ const isSearchedRelation = searchedRelationIds.includes(relationId);
const toGroupBox = mergeGroupBoxMap[relation.toGroupKey];
const fromLevel = levelMap[relation.from.id] ?? 0;
const toLevel = levelMap[relation.to.id] ?? 0;
@@ -2397,25 +2478,25 @@ function RelationTreePanel({
onPointerDown={stopRelationDrag}
onClick={handleRelationClick}
/>
- {isSelectedRelation && (
+ {(isSelectedRelation || isSearchedRelation) && (
)}
)}
+ {onStepSearchChange && (
+
+
+ onStepSearchChange(event.target.value)}
+ placeholder="변경사항 검색 예: 노선변경"
+ className="min-w-0 flex-1 rounded-full border border-slate-200 bg-slate-50 px-3 py-2 text-[12px] font-bold text-slate-700 outline-none focus:border-sky-300"
+ />
+ {stepSearchQuery && (
+
+ )}
+
+ {stepSearchQuery.trim() && (
+
+ {stepSearchResult?.programPath?.length ? (
+ <>
+
+
+ {stepSearchResult.programPath.map((program, index) => (
+
+
+ {program.name}
+
+ {index < stepSearchResult.programPath.length - 1 && (
+
+ )}
+
+ ))}
+
+
+ {stepSearchResult.program?.name} · {stepSearchResult.step?.title}까지 필요한 업무 스텝을 노란색으로 표시합니다.
+
+
+ {stepSearchResult.matches?.length > 1 && (
+
+
+ 비슷한 항목 {stepSearchResult.matches.length}개 중 선택
+
+
+ {stepSearchResult.matches.map((match) => {
+ const isSelected = selectedSearchMatchId === match.id;
+ return (
+
+ );
+ })}
+
+
+ )}
+ >
+ ) : (
+
검색된 변경사항/재진입 스텝이 없습니다.
+ )}
+
+ )}
+
+ )}
);
@@ -2479,6 +2639,8 @@ export default function App() {
const [isComparePopupOpen, setIsComparePopupOpen] = useState(false);
const [compareInitialPair, setCompareInitialPair] = useState(null);
const [selectedRelationId, setSelectedRelationId] = useState('');
+ const [stepSearchQuery, setStepSearchQuery] = useState('');
+ const [selectedStepSearchMatchId, setSelectedStepSearchMatchId] = useState('');
const [sidebarWidth, setSidebarWidth] = useState(420);
const [isServerLoaded, setIsServerLoaded] = useState(false);
const relationBlockRefs = useRef({});
@@ -2490,23 +2652,25 @@ export default function App() {
id: 'cheonjiin',
name: content.cheonjiin.name,
description: content.cheonjiin.description,
+ programNote: content.cheonjiin.programNote ?? '',
steps: editableCheonjiinFlow,
format: content.cheonjiin.format,
programType: getProgramType(content.cheonjiin.programType),
predecessors: content.cheonjiin.predecessors ?? [],
successors: content.cheonjiin.successors ?? [],
mergeGroup: content.cheonjiin.mergeGroup ?? '',
- accent: {
- iconBg: 'bg-teal-50',
- iconText: 'text-teal-700',
- labelText: 'text-teal-700',
- arrowText: 'text-teal-600'
+ accent: {
+ iconBg: 'bg-blue-50',
+ iconText: 'text-blue-700',
+ labelText: 'text-blue-700',
+ arrowText: 'text-blue-600'
}
},
{
id: 'wayPrimal',
name: content.wayPrimal.name,
description: content.wayPrimal.description,
+ programNote: content.wayPrimal.programNote ?? '',
steps: editableWayPrimalFlow,
format: content.wayPrimal.format,
programType: getProgramType(content.wayPrimal.programType),
@@ -2515,15 +2679,16 @@ export default function App() {
mergeGroup: content.wayPrimal.mergeGroup ?? '',
accent: {
iconBg: 'bg-indigo-50',
- iconText: 'text-indigo-700',
- labelText: 'text-indigo-700',
- arrowText: 'text-indigo-600'
+ iconText: 'text-blue-700',
+ labelText: 'text-blue-700',
+ arrowText: 'text-blue-600'
}
},
...content.extraPrograms.map((program) => ({
id: program.id,
name: program.name,
description: program.description,
+ programNote: program.programNote ?? '',
steps: normalizeProgramSteps(program),
format: program.format,
programType: getProgramType(program.programType),
@@ -2554,6 +2719,88 @@ export default function App() {
return representatives;
}, {});
const getRelationId = (fromId, toId) => `${fromId}->${toId}`;
+ const normalizeSearchText = (value) => String(value ?? '').trim().toLowerCase();
+ const stepSearchTerm = normalizeSearchText(stepSearchQuery);
+ const stepSearchMatches = stepSearchTerm
+ ? programs.flatMap((program) =>
+ buildReviewItems(program.id, program.steps, getStoredProgram(program.id)?.detailGates)
+ .map((gate, gateIndex) => {
+ const targetProgramId = resolveGateTargetProgramId(gate, program.id, programs);
+ const targetProgram = programById[targetProgramId] ?? program;
+ const targetStep = targetProgram.steps.find((step) => step.id === gate.stepId);
+ return {
+ id: `${program.id}::${gate.key}`,
+ sourceProgram: program,
+ program: targetProgram,
+ step: targetStep,
+ stepIndex: targetProgram.steps.findIndex((step) => step.id === gate.stepId),
+ gate,
+ gateIndex
+ };
+ })
+ .filter(({ sourceProgram, program, step, gate }) =>
+ step &&
+ [
+ gate.question,
+ gate.yes,
+ gate.no,
+ `${program.name} ${step.title}`,
+ `${sourceProgram.name} ${program.name} ${step.title}`
+ ].some((value) => normalizeSearchText(value).includes(stepSearchTerm))
+ )
+ )
+ : [];
+ const primaryStepSearchMatch =
+ stepSearchMatches.find((match) => match.id === selectedStepSearchMatchId) ??
+ stepSearchMatches[0] ??
+ null;
+ const findProgramPath = (targetProgramId) => {
+ if (!targetProgramId || !programById[targetProgramId]) return [];
+ const rootPrograms = programs.filter((program) => (program.predecessors ?? []).length === 0);
+ const queue = (rootPrograms.length ? rootPrograms : programs.slice(0, 1)).map((program) => [program.id]);
+ const visited = new Set();
+ while (queue.length) {
+ const path = queue.shift();
+ const currentId = path[path.length - 1];
+ if (currentId === targetProgramId) return path.map((programId) => programById[programId]).filter(Boolean);
+ if (visited.has(currentId)) continue;
+ visited.add(currentId);
+ (programById[currentId]?.successors ?? []).forEach((successorId) => {
+ if (!programById[successorId] || path.includes(successorId)) return;
+ queue.push([...path, successorId]);
+ });
+ }
+ return [programById[targetProgramId]].filter(Boolean);
+ };
+ const searchedProgramPath = primaryStepSearchMatch ? findProgramPath(primaryStepSearchMatch.program.id) : [];
+ const searchedRelationIds = searchedProgramPath.slice(0, -1).map((program, index) => {
+ const nextProgram = searchedProgramPath[index + 1];
+ const nextGroupKey = getProgramGroupKey(nextProgram);
+ return getRelationId(program.id, groupRepresentativeByKey[nextGroupKey] ?? nextProgram.id);
+ });
+ const searchedStepIdsByProgram = searchedProgramPath.reduce((matches, program, pathIndex) => {
+ const isTargetProgram = pathIndex === searchedProgramPath.length - 1;
+ const targetStepIndex = isTargetProgram ? primaryStepSearchMatch?.stepIndex ?? -1 : program.steps.length - 1;
+ matches[program.id] = program.steps
+ .slice(0, Math.max(0, targetStepIndex + 1))
+ .map((step) => step.id);
+ return matches;
+ }, {});
+ const stepSearchResult = {
+ query: stepSearchQuery,
+ matches: stepSearchMatches,
+ program: primaryStepSearchMatch?.program,
+ step: primaryStepSearchMatch?.step,
+ programPath: searchedProgramPath
+ };
+ useEffect(() => {
+ if (!stepSearchTerm) {
+ if (selectedStepSearchMatchId) setSelectedStepSearchMatchId('');
+ return;
+ }
+ if (selectedStepSearchMatchId && stepSearchMatches.some((match) => match.id === selectedStepSearchMatchId)) return;
+ setSelectedStepSearchMatchId(stepSearchMatches[0]?.id ?? '');
+ }, [stepSearchTerm, selectedStepSearchMatchId, stepSearchMatches]);
const getRelationLabel = (fromProgram, toPrograms) => {
const targetPrograms = Array.isArray(toPrograms) ? toPrograms : [toPrograms];
const storedTarget = targetPrograms.map((program) => getStoredProgram(program.id)).find((program) => program?.linkLabel);
@@ -2587,6 +2834,10 @@ export default function App() {
focusRelation(relationId, !isRelationMapWindow);
broadcastRelationSelection(relationId);
};
+ const resetRelationSelection = () => {
+ focusRelation('', false);
+ broadcastRelationSelection('');
+ };
const focusProgram = (programId, shouldScroll = true) => {
if (shouldScroll) {
programSectionRefs.current[programId]?.scrollIntoView({
@@ -2715,7 +2966,7 @@ export default function App() {
if (event.data?.states) {
setProgramStates(event.data.states);
}
- if (event.data?.selectedRelationId) {
+ if (event.data && Object.prototype.hasOwnProperty.call(event.data, 'selectedRelationId')) {
focusRelation(event.data.selectedRelationId, !isRelationMapWindow);
}
if (event.data?.selectedProgramId) {
@@ -2813,7 +3064,7 @@ export default function App() {
if (event.data?.type === 'program-flow-selected-program' && event.data.programId) {
focusProgram(event.data.programId, true);
}
- if (event.data?.type === 'program-flow-selected-relation' && event.data.relationId) {
+ if (event.data?.type === 'program-flow-selected-relation' && Object.prototype.hasOwnProperty.call(event.data, 'relationId')) {
focusRelation(event.data.relationId, true);
}
};
@@ -3206,6 +3457,7 @@ export default function App() {
format: '',
deliverables: ['성과물'],
programType: 'internal',
+ programNote: '',
predecessors: [],
successors: [],
mergeGroup: '',
@@ -3429,7 +3681,9 @@ export default function App() {
onProgramClick={selectProgramFromMap}
onOpenComparePair={openComparePair}
onRelationSelect={selectRelation}
+ onRelationReset={resetRelationSelection}
selectedRelationId={selectedRelationId}
+ searchedRelationIds={searchedRelationIds}
onOpenRelationPopup={() => setIsRelationPopupOpen(true)}
sidebarWidth={1280}
onSidebarWidthChange={() => {}}
@@ -3479,7 +3733,14 @@ export default function App() {
onProgramClick={selectProgramFromMap}
onOpenComparePair={openComparePair}
onRelationSelect={selectRelation}
+ onRelationReset={resetRelationSelection}
selectedRelationId={selectedRelationId}
+ searchedRelationIds={searchedRelationIds}
+ stepSearchQuery={stepSearchQuery}
+ onStepSearchChange={setStepSearchQuery}
+ stepSearchResult={stepSearchResult}
+ selectedSearchMatchId={primaryStepSearchMatch?.id ?? ''}
+ onSearchMatchSelect={setSelectedStepSearchMatchId}
onOpenRelationPopup={() => setIsRelationPopupOpen(true)}
onOpenMapWindow={openRelationMapWindow}
sidebarWidth={sidebarWidth}
@@ -3511,6 +3772,7 @@ export default function App() {
openProgramWindow('cheonjiin')}
activeStep={getMainActiveStep('cheonjiin')}
@@ -3518,6 +3780,7 @@ export default function App() {
onStepChange={(index, field, value) => updateStep('cheonjiin', index, field, value)}
onLabelChange={(value) => updateProgramTitle('cheonjiin', 'name', value)}
onDescriptionChange={(value) => updateProgramTitle('cheonjiin', 'description', value)}
+ onProgramNoteChange={(value) => updateProgramTitle('cheonjiin', 'programNote', value)}
programType={content.cheonjiin.programType}
onProgramTypeChange={(value) => updateProgramTitle('cheonjiin', 'programType', value)}
format={content.cheonjiin.format}
@@ -3527,14 +3790,15 @@ export default function App() {
onAddStep={() => addStep('cheonjiin')}
onRemoveStep={(index) => removeStep('cheonjiin', index)}
onMoveStep={(index, nextIndex) => moveStep('cheonjiin', index, nextIndex)}
+ searchMatchedStepIds={searchedStepIdsByProgram.cheonjiin ?? []}
rowRef={(node) => {
programSectionRefs.current.cheonjiin = node;
}}
accent={{
- iconBg: 'bg-teal-50',
- iconText: 'text-teal-700',
- labelText: 'text-teal-700',
- arrowText: 'text-teal-600'
+ iconBg: 'bg-blue-50',
+ iconText: 'text-blue-700',
+ labelText: 'text-blue-700',
+ arrowText: 'text-blue-600'
}}
/>
{renderRelationBlock(programById.cheonjiin)}
@@ -3542,6 +3806,7 @@ export default function App() {
openProgramWindow('wayPrimal')}
activeStep={getMainActiveStep('wayPrimal')}
@@ -3549,6 +3814,7 @@ export default function App() {
onStepChange={(index, field, value) => updateStep('wayPrimal', index, field, value)}
onLabelChange={(value) => updateProgramTitle('wayPrimal', 'name', value)}
onDescriptionChange={(value) => updateProgramTitle('wayPrimal', 'description', value)}
+ onProgramNoteChange={(value) => updateProgramTitle('wayPrimal', 'programNote', value)}
programType={content.wayPrimal.programType}
onProgramTypeChange={(value) => updateProgramTitle('wayPrimal', 'programType', value)}
format={content.wayPrimal.format}
@@ -3558,14 +3824,15 @@ export default function App() {
onAddStep={() => addStep('wayPrimal')}
onRemoveStep={(index) => removeStep('wayPrimal', index)}
onMoveStep={(index, nextIndex) => moveStep('wayPrimal', index, nextIndex)}
+ searchMatchedStepIds={searchedStepIdsByProgram.wayPrimal ?? []}
rowRef={(node) => {
programSectionRefs.current.wayPrimal = node;
}}
accent={{
iconBg: 'bg-indigo-50',
- iconText: 'text-indigo-700',
- labelText: 'text-indigo-700',
- arrowText: 'text-indigo-600'
+ iconText: 'text-blue-700',
+ labelText: 'text-blue-700',
+ arrowText: 'text-blue-600'
}}
/>
{renderRelationBlock(programById.wayPrimal)}
@@ -3592,6 +3859,13 @@ export default function App() {
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"
/>
+