Files

679 lines
26 KiB
JavaScript

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