868 lines
32 KiB
JavaScript
868 lines
32 KiB
JavaScript
window.createFlowRenderer = function createFlowRenderer(options) {
|
||
const {
|
||
elements,
|
||
getState,
|
||
setSelectedChain,
|
||
openEditor,
|
||
closeEditor,
|
||
saveFlowModel,
|
||
closeSitemapModal,
|
||
askStepLabel,
|
||
getNextFlow,
|
||
getPivotLabel,
|
||
getSubFlowLinkInfo,
|
||
formatConnectorReason,
|
||
setSubFlowLinkReason,
|
||
remapSubFlowLinksOnRename,
|
||
syncSubFlowLinks,
|
||
getSubFlowLinks,
|
||
setSubFlowLinks,
|
||
getMainBarMode = () => false,
|
||
getLv2BarMode = () => false,
|
||
setMainBarMode = () => {},
|
||
setLv2BarMode = () => {},
|
||
onBarModeLayoutChange = () => {}
|
||
} = options || {};
|
||
|
||
const {
|
||
mainFlowEl,
|
||
mainPanelEl,
|
||
mainCommonSectionEl,
|
||
drillColumnsEl
|
||
} = elements || {};
|
||
|
||
let barHoverTooltipEl = null;
|
||
|
||
function ensureBarHoverTooltip() {
|
||
if (barHoverTooltipEl && document.body.contains(barHoverTooltipEl)) {
|
||
return barHoverTooltipEl;
|
||
}
|
||
const existing = document.getElementById('barStepHoverTooltip');
|
||
if (existing) {
|
||
barHoverTooltipEl = existing;
|
||
return barHoverTooltipEl;
|
||
}
|
||
const tooltip = document.createElement('div');
|
||
tooltip.id = 'barStepHoverTooltip';
|
||
tooltip.className = 'bar-step-tooltip';
|
||
tooltip.setAttribute('role', 'tooltip');
|
||
document.body.appendChild(tooltip);
|
||
barHoverTooltipEl = tooltip;
|
||
return barHoverTooltipEl;
|
||
}
|
||
|
||
function hideBarStepTooltip() {
|
||
const tooltip = ensureBarHoverTooltip();
|
||
tooltip.classList.remove('show');
|
||
}
|
||
|
||
function positionBarStepTooltip(btn) {
|
||
const tooltip = ensureBarHoverTooltip();
|
||
const rect = btn.getBoundingClientRect();
|
||
let left = rect.right + 10;
|
||
const top = rect.top + (rect.height / 2);
|
||
|
||
tooltip.style.left = `${Math.round(left)}px`;
|
||
tooltip.style.top = `${Math.round(top)}px`;
|
||
|
||
const tooltipRect = tooltip.getBoundingClientRect();
|
||
const viewportPadding = 8;
|
||
if ((left + tooltipRect.width + viewportPadding) > window.innerWidth) {
|
||
left = Math.max(viewportPadding, rect.left - tooltipRect.width - 10);
|
||
tooltip.style.left = `${Math.round(left)}px`;
|
||
}
|
||
}
|
||
|
||
function showBarStepTooltip(btn, label) {
|
||
const barPanel = btn.closest('.flow-panel.flow-bar-mode');
|
||
if (!barPanel) return;
|
||
const text = String(label || '').trim();
|
||
if (!text) return;
|
||
const tooltip = ensureBarHoverTooltip();
|
||
tooltip.textContent = text;
|
||
tooltip.classList.add('show');
|
||
positionBarStepTooltip(btn);
|
||
}
|
||
|
||
function bindBarStepTooltip(btn, label) {
|
||
btn.addEventListener('mouseenter', () => showBarStepTooltip(btn, label));
|
||
btn.addEventListener('mousemove', () => positionBarStepTooltip(btn));
|
||
btn.addEventListener('mouseleave', hideBarStepTooltip);
|
||
btn.addEventListener('focus', () => showBarStepTooltip(btn, label));
|
||
btn.addEventListener('blur', hideBarStepTooltip);
|
||
}
|
||
|
||
function formatUpdatedAtText(value) {
|
||
const text = String(value || '').trim();
|
||
if (!text) return '';
|
||
const parsed = new Date(text);
|
||
if (Number.isNaN(parsed.getTime())) return text;
|
||
return `최종 수정 ${parsed.toLocaleString('ko-KR', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}`;
|
||
}
|
||
|
||
function getEntityMetaValue(mapName, key) {
|
||
const state = getState();
|
||
const entityMeta = state && state.entityMeta ? state.entityMeta : {};
|
||
const map = entityMeta[mapName];
|
||
if (!map || typeof map !== 'object') return '';
|
||
return String(map[key] || '').trim();
|
||
}
|
||
|
||
function getStepMetaText(stepKey) {
|
||
const stableId = getEntityMetaValue('stepStableIdByKey', stepKey);
|
||
const parts = [];
|
||
if (stableId) parts.push(`ID: ${stableId}`);
|
||
return parts.join('\n');
|
||
}
|
||
|
||
function getFlowMetaText(flowKey) {
|
||
const stableId = getEntityMetaValue('flowStableIdByKey', flowKey);
|
||
const contentHash = getEntityMetaValue('flowContentHashByKey', flowKey);
|
||
const parts = [];
|
||
if (stableId) parts.push(`ID: ${stableId}`);
|
||
if (contentHash) parts.push(`HASH: ${contentHash}`);
|
||
return parts.join('\n');
|
||
}
|
||
|
||
function getLinkMetaText(mainStep, fromStep, toStep) {
|
||
const linkKey = `${mainStep}||${fromStep}>>${toStep}`;
|
||
const stableId = getEntityMetaValue('linkStableIdByKey', linkKey);
|
||
const parts = [];
|
||
if (stableId) parts.push(`ID: ${stableId}`);
|
||
return parts.join('\n');
|
||
}
|
||
|
||
function renderVerticalFlow(container, list, activeValue, onSelect, renderOptions) {
|
||
const listEl = document.createElement('div');
|
||
listEl.className = 'flow-list';
|
||
|
||
const {
|
||
allowEdit = false,
|
||
onEdit = null,
|
||
onDelete = null,
|
||
onMoveUp = null,
|
||
onMoveDown = null,
|
||
onInsertAfter = null,
|
||
onAddEnd = null,
|
||
getMetaText = null,
|
||
getConnectorInfo = null,
|
||
onEditConnector = null,
|
||
onClearConnector = null
|
||
} = renderOptions || {};
|
||
|
||
list.forEach((step, idx) => {
|
||
if (allowEdit) {
|
||
const row = document.createElement('div');
|
||
row.className = 'step-row';
|
||
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'step-btn';
|
||
if (activeValue === step) btn.classList.add('active');
|
||
const metaText = typeof getMetaText === 'function' ? getMetaText(step, idx) : '';
|
||
if (metaText) {
|
||
btn.classList.add('with-meta');
|
||
// 편집 모드에서는 메타 정보를 버튼 하단(step-edit-meta)에만 표시한다.
|
||
// 버튼 내부 중복 표시는 제거한다.
|
||
btn.innerHTML = `<span class="step-main">${step}</span>`;
|
||
} else {
|
||
btn.textContent = step;
|
||
}
|
||
btn.setAttribute('data-step-label', String(step));
|
||
btn.setAttribute('aria-label', String(step));
|
||
btn.removeAttribute('title');
|
||
bindBarStepTooltip(btn, step);
|
||
btn.addEventListener('click', () => onSelect(step));
|
||
row.appendChild(btn);
|
||
|
||
if (metaText) {
|
||
const meta = document.createElement('div');
|
||
meta.className = 'step-edit-meta';
|
||
meta.textContent = metaText;
|
||
row.appendChild(meta);
|
||
}
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'step-actions';
|
||
|
||
const upBtn = document.createElement('button');
|
||
upBtn.type = 'button';
|
||
upBtn.className = 'icon-btn';
|
||
upBtn.title = '위로 이동';
|
||
upBtn.textContent = '↑';
|
||
upBtn.disabled = idx === 0;
|
||
upBtn.style.opacity = idx === 0 ? '0.4' : '1';
|
||
upBtn.addEventListener('click', () => onMoveUp && onMoveUp(idx));
|
||
actions.appendChild(upBtn);
|
||
|
||
const downBtn = document.createElement('button');
|
||
downBtn.type = 'button';
|
||
downBtn.className = 'icon-btn';
|
||
downBtn.title = '아래로 이동';
|
||
downBtn.textContent = '↓';
|
||
downBtn.disabled = idx === list.length - 1;
|
||
downBtn.style.opacity = idx === list.length - 1 ? '0.4' : '1';
|
||
downBtn.addEventListener('click', () => onMoveDown && onMoveDown(idx));
|
||
actions.appendChild(downBtn);
|
||
|
||
const editBtn = document.createElement('button');
|
||
editBtn.type = 'button';
|
||
editBtn.className = 'icon-btn';
|
||
editBtn.title = '이름 수정';
|
||
editBtn.textContent = '✎';
|
||
editBtn.addEventListener('click', () => onEdit && onEdit(idx));
|
||
actions.appendChild(editBtn);
|
||
|
||
const delBtn = document.createElement('button');
|
||
delBtn.type = 'button';
|
||
delBtn.className = 'icon-btn delete';
|
||
delBtn.title = '삭제';
|
||
delBtn.textContent = '×';
|
||
delBtn.addEventListener('click', () => onDelete && onDelete(idx));
|
||
actions.appendChild(delBtn);
|
||
|
||
row.appendChild(actions);
|
||
listEl.appendChild(row);
|
||
} else {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'step-btn';
|
||
if (activeValue === step) btn.classList.add('active');
|
||
const metaText = typeof getMetaText === 'function' ? getMetaText(step, idx) : '';
|
||
if (metaText) {
|
||
btn.classList.add('with-meta');
|
||
btn.innerHTML = `<span class="step-main">${step}</span><span class="step-meta">${metaText}</span>`;
|
||
} else {
|
||
btn.textContent = step;
|
||
}
|
||
btn.setAttribute('data-step-label', String(step));
|
||
btn.setAttribute('aria-label', String(step));
|
||
btn.removeAttribute('title');
|
||
bindBarStepTooltip(btn, step);
|
||
btn.addEventListener('click', () => onSelect(step));
|
||
listEl.appendChild(btn);
|
||
}
|
||
|
||
if (idx < list.length - 1) {
|
||
const connectorInfo = typeof getConnectorInfo === 'function'
|
||
? (getConnectorInfo(step, idx, list[idx + 1]) || { broken: false, reason: '' })
|
||
: { broken: false, reason: '' };
|
||
if (allowEdit) {
|
||
const insertWrap = document.createElement('div');
|
||
insertWrap.className = 'insert-row';
|
||
const arrow = document.createElement('div');
|
||
arrow.className = `down-arrow${connectorInfo.broken ? ' broken' : ''}`;
|
||
arrow.textContent = '↓';
|
||
insertWrap.appendChild(arrow);
|
||
if (connectorInfo.reason) {
|
||
const reason = document.createElement('div');
|
||
reason.className = 'link-reason';
|
||
reason.textContent = formatConnectorReason(connectorInfo.reason);
|
||
insertWrap.appendChild(reason);
|
||
}
|
||
const connectorMetaText = String(connectorInfo.metaText || '').trim();
|
||
if (connectorMetaText) {
|
||
const meta = document.createElement('div');
|
||
meta.className = 'step-edit-meta';
|
||
meta.textContent = connectorMetaText;
|
||
insertWrap.appendChild(meta);
|
||
}
|
||
if (typeof onEditConnector === 'function') {
|
||
const linkBtn = document.createElement('button');
|
||
linkBtn.type = 'button';
|
||
linkBtn.className = 'link-edit-btn';
|
||
linkBtn.textContent = connectorInfo.broken ? '수동 처리 수정' : '수동 처리 추가';
|
||
linkBtn.addEventListener('click', () => onEditConnector(idx));
|
||
insertWrap.appendChild(linkBtn);
|
||
if (connectorInfo.broken && typeof onClearConnector === 'function') {
|
||
const clearBtn = document.createElement('button');
|
||
clearBtn.type = 'button';
|
||
clearBtn.className = 'link-clear-btn';
|
||
clearBtn.textContent = '자동 복구';
|
||
clearBtn.addEventListener('click', () => onClearConnector(idx));
|
||
insertWrap.appendChild(clearBtn);
|
||
}
|
||
}
|
||
const insertBtn = document.createElement('button');
|
||
insertBtn.type = 'button';
|
||
insertBtn.className = 'insert-btn';
|
||
insertBtn.title = '아래에 삽입';
|
||
insertBtn.textContent = '+';
|
||
insertBtn.addEventListener('click', () => onInsertAfter && onInsertAfter(idx));
|
||
insertWrap.appendChild(insertBtn);
|
||
listEl.appendChild(insertWrap);
|
||
} else {
|
||
const arrow = document.createElement('div');
|
||
arrow.className = `down-arrow${connectorInfo.broken ? ' broken' : ''}`;
|
||
arrow.textContent = '↓';
|
||
listEl.appendChild(arrow);
|
||
if (connectorInfo.reason) {
|
||
const reason = document.createElement('div');
|
||
reason.className = 'link-reason';
|
||
reason.textContent = connectorInfo.reason;
|
||
listEl.appendChild(reason);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
if (allowEdit) {
|
||
const addEndWrap = document.createElement('div');
|
||
addEndWrap.className = 'insert-row';
|
||
const addEndBtn = document.createElement('button');
|
||
addEndBtn.type = 'button';
|
||
addEndBtn.className = 'add-end-btn';
|
||
addEndBtn.textContent = '+ 마지막에 추가';
|
||
addEndBtn.addEventListener('click', () => onAddEnd && onAddEnd());
|
||
addEndWrap.appendChild(addEndBtn);
|
||
listEl.appendChild(addEndWrap);
|
||
}
|
||
|
||
container.appendChild(listEl);
|
||
}
|
||
|
||
function renderCommonSection() {
|
||
const state = getState();
|
||
const { editMode, commonItems, commonSubFlow, selectedChain } = state;
|
||
mainCommonSectionEl.innerHTML = '';
|
||
|
||
const head = document.createElement('div');
|
||
head.className = 'common-head';
|
||
const title = document.createElement('p');
|
||
title.className = 'common-title';
|
||
title.textContent = '공통';
|
||
head.appendChild(title);
|
||
|
||
if (editMode) {
|
||
const addBtn = document.createElement('button');
|
||
addBtn.type = 'button';
|
||
addBtn.className = 'common-add-btn';
|
||
addBtn.textContent = '+ 공통 추가';
|
||
addBtn.addEventListener('click', () => {
|
||
const next = askStepLabel('');
|
||
if (!next) return;
|
||
commonItems.push(next);
|
||
if (!commonSubFlow[next]) commonSubFlow[next] = [];
|
||
saveFlowModel();
|
||
renderCommonSection();
|
||
});
|
||
head.appendChild(addBtn);
|
||
}
|
||
|
||
mainCommonSectionEl.appendChild(head);
|
||
|
||
const list = document.createElement('div');
|
||
list.className = 'common-list';
|
||
if (!commonItems.length) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'common-empty';
|
||
empty.textContent = '공통 항목이 없습니다.';
|
||
list.appendChild(empty);
|
||
} else {
|
||
commonItems.forEach((item, idx) => {
|
||
const commonMetaText = editMode ? getStepMetaText(`common|${item}`) : '';
|
||
const isActiveCommon = selectedChain[0] === `common::${item}`;
|
||
if (editMode) {
|
||
const row = document.createElement('div');
|
||
row.className = 'common-row';
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'common-btn';
|
||
if (isActiveCommon) btn.classList.add('active');
|
||
btn.textContent = item;
|
||
btn.setAttribute('data-step-label', String(item));
|
||
btn.setAttribute('aria-label', String(item));
|
||
btn.removeAttribute('title');
|
||
bindBarStepTooltip(btn, item);
|
||
btn.addEventListener('click', () => {
|
||
api.openFirstSubStepFromCommonFlow(item);
|
||
});
|
||
row.appendChild(btn);
|
||
|
||
if (commonMetaText) {
|
||
const meta = document.createElement('div');
|
||
meta.className = 'step-edit-meta';
|
||
meta.textContent = commonMetaText;
|
||
row.appendChild(meta);
|
||
}
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'step-actions';
|
||
|
||
const upBtn = document.createElement('button');
|
||
upBtn.type = 'button';
|
||
upBtn.className = 'icon-btn';
|
||
upBtn.title = '위로 이동';
|
||
upBtn.textContent = '↑';
|
||
upBtn.disabled = idx === 0;
|
||
upBtn.style.opacity = idx === 0 ? '0.4' : '1';
|
||
upBtn.addEventListener('click', () => {
|
||
if (idx <= 0) return;
|
||
[commonItems[idx - 1], commonItems[idx]] = [commonItems[idx], commonItems[idx - 1]];
|
||
saveFlowModel();
|
||
renderCommonSection();
|
||
});
|
||
actions.appendChild(upBtn);
|
||
|
||
const downBtn = document.createElement('button');
|
||
downBtn.type = 'button';
|
||
downBtn.className = 'icon-btn';
|
||
downBtn.title = '아래로 이동';
|
||
downBtn.textContent = '↓';
|
||
downBtn.disabled = idx === commonItems.length - 1;
|
||
downBtn.style.opacity = idx === commonItems.length - 1 ? '0.4' : '1';
|
||
downBtn.addEventListener('click', () => {
|
||
if (idx >= commonItems.length - 1) return;
|
||
[commonItems[idx + 1], commonItems[idx]] = [commonItems[idx], commonItems[idx + 1]];
|
||
saveFlowModel();
|
||
renderCommonSection();
|
||
});
|
||
actions.appendChild(downBtn);
|
||
|
||
const editBtn = document.createElement('button');
|
||
editBtn.type = 'button';
|
||
editBtn.className = 'icon-btn';
|
||
editBtn.title = '이름 수정';
|
||
editBtn.textContent = '✎';
|
||
editBtn.addEventListener('click', () => {
|
||
const next = askStepLabel(item);
|
||
if (!next || next === item) return;
|
||
commonItems[idx] = next;
|
||
if (commonSubFlow[item]) {
|
||
commonSubFlow[next] = commonSubFlow[item];
|
||
delete commonSubFlow[item];
|
||
}
|
||
saveFlowModel();
|
||
renderCommonSection();
|
||
});
|
||
actions.appendChild(editBtn);
|
||
|
||
const delBtn = document.createElement('button');
|
||
delBtn.type = 'button';
|
||
delBtn.className = 'icon-btn delete';
|
||
delBtn.title = '삭제';
|
||
delBtn.textContent = '×';
|
||
delBtn.addEventListener('click', () => {
|
||
if (!window.confirm(`'${item}' 공통 항목을 삭제할까요?`)) return;
|
||
commonItems.splice(idx, 1);
|
||
delete commonSubFlow[item];
|
||
if (selectedChain[0] === `common::${item}`) {
|
||
setSelectedChain([]);
|
||
closeEditor();
|
||
}
|
||
saveFlowModel();
|
||
renderCommonSection();
|
||
});
|
||
actions.appendChild(delBtn);
|
||
row.appendChild(actions);
|
||
list.appendChild(row);
|
||
} else {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'common-btn';
|
||
if (isActiveCommon) btn.classList.add('active');
|
||
btn.textContent = item;
|
||
btn.setAttribute('data-step-label', String(item));
|
||
btn.setAttribute('aria-label', String(item));
|
||
btn.removeAttribute('title');
|
||
bindBarStepTooltip(btn, item);
|
||
btn.addEventListener('click', () => {
|
||
api.openFirstSubStepFromCommonFlow(item);
|
||
});
|
||
list.appendChild(btn);
|
||
}
|
||
});
|
||
}
|
||
mainCommonSectionEl.appendChild(list);
|
||
}
|
||
|
||
function openFirstSubStepFromCommonFlow(item, fromSitemap = false) {
|
||
const targetCommonItem = String(item || '').trim();
|
||
if (!targetCommonItem) return;
|
||
const { commonSubFlow } = getState();
|
||
const subSteps = commonSubFlow[targetCommonItem] || [];
|
||
if (subSteps.length) {
|
||
const firstSubStep = subSteps[0];
|
||
setSelectedChain([`common::${targetCommonItem}`, firstSubStep]);
|
||
api.renderAll();
|
||
openEditor(`0|${targetCommonItem}|${firstSubStep}`, firstSubStep);
|
||
} else {
|
||
setSelectedChain([`common::${targetCommonItem}`]);
|
||
api.renderAll();
|
||
openEditor(`common|${targetCommonItem}`, targetCommonItem);
|
||
}
|
||
if (fromSitemap) closeSitemapModal();
|
||
}
|
||
|
||
function openFirstSubStepFromProjectFlow(mainStep) {
|
||
const targetMainStep = String(mainStep || '').trim();
|
||
if (!targetMainStep) return;
|
||
const { subFlow } = getState();
|
||
const subSteps = subFlow[targetMainStep] || [];
|
||
if (subSteps.length) {
|
||
const firstSubStep = subSteps[0];
|
||
setSelectedChain([targetMainStep, firstSubStep]);
|
||
api.renderAll();
|
||
openEditor(`0|${targetMainStep}|${firstSubStep}`, firstSubStep);
|
||
return;
|
||
}
|
||
setSelectedChain([targetMainStep]);
|
||
api.renderAll();
|
||
openEditor(`main|${targetMainStep}`, targetMainStep);
|
||
}
|
||
|
||
function renderMain() {
|
||
const { mainSteps, selectedChain, editMode, subFlow } = getState();
|
||
const mainBarMode = Boolean(!editMode && getMainBarMode());
|
||
mainFlowEl.innerHTML = '';
|
||
mainFlowEl.scrollTop = 0;
|
||
mainPanelEl.scrollTop = 0;
|
||
mainPanelEl.classList.toggle('flow-bar-mode', mainBarMode);
|
||
mainPanelEl.removeAttribute('data-bar-level');
|
||
const mainTitleEl = mainPanelEl.querySelector('.panel-head .title');
|
||
const mainHeadEl = mainPanelEl.querySelector('.panel-head');
|
||
if (mainHeadEl) {
|
||
const existingBtn = mainHeadEl.querySelector('.bar-collapse-btn');
|
||
if (existingBtn) existingBtn.remove();
|
||
if (!editMode && !mainBarMode) {
|
||
const collapseBtn = document.createElement('button');
|
||
collapseBtn.type = 'button';
|
||
collapseBtn.className = 'bar-collapse-btn';
|
||
collapseBtn.title = '바 보기로 전환';
|
||
collapseBtn.setAttribute('aria-label', '바 보기로 전환');
|
||
collapseBtn.textContent = '−';
|
||
collapseBtn.addEventListener('click', () => {
|
||
setMainBarMode(true);
|
||
onBarModeLayoutChange();
|
||
api.renderAll();
|
||
});
|
||
mainHeadEl.appendChild(collapseBtn);
|
||
}
|
||
}
|
||
if (mainTitleEl) {
|
||
mainTitleEl.classList.remove('bar-toggle-title');
|
||
mainTitleEl.title = editMode ? getFlowMetaText('main') : '';
|
||
mainTitleEl.onclick = null;
|
||
}
|
||
if (mainBarMode) {
|
||
const restoreBtn = document.createElement('button');
|
||
restoreBtn.type = 'button';
|
||
restoreBtn.className = 'bar-restore-btn';
|
||
restoreBtn.textContent = '+';
|
||
restoreBtn.addEventListener('click', () => {
|
||
setMainBarMode(false);
|
||
onBarModeLayoutChange();
|
||
api.renderAll();
|
||
});
|
||
mainFlowEl.appendChild(restoreBtn);
|
||
}
|
||
renderVerticalFlow(mainFlowEl, mainSteps, selectedChain[0], (step) => {
|
||
openFirstSubStepFromProjectFlow(step);
|
||
}, {
|
||
allowEdit: editMode,
|
||
getMetaText: editMode ? (step) => getStepMetaText(`main|${step}`) : null,
|
||
onEdit: (idx) => {
|
||
const oldStep = mainSteps[idx];
|
||
const next = askStepLabel(oldStep);
|
||
if (!next || next === oldStep) return;
|
||
mainSteps[idx] = next;
|
||
if (subFlow[oldStep]) {
|
||
subFlow[next] = subFlow[oldStep];
|
||
delete subFlow[oldStep];
|
||
const migrated = {};
|
||
Object.entries(getSubFlowLinks()).forEach(([k, v]) => {
|
||
if (k.startsWith(`${oldStep}||`)) {
|
||
migrated[k.replace(`${oldStep}||`, `${next}||`)] = v;
|
||
} else {
|
||
migrated[k] = v;
|
||
}
|
||
});
|
||
setSubFlowLinks(migrated);
|
||
} else if (!subFlow[next]) {
|
||
subFlow[next] = [];
|
||
}
|
||
if (selectedChain[0] === oldStep) {
|
||
const nextChain = [...selectedChain];
|
||
nextChain[0] = next;
|
||
setSelectedChain(nextChain);
|
||
}
|
||
saveFlowModel();
|
||
api.renderAll();
|
||
},
|
||
onDelete: (idx) => {
|
||
const target = mainSteps[idx];
|
||
if (!window.confirm(`'${target}' 스텝을 삭제할까요?`)) return;
|
||
mainSteps.splice(idx, 1);
|
||
delete subFlow[target];
|
||
const nextLinks = { ...getSubFlowLinks() };
|
||
Object.keys(nextLinks).forEach((k) => {
|
||
if (k.startsWith(`${target}||`)) delete nextLinks[k];
|
||
});
|
||
setSubFlowLinks(nextLinks);
|
||
if (selectedChain[0] === target) {
|
||
setSelectedChain([]);
|
||
closeEditor();
|
||
}
|
||
saveFlowModel();
|
||
api.renderAll();
|
||
},
|
||
onMoveUp: (idx) => {
|
||
if (idx <= 0) return;
|
||
[mainSteps[idx - 1], mainSteps[idx]] = [mainSteps[idx], mainSteps[idx - 1]];
|
||
saveFlowModel();
|
||
api.renderAll();
|
||
},
|
||
onMoveDown: (idx) => {
|
||
if (idx >= mainSteps.length - 1) return;
|
||
[mainSteps[idx + 1], mainSteps[idx]] = [mainSteps[idx], mainSteps[idx + 1]];
|
||
saveFlowModel();
|
||
api.renderAll();
|
||
},
|
||
onInsertAfter: (idx) => {
|
||
const next = askStepLabel('');
|
||
if (!next) return;
|
||
mainSteps.splice(idx + 1, 0, next);
|
||
if (!subFlow[next]) subFlow[next] = [];
|
||
saveFlowModel();
|
||
api.renderAll();
|
||
},
|
||
onAddEnd: () => {
|
||
const next = askStepLabel('');
|
||
if (!next) return;
|
||
mainSteps.push(next);
|
||
if (!subFlow[next]) subFlow[next] = [];
|
||
saveFlowModel();
|
||
api.renderAll();
|
||
}
|
||
});
|
||
renderCommonSection();
|
||
}
|
||
|
||
function renderDrillColumns() {
|
||
const state = getState();
|
||
const { selectedChain, editMode, commonSubFlow, subFlow, drillFlow, stepMeta } = state;
|
||
drillColumnsEl.innerHTML = '';
|
||
|
||
if (!selectedChain[0]) return;
|
||
|
||
let level = 0;
|
||
while (true) {
|
||
if (level >= 1) break;
|
||
const pivot = selectedChain[level];
|
||
const list = getNextFlow(level, pivot);
|
||
if (!list.length) break;
|
||
|
||
const panel = document.createElement('section');
|
||
panel.className = 'flow-panel';
|
||
const panelLevel = level;
|
||
const lv2BarMode = Boolean(!editMode && panelLevel === 0 && getLv2BarMode());
|
||
panel.classList.toggle('flow-bar-mode', lv2BarMode);
|
||
panel.removeAttribute('data-bar-level');
|
||
const panelPivot = getPivotLabel(pivot);
|
||
const isCommonPivot = String(pivot).startsWith('common::');
|
||
|
||
const titleRow = document.createElement('div');
|
||
titleRow.className = 'panel-head';
|
||
|
||
const title = document.createElement('h2');
|
||
title.className = 'title';
|
||
title.textContent = panelLevel === 0 ? 'STEP FLOW' : `${panelPivot} FLOW`;
|
||
if (editMode && panelLevel === 0) {
|
||
title.title = getFlowMetaText(`0|${panelPivot}`);
|
||
}
|
||
if (!editMode && panelLevel === 0) {
|
||
title.classList.remove('bar-toggle-title');
|
||
title.title = '';
|
||
}
|
||
titleRow.appendChild(title);
|
||
if (!editMode && panelLevel === 0 && !lv2BarMode) {
|
||
const collapseBtn = document.createElement('button');
|
||
collapseBtn.type = 'button';
|
||
collapseBtn.className = 'bar-collapse-btn';
|
||
collapseBtn.title = '바 보기로 전환';
|
||
collapseBtn.setAttribute('aria-label', '바 보기로 전환');
|
||
collapseBtn.textContent = '−';
|
||
collapseBtn.addEventListener('click', () => {
|
||
setLv2BarMode(true);
|
||
onBarModeLayoutChange();
|
||
api.renderAll();
|
||
});
|
||
titleRow.appendChild(collapseBtn);
|
||
}
|
||
|
||
panel.appendChild(titleRow);
|
||
|
||
const subtitleSpacer = document.createElement('p');
|
||
subtitleSpacer.className = 'project-title';
|
||
subtitleSpacer.textContent = panelLevel === 0 ? panelPivot : '프로젝트';
|
||
panel.appendChild(subtitleSpacer);
|
||
if (lv2BarMode) {
|
||
const restoreBtn = document.createElement('button');
|
||
restoreBtn.type = 'button';
|
||
restoreBtn.className = 'bar-restore-btn';
|
||
restoreBtn.textContent = '+';
|
||
restoreBtn.addEventListener('click', () => {
|
||
setLv2BarMode(false);
|
||
onBarModeLayoutChange();
|
||
api.renderAll();
|
||
});
|
||
panel.appendChild(restoreBtn);
|
||
}
|
||
|
||
renderVerticalFlow(panel, list, selectedChain[panelLevel + 1], (step) => {
|
||
const nextChain = selectedChain.slice(0, panelLevel + 1);
|
||
nextChain[panelLevel + 1] = step;
|
||
setSelectedChain(nextChain);
|
||
api.renderAll();
|
||
openEditor(`${panelLevel}|${panelPivot}|${step}`, step);
|
||
}, {
|
||
getMetaText: editMode
|
||
? (step) => getStepMetaText(`${panelLevel}|${panelPivot}|${step}`)
|
||
: (panelLevel === 0 && !isCommonPivot ? (step) => {
|
||
const key = `0|${panelPivot}|${step}`;
|
||
const info = stepMeta[key] || {};
|
||
const team = info.team || '-';
|
||
const system = info.system || '-';
|
||
return `${team} | ${system}`;
|
||
} : null),
|
||
getConnectorInfo: panelLevel === 0 ? (fromStep, idx, toStep) => {
|
||
const link = getSubFlowLinkInfo(panelPivot, fromStep, toStep);
|
||
return {
|
||
broken: Boolean(link.reason),
|
||
reason: link.reason || '',
|
||
metaText: editMode ? getLinkMetaText(panelPivot, fromStep, toStep) : ''
|
||
};
|
||
} : null,
|
||
onEditConnector: panelLevel === 0 ? (idx) => {
|
||
const fromStep = list[idx];
|
||
const toStep = list[idx + 1];
|
||
if (!fromStep || !toStep) return;
|
||
const current = getSubFlowLinkInfo(panelPivot, fromStep, toStep).reason || '';
|
||
const value = window.prompt(`'${fromStep}' -> '${toStep}' 사이의 수동 처리 내용을 입력하세요.\n(비우면 자동 연결로 복구)`, current);
|
||
if (value === null) return;
|
||
setSubFlowLinkReason(panelPivot, fromStep, toStep, value);
|
||
saveFlowModel();
|
||
api.renderAll();
|
||
} : null,
|
||
onClearConnector: panelLevel === 0 ? (idx) => {
|
||
const fromStep = list[idx];
|
||
const toStep = list[idx + 1];
|
||
if (!fromStep || !toStep) return;
|
||
setSubFlowLinkReason(panelPivot, fromStep, toStep, '');
|
||
saveFlowModel();
|
||
api.renderAll();
|
||
} : null,
|
||
allowEdit: editMode,
|
||
onEdit: (idx) => {
|
||
const targetList = panelLevel === 0
|
||
? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || []))
|
||
: (drillFlow[panelPivot] || []);
|
||
const oldStep = targetList[idx];
|
||
const next = askStepLabel(oldStep);
|
||
if (!next || next === oldStep) return;
|
||
targetList[idx] = next;
|
||
if (panelLevel === 0) remapSubFlowLinksOnRename(panelPivot, oldStep, next);
|
||
if (drillFlow[oldStep]) {
|
||
drillFlow[next] = drillFlow[oldStep];
|
||
delete drillFlow[oldStep];
|
||
}
|
||
if (selectedChain[panelLevel + 1] === oldStep) {
|
||
const nextChain = [...selectedChain];
|
||
nextChain[panelLevel + 1] = next;
|
||
setSelectedChain(nextChain);
|
||
}
|
||
if (panelLevel === 0) syncSubFlowLinks(panelPivot);
|
||
saveFlowModel();
|
||
api.renderAll();
|
||
},
|
||
onDelete: (idx) => {
|
||
const targetList = panelLevel === 0
|
||
? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || []))
|
||
: (drillFlow[panelPivot] || []);
|
||
const target = targetList[idx];
|
||
if (!window.confirm(`'${target}' 스텝을 삭제할까요?`)) return;
|
||
targetList.splice(idx, 1);
|
||
if (selectedChain[panelLevel + 1] === target) {
|
||
setSelectedChain(selectedChain.slice(0, panelLevel + 1));
|
||
closeEditor();
|
||
}
|
||
if (panelLevel === 0) syncSubFlowLinks(panelPivot);
|
||
saveFlowModel();
|
||
api.renderAll();
|
||
},
|
||
onMoveUp: (idx) => {
|
||
const targetList = panelLevel === 0
|
||
? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || []))
|
||
: (drillFlow[panelPivot] || []);
|
||
if (idx <= 0) return;
|
||
[targetList[idx - 1], targetList[idx]] = [targetList[idx], targetList[idx - 1]];
|
||
if (panelLevel === 0) syncSubFlowLinks(panelPivot);
|
||
saveFlowModel();
|
||
api.renderAll();
|
||
},
|
||
onMoveDown: (idx) => {
|
||
const targetList = panelLevel === 0
|
||
? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || []))
|
||
: (drillFlow[panelPivot] || []);
|
||
if (idx >= targetList.length - 1) return;
|
||
[targetList[idx + 1], targetList[idx]] = [targetList[idx], targetList[idx + 1]];
|
||
if (panelLevel === 0) syncSubFlowLinks(panelPivot);
|
||
saveFlowModel();
|
||
api.renderAll();
|
||
},
|
||
onInsertAfter: (idx) => {
|
||
const targetList = panelLevel === 0
|
||
? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || []))
|
||
: (drillFlow[panelPivot] || []);
|
||
const next = askStepLabel('');
|
||
if (!next) return;
|
||
targetList.splice(idx + 1, 0, next);
|
||
if (!drillFlow[next]) drillFlow[next] = [];
|
||
if (panelLevel === 0) syncSubFlowLinks(panelPivot);
|
||
saveFlowModel();
|
||
api.renderAll();
|
||
},
|
||
onAddEnd: () => {
|
||
const targetList = panelLevel === 0
|
||
? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || []))
|
||
: (drillFlow[panelPivot] || []);
|
||
const next = askStepLabel('');
|
||
if (!next) return;
|
||
targetList.push(next);
|
||
if (!drillFlow[next]) drillFlow[next] = [];
|
||
if (panelLevel === 0) syncSubFlowLinks(panelPivot);
|
||
saveFlowModel();
|
||
api.renderAll();
|
||
}
|
||
});
|
||
|
||
drillColumnsEl.appendChild(panel);
|
||
|
||
if (!selectedChain[panelLevel + 1]) break;
|
||
level += 1;
|
||
}
|
||
}
|
||
|
||
function renderAll() {
|
||
renderMain();
|
||
renderDrillColumns();
|
||
}
|
||
|
||
const api = {
|
||
renderVerticalFlow,
|
||
renderCommonSection,
|
||
openFirstSubStepFromCommonFlow,
|
||
openFirstSubStepFromProjectFlow,
|
||
renderMain,
|
||
renderDrillColumns,
|
||
renderAll
|
||
};
|
||
|
||
return api;
|
||
};
|