Update intranet tools and voucher comparison

This commit is contained in:
b17301
2026-06-04 09:00:51 +09:00
parent 2ab74bac88
commit b93289bfa8
55 changed files with 32809 additions and 1063 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
const HMBIZ_LOCATION_HOST = String(window.location.hostname || '').trim();
const HMBIZ_LOCATION_PORT = String(window.location.port || '').trim();
const HMBIZ_LOCATION_PROTOCOL = String(window.location.protocol || 'http:');
const HMBIZ_LOCATION_PATHNAME = String(window.location.pathname || '/');
const HMBIZ_IS_LOCAL_LIVE_SERVER = HMBIZ_LOCATION_PORT === '5500';
const HMBIZ_IS_HMAC_HOST = /\.hmac\.kr$/i.test(HMBIZ_LOCATION_HOST) || HMBIZ_LOCATION_HOST === 'hmac.kr';
const HMBIZ_IS_GITEA_HOST = /^gitea\.hmac\.kr$/i.test(HMBIZ_LOCATION_HOST);
const HMBIZ_IS_STATIC_HTML_VIEW = /\.html?$/i.test(HMBIZ_LOCATION_PATHNAME);
const HMBIZ_REMOTE_RAW_BASE = 'https://gitea.hmac.kr/tech-planning/hm-biz-process/raw/branch/feature/viewer-ui-cleanup';
function hmbizNormalizePath(path) {
const raw = String(path || '').trim();
if (!raw) return '/';
return raw.startsWith('/') ? raw : `/${raw}`;
}
function hmbizDirectoryPath(pathname) {
const normalized = hmbizNormalizePath(pathname);
const idx = normalized.lastIndexOf('/');
if (idx <= 0) return '/';
return normalized.slice(0, idx);
}
function hmbizJoinPath(baseDir, fileName) {
const dir = hmbizDirectoryPath(baseDir);
return `${dir}/${String(fileName || '').replace(/^\/+/, '')}`;
}
const HMBIZ_CURRENT_DIR = hmbizDirectoryPath(HMBIZ_LOCATION_PATHNAME);
const HMBIZ_API_BASE_URL = '/biz-process-viewer';
const HMBIZ_PROCESS_MAP_ROUTE = '/biz-process-viewer/process-map';
const HMBIZ_MAIN_APP_PATH = '/static/hm-biz-process/flow_260320.html';
const HMBIZ_DEFAULT_JSON_CANDIDATES = [];
window.HMBIZ_CONFIG = Object.freeze({
apiBaseUrl: HMBIZ_API_BASE_URL,
forceRemoteSync: true,
seedVersion: 'flow-data-2026-03-23',
defaultJsonCandidates: HMBIZ_DEFAULT_JSON_CANDIDATES,
processMapRoute: HMBIZ_PROCESS_MAP_ROUTE,
processMapPopupName: 'hm-process-map',
processMapBroadcastChannel: 'hm-biz-process-map',
processMapStorageSignalKey: 'hm-biz-process-map:refresh',
processMapNavigationStorageKey: 'hm-biz-process-map:navigate',
processMapFocusStorageKey: 'hm-biz-process-map:focus',
processMapPollIntervalMs: 60000,
apiFetchTimeoutMs: 4500,
mainAppPath: HMBIZ_MAIN_APP_PATH,
sitemapAutoLinkWidth: 132,
sitemapManualLinkWidth: 132,
sharedSegment: ['전표작성', '검토', '출금']
});
File diff suppressed because one or more lines are too long
+441
View File
@@ -0,0 +1,441 @@
(function () {
function createFlowDataStore(options) {
const {
config,
getFlowModelPayload,
getStepMeta,
getEntityMeta = () => ({}),
setStepMeta,
setEntityMeta = () => {},
applyFlowModelObject,
migrateStepMetaKeys = () => false,
setSyncStatus = () => {},
onRemoteSaved = () => {}
} = options;
const {
apiFlowDataUrl,
stepMetaKey,
flowModelKey,
entityMetaKey,
bootstrapMarkerKey,
bootstrapMarkerValue,
idbDbName,
idbStoreName,
defaultJsonCandidates = []
} = config;
let idbOpenPromise = null;
let remoteSaveTimer = null;
let remoteSaveInFlight = false;
let remoteSaveQueued = false;
function buildNoCacheUrl(url) {
const target = String(url || '').trim();
if (!target) return target;
const joiner = target.includes('?') ? '&' : '?';
return `${target}${joiner}_ts=${Date.now()}`;
}
function openIdb() {
if (!('indexedDB' in window)) return Promise.resolve(null);
if (idbOpenPromise) return idbOpenPromise;
idbOpenPromise = new Promise((resolve) => {
try {
const req = indexedDB.open(idbDbName, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(idbStoreName)) {
db.createObjectStore(idbStoreName, { keyPath: 'k' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
} catch (error) {
resolve(null);
}
});
return idbOpenPromise;
}
async function idbSet(key, value) {
const db = await openIdb();
if (!db) return false;
return new Promise((resolve) => {
try {
const tx = db.transaction(idbStoreName, 'readwrite');
tx.objectStore(idbStoreName).put({ k: key, v: value });
tx.oncomplete = () => resolve(true);
tx.onerror = () => resolve(false);
} catch (error) {
resolve(false);
}
});
}
async function idbGet(key) {
const db = await openIdb();
if (!db) return null;
return new Promise((resolve) => {
try {
const tx = db.transaction(idbStoreName, 'readonly');
const req = tx.objectStore(idbStoreName).get(key);
req.onsuccess = () => resolve(req.result ? req.result.v : null);
req.onerror = () => resolve(null);
} catch (error) {
resolve(null);
}
});
}
function persistMarker() {
try {
localStorage.setItem(bootstrapMarkerKey, bootstrapMarkerValue);
} catch (error) {
// ignore localStorage failures
}
idbSet(bootstrapMarkerKey, bootstrapMarkerValue);
}
function persistLocalSnapshot(
flowModelPayload = getFlowModelPayload(),
stepMetaPayload = getStepMeta(),
entityMetaPayload = getEntityMeta()
) {
try {
localStorage.setItem(flowModelKey, JSON.stringify(flowModelPayload));
} catch (error) {
// localStorage quota exceeded; IndexedDB fallback handles persistence
}
try {
localStorage.setItem(stepMetaKey, JSON.stringify(stepMetaPayload));
} catch (error) {
// localStorage quota exceeded; IndexedDB fallback handles persistence
}
try {
localStorage.setItem(entityMetaKey, JSON.stringify(entityMetaPayload));
} catch (error) {
// localStorage quota exceeded; IndexedDB fallback handles persistence
}
idbSet(flowModelKey, flowModelPayload);
idbSet(stepMetaKey, stepMetaPayload);
idbSet(entityMetaKey, entityMetaPayload);
}
function buildCurrentPayload() {
return {
version: 1,
exportedAt: new Date().toISOString(),
flowModel: getFlowModelPayload(),
stepMeta: getStepMeta(),
entityMeta: getEntityMeta()
};
}
function extractImportPayload(parsed) {
if (!parsed || typeof parsed !== 'object') return null;
const flowModel = (parsed.flowModel && typeof parsed.flowModel === 'object')
? parsed.flowModel
: ((Array.isArray(parsed.mainSteps) || (parsed.subFlow && typeof parsed.subFlow === 'object'))
? parsed
: null);
if (!flowModel || typeof flowModel !== 'object') return null;
return {
flowModel,
importedMeta: (parsed.stepMeta && typeof parsed.stepMeta === 'object') ? parsed.stepMeta : {},
entityMeta: (parsed.entityMeta && typeof parsed.entityMeta === 'object') ? parsed.entityMeta : {}
};
}
function applyImportedData(flowModel, importedMeta, entityMeta, persistRemote = true) {
applyFlowModelObject(flowModel);
setStepMeta((importedMeta && typeof importedMeta === 'object') ? importedMeta : {});
setEntityMeta((entityMeta && typeof entityMeta === 'object') ? entityMeta : {});
migrateStepMetaKeys();
persistLocalSnapshot(getFlowModelPayload(), getStepMeta(), getEntityMeta());
if (persistRemote) scheduleRemoteSave();
return true;
}
async function persistRemoteData() {
if (remoteSaveInFlight) {
remoteSaveQueued = true;
return;
}
remoteSaveInFlight = true;
setSyncStatus('saving', 'DB 저장 중');
try {
const res = await fetch(buildNoCacheUrl(apiFlowDataUrl), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
Pragma: 'no-cache'
},
body: JSON.stringify(buildCurrentPayload())
});
if (!res.ok) {
console.warn('DB save failed', res.status);
setSyncStatus('error', 'DB 저장 실패');
} else {
setSyncStatus('ready', 'DB 저장됨');
onRemoteSaved();
}
} catch (error) {
console.warn('DB save failed', error);
setSyncStatus('error', 'DB 저장 실패');
} finally {
remoteSaveInFlight = false;
if (remoteSaveQueued) {
remoteSaveQueued = false;
persistRemoteData();
}
}
}
function scheduleRemoteSave() {
if (remoteSaveTimer) clearTimeout(remoteSaveTimer);
remoteSaveTimer = window.setTimeout(() => {
remoteSaveTimer = null;
persistRemoteData();
}, 250);
}
function loadStepMeta() {
try {
const raw = localStorage.getItem(stepMetaKey);
if (!raw) return;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return;
setStepMeta(parsed);
migrateStepMetaKeys();
} catch (error) {
setStepMeta({});
}
}
function loadFlowModel() {
try {
const raw = localStorage.getItem(flowModelKey);
if (!raw) return;
const parsed = JSON.parse(raw);
applyFlowModelObject(parsed);
} catch (error) {
// ignore malformed saved model
}
}
async function loadFromIdbFallback() {
const hasLocalFlow = !!localStorage.getItem(flowModelKey);
const hasLocalMeta = !!localStorage.getItem(stepMetaKey);
const hasLocalEntityMeta = !!localStorage.getItem(entityMetaKey);
if (!hasLocalFlow) {
const flow = await idbGet(flowModelKey);
if (flow && typeof flow === 'object') applyFlowModelObject(flow);
}
if (!hasLocalMeta) {
const meta = await idbGet(stepMetaKey);
if (meta && typeof meta === 'object') {
setStepMeta(meta);
migrateStepMetaKeys();
}
}
if (!hasLocalEntityMeta) {
const entityMeta = await idbGet(entityMetaKey);
if (entityMeta && typeof entityMeta === 'object') {
setEntityMeta(entityMeta);
}
}
if (localStorage.getItem(bootstrapMarkerKey) !== bootstrapMarkerValue) {
const marker = await idbGet(bootstrapMarkerKey);
if (marker === bootstrapMarkerValue) {
try {
localStorage.setItem(bootstrapMarkerKey, bootstrapMarkerValue);
} catch (error) {
// ignore localStorage failures
}
}
}
}
async function loadRemoteData() {
setSyncStatus('loading', 'DB 불러오는 중');
try {
const res = await fetch(buildNoCacheUrl(apiFlowDataUrl), {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
Pragma: 'no-cache'
}
});
if (!res.ok) {
if (res.status === 404) return false;
throw new Error(`HTTP ${res.status}`);
}
const parsed = await res.json();
const extracted = extractImportPayload(parsed);
if (!extracted) return false;
applyImportedData(extracted.flowModel, extracted.importedMeta, extracted.entityMeta, false);
persistMarker();
setSyncStatus('ready', 'DB 연결됨');
return true;
} catch (error) {
console.warn('DB load failed', error);
const loadedFromJson = await loadFromDefaultJsonCandidates(false);
if (loadedFromJson) {
setSyncStatus('ready', 'Gitea 데이터 불러옴');
return true;
}
return false;
}
}
async function loadFromDefaultJsonCandidates(persistRemote = true) {
for (const path of defaultJsonCandidates) {
try {
let parsed = null;
try {
const res = await fetch(path, { cache: 'no-store' });
if (res.ok) parsed = await res.json();
} catch (error) {
// fall through to XHR
}
if (!parsed) {
parsed = await new Promise((resolve) => {
try {
const xhr = new XMLHttpRequest();
xhr.open('GET', path, true);
xhr.onreadystatechange = () => {
if (xhr.readyState !== 4) return;
if (xhr.status === 200 || (xhr.status === 0 && xhr.responseText)) {
try {
resolve(JSON.parse(xhr.responseText));
} catch (error) {
resolve(null);
}
} else {
resolve(null);
}
};
xhr.send();
} catch (error) {
resolve(null);
}
});
}
if (!parsed) continue;
const extracted = extractImportPayload(parsed);
if (!extracted) continue;
applyImportedData(
extracted.flowModel,
extracted.importedMeta,
extracted.entityMeta,
persistRemote
);
persistMarker();
return true;
} catch (error) {
// ignore and try next candidate
}
}
return false;
}
async function bootstrapDefaultJsonIfEmpty() {
let hasMarker = localStorage.getItem(bootstrapMarkerKey) === bootstrapMarkerValue;
if (!hasMarker) {
const marker = await idbGet(bootstrapMarkerKey);
hasMarker = marker === bootstrapMarkerValue;
}
if (hasMarker) return false;
try {
const res = await fetch(buildNoCacheUrl(apiFlowDataUrl), {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
Pragma: 'no-cache'
}
});
if (res.ok) {
const parsed = await res.json();
const extracted = extractImportPayload(parsed);
if (extracted) {
applyImportedData(extracted.flowModel, extracted.importedMeta, extracted.entityMeta, true);
persistMarker();
setSyncStatus('ready', '기본 데이터 적재됨');
return true;
}
}
} catch (error) {
// fallback to local json seed
}
const loadedFromJson = await loadFromDefaultJsonCandidates(true);
if (loadedFromJson) {
setSyncStatus('ready', '기본 데이터 적재됨');
return true;
}
return false;
}
function saveStepMeta() {
persistLocalSnapshot(getFlowModelPayload(), getStepMeta(), getEntityMeta());
scheduleRemoteSave();
}
function saveFlowModel() {
persistLocalSnapshot(getFlowModelPayload(), getStepMeta(), getEntityMeta());
scheduleRemoteSave();
}
function exportAllData() {
const payload = buildCurrentPayload();
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
const day = new Date().toISOString().slice(0, 10);
anchor.href = url;
anchor.download = `flow-data-${day}.json`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function importAllData(rawText) {
let parsed;
try {
parsed = JSON.parse(rawText);
} catch (error) {
return { ok: false, message: 'JSON 파일 형식이 올바르지 않습니다.' };
}
if (!parsed || typeof parsed !== 'object') {
return { ok: false, message: '가져올 데이터 형식이 아닙니다.' };
}
const extracted = extractImportPayload(parsed);
if (!extracted) {
return { ok: false, message: 'flowModel 데이터가 없습니다.' };
}
applyImportedData(extracted.flowModel, extracted.importedMeta, extracted.entityMeta, true);
persistMarker();
return { ok: true };
}
return {
persistMarker,
saveStepMeta,
saveFlowModel,
applyImportedData,
exportAllData,
importAllData,
loadFlowModel,
loadStepMeta,
loadFromIdbFallback,
loadRemoteData,
bootstrapDefaultJsonIfEmpty
};
}
window.createFlowDataStore = createFlowDataStore;
})();
+50
View File
@@ -0,0 +1,50 @@
window.createFlowDetailView = function createFlowDetailView(options) {
const {
elements,
getEditMode,
setEditMode,
flowEditorCore,
flowEditorMedia,
renderAll
} = options || {};
const { editModeBtnEl } = elements || {};
function applyEditModeUI() {
const editMode = Boolean(getEditMode && getEditMode());
if (editModeBtnEl) {
editModeBtnEl.classList.toggle('active', editMode);
editModeBtnEl.textContent = '편집';
}
if (flowEditorCore && typeof flowEditorCore.applyEditMode === 'function') {
flowEditorCore.applyEditMode(editMode);
}
if (flowEditorMedia && typeof flowEditorMedia.applyEditMode === 'function') {
flowEditorMedia.applyEditMode(editMode);
}
}
function toggleEditMode() {
const nextMode = !Boolean(getEditMode && getEditMode());
if (typeof setEditMode === 'function') {
setEditMode(nextMode);
}
applyEditModeUI();
if (typeof renderAll === 'function') {
renderAll();
}
}
function bind() {
applyEditModeUI();
if (editModeBtnEl) {
editModeBtnEl.addEventListener('click', toggleEditMode);
}
}
return {
bind,
applyEditModeUI,
toggleEditMode
};
};
+181
View File
@@ -0,0 +1,181 @@
(function () {
function createFlowEditorCore(options) {
const {
elements,
getEditingKey,
setEditingKey,
isEditMode,
getCurrentEditorLabel,
setCurrentEditorLabel,
setCurrentImageIndex,
ensureMeta,
isSecondFlowKey,
saveStepMeta,
renderAll,
renderEditorPreview
} = options;
const {
editorPanelEl,
editorTitleEl,
editorSubEl,
editorViewerTitleEl,
editorViewerTeamEl,
editorViewerSystemEl,
editorViewerRemarkEl,
editorCloseEl,
editorImageInputEl,
editorNoteInputEl,
editorNoteViewEl,
flowInfoCardEl,
editorTeamInputEl,
editorRemarkInputEl,
editorSystemInputEl,
editorTeamViewEl,
editorRemarkViewEl,
editorSystemViewEl
} = elements;
function setViewerValue(element, value, emptyText = '정보 없음') {
if (!element) return;
const text = String(value || '').trim();
element.textContent = text || emptyText;
element.classList.toggle('is-empty', !text);
}
function setViewerTitle(element, value, emptyText = '정보 없음') {
if (!element) return;
const text = String(value || '').trim();
const finalText = text || emptyText;
const lines = finalText
.split(/\r?\n+/)
.map((line) => line.trim())
.filter(Boolean);
element.textContent = '';
element.setAttribute('aria-label', finalText);
element.classList.toggle('is-empty', !text);
(lines.length ? lines : [finalText]).forEach((line, index) => {
const lineEl = document.createElement('span');
lineEl.className = `viewer-title-line ${index === 0 ? 'is-primary' : 'is-continuation'}`;
lineEl.textContent = line;
element.appendChild(lineEl);
});
}
function setViewerChip(element, value) {
if (!element) return;
const text = String(value || '').trim();
element.textContent = text;
element.classList.toggle('is-empty', !text);
}
function updateStepViewer(info, stepLabel = getCurrentEditorLabel()) {
setViewerValue(editorNoteViewEl, info.note, 'STEP NOTE가 없습니다.');
setViewerValue(editorTeamViewEl, info.team, '담당팀 정보 없음');
setViewerValue(editorRemarkViewEl, info.remark, '비고 없음');
setViewerValue(editorSystemViewEl, info.system, '시스템 정보 없음');
setViewerTitle(editorViewerTitleEl, info.note, stepLabel || '설명 없음');
setViewerChip(editorViewerTeamEl, info.team);
setViewerChip(editorViewerSystemEl, info.system);
setViewerChip(editorViewerRemarkEl, info.remark);
}
function applyEditMode(mode = isEditMode()) {
const isEditing = Boolean(mode);
editorPanelEl.classList.toggle('viewer-mode', !isEditing);
editorNoteInputEl.readOnly = !isEditing;
editorTeamInputEl.readOnly = !isEditing;
editorRemarkInputEl.readOnly = !isEditing;
editorSystemInputEl.readOnly = !isEditing;
if (editorSubEl) {
editorSubEl.textContent = isEditing
? '편집 모드입니다. 변경사항은 자동 저장됩니다.'
: '뷰어 모드입니다. 편집모드에서만 수정할 수 있습니다.';
}
}
function openEditor(stepKey, stepLabel) {
setEditingKey(stepKey);
setCurrentEditorLabel(stepLabel);
const info = ensureMeta(stepKey);
editorTitleEl.textContent = `${stepLabel} DETAIL`;
editorNoteInputEl.value = info.note || '';
editorTeamInputEl.value = info.team || '';
editorRemarkInputEl.value = info.remark || '';
editorSystemInputEl.value = info.system || '';
updateStepViewer(info, stepLabel);
flowInfoCardEl.classList.toggle('open', isSecondFlowKey(stepKey));
editorImageInputEl.value = '';
setCurrentImageIndex(0);
renderEditorPreview(stepLabel);
applyEditMode();
editorPanelEl.classList.add('open');
editorPanelEl.setAttribute('aria-hidden', 'false');
}
function closeEditor() {
setCurrentEditorLabel('');
editorPanelEl.classList.remove('open');
editorPanelEl.setAttribute('aria-hidden', 'true');
}
function bind() {
editorCloseEl.addEventListener('click', closeEditor);
editorNoteInputEl.addEventListener('input', () => {
if (!isEditMode()) return;
const editingKey = getEditingKey();
if (!editingKey) return;
const info = ensureMeta(editingKey);
info.note = editorNoteInputEl.value;
updateStepViewer(info);
saveStepMeta();
});
editorTeamInputEl.addEventListener('input', () => {
if (!isEditMode()) return;
const editingKey = getEditingKey();
if (!editingKey || !isSecondFlowKey(editingKey)) return;
const info = ensureMeta(editingKey);
info.team = editorTeamInputEl.value;
updateStepViewer(info);
saveStepMeta();
renderAll();
});
editorRemarkInputEl.addEventListener('input', () => {
if (!isEditMode()) return;
const editingKey = getEditingKey();
if (!editingKey || !isSecondFlowKey(editingKey)) return;
const info = ensureMeta(editingKey);
info.remark = editorRemarkInputEl.value;
updateStepViewer(info);
saveStepMeta();
});
editorSystemInputEl.addEventListener('input', () => {
if (!isEditMode()) return;
const editingKey = getEditingKey();
if (!editingKey || !isSecondFlowKey(editingKey)) return;
const info = ensureMeta(editingKey);
info.system = editorSystemInputEl.value;
updateStepViewer(info);
saveStepMeta();
renderAll();
});
applyEditMode();
}
return {
openEditor,
closeEditor,
bind,
applyEditMode
};
}
window.createFlowEditorCore = createFlowEditorCore;
})();
+401
View File
@@ -0,0 +1,401 @@
(function () {
function createFlowEditorMedia(options) {
const {
elements,
getEditingKey,
getEditMode,
getCurrentEditorLabel,
getCurrentImageIndex,
setCurrentImageIndex,
getModalImageIndex,
setModalImageIndex,
ensureMeta,
getAllStepMeta,
syncLegacyMetaFromScreens,
saveStepMeta
} = options;
const {
editorPathInputEl,
editorPathViewEl,
editorImageInputEl,
editorUploaderEl,
editorFileStatusEl,
editorClearImageEl,
editorPreviewEl,
editorPrevImageEl,
editorNextImageEl,
editorPageInfoEl,
editorImageNoteInputEl,
editorImageNoteViewEl,
imageModalEl,
imageModalImgEl,
imageModalCloseEl,
imageModalPrevEl,
imageModalNextEl,
imageModalPageEl,
imageModalNoteEl
} = elements;
const DEFAULT_PREVIEW_RATIO = 2;
const imageRatioCache = new Map();
let previewRatioTaskId = 0;
function setViewerValue(element, value, emptyText = '정보 없음') {
if (!element) return;
const text = String(value || '').trim();
element.textContent = text || emptyText;
element.classList.toggle('is-empty', !text);
}
function getScreens(info) {
if (!Array.isArray(info.screens)) {
info.screens = [];
}
return info.screens;
}
function applyPreviewRatio(ratio) {
const safeRatio = Number.isFinite(ratio) && ratio > 0 ? ratio : DEFAULT_PREVIEW_RATIO;
if (editorPreviewEl) {
editorPreviewEl.style.setProperty('--preview-ratio', String(safeRatio));
}
}
function loadImageRatio(imageSrc) {
const src = String(imageSrc || '').trim();
if (!src) return Promise.resolve(DEFAULT_PREVIEW_RATIO);
if (imageRatioCache.has(src)) return imageRatioCache.get(src);
const task = new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const width = Number(img.naturalWidth || 0);
const height = Number(img.naturalHeight || 0);
if (width > 0 && height > 0) {
resolve(width / height);
return;
}
resolve(DEFAULT_PREVIEW_RATIO);
};
img.onerror = () => resolve(DEFAULT_PREVIEW_RATIO);
img.src = src;
});
imageRatioCache.set(src, task);
return task;
}
function collectAllImageSources() {
const allMeta = (typeof getAllStepMeta === 'function' ? getAllStepMeta() : null) || {};
const sources = new Set();
Object.values(allMeta).forEach((info) => {
const screens = Array.isArray(info && info.screens) ? info.screens : [];
screens.forEach((screen) => {
const src = String(screen && screen.image ? screen.image : '').trim();
if (src) sources.add(src);
});
});
return Array.from(sources);
}
function refreshGlobalPreviewRatio() {
applyPreviewRatio(2);
return;
const taskId = ++previewRatioTaskId;
const sources = collectAllImageSources();
if (!sources.length) {
applyPreviewRatio(DEFAULT_PREVIEW_RATIO);
return;
}
Promise.all(sources.map((src) => loadImageRatio(src)))
.then((ratios) => {
if (taskId !== previewRatioTaskId) return;
const validRatios = ratios.filter((ratio) => Number.isFinite(ratio) && ratio > 0);
if (!validRatios.length) {
applyPreviewRatio(DEFAULT_PREVIEW_RATIO);
return;
}
// Use the most portrait image as the global frame ratio for consistency across all pages.
const minRatio = validRatios.reduce((acc, ratio) => Math.min(acc, ratio), validRatios[0]);
applyPreviewRatio(minRatio);
})
.catch(() => {
if (taskId !== previewRatioTaskId) return;
applyPreviewRatio(DEFAULT_PREVIEW_RATIO);
});
}
function ensureScreenAt(info, index, { createIfMissing = false } = {}) {
const screens = getScreens(info);
if (createIfMissing && !screens.length) {
screens.push({ path: '', note: '', image: '' });
}
while (createIfMissing && index >= screens.length) {
screens.push({ path: '', note: '', image: '' });
}
const screen = screens[index] || null;
if (screen) {
screen.path = String(screen.path || '');
screen.note = String(screen.note || '');
screen.image = String(screen.image || '');
}
return screen;
}
function applyEditMode(mode = getEditMode()) {
const isEditing = Boolean(mode);
if (editorUploaderEl) {
editorUploaderEl.hidden = !isEditing;
}
editorPathInputEl.readOnly = !isEditing;
editorImageInputEl.disabled = !isEditing;
editorClearImageEl.disabled = !isEditing;
editorImageNoteInputEl.readOnly = !isEditing;
}
function renderEditorPreview(label) {
const editingKey = getEditingKey();
if (!editingKey) return;
refreshGlobalPreviewRatio();
const info = ensureMeta(editingKey);
const screens = getScreens(info);
let currentIndex = getCurrentImageIndex();
if (currentIndex >= screens.length) {
currentIndex = Math.max(0, screens.length - 1);
setCurrentImageIndex(currentIndex);
}
const currentScreen = screens[currentIndex] || null;
const imageSrc = currentScreen ? currentScreen.image : '';
editorPreviewEl.innerHTML = '';
if (!imageSrc) {
const empty = document.createElement('p');
empty.className = 'preview-empty';
empty.textContent = '이미지가 없습니다.';
editorPreviewEl.appendChild(empty);
} else {
const img = document.createElement('img');
img.src = imageSrc;
img.alt = `${label} 이미지`;
img.style.cursor = 'zoom-in';
img.addEventListener('click', () => openImageModal(currentIndex, `${label} 확대 이미지`));
editorPreviewEl.appendChild(img);
}
editorPageInfoEl.textContent = `${screens.length ? (currentIndex + 1) : 0} / ${screens.length}`;
editorFileStatusEl.textContent = screens.length ? `저장된 화면 ${screens.length}` : '저장된 화면 0장';
editorPrevImageEl.disabled = screens.length <= 1 || currentIndex <= 0;
editorNextImageEl.disabled = screens.length <= 1 || currentIndex >= screens.length - 1;
editorPathInputEl.value = currentScreen ? currentScreen.path : '';
editorImageNoteInputEl.value = currentScreen ? currentScreen.note : '';
setViewerValue(editorPathViewEl, currentScreen ? currentScreen.path : '', '');
setViewerValue(editorImageNoteViewEl, currentScreen ? currentScreen.note : '', '');
editorImageNoteInputEl.disabled = !currentScreen;
editorImageNoteInputEl.readOnly = !getEditMode();
editorClearImageEl.disabled = !currentScreen || !getEditMode();
}
function updateImageModalView(altText) {
const editingKey = getEditingKey();
if (!editingKey) return;
const info = ensureMeta(editingKey);
const screens = getScreens(info);
if (!screens.length) return;
let modalIndex = getModalImageIndex();
if (modalIndex < 0) modalIndex = 0;
if (modalIndex > screens.length - 1) modalIndex = screens.length - 1;
setModalImageIndex(modalIndex);
const currentScreen = screens[modalIndex];
imageModalImgEl.src = currentScreen.image || '';
imageModalImgEl.alt = altText || '확대 이미지';
imageModalPageEl.textContent = `${modalIndex + 1} / ${screens.length}`;
imageModalPrevEl.disabled = modalIndex <= 0;
imageModalNextEl.disabled = modalIndex >= screens.length - 1;
const note = String(currentScreen.note || '').trim();
if (note) {
imageModalNoteEl.textContent = note;
imageModalNoteEl.classList.add('show');
} else {
imageModalNoteEl.textContent = '';
imageModalNoteEl.classList.remove('show');
}
}
function openImageModal(index, altText) {
const editingKey = getEditingKey();
if (!editingKey) return;
const info = ensureMeta(editingKey);
const screens = getScreens(info);
if (!screens.length) return;
const targetScreen = screens[index] || null;
if (!targetScreen || !targetScreen.image) return;
setModalImageIndex(Number.isInteger(index) ? index : 0);
updateImageModalView(altText);
imageModalEl.classList.add('open');
imageModalEl.setAttribute('aria-hidden', 'false');
}
function closeImageModal() {
imageModalEl.classList.remove('open');
imageModalEl.setAttribute('aria-hidden', 'true');
imageModalImgEl.src = '';
imageModalPageEl.textContent = '0 / 0';
imageModalNoteEl.textContent = '';
imageModalNoteEl.classList.remove('show');
}
function bind() {
refreshGlobalPreviewRatio();
editorClearImageEl.addEventListener('click', () => {
if (!getEditMode()) return;
const editingKey = getEditingKey();
if (!editingKey) return;
const info = ensureMeta(editingKey);
const screens = getScreens(info);
const currentIndex = getCurrentImageIndex();
if (!screens.length) return;
screens.splice(currentIndex, 1);
let nextIndex = currentIndex;
if (nextIndex >= screens.length) {
nextIndex = Math.max(0, screens.length - 1);
}
setCurrentImageIndex(nextIndex);
syncLegacyMetaFromScreens(info);
saveStepMeta();
renderEditorPreview(getCurrentEditorLabel());
});
editorImageInputEl.addEventListener('change', (event) => {
if (!getEditMode()) {
event.target.value = '';
return;
}
const editingKey = getEditingKey();
if (!editingKey) return;
const files = Array.from(event.target.files || []);
if (!files.length) return;
const imageFiles = files.filter((file) => file.type.startsWith('image/'));
if (!imageFiles.length) {
alert('이미지 파일만 선택할 수 있습니다.');
event.target.value = '';
return;
}
const targetKey = editingKey;
const readJobs = imageFiles.map((file) => new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => resolve('');
reader.readAsDataURL(file);
}));
Promise.all(readJobs).then((images) => {
const validImages = images.filter(Boolean);
if (!validImages.length) return;
const info = ensureMeta(targetKey);
const screens = getScreens(info);
const currentScreen = ensureScreenAt(info, getCurrentImageIndex());
const pendingImages = [...validImages];
if (currentScreen && !currentScreen.image && pendingImages.length) {
currentScreen.image = pendingImages.shift();
}
pendingImages.forEach((imageSrc, offset) => {
screens.push({
path: currentScreen && offset === 0 ? String(currentScreen.path || '') : '',
note: '',
image: imageSrc
});
});
syncLegacyMetaFromScreens(info);
if (getEditingKey() === targetKey) {
setCurrentImageIndex(Math.max(0, screens.length - Math.max(1, pendingImages.length)));
renderEditorPreview(getCurrentEditorLabel());
}
saveStepMeta();
});
event.target.value = '';
});
editorPrevImageEl.addEventListener('click', () => {
const editingKey = getEditingKey();
let currentIndex = getCurrentImageIndex();
if (!editingKey || currentIndex <= 0) return;
currentIndex -= 1;
setCurrentImageIndex(currentIndex);
renderEditorPreview(getCurrentEditorLabel());
});
editorNextImageEl.addEventListener('click', () => {
const editingKey = getEditingKey();
if (!editingKey) return;
const info = ensureMeta(editingKey);
const screens = getScreens(info);
let currentIndex = getCurrentImageIndex();
if (currentIndex >= screens.length - 1) return;
currentIndex += 1;
setCurrentImageIndex(currentIndex);
renderEditorPreview(getCurrentEditorLabel());
});
editorImageNoteInputEl.addEventListener('input', () => {
if (!getEditMode()) return;
const editingKey = getEditingKey();
if (!editingKey) return;
const info = ensureMeta(editingKey);
const screen = ensureScreenAt(info, getCurrentImageIndex(), { createIfMissing: true });
screen.note = editorImageNoteInputEl.value;
syncLegacyMetaFromScreens(info);
setViewerValue(editorImageNoteViewEl, screen.note, '');
saveStepMeta();
});
editorPathInputEl.addEventListener('input', () => {
if (!getEditMode()) return;
const editingKey = getEditingKey();
if (!editingKey) return;
const info = ensureMeta(editingKey);
const screen = ensureScreenAt(info, getCurrentImageIndex(), { createIfMissing: true });
screen.path = editorPathInputEl.value;
syncLegacyMetaFromScreens(info);
setViewerValue(editorPathViewEl, screen.path, '');
saveStepMeta();
});
imageModalCloseEl.addEventListener('click', closeImageModal);
imageModalPrevEl.addEventListener('click', () => {
if (!imageModalEl.classList.contains('open')) return;
setModalImageIndex(getModalImageIndex() - 1);
updateImageModalView('확대 이미지');
});
imageModalNextEl.addEventListener('click', () => {
if (!imageModalEl.classList.contains('open')) return;
setModalImageIndex(getModalImageIndex() + 1);
updateImageModalView('확대 이미지');
});
imageModalEl.addEventListener('click', (event) => {
if (event.target === imageModalEl) closeImageModal();
});
document.addEventListener('keydown', (event) => {
if (!imageModalEl.classList.contains('open')) return;
if (event.key === 'ArrowLeft') {
setModalImageIndex(getModalImageIndex() - 1);
updateImageModalView('확대 이미지');
}
if (event.key === 'ArrowRight') {
setModalImageIndex(getModalImageIndex() + 1);
updateImageModalView('확대 이미지');
}
if (event.key === 'Escape') {
closeImageModal();
}
});
applyEditMode();
}
return {
renderEditorPreview,
updateImageModalView,
openImageModal,
closeImageModal,
bind,
applyEditMode
};
}
window.createFlowEditorMedia = createFlowEditorMedia;
})();
+867
View File
@@ -0,0 +1,867 @@
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;
};
File diff suppressed because it is too large Load Diff
+678
View File
@@ -0,0 +1,678 @@
(function () {
function createProcessMapRenderer(options) {
const {
elements,
getState,
onMainClick,
onStepClick,
onCommonRootClick,
isActiveFocus = () => false,
linkWidths = { auto: 132, manual: 132 },
sharedSegment = ['전표작성', '검토', '출금']
} = options;
const {
fullContentEl,
legendEl,
teamSelectEl,
systemSelectEl
} = elements;
let systemColorMap = new Map();
let filters = { team: '', system: '' };
function parseMultiValues(text) {
return String(text || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
function getSecondFlowMeta(mainStep, step) {
const { stepMeta = {} } = getState();
return stepMeta[`0|${mainStep}|${step}`] || {};
}
function collectSystemsForLegend() {
const {
mainSteps = [],
subFlow = {},
commonItems = [],
commonSubFlow = {}
} = getState();
const set = new Set();
mainSteps.forEach((mainStep) => {
(subFlow[mainStep] || []).forEach((step) => {
parseMultiValues(getSecondFlowMeta(mainStep, step).system).forEach((system) => set.add(system));
});
});
commonItems.forEach((item) => {
(commonSubFlow[item] || []).forEach((step) => {
parseMultiValues(getSecondFlowMeta(item, step).system).forEach((system) => set.add(system));
});
});
return Array.from(set).sort((a, b) => a.localeCompare(b, 'ko'));
}
function normalizeSystemName(name) {
return String(name || '').trim().toLowerCase();
}
function getSystemCategory(systemName) {
const normalized = normalizeSystemName(systemName);
if (!normalized) return 'unknown';
if (normalized.includes('미사용')) return 'unused';
if (normalized.includes('외부')) return 'external';
if (normalized.includes('내부') || normalized.includes('erp') || normalized.includes('pq')) return 'internal';
return 'other';
}
const INTERNAL_PALETTE = [
{ bg: '#8CCBF7', border: '#69AFDF', text: '#173f6f' }, // pastel dodger
{ bg: '#A7D7F9', border: '#84C0E5', text: '#173f6f' }, // pastel picton
{ bg: '#BFE2FB', border: '#98CAE9', text: '#173f6f' }, // pastel maya
{ bg: '#D3EBFC', border: '#AFCFE6', text: '#173f6f' }, // pastel uranian
{ bg: '#E9F5FE', border: '#C5DBEC', text: '#173f6f' } // pastel beau
];
const UNUSED_PALETTE = [
{ bg: '#fff1f1', border: '#e6a3a3', text: '#762222' }, // red-1
{ bg: '#fff3f3', border: '#e8acac', text: '#7a2424' }, // red-2
{ bg: '#fff5f5', border: '#e9b4b4', text: '#7f2f2f' } // rose-red
];
const EXTERNAL_PALETTE = [
{ bg: '#eaf8ef', border: '#87ca9c', text: '#11492a' }, // green-1
{ bg: '#e8f7f1', border: '#7ac8ae', text: '#0f544d' }, // green-teal
{ bg: '#eef9f4', border: '#8bcdb0', text: '#184f39' } // green-3
];
const OTHER_PALETTE = [
{ bg: '#edf3fb', border: '#9fb8d7', text: '#26384a' },
{ bg: '#f1f4fa', border: '#a9b8cd', text: '#2b3a4d' }
];
const SPECIAL_STEP_CATEGORY = new Map([
['입찰공고', 'unused'],
['외주발주의뢰', 'unused'],
['외주발주검토', 'unused']
]);
function pickColorFromCategory(category, seedName) {
let palette = OTHER_PALETTE;
if (category === 'internal') palette = INTERNAL_PALETTE;
if (category === 'unused') palette = UNUSED_PALETTE;
if (category === 'external') palette = EXTERNAL_PALETTE;
const seed = hashString(seedName || category) % palette.length;
return palette[seed];
}
function hashString(value) {
const str = String(value || '');
let hash = 0;
for (let i = 0; i < str.length; i += 1) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
function makeDistinctSystemColor(systemName, index = 0) {
const category = getSystemCategory(systemName);
let palette = OTHER_PALETTE;
if (category === 'internal') palette = INTERNAL_PALETTE;
if (category === 'unused') palette = UNUSED_PALETTE;
if (category === 'external') palette = EXTERNAL_PALETTE;
const seed = (hashString(systemName) + (index * 7)) % palette.length;
return palette[seed];
}
function rebuildSystemColorMap() {
const systems = collectSystemsForLegend();
systemColorMap = new Map();
systems.forEach((system, index) => {
systemColorMap.set(system, makeDistinctSystemColor(system, index));
});
}
function getRandomLikeColorBySystem(systemName) {
if (!systemColorMap.has(systemName)) {
systemColorMap.set(systemName, makeDistinctSystemColor(systemName, systemColorMap.size));
}
return systemColorMap.get(systemName);
}
function applyChipSystemColor(chipEl, info, stepLabel = '') {
const systems = parseMultiValues(info.system);
if (!systems.length) {
const specialCategory = SPECIAL_STEP_CATEGORY.get(String(stepLabel || '').trim()) || '';
if (specialCategory) {
const color = pickColorFromCategory(specialCategory, `${specialCategory}:${stepLabel}`);
chipEl.classList.add('system-colored');
chipEl.style.setProperty('--sys-bg', color.bg);
chipEl.style.setProperty('--sys-border', color.border);
chipEl.style.setProperty('--sys-text', color.text);
return;
}
const color = pickColorFromCategory('other', `no-system:${stepLabel}`);
chipEl.classList.add('system-colored', 'no-system');
chipEl.style.setProperty('--sys-bg', color.bg);
chipEl.style.setProperty('--sys-border', color.border);
chipEl.style.setProperty('--sys-text', color.text);
return;
}
if (systems.length >= 2) {
const colorA = getRandomLikeColorBySystem(systems[0]);
const colorB = getRandomLikeColorBySystem(systems[1]);
chipEl.classList.add('system-colored', 'multi-system');
chipEl.style.setProperty('--sys-bg-a', colorA.bg);
chipEl.style.setProperty('--sys-bg-b', colorB.bg);
chipEl.style.setProperty('--sys-border-a', colorA.border);
chipEl.style.setProperty('--sys-border-b', colorB.border);
chipEl.style.setProperty('--sys-text', colorA.text || '#1f4f83');
return;
}
const color = getRandomLikeColorBySystem(systems[0]);
chipEl.classList.add('system-colored');
chipEl.style.setProperty('--sys-bg', color.bg);
chipEl.style.setProperty('--sys-border', color.border);
chipEl.style.setProperty('--sys-text', color.text);
}
function isMetaMatchedByFilter(info) {
const teams = parseMultiValues(info.team);
const systems = parseMultiValues(info.system);
const hasAnyMeta = teams.length > 0 || systems.length > 0;
if (!filters.team && !filters.system) return true;
if (!hasAnyMeta) return false;
const teamMatch = !filters.team || teams.includes(filters.team);
const systemMatch = !filters.system || systems.includes(filters.system);
return teamMatch && systemMatch;
}
function findSharedSegmentStart(list) {
for (let index = 0; index <= list.length - sharedSegment.length; index += 1) {
const matched = sharedSegment.every((value, offset) => String(list[index + offset] || '').trim() === value);
if (matched) return index;
}
return -1;
}
function subFlowLinkKey(mainStep, fromStep, toStep) {
return `${mainStep}||${fromStep}>>${toStep}`;
}
function getSubFlowLinkInfo(mainStep, fromStep, toStep) {
const { subFlowLinks = {} } = getState();
return subFlowLinks[subFlowLinkKey(mainStep, fromStep, toStep)] || { reason: '' };
}
function formatConnectorReason(reason) {
return String(reason || '').trim();
}
function renderLegend() {
if (!legendEl) return;
legendEl.innerHTML = '';
const categoryOrder = {
internal: 0,
external: 1,
unused: 2,
other: 3,
unknown: 4
};
const systems = collectSystemsForLegend().sort((a, b) => {
const catA = getSystemCategory(a);
const catB = getSystemCategory(b);
const rankA = categoryOrder[catA] ?? 99;
const rankB = categoryOrder[catB] ?? 99;
if (rankA !== rankB) return rankA - rankB;
return a.localeCompare(b, 'ko');
});
const applyLegendSystemFilter = (systemValue) => {
const nextSystem = String(systemValue || '');
if (systemSelectEl) {
const hasOption = Array.from(systemSelectEl.options || []).some((option) => option.value === nextSystem);
systemSelectEl.value = hasOption ? nextSystem : '';
systemSelectEl.dispatchEvent(new Event('change', { bubbles: true }));
return;
}
setFilters({ system: nextSystem });
render();
};
const makeItem = (label, bg, border, opts = {}) => {
const { clickable = false, selected = false, onClick = null } = opts;
const item = document.createElement('span');
item.className = 'sm-legend-item';
if (selected) item.classList.add('active-focus');
const swatch = document.createElement('span');
swatch.className = 'sm-legend-swatch';
swatch.style.background = bg;
swatch.style.borderColor = border;
const text = document.createElement('span');
text.textContent = label;
item.appendChild(swatch);
item.appendChild(text);
if (clickable && typeof onClick === 'function') {
item.style.cursor = 'pointer';
item.title = `${label} 필터 적용`;
item.setAttribute('role', 'button');
item.setAttribute('tabindex', '0');
item.addEventListener('click', onClick);
item.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onClick();
});
}
legendEl.appendChild(item);
};
systems.forEach((system) => {
const color = getRandomLikeColorBySystem(system);
makeItem(system, color.bg, color.border, {
clickable: true,
selected: filters.system === system,
onClick: () => applyLegendSystemFilter(system)
});
});
makeItem('시스템 미입력', '#e5e7eb', '#cbd5e1', {
clickable: true,
selected: !filters.system,
onClick: () => applyLegendSystemFilter('')
});
}
function refreshFilterOptions() {
if (!teamSelectEl || !systemSelectEl) return;
const {
mainSteps = [],
subFlow = {},
commonItems = [],
commonSubFlow = {}
} = getState();
const teamSet = new Set();
const systemSet = new Set();
mainSteps.forEach((mainStep) => {
(subFlow[mainStep] || []).forEach((step) => {
const info = getSecondFlowMeta(mainStep, step);
parseMultiValues(info.team).forEach((value) => teamSet.add(value));
parseMultiValues(info.system).forEach((value) => systemSet.add(value));
});
});
commonItems.forEach((item) => {
(commonSubFlow[item] || []).forEach((step) => {
const info = getSecondFlowMeta(item, step);
parseMultiValues(info.team).forEach((value) => teamSet.add(value));
parseMultiValues(info.system).forEach((value) => systemSet.add(value));
});
});
const previousTeam = filters.team;
const previousSystem = filters.system;
teamSelectEl.innerHTML = '<option value="">전체 팀 필터</option>';
Array.from(teamSet).sort((a, b) => a.localeCompare(b, 'ko')).forEach((value) => {
const option = document.createElement('option');
option.value = value;
option.textContent = value;
teamSelectEl.appendChild(option);
});
systemSelectEl.innerHTML = '<option value="">전체 시스템 필터</option>';
Array.from(systemSet).sort((a, b) => a.localeCompare(b, 'ko')).forEach((value) => {
const option = document.createElement('option');
option.value = value;
option.textContent = value;
systemSelectEl.appendChild(option);
});
filters.team = Array.from(teamSet).includes(previousTeam) ? previousTeam : '';
filters.system = Array.from(systemSet).includes(previousSystem) ? previousSystem : '';
teamSelectEl.value = filters.team;
systemSelectEl.value = filters.system;
}
function markFlowItem(el, kind, colIndex) {
if (!el) return el;
el.dataset.flowKind = kind;
el.dataset.flowCol = String(colIndex);
return el;
}
function markInteractiveTarget(el, action, mainStep, step = '', isCommon = false) {
if (!el) return el;
el.dataset.mapAction = action;
el.dataset.mainStep = String(mainStep || '');
if (step) el.dataset.step = String(step);
if (isCommon) el.dataset.isCommon = '1';
el.style.cursor = 'pointer';
return el;
}
const STEP_BOX_SPACING_MULTIPLIER = 1.7;
function getSegmentBaseWidth(hasPreviousStep, isManual) {
if (!hasPreviousStep) return 0;
const baseWidth = isManual ? Number(linkWidths.manual || 156) : Number(linkWidths.auto || 132);
return baseWidth * STEP_BOX_SPACING_MULTIPLIER;
}
function applyColumnAlignment() {
const segments = Array.from(fullContentEl.querySelectorAll('.sm-segment[data-flow-col]'));
if (!segments.length) return;
const maxWidthByCol = new Map();
segments.forEach((segment) => {
const colIndex = Number(segment.dataset.flowCol || '-1');
const baseWidth = Number(segment.dataset.flowWidth || '0');
if (colIndex < 0 || baseWidth <= 0) return;
const current = maxWidthByCol.get(colIndex) || 0;
if (baseWidth > current) maxWidthByCol.set(colIndex, baseWidth);
});
segments.forEach((segment) => {
const chip = segment.querySelector('.sm-chip');
const chipWidth = chip ? Math.ceil(chip.getBoundingClientRect().width) : 102;
const hasLink = segment.dataset.hasLink === '1';
const colIndex = Number(segment.dataset.flowCol || '-1');
const totalWidth = maxWidthByCol.get(colIndex) || chipWidth;
const safeTotalWidth = Math.max(totalWidth, chipWidth);
const linkWidth = hasLink ? Math.max(safeTotalWidth - chipWidth, 24) : 0;
segment.style.setProperty('--flow-col-width', `${safeTotalWidth}px`);
segment.style.setProperty('--flow-link-width', `${linkWidth}px`);
});
}
function buildLink(mainStep, fromStep, toStep, dim) {
const link = getSubFlowLinkInfo(mainStep, fromStep, toStep);
const linkWrap = document.createElement('span');
linkWrap.className = `sm-link-wrap${link.reason ? ' manual' : ''}`;
if (dim) linkWrap.classList.add('dim');
if (link.reason) {
const prefix = document.createElement('span');
prefix.className = 'sm-link-arrow-frag';
const reason = document.createElement('span');
reason.className = 'sm-link-reason';
reason.textContent = formatConnectorReason(link.reason);
reason.title = link.reason;
const suffix = document.createElement('span');
suffix.className = 'sm-link-arrow-frag';
if (dim) {
prefix.classList.add('dim');
reason.classList.add('dim');
suffix.classList.add('dim');
}
linkWrap.appendChild(prefix);
linkWrap.appendChild(reason);
linkWrap.appendChild(suffix);
} else {
const arrow = document.createElement('span');
arrow.className = 'sm-arrow';
if (dim) arrow.classList.add('dim');
linkWrap.appendChild(arrow);
}
return linkWrap;
}
function buildStepChip(mainStep, step, info, isCommon, isMatch, hasFilter) {
const chip = document.createElement('span');
chip.className = 'sm-chip';
chip.textContent = step;
chip.title = `담당팀: ${info.team || '-'} / 시스템: ${info.system || '-'}`;
applyChipSystemColor(chip, info, step);
if (isActiveFocus('step', mainStep, step, isCommon)) chip.classList.add('active-focus');
if (typeof onStepClick === 'function') {
markInteractiveTarget(chip, 'step', mainStep, step, isCommon);
}
if (hasFilter) chip.classList.add(isMatch ? 'match' : 'dim');
return chip;
}
function buildStepSegment(mainStep, step, info, isCommon, isMatch, hasFilter, previousStep = '', dimLink = false, colIndex = 0) {
const segment = document.createElement('span');
segment.className = 'sm-segment';
markFlowItem(segment, 'segment', colIndex);
segment.dataset.hasLink = previousStep ? '1' : '0';
if (previousStep) {
const linkEl = buildLink(mainStep, previousStep, step, dimLink);
if (linkEl.classList.contains('manual')) segment.classList.add('has-manual-link');
segment.dataset.flowWidth = String(getSegmentBaseWidth(true, linkEl.classList.contains('manual')));
segment.appendChild(linkEl);
} else {
segment.classList.add('is-first');
}
if (!segment.dataset.flowWidth) segment.dataset.flowWidth = '0';
segment.appendChild(buildStepChip(mainStep, step, info, isCommon, isMatch, hasFilter));
return segment;
}
function render() {
fullContentEl.innerHTML = '';
const {
mainSteps = [],
subFlow = {},
commonItems = [],
commonSubFlow = {}
} = getState();
if (!mainSteps.length) {
const empty = document.createElement('div');
empty.className = 'empty-state';
empty.textContent = '표시할 프로세스 데이터가 없습니다.';
fullContentEl.appendChild(empty);
return;
}
rebuildSystemColorMap();
renderLegend();
const hasFilter = Boolean(filters.team || filters.system);
const projectTitle = document.createElement('p');
projectTitle.className = 'sm-section-title';
projectTitle.textContent = '프로젝트';
fullContentEl.appendChild(projectTitle);
mainSteps.forEach((mainStep) => {
const row = document.createElement('div');
row.className = 'sm-row';
const main = document.createElement('div');
main.className = 'sm-main';
main.textContent = mainStep;
if (isActiveFocus('main', mainStep)) main.classList.add('active-focus');
if (typeof onMainClick === 'function') {
main.title = `${mainStep} DETAIL 열기`;
markInteractiveTarget(main, 'main', mainStep);
}
row.appendChild(main);
const sub = subFlow[mainStep] || [];
let rowHasMatch = false;
let flowColIndex = 0;
if (sub.length > 0) {
const chain = document.createElement('div');
chain.className = 'sm-chain';
const sharedStart = findSharedSegmentStart(sub);
sub.forEach((step, index) => {
if (sharedStart >= 0 && index > sharedStart && index < sharedStart + sharedSegment.length) return;
const info = getSecondFlowMeta(mainStep, step);
const isMatch = isMetaMatchedByFilter(info);
if (isMatch) rowHasMatch = true;
if (sharedStart >= 0 && index === sharedStart) {
const box = document.createElement('span');
box.className = 'sm-shared-box';
const sharedMatches = sharedSegment.map((segmentName) => {
const segmentInfo = getSecondFlowMeta(mainStep, segmentName);
const segmentMatch = isMetaMatchedByFilter(segmentInfo);
if (segmentMatch) rowHasMatch = true;
return segmentMatch;
});
for (let offset = 0; offset < sharedSegment.length; offset += 1) {
const segmentName = sharedSegment[offset];
const segmentInfo = getSecondFlowMeta(mainStep, segmentName);
const previousStep = offset > 0
? sharedSegment[offset - 1]
: (sharedStart > 0 ? sub[sharedStart - 1] : '');
const previousMatch = offset > 0
? sharedMatches[offset - 1]
: (previousStep ? isMetaMatchedByFilter(getSecondFlowMeta(mainStep, previousStep)) : false);
const dimLink = Boolean(previousStep && hasFilter && !(previousMatch || sharedMatches[offset]));
box.appendChild(buildStepSegment(mainStep, segmentName, segmentInfo, false, sharedMatches[offset], hasFilter, previousStep, dimLink, flowColIndex));
flowColIndex += 1;
}
chain.appendChild(box);
} else {
const previousStep = index > 0 ? sub[index - 1] : '';
const previousMatch = previousStep ? isMetaMatchedByFilter(getSecondFlowMeta(mainStep, previousStep)) : false;
const dimLink = Boolean(previousStep && hasFilter && !(previousMatch || isMatch));
chain.appendChild(buildStepSegment(mainStep, step, info, false, isMatch, hasFilter, previousStep, dimLink, flowColIndex));
flowColIndex += 1;
}
});
if (hasFilter && !rowHasMatch) main.classList.add('dim');
row.appendChild(chain);
}
fullContentEl.appendChild(row);
});
const commonTitle = document.createElement('p');
commonTitle.className = 'sm-section-title';
commonTitle.textContent = '공통';
fullContentEl.appendChild(commonTitle);
if (commonItems.length) {
commonItems.forEach((item) => {
const row = document.createElement('div');
row.className = 'sm-row';
const main = document.createElement('div');
main.className = 'sm-main';
main.textContent = item;
if (isActiveFocus('common-root', item, '', true)) main.classList.add('active-focus');
if (typeof onCommonRootClick === 'function') {
main.title = `${item} DETAIL 열기`;
markInteractiveTarget(main, 'common-root', item, '', true);
}
row.appendChild(main);
const chain = document.createElement('div');
chain.className = 'sm-chain';
const sub = commonSubFlow[item] || [];
let rowHasMatch = false;
let flowColIndex = 0;
const isCommonStepMatch = (step) => isMetaMatchedByFilter(getSecondFlowMeta(item, step));
const getStepRenderMeta = (label) => {
const info = getSecondFlowMeta(item, label);
const isMatch = isCommonStepMatch(label);
if (isMatch) rowHasMatch = true;
return { info, isMatch };
};
const sharedStart = findSharedSegmentStart(sub);
if (sharedStart < 0) {
sub.forEach((step, index) => {
const { info, isMatch } = getStepRenderMeta(step);
const previousStep = index > 0 ? sub[index - 1] : '';
const previousMatch = previousStep ? isCommonStepMatch(previousStep) : false;
const dimLink = Boolean(previousStep && hasFilter && !(previousMatch || isMatch));
chain.appendChild(buildStepSegment(item, step, info, true, isMatch, hasFilter, previousStep, dimLink, flowColIndex));
flowColIndex += 1;
});
} else {
const prefixSteps = sub.slice(0, sharedStart);
const sharedSteps = sub.slice(sharedStart, sharedStart + sharedSegment.length);
const suffixSteps = sub.slice(sharedStart + sharedSegment.length);
prefixSteps.forEach((step, index) => {
const { info, isMatch } = getStepRenderMeta(step);
const previousStep = index > 0 ? prefixSteps[index - 1] : '';
const previousMatch = previousStep ? isCommonStepMatch(previousStep) : false;
const dimLink = Boolean(previousStep && hasFilter && !(previousMatch || isMatch));
chain.appendChild(buildStepSegment(item, step, info, true, isMatch, hasFilter, previousStep, dimLink, flowColIndex));
flowColIndex += 1;
});
const box = document.createElement('span');
box.className = 'sm-shared-box';
sharedSteps.forEach((step, index) => {
const { info, isMatch } = getStepRenderMeta(step);
const previousStep = index > 0
? sharedSteps[index - 1]
: (prefixSteps.length ? prefixSteps[prefixSteps.length - 1] : '');
const previousMatch = previousStep ? isCommonStepMatch(previousStep) : false;
const dimLink = Boolean(previousStep && hasFilter && !(previousMatch || isMatch));
box.appendChild(buildStepSegment(item, step, info, true, isMatch, hasFilter, previousStep, dimLink, flowColIndex));
flowColIndex += 1;
});
if (hasFilter && !rowHasMatch) box.classList.add('dim');
chain.appendChild(box);
suffixSteps.forEach((step, index) => {
const { info, isMatch } = getStepRenderMeta(step);
const previousStep = index === 0 ? sharedSteps[sharedSteps.length - 1] : suffixSteps[index - 1];
const previousMatch = previousStep ? isCommonStepMatch(previousStep) : false;
const dimLink = Boolean(previousStep && hasFilter && !(previousMatch || isMatch));
chain.appendChild(buildStepSegment(item, step, info, true, isMatch, hasFilter, previousStep, dimLink, flowColIndex));
flowColIndex += 1;
});
}
if (hasFilter && !rowHasMatch) main.classList.add('dim');
row.appendChild(chain);
fullContentEl.appendChild(row);
});
} else {
const row = document.createElement('div');
row.className = 'sm-row';
const main = document.createElement('div');
main.className = 'sm-main';
main.textContent = '공통';
row.appendChild(main);
const empty = document.createElement('div');
empty.className = 'empty-state';
empty.style.minHeight = '120px';
empty.textContent = '공통 플로우가 없습니다.';
row.appendChild(empty);
fullContentEl.appendChild(row);
}
requestAnimationFrame(() => applyColumnAlignment());
}
function setFilters(nextFilters) {
filters = { ...filters, ...nextFilters };
}
return {
render,
refreshFilterOptions,
setFilters,
realign: applyColumnAlignment
};
}
window.createProcessMapRenderer = createProcessMapRenderer;
})();