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 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;
})();