Update intranet tools and voucher comparison
This commit is contained in:
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: ['전표작성', '검토', '출금']
|
||||
});
|
||||
Executable
+1430
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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
@@ -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;
|
||||
})();
|
||||
Reference in New Issue
Block a user