Files
issue-sample/flow.html

1986 lines
62 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flow Drilldown</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "Noto Sans KR", sans-serif;
color: #0f172a;
min-height: 100vh;
background:
linear-gradient(#dfe6ee 1px, transparent 1px),
linear-gradient(90deg, #dfe6ee 1px, transparent 1px),
#f8fbff;
background-size: 40px 40px, 40px 40px, auto;
}
.board {
width: 100vw;
height: 100vh;
padding: 10px;
overflow: auto;
}
.sitemap-fab {
position: fixed;
left: 16px;
top: 16px;
z-index: 25;
width: 44px;
height: 44px;
border-radius: 999px;
border: 1px solid #93c5fd;
background: #eff6ff;
color: #1d4ed8;
font-size: 11px;
font-weight: 900;
line-height: 1;
cursor: pointer;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12);
}
.data-io {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 24;
display: grid;
gap: 6px;
}
.data-io-btn {
width: 38px;
height: 38px;
border: 1px solid #c7d5ea;
background: #ffffff;
color: #1e3a8a;
border-radius: 999px;
padding: 0;
font-weight: 800;
cursor: pointer;
display: grid;
place-items: center;
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.08);
}
.data-io-btn svg {
width: 22px;
height: 22px;
stroke: currentColor;
stroke-width: 2.6;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.data-io-btn:hover {
border-color: #93c5fd;
background: #eff6ff;
}
.data-io-btn.export {
color: #1d4ed8;
}
.data-io-btn.import {
color: #0f766e;
}
.data-io-input {
display: none;
}
.board-track {
width: 100%;
min-width: max-content;
min-height: calc(100vh - 20px);
margin: 0;
padding-left: 80px;
padding-right: 80px;
display: flex;
gap: 14px;
align-items: flex-start;
}
#drillColumns {
display: flex;
gap: 14px;
align-items: flex-start;
}
.flow-panel {
width: 250px;
min-width: 250px;
background: #f8fbff;
border: 1px solid #dbe5f2;
border-radius: 16px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.10);
padding: 12px 12px 14px;
height: calc(100vh - 20px);
overflow: auto;
}
.title {
margin: 0 0 10px;
font-size: 13px;
font-weight: 900;
color: #2f5d8a;
letter-spacing: .02em;
text-transform: uppercase;
}
.panel-title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
gap: 8px;
}
.panel-badge {
border-radius: 999px;
background: #eef2ff;
color: #4f46e5;
border: 1px solid #dbe4ff;
padding: 4px 9px;
font-size: 11px;
font-weight: 900;
letter-spacing: .02em;
line-height: 1;
white-space: nowrap;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.panel-head .title {
margin: 0;
}
.mode-btn {
border: 1px solid #cbd5e1;
background: #fff;
color: #334155;
border-radius: 7px;
padding: 5px 8px;
font-size: 11px;
font-weight: 800;
cursor: pointer;
white-space: nowrap;
}
.mode-btn.active {
background: #1d4ed8;
color: #fff;
border-color: #1d4ed8;
}
.panel-tools {
display: flex;
gap: 6px;
align-items: center;
}
.add-end-btn {
border: 1px solid #93c5fd;
background: #eff6ff;
color: #1d4ed8;
border-radius: 7px;
padding: 4px 8px;
font-size: 11px;
font-weight: 800;
cursor: pointer;
}
.flow-list {
display: grid;
gap: 6px;
}
.step-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 6px;
align-items: center;
}
.step-actions {
display: flex;
gap: 4px;
align-items: center;
}
.icon-btn {
border: 1px solid #cbd5e1;
background: #fff;
color: #334155;
border-radius: 6px;
width: 26px;
height: 26px;
font-size: 13px;
font-weight: 900;
cursor: pointer;
line-height: 1;
display: grid;
place-items: center;
}
.icon-btn.delete {
color: #b91c1c;
border-color: #fecaca;
background: #fff5f5;
}
.step-btn {
border: 1px solid #d6e1f0;
background: #ffffff;
border-radius: 11px;
padding: 11px 12px;
text-align: left;
font-size: 14px;
font-weight: 800;
color: #0f172a;
cursor: pointer;
transition: all .15s ease;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.step-btn:hover { border-color: #8fb0f6; }
.step-btn.active {
border-color: #4f46e5;
background: linear-gradient(135deg, #3f55f2 0%, #6b3cf1 100%);
box-shadow: 0 10px 18px rgba(79, 70, 229, 0.28);
color: #ffffff;
}
.step-btn.with-meta {
white-space: normal;
display: grid;
gap: 2px;
align-content: start;
}
.step-main {
font-size: 14px;
font-weight: 800;
color: #0f172a;
line-height: 1.2;
}
.step-btn.active .step-main {
color: #ffffff;
}
.step-meta {
font-size: 10px;
font-weight: 700;
color: #7b8aa4;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.step-btn.active .step-meta {
color: #dbe6ff;
}
.down-arrow {
text-align: center;
color: #c8d3e6;
font-size: 18px;
line-height: 1;
user-select: none;
}
.insert-row {
text-align: center;
display: grid;
justify-items: center;
gap: 4px;
}
.insert-btn {
border: 1px dashed #93c5fd;
background: #f8fbff;
color: #2563eb;
border-radius: 999px;
width: 26px;
height: 26px;
font-size: 14px;
font-weight: 900;
cursor: pointer;
line-height: 1;
}
.link-edit-btn {
border: 1px solid #f59e0b;
background: #fffbeb;
color: #b45309;
border-radius: 999px;
padding: 2px 7px;
font-size: 10px;
font-weight: 800;
cursor: pointer;
line-height: 1.2;
white-space: nowrap;
}
.down-arrow.broken {
color: #dc2626;
font-weight: 900;
}
.link-reason {
width: fit-content;
max-width: 170px;
border: 1px dashed #fda4af;
background: #fff1f2;
color: #9f1239;
border-radius: 999px;
padding: 2px 9px;
font-size: 9px;
font-weight: 700;
line-height: 1.2;
text-align: center;
word-break: keep-all;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
justify-self: center;
}
.empty {
border: 1px dashed #cbd5e1;
border-radius: 10px;
padding: 12px;
font-size: 12px;
color: #64748b;
font-weight: 700;
background: #fff;
line-height: 1.4;
}
.editor-panel {
display: none;
width: auto;
min-width: 760px;
max-width: 1240px;
flex: 1 1 auto;
height: calc(100vh - 20px);
max-height: calc(100vh - 20px);
background: #f8fbff;
border: 1px solid #dbe5f2;
border-radius: 16px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
grid-template-rows: auto 1fr;
overflow: hidden;
overscroll-behavior: none;
}
.editor-panel.open { display: grid; }
.editor-head {
padding: 16px 16px 14px;
border-bottom: 1px solid #dbe5f2;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
background: #ffffff;
}
.editor-title {
margin: 0;
font-size: 17px;
font-weight: 900;
color: #1e293b;
line-height: 1.2;
}
.editor-sub {
margin: 4px 0 0;
font-size: 11px;
font-weight: 700;
color: #7a8da8;
}
.editor-close {
border: 1px solid #cbd5e1;
background: #fff;
color: #475569;
width: 34px;
height: 34px;
border-radius: 999px;
font-weight: 900;
cursor: pointer;
line-height: 1;
}
.editor-body {
padding: 14px 16px 16px;
overflow: auto;
display: grid;
align-content: start;
gap: 12px;
background: #f8fbff;
min-height: 0;
overscroll-behavior: none;
}
.editor-card {
background: transparent;
border: 0;
border-radius: 0;
padding: 0;
}
.image-card {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(460px, 1fr) auto auto auto;
gap: 10px;
overscroll-behavior: none;
}
.label {
margin: 0 0 4px;
font-size: 10px;
color: #60738f;
font-weight: 900;
letter-spacing: .03em;
text-transform: uppercase;
}
.label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 0;
}
.label-row .label {
margin: 0;
white-space: nowrap;
}
.uploader {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 0;
width: 100%;
}
.file-input-hidden {
display: none;
}
.file-btn {
border: 1px solid #564cf5;
background: linear-gradient(135deg, #3f55f2 0%, #6b3cf1 100%);
color: #fff;
border-radius: 10px;
padding: 10px 14px;
font-size: 13px;
font-weight: 800;
cursor: pointer;
line-height: 1.2;
white-space: nowrap;
flex: 1;
text-align: center;
}
.file-status {
font-size: 10px;
font-weight: 700;
color: #475569;
white-space: nowrap;
}
.path-wrap { margin-bottom: 0; }
.path-input {
width: 100%;
border: 1px solid #cbd5e1;
border-radius: 9px;
padding: 10px 12px;
font-size: 12px;
outline: none;
background: #fff;
}
.path-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 2px #dbeafe;
}
.clear-btn {
border: 1px solid #cbd5e1;
border-radius: 10px;
background: #eef2f7;
color: #1e293b;
font-size: 13px;
font-weight: 800;
padding: 10px 14px;
cursor: pointer;
white-space: nowrap;
}
.preview {
border: 1px dashed #cbd5e1;
border-radius: 12px;
background: #f6f9ff;
height: 100%;
min-height: 460px;
max-height: 640px;
display: grid;
place-items: center;
overflow: hidden;
flex-shrink: 0;
overscroll-behavior: none;
}
.image-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 2px;
}
.nav-btn {
border: 1px solid #cbd5e1;
background: #fff;
color: #334155;
border-radius: 9px;
padding: 8px 10px;
font-size: 11px;
font-weight: 800;
cursor: pointer;
}
.nav-btn:disabled {
opacity: 0.45;
cursor: default;
}
.page-info {
min-width: 54px;
text-align: center;
font-size: 11px;
font-weight: 800;
color: #475569;
}
.editor-panel *::-webkit-scrollbar {
width: 0;
height: 0;
}
.image-modal {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.72);
display: none;
align-items: center;
justify-content: center;
z-index: 80;
padding: 24px;
}
.image-modal.open {
display: flex;
}
.image-modal-inner {
position: relative;
width: min(92vw, 1600px);
height: min(90vh, 980px);
background: #0b1220;
border: 1px solid #334155;
border-radius: 12px;
overflow: hidden;
display: grid;
place-items: center;
}
.image-modal-nav {
position: absolute;
left: 50%;
bottom: 12px;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
z-index: 2;
background: rgba(15, 23, 42, 0.55);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 999px;
padding: 6px 8px;
}
.image-modal-btn {
border: 1px solid #64748b;
background: rgba(15, 23, 42, 0.6);
color: #e2e8f0;
border-radius: 999px;
padding: 5px 10px;
font-size: 12px;
font-weight: 800;
cursor: pointer;
line-height: 1.1;
}
.image-modal-btn:disabled {
opacity: 0.4;
cursor: default;
}
.image-modal-page {
min-width: 56px;
text-align: center;
color: #e2e8f0;
font-size: 12px;
font-weight: 800;
}
.image-modal img {
width: 100%;
height: 100%;
object-fit: contain;
background: #0b1220;
}
.image-modal-close {
position: absolute;
top: 10px;
right: 10px;
border: 1px solid #475569;
background: rgba(15, 23, 42, 0.75);
color: #fff;
width: 32px;
height: 32px;
border-radius: 999px;
font-size: 18px;
font-weight: 900;
cursor: pointer;
line-height: 1;
}
.sitemap-modal {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
display: none;
align-items: center;
justify-content: center;
z-index: 70;
padding: 20px;
}
.sitemap-modal.open {
display: flex;
}
.sitemap-modal-inner {
width: min(96vw, 1240px);
max-height: 88vh;
background: #ffffff;
border: 1px solid #dbe5f2;
border-radius: 24px;
box-shadow: 0 20px 44px rgba(15, 23, 42, 0.20);
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
}
.sitemap-modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: 28px 34px 24px;
border-bottom: 1px solid #dbe5f2;
background: #ffffff;
}
.sitemap-modal-title {
margin: 0;
font-size: 28px;
font-weight: 900;
color: #132949;
letter-spacing: .01em;
text-transform: none;
}
.sitemap-modal-sub {
margin: 6px 0 0;
font-size: 14px;
font-weight: 700;
color: #6f819d;
}
.sitemap-close-btn {
border: 1px solid #dbe5f2;
background: #f3f7fc;
color: #5f738f;
width: 40px;
height: 40px;
border-radius: 999px;
font-size: 24px;
font-weight: 900;
cursor: pointer;
line-height: 1;
flex: 0 0 auto;
}
.sitemap-toolbar {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: nowrap;
margin-top: 8px;
}
.sm-select {
border: 1px solid #ced9e8;
background: #fff;
color: #0f172a;
border-radius: 12px;
padding: 9px 12px;
font-size: 14px;
font-weight: 700;
min-width: 200px;
}
.sitemap-full {
display: grid;
gap: 0;
align-content: start;
font-size: 15px;
color: #0f172a;
font-weight: 800;
overflow: auto;
background: #ffffff;
padding: 14px 34px 24px;
}
.sm-row {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: nowrap;
min-width: 100%;
padding: 18px 0;
border-bottom: 1px solid #e2e8f0;
}
.sm-main {
min-width: 150px;
font-weight: 900;
color: #0f2343;
font-size: 20px;
}
.sm-chain {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.sm-arrow {
color: #8da1bd;
font-weight: 900;
font-size: 14px;
}
.sm-link-wrap {
display: inline-grid;
justify-items: center;
align-items: center;
gap: 2px;
min-width: 22px;
}
.sm-arrow.broken {
color: #dc2626;
}
.sm-chip {
border: 1px solid #d2ddeb;
border-radius: 14px;
padding: 8px 12px;
background: #f8fbff;
font-size: 14px;
font-weight: 800;
color: #3b5b80;
line-height: 1.2;
}
.sm-chip.match {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
}
.sm-chip.dim {
opacity: 0.28;
filter: grayscale(0.2);
}
.sm-link-reason {
border: 1px dashed #fda4af;
background: #fff1f2;
color: #9f1239;
border-radius: 10px;
padding: 5px 8px;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
max-width: 220px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sm-arrow.dim,
.sm-main.dim {
opacity: 0.28;
}
.preview img {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: contain;
display: block;
background: #fff;
}
.note-card {
display: block;
}
.flow-info-card {
display: none;
}
.flow-info-card.open {
display: block;
}
.section-divider {
height: 1px;
background: #dbe5f2;
margin: 2px 0 0;
}
.mini-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.mini-label {
margin: 0 0 4px;
font-size: 10px;
color: #60738f;
font-weight: 900;
letter-spacing: .03em;
text-transform: uppercase;
}
.mini-input {
width: 100%;
border: 1px solid #cbd5e1;
border-radius: 9px;
padding: 10px 12px;
font-size: 12px;
outline: none;
background: #fff;
}
.mini-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 2px #dbeafe;
}
.preview-empty {
margin: 0;
padding: 8px;
color: #64748b;
font-size: 11px;
font-weight: 700;
text-align: center;
line-height: 1.35;
}
.note-input {
width: 100%;
border: 1px solid #cbd5e1;
border-radius: 10px;
padding: 10px 12px;
font-size: 12px;
height: 42px;
outline: none;
background: #fff;
}
.note-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 2px #dbeafe;
}
@media (max-width: 1000px) {
.board-track {
padding-left: 20px;
padding-right: 20px;
}
.sitemap-fab {
left: 10px;
top: 10px;
width: 40px;
height: 40px;
font-size: 10px;
}
.data-io {
left: 10px;
bottom: 10px;
}
.data-io-btn {
width: 34px;
height: 34px;
font-size: 14px;
}
.flow-panel {
width: 220px;
min-width: 220px;
}
.step-btn { font-size: 16px; }
.editor-panel {
width: auto;
min-width: 560px;
max-width: 980px;
}
.preview {
height: 100%;
min-height: 240px;
}
.sitemap-modal-inner {
width: min(96vw, 900px);
max-height: 92vh;
}
.sitemap-modal-head {
padding: 18px 18px 14px;
}
.sitemap-modal-title { font-size: 22px; }
.sitemap-modal-sub { font-size: 14px; }
.sm-select {
min-width: 180px;
font-size: 14px;
}
.sitemap-full {
padding: 10px 18px 16px;
}
.sm-main {
min-width: 118px;
font-size: 18px;
}
.sm-chip {
font-size: 13px;
padding: 8px 12px;
}
}
</style>
</head>
<body>
<button id="sitemapOpenBtn" class="sitemap-fab" type="button" title="사이트맵 보기">MAP</button>
<div class="data-io">
<button id="dataExportBtn" class="data-io-btn export" type="button" title="데이터 내보내기" aria-label="데이터 내보내기">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 15v2a4 4 0 0 0 4 4h10a4 4 0 0 0 4-4v-2"></path>
<path d="M12 3v12"></path>
<path d="M7.5 10.5 12 15l4.5-4.5"></path>
</svg>
</button>
<button id="dataImportBtn" class="data-io-btn import" type="button" title="데이터 가져오기" aria-label="데이터 가져오기">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 15v2a4 4 0 0 0 4 4h10a4 4 0 0 0 4-4v-2"></path>
<path d="M12 15V3"></path>
<path d="M7.5 7.5 12 3l4.5 4.5"></path>
</svg>
</button>
<input id="dataImportInput" class="data-io-input" type="file" accept="application/json,.json" />
</div>
<div class="board" id="board">
<div class="board-track" id="boardTrack">
<section class="flow-panel" id="mainPanel">
<div class="panel-head">
<h2 class="title">MAIN FLOW</h2>
<button id="editModeBtn" class="mode-btn" type="button">편집모드</button>
</div>
<div id="mainFlow" class="flow-list"></div>
</section>
<div id="drillColumns"></div>
<aside id="editorPanel" class="editor-panel" aria-hidden="true">
<div class="editor-head">
<div>
<h3 id="editorTitle" class="editor-title">STEP DETAIL</h3>
<p class="editor-sub">이미지와 메모는 자동 저장됩니다.</p>
</div>
<button id="editorClose" class="editor-close" type="button">×</button>
</div>
<div class="editor-body">
<div class="editor-card note-card">
<p class="label">STEP NOTE</p>
<input id="editorNoteInput" class="note-input" type="text" placeholder="해당 단계에 대한 설명을 입력하세요..." />
</div>
<div class="editor-card image-card">
<div class="label-row">
<p class="label">PAGE IMAGE</p>
<span id="editorFileStatus" class="file-status">저장된 이미지 0장</span>
</div>
<div class="preview" id="editorPreview">
<p class="preview-empty">이미지가 없습니다.</p>
</div>
<div class="image-nav">
<button id="editorPrevImage" class="nav-btn" type="button">이전 페이지</button>
<span id="editorPageInfo" class="page-info">0 / 0</span>
<button id="editorNextImage" class="nav-btn" type="button">다음 페이지</button>
</div>
<div class="uploader">
<label for="editorImageInput" class="file-btn">이미지 업로드</label>
<input id="editorImageInput" class="file-input-hidden" type="file" accept="image/*" multiple />
<button id="editorClearImage" class="clear-btn" type="button">삭제</button>
</div>
<div>
<p class="label">SYSTEM PATH</p>
<div class="path-wrap">
<input id="editorPathInput" class="path-input" type="text" placeholder="예: finance/billing/request" />
</div>
</div>
</div>
<div class="section-divider"></div>
<div id="flowInfoCard" class="editor-card flow-info-card">
<p class="label">FLOW INFO</p>
<div class="mini-grid">
<div>
<p class="mini-label">담당팀</p>
<input id="editorTeamInput" class="mini-input" type="text" placeholder="예: 사업관리팀" />
</div>
<div>
<p class="mini-label">해당 시스템</p>
<input id="editorSystemInput" class="mini-input" type="text" placeholder="예: ERP" />
</div>
</div>
</div>
</div>
</aside>
</div>
</div>
<div id="imageModal" class="image-modal" aria-hidden="true">
<div class="image-modal-inner">
<button id="imageModalClose" class="image-modal-close" type="button">×</button>
<img id="imageModalImg" alt="확대 이미지" />
<div class="image-modal-nav">
<button id="imageModalPrev" class="image-modal-btn" type="button">이전</button>
<span id="imageModalPage" class="image-modal-page">0 / 0</span>
<button id="imageModalNext" class="image-modal-btn" type="button">다음</button>
</div>
</div>
</div>
<div id="sitemapModal" class="sitemap-modal" aria-hidden="true">
<div class="sitemap-modal-inner">
<div class="sitemap-modal-head">
<div>
<h3 class="sitemap-modal-title">Process Map</h3>
<p class="sitemap-modal-sub">전체 비즈니스 프로세스 현황입니다.</p>
</div>
<div class="sitemap-toolbar">
<select id="sitemapTeamSelect" class="sm-select">
<option value="">전체 팀 필터</option>
</select>
<select id="sitemapSystemSelect" class="sm-select">
<option value="">전체 시스템 필터</option>
</select>
<button id="sitemapCloseBtn" class="sitemap-close-btn" type="button">×</button>
</div>
</div>
<div id="sitemapFullContent" class="sitemap-full"></div>
</div>
</div>
<script>
const MAIN_STEPS = [
'사전조사',
'입찰',
'계약',
'계획',
'기성',
'외주',
'실행률/손익분석'
];
const SUB_FLOW = {
'사전조사': ['사전조사'],
'입찰': ['입찰공고', '참여검토', '입찰진행', '입찰결과'],
'계약': ['계약', '계약변경', '차수계약'],
'계획': ['과업수행계획 작성', '실행계획 작성'],
'기성': ['견적검토', '기성청구', '기성검토', '전표작성', '수금'],
'외주': ['외주견적검토', '외주계약', '외주기성청구', '외주기성검토', '전표작성', '출금'],
'실행률/손익분석': ['실행률 분석', '손익 분석', '피드백 반영']
};
const DRILL_FLOW = {
'입찰공고': ['공고 확인', '요건 분석', '참여 여부 결정'],
'참여검토': ['리스크 검토', '인력 검토', '원가 검토'],
'입찰진행': ['서류 작성', '내부 승인', '제출'],
'계약': ['계약서 검토', '법무 협의', '체결'],
'기성청구': ['기성내역 등록', '증빙 첨부', '청구 상신'],
'외주계약': ['외주업체 선정', '단가 협의', '외주계약관리'],
'외주기성청구': ['외주기성청구내역등록', '검토 요청', '승인 완료'],
'전표작성': ['전표 생성', '회계 검토', '전기 처리'],
'수금': ['수금 확인', '시스템 반영', '마감']
};
const mainFlowEl = document.getElementById('mainFlow');
const drillColumnsEl = document.getElementById('drillColumns');
const editorPanelEl = document.getElementById('editorPanel');
const editorTitleEl = document.getElementById('editorTitle');
const editorCloseEl = document.getElementById('editorClose');
const editorPathInputEl = document.getElementById('editorPathInput');
const editorImageInputEl = document.getElementById('editorImageInput');
const editorFileStatusEl = document.getElementById('editorFileStatus');
const editorClearImageEl = document.getElementById('editorClearImage');
const editorPreviewEl = document.getElementById('editorPreview');
const editorPrevImageEl = document.getElementById('editorPrevImage');
const editorNextImageEl = document.getElementById('editorNextImage');
const editorPageInfoEl = document.getElementById('editorPageInfo');
const editorNoteInputEl = document.getElementById('editorNoteInput');
const flowInfoCardEl = document.getElementById('flowInfoCard');
const editorTeamInputEl = document.getElementById('editorTeamInput');
const editorSystemInputEl = document.getElementById('editorSystemInput');
const editModeBtnEl = document.getElementById('editModeBtn');
const imageModalEl = document.getElementById('imageModal');
const imageModalImgEl = document.getElementById('imageModalImg');
const imageModalCloseEl = document.getElementById('imageModalClose');
const imageModalPrevEl = document.getElementById('imageModalPrev');
const imageModalNextEl = document.getElementById('imageModalNext');
const imageModalPageEl = document.getElementById('imageModalPage');
const sitemapOpenBtnEl = document.getElementById('sitemapOpenBtn');
const dataExportBtnEl = document.getElementById('dataExportBtn');
const dataImportBtnEl = document.getElementById('dataImportBtn');
const dataImportInputEl = document.getElementById('dataImportInput');
const sitemapModalEl = document.getElementById('sitemapModal');
const sitemapCloseBtnEl = document.getElementById('sitemapCloseBtn');
const sitemapTeamSelectEl = document.getElementById('sitemapTeamSelect');
const sitemapSystemSelectEl = document.getElementById('sitemapSystemSelect');
const sitemapFullContentEl = document.getElementById('sitemapFullContent');
// 선택 체인: [메인선택, 1차선택, 2차선택, ...]
let selectedChain = [];
let editMode = false;
let editingKey = null;
let currentImageIndex = 0;
let modalImageIndex = 0;
let sitemapFilterTeam = '';
let sitemapFilterSystem = '';
const STEP_META_KEY = 'flow_step_meta_v1';
const FLOW_MODEL_KEY = 'flow_model_v1';
let stepMeta = {};
let subFlowLinks = {};
function loadStepMeta() {
try {
const raw = localStorage.getItem(STEP_META_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return;
stepMeta = parsed;
} catch (e) {
stepMeta = {};
}
}
function saveStepMeta() {
localStorage.setItem(STEP_META_KEY, JSON.stringify(stepMeta));
}
function isSecondFlowKey(key) {
return typeof key === 'string' && key.startsWith('0|');
}
function ensureMeta(key) {
stepMeta[key] = stepMeta[key] || { image: '', images: [], note: '', team: '', system: '', path: '' };
if (typeof stepMeta[key].image !== 'string') stepMeta[key].image = '';
if (!Array.isArray(stepMeta[key].images)) {
stepMeta[key].images = stepMeta[key].image ? [stepMeta[key].image] : [];
}
stepMeta[key].images = stepMeta[key].images.filter((v) => typeof v === 'string' && v);
if (!stepMeta[key].image && stepMeta[key].images.length) {
stepMeta[key].image = stepMeta[key].images[0];
}
if (stepMeta[key].image && !stepMeta[key].images.length) {
stepMeta[key].images = [stepMeta[key].image];
}
stepMeta[key].image = stepMeta[key].images[0] || '';
if (typeof stepMeta[key].note !== 'string') stepMeta[key].note = '';
if (typeof stepMeta[key].team !== 'string') stepMeta[key].team = '';
if (typeof stepMeta[key].system !== 'string') stepMeta[key].system = '';
if (typeof stepMeta[key].path !== 'string') stepMeta[key].path = '';
return stepMeta[key];
}
function saveFlowModel() {
const payload = {
mainSteps: MAIN_STEPS,
subFlow: SUB_FLOW,
drillFlow: DRILL_FLOW,
subFlowLinks
};
localStorage.setItem(FLOW_MODEL_KEY, JSON.stringify(payload));
}
function exportAllData() {
const payload = {
version: 1,
exportedAt: new Date().toISOString(),
flowModel: {
mainSteps: MAIN_STEPS,
subFlow: SUB_FLOW,
drillFlow: DRILL_FLOW,
subFlowLinks
},
stepMeta
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const day = new Date().toISOString().slice(0, 10);
a.href = url;
a.download = `flow-data-${day}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function importAllData(rawText) {
let parsed;
try {
parsed = JSON.parse(rawText);
} catch (e) {
alert('JSON 파일 형식이 올바르지 않습니다.');
return;
}
if (!parsed || typeof parsed !== 'object') {
alert('가져올 데이터 형식이 아닙니다.');
return;
}
const flowModel = parsed.flowModel;
const importedMeta = parsed.stepMeta;
if (!flowModel || typeof flowModel !== 'object') {
alert('flowModel 데이터가 없습니다.');
return;
}
if (!importedMeta || typeof importedMeta !== 'object') {
alert('stepMeta 데이터가 없습니다.');
return;
}
localStorage.setItem(FLOW_MODEL_KEY, JSON.stringify(flowModel));
localStorage.setItem(STEP_META_KEY, JSON.stringify(importedMeta));
loadFlowModel();
loadStepMeta();
selectedChain = [];
closeEditor();
renderAll();
alert('데이터를 가져왔습니다.');
}
function loadFlowModel() {
try {
const raw = localStorage.getItem(FLOW_MODEL_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return;
if (Array.isArray(parsed.mainSteps)) {
MAIN_STEPS.splice(0, MAIN_STEPS.length, ...parsed.mainSteps.map(v => String(v)));
}
if (parsed.subFlow && typeof parsed.subFlow === 'object') {
Object.keys(SUB_FLOW).forEach(k => delete SUB_FLOW[k]);
Object.entries(parsed.subFlow).forEach(([k, arr]) => {
if (Array.isArray(arr)) SUB_FLOW[k] = arr.map(v => String(v));
});
}
if (parsed.drillFlow && typeof parsed.drillFlow === 'object') {
Object.keys(DRILL_FLOW).forEach(k => delete DRILL_FLOW[k]);
Object.entries(parsed.drillFlow).forEach(([k, arr]) => {
if (Array.isArray(arr)) DRILL_FLOW[k] = arr.map(v => String(v));
});
}
if (parsed.subFlowLinks && typeof parsed.subFlowLinks === 'object') {
subFlowLinks = {};
Object.entries(parsed.subFlowLinks).forEach(([k, v]) => {
if (v && typeof v === 'object' && typeof v.reason === 'string') {
subFlowLinks[k] = { reason: v.reason };
}
});
} else {
subFlowLinks = {};
}
} catch (e) {
// ignore malformed saved model
}
}
function renderEditorPreview(label) {
if (!editingKey) return;
const info = ensureMeta(editingKey);
const images = info.images || [];
if (currentImageIndex >= images.length) {
currentImageIndex = Math.max(0, images.length - 1);
}
const imageSrc = images[currentImageIndex] || '';
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(currentImageIndex, `${label} 확대 이미지`));
editorPreviewEl.appendChild(img);
}
editorPageInfoEl.textContent = `${images.length ? (currentImageIndex + 1) : 0} / ${images.length}`;
editorFileStatusEl.textContent = images.length ? `저장된 이미지 ${images.length}` : '저장된 이미지 0장';
editorPrevImageEl.disabled = images.length <= 1 || currentImageIndex <= 0;
editorNextImageEl.disabled = images.length <= 1 || currentImageIndex >= images.length - 1;
}
function updateImageModalView(altText) {
if (!editingKey) return;
const info = ensureMeta(editingKey);
const images = info.images || [];
if (!images.length) return;
if (modalImageIndex < 0) modalImageIndex = 0;
if (modalImageIndex > images.length - 1) modalImageIndex = images.length - 1;
imageModalImgEl.src = images[modalImageIndex];
imageModalImgEl.alt = altText || '확대 이미지';
imageModalPageEl.textContent = `${modalImageIndex + 1} / ${images.length}`;
imageModalPrevEl.disabled = modalImageIndex <= 0;
imageModalNextEl.disabled = modalImageIndex >= images.length - 1;
}
function openImageModal(index, altText) {
if (!editingKey) return;
const info = ensureMeta(editingKey);
if (!info.images.length) return;
modalImageIndex = 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';
}
function openEditor(stepKey, stepLabel) {
editingKey = stepKey;
const info = ensureMeta(stepKey);
editorTitleEl.textContent = `${stepLabel} DETAIL`;
editorPathInputEl.value = info.path || '';
editorNoteInputEl.value = info.note || '';
editorTeamInputEl.value = info.team || '';
editorSystemInputEl.value = info.system || '';
flowInfoCardEl.classList.toggle('open', isSecondFlowKey(stepKey));
editorImageInputEl.value = '';
currentImageIndex = 0;
renderEditorPreview(stepLabel);
editorPanelEl.classList.add('open');
editorPanelEl.setAttribute('aria-hidden', 'false');
}
function closeEditor() {
editorPanelEl.classList.remove('open');
editorPanelEl.setAttribute('aria-hidden', 'true');
}
function getNextFlow(level, step) {
if (!step) return [];
if (level === 0) return SUB_FLOW[step] || [];
return DRILL_FLOW[step] || [];
}
function getSecondFlowMeta(mainStep, step) {
return stepMeta[`0|${mainStep}|${step}`] || {};
}
function subFlowLinkKey(mainStep, fromStep, toStep) {
return `${mainStep}||${fromStep}>>${toStep}`;
}
function getSubFlowLinkInfo(mainStep, fromStep, toStep) {
return subFlowLinks[subFlowLinkKey(mainStep, fromStep, toStep)] || { reason: '' };
}
function setSubFlowLinkReason(mainStep, fromStep, toStep, reason) {
const key = subFlowLinkKey(mainStep, fromStep, toStep);
const text = String(reason || '').trim();
if (!text) {
delete subFlowLinks[key];
} else {
subFlowLinks[key] = { reason: text };
}
}
function remapSubFlowLinksOnRename(mainStep, oldStep, newStep) {
const nextLinks = {};
Object.entries(subFlowLinks).forEach(([k, v]) => {
if (!k.startsWith(`${mainStep}||`)) {
nextLinks[k] = v;
return;
}
const raw = k.slice(`${mainStep}||`.length);
const [fromRaw, toRaw] = raw.split('>>');
const from = fromRaw === oldStep ? newStep : fromRaw;
const to = toRaw === oldStep ? newStep : toRaw;
nextLinks[subFlowLinkKey(mainStep, from, to)] = v;
});
subFlowLinks = nextLinks;
}
function syncSubFlowLinks(mainStep) {
const list = SUB_FLOW[mainStep] || [];
const valid = new Set();
for (let i = 0; i < list.length - 1; i += 1) {
valid.add(subFlowLinkKey(mainStep, list[i], list[i + 1]));
}
Object.keys(subFlowLinks).forEach((k) => {
if (k.startsWith(`${mainStep}||`) && !valid.has(k)) delete subFlowLinks[k];
});
}
function refreshSitemapFilterOptions() {
const teamSet = new Set();
const systemSet = new Set();
MAIN_STEPS.forEach((mainStep) => {
const sub = SUB_FLOW[mainStep] || [];
sub.forEach((s) => {
const info = getSecondFlowMeta(mainStep, s);
if (info.team) teamSet.add(info.team);
if (info.system) systemSet.add(info.system);
});
});
const prevTeam = sitemapFilterTeam;
const prevSystem = sitemapFilterSystem;
sitemapTeamSelectEl.innerHTML = '<option value=\"\">전체 팀 필터</option>';
Array.from(teamSet).sort((a, b) => a.localeCompare(b, 'ko')).forEach((v) => {
const opt = document.createElement('option');
opt.value = v;
opt.textContent = v;
sitemapTeamSelectEl.appendChild(opt);
});
sitemapSystemSelectEl.innerHTML = '<option value=\"\">전체 시스템 필터</option>';
Array.from(systemSet).sort((a, b) => a.localeCompare(b, 'ko')).forEach((v) => {
const opt = document.createElement('option');
opt.value = v;
opt.textContent = v;
sitemapSystemSelectEl.appendChild(opt);
});
sitemapTeamSelectEl.value = Array.from(teamSet).includes(prevTeam) ? prevTeam : '';
sitemapSystemSelectEl.value = Array.from(systemSet).includes(prevSystem) ? prevSystem : '';
sitemapFilterTeam = sitemapTeamSelectEl.value;
sitemapFilterSystem = sitemapSystemSelectEl.value;
}
function renderSiteMapFull() {
sitemapFullContentEl.innerHTML = '';
const hasFilter = Boolean(sitemapFilterTeam || sitemapFilterSystem);
MAIN_STEPS.forEach((mainStep) => {
const row = document.createElement('div');
row.className = 'sm-row';
const main = document.createElement('div');
main.className = 'sm-main';
main.textContent = mainStep;
row.appendChild(main);
const sub = SUB_FLOW[mainStep] || [];
let rowHasMatch = false;
if (sub.length > 0) {
const chain = document.createElement('div');
chain.className = 'sm-chain';
sub.forEach((s, idx) => {
const info = getSecondFlowMeta(mainStep, s);
const teamMatch = !sitemapFilterTeam || info.team === sitemapFilterTeam;
const sysMatch = !sitemapFilterSystem || info.system === sitemapFilterSystem;
const isMatch = teamMatch && sysMatch;
if (isMatch) rowHasMatch = true;
const chip = document.createElement('span');
chip.className = 'sm-chip';
chip.textContent = s;
chip.title = `담당팀: ${info.team || '-'} / 시스템: ${info.system || '-'}`;
if (hasFilter) {
chip.classList.add(isMatch ? 'match' : 'dim');
}
chain.appendChild(chip);
if (idx < sub.length - 1) {
const nextStep = sub[idx + 1];
const link = getSubFlowLinkInfo(mainStep, s, nextStep);
const linkWrap = document.createElement('span');
linkWrap.className = 'sm-link-wrap';
if (hasFilter && !isMatch) linkWrap.classList.add('dim');
const arrow = document.createElement('span');
arrow.className = `sm-arrow${link.reason ? ' broken' : ''}`;
arrow.textContent = '→';
if (link.reason) {
const reason = document.createElement('span');
reason.className = 'sm-link-reason';
reason.textContent = link.reason;
reason.title = link.reason;
if (hasFilter && !isMatch) reason.classList.add('dim');
linkWrap.appendChild(reason);
}
if (hasFilter && !isMatch) arrow.classList.add('dim');
linkWrap.appendChild(arrow);
chain.appendChild(linkWrap);
}
});
if (hasFilter && !rowHasMatch) {
main.classList.add('dim');
}
row.appendChild(chain);
}
sitemapFullContentEl.appendChild(row);
});
}
function openSitemapModal() {
refreshSitemapFilterOptions();
renderSiteMapFull();
sitemapModalEl.classList.add('open');
sitemapModalEl.setAttribute('aria-hidden', 'false');
}
function closeSitemapModal() {
sitemapModalEl.classList.remove('open');
sitemapModalEl.setAttribute('aria-hidden', 'true');
}
function renderVerticalFlow(container, list, activeValue, onSelect, options) {
const listEl = document.createElement('div');
listEl.className = 'flow-list';
const {
allowEdit = false,
onEdit = null,
onDelete = null,
onInsertAfter = null,
onAddEnd = null,
getMetaText = null,
getConnectorInfo = null,
onEditConnector = null
} = options || {};
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');
btn.innerHTML = `<span class=\"step-main\">${step}</span><span class=\"step-meta\">${metaText}</span>`;
} else {
btn.textContent = step;
}
btn.title = step;
btn.addEventListener('click', () => onSelect(step));
row.appendChild(btn);
const actions = document.createElement('div');
actions.className = 'step-actions';
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.title = 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 = connectorInfo.reason;
insertWrap.appendChild(reason);
}
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);
}
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 askStepLabel(defaultValue = '') {
const value = prompt('스텝명을 입력해 주세요.', defaultValue);
if (value === null) return null;
const trimmed = value.trim();
if (!trimmed) {
alert('스텝명은 비워둘 수 없습니다.');
return null;
}
return trimmed;
}
function renderMain() {
mainFlowEl.innerHTML = '';
renderVerticalFlow(mainFlowEl, MAIN_STEPS, selectedChain[0], (step) => {
selectedChain = [step];
renderAll();
openEditor(`main|${step}`, step);
}, {
allowEdit: editMode,
onEdit: (idx) => {
const oldStep = MAIN_STEPS[idx];
const next = askStepLabel(oldStep);
if (!next || next === oldStep) return;
MAIN_STEPS[idx] = next;
if (SUB_FLOW[oldStep]) {
SUB_FLOW[next] = SUB_FLOW[oldStep];
delete SUB_FLOW[oldStep];
const migrated = {};
Object.entries(subFlowLinks).forEach(([k, v]) => {
if (k.startsWith(`${oldStep}||`)) {
migrated[k.replace(`${oldStep}||`, `${next}||`)] = v;
} else {
migrated[k] = v;
}
});
subFlowLinks = migrated;
} else if (!SUB_FLOW[next]) {
SUB_FLOW[next] = [];
}
if (selectedChain[0] === oldStep) {
selectedChain[0] = next;
}
saveFlowModel();
renderAll();
},
onDelete: (idx) => {
const target = MAIN_STEPS[idx];
if (!confirm(`'${target}' 스텝을 삭제할까요?`)) return;
MAIN_STEPS.splice(idx, 1);
delete SUB_FLOW[target];
Object.keys(subFlowLinks).forEach((k) => {
if (k.startsWith(`${target}||`)) delete subFlowLinks[k];
});
if (selectedChain[0] === target) {
selectedChain = [];
closeEditor();
}
saveFlowModel();
renderAll();
},
onInsertAfter: (idx) => {
const next = askStepLabel('');
if (!next) return;
MAIN_STEPS.splice(idx + 1, 0, next);
if (!SUB_FLOW[next]) SUB_FLOW[next] = [];
saveFlowModel();
renderAll();
},
onAddEnd: () => {
const next = askStepLabel('');
if (!next) return;
MAIN_STEPS.push(next);
if (!SUB_FLOW[next]) SUB_FLOW[next] = [];
saveFlowModel();
renderAll();
}
});
}
function renderDrillColumns() {
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 panelPivot = pivot;
const titleRow = document.createElement('div');
titleRow.className = 'panel-title-row';
const title = document.createElement('h2');
title.className = 'title';
title.textContent = `${panelPivot} FLOW`;
titleRow.appendChild(title);
const badge = document.createElement('span');
badge.className = 'panel-badge';
badge.textContent = `LV.${panelLevel + 2}`;
titleRow.appendChild(badge);
panel.appendChild(titleRow);
renderVerticalFlow(panel, list, selectedChain[panelLevel + 1], (step) => {
selectedChain = selectedChain.slice(0, panelLevel + 1);
selectedChain[panelLevel + 1] = step;
renderAll();
openEditor(`${panelLevel}|${panelPivot}|${step}`, step);
}, {
getMetaText: panelLevel === 0 ? (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 || '' };
} : 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 = prompt(`'${fromStep}' -> '${toStep}' 연결 사유를 입력하세요.\n(비우면 정상 연결로 복구)`, current);
if (value === null) return;
setSubFlowLinkReason(panelPivot, fromStep, toStep, value);
saveFlowModel();
renderAll();
} : null,
allowEdit: editMode,
onEdit: (idx) => {
const targetList = panelLevel === 0 ? (SUB_FLOW[panelPivot] || []) : (DRILL_FLOW[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 (DRILL_FLOW[oldStep]) {
DRILL_FLOW[next] = DRILL_FLOW[oldStep];
delete DRILL_FLOW[oldStep];
}
if (selectedChain[panelLevel + 1] === oldStep) {
selectedChain[panelLevel + 1] = next;
}
if (panelLevel === 0) syncSubFlowLinks(panelPivot);
saveFlowModel();
renderAll();
},
onDelete: (idx) => {
const targetList = panelLevel === 0 ? (SUB_FLOW[panelPivot] || []) : (DRILL_FLOW[panelPivot] || []);
const target = targetList[idx];
if (!confirm(`'${target}' 스텝을 삭제할까요?`)) return;
targetList.splice(idx, 1);
if (selectedChain[panelLevel + 1] === target) {
selectedChain = selectedChain.slice(0, panelLevel + 1);
closeEditor();
}
if (panelLevel === 0) syncSubFlowLinks(panelPivot);
saveFlowModel();
renderAll();
},
onInsertAfter: (idx) => {
const targetList = panelLevel === 0 ? (SUB_FLOW[panelPivot] || []) : (DRILL_FLOW[panelPivot] || []);
const next = askStepLabel('');
if (!next) return;
targetList.splice(idx + 1, 0, next);
if (!DRILL_FLOW[next]) DRILL_FLOW[next] = [];
if (panelLevel === 0) syncSubFlowLinks(panelPivot);
saveFlowModel();
renderAll();
},
onAddEnd: () => {
const targetList = panelLevel === 0 ? (SUB_FLOW[panelPivot] || []) : (DRILL_FLOW[panelPivot] || []);
const next = askStepLabel('');
if (!next) return;
targetList.push(next);
if (!DRILL_FLOW[next]) DRILL_FLOW[next] = [];
if (panelLevel === 0) syncSubFlowLinks(panelPivot);
saveFlowModel();
renderAll();
}
});
drillColumnsEl.appendChild(panel);
if (!selectedChain[panelLevel + 1]) {
break;
}
level += 1;
}
}
function renderAll() {
renderMain();
renderDrillColumns();
}
editorCloseEl.addEventListener('click', closeEditor);
editorClearImageEl.addEventListener('click', () => {
if (!editingKey) return;
const info = ensureMeta(editingKey);
if (!info.images.length) return;
info.images.splice(currentImageIndex, 1);
if (currentImageIndex >= info.images.length) {
currentImageIndex = Math.max(0, info.images.length - 1);
}
info.image = info.images[0] || '';
saveStepMeta();
renderEditorPreview(editorTitleEl.textContent.replace(' DETAIL', ''));
});
editorImageInputEl.addEventListener('change', (e) => {
if (!editingKey) return;
const files = Array.from(e.target.files || []);
if (!files.length) return;
const imageFiles = files.filter((file) => file.type.startsWith('image/'));
if (!imageFiles.length) {
alert('이미지 파일만 선택할 수 있습니다.');
e.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);
info.images.push(...validImages);
info.image = info.images[0] || '';
if (editingKey === targetKey) {
currentImageIndex = info.images.length - validImages.length;
renderEditorPreview(editorTitleEl.textContent.replace(' DETAIL', ''));
}
saveStepMeta();
});
e.target.value = '';
});
editorPrevImageEl.addEventListener('click', () => {
if (!editingKey || currentImageIndex <= 0) return;
currentImageIndex -= 1;
renderEditorPreview(editorTitleEl.textContent.replace(' DETAIL', ''));
});
editorNextImageEl.addEventListener('click', () => {
if (!editingKey) return;
const info = ensureMeta(editingKey);
if (currentImageIndex >= info.images.length - 1) return;
currentImageIndex += 1;
renderEditorPreview(editorTitleEl.textContent.replace(' DETAIL', ''));
});
editorNoteInputEl.addEventListener('input', () => {
if (!editingKey) return;
const info = ensureMeta(editingKey);
info.note = editorNoteInputEl.value;
saveStepMeta();
});
editorPathInputEl.addEventListener('input', () => {
if (!editingKey) return;
const info = ensureMeta(editingKey);
info.path = editorPathInputEl.value;
saveStepMeta();
});
editorTeamInputEl.addEventListener('input', () => {
if (!editingKey || !isSecondFlowKey(editingKey)) return;
const info = ensureMeta(editingKey);
info.team = editorTeamInputEl.value;
saveStepMeta();
renderAll();
});
editorSystemInputEl.addEventListener('input', () => {
if (!editingKey || !isSecondFlowKey(editingKey)) return;
const info = ensureMeta(editingKey);
info.system = editorSystemInputEl.value;
saveStepMeta();
renderAll();
});
editModeBtnEl.addEventListener('click', () => {
editMode = !editMode;
editModeBtnEl.classList.toggle('active', editMode);
editModeBtnEl.textContent = editMode ? '편집중' : '편집모드';
renderAll();
});
imageModalCloseEl.addEventListener('click', closeImageModal);
imageModalPrevEl.addEventListener('click', () => {
if (!imageModalEl.classList.contains('open')) return;
modalImageIndex -= 1;
updateImageModalView('확대 이미지');
});
imageModalNextEl.addEventListener('click', () => {
if (!imageModalEl.classList.contains('open')) return;
modalImageIndex += 1;
updateImageModalView('확대 이미지');
});
imageModalEl.addEventListener('click', (e) => {
if (e.target === imageModalEl) closeImageModal();
});
document.addEventListener('keydown', (e) => {
if (imageModalEl.classList.contains('open')) {
if (e.key === 'ArrowLeft') {
modalImageIndex -= 1;
updateImageModalView('확대 이미지');
}
if (e.key === 'ArrowRight') {
modalImageIndex += 1;
updateImageModalView('확대 이미지');
}
}
if (e.key === 'Escape' && imageModalEl.classList.contains('open')) {
closeImageModal();
}
if (e.key === 'Escape' && sitemapModalEl.classList.contains('open')) {
closeSitemapModal();
}
});
sitemapOpenBtnEl.addEventListener('click', openSitemapModal);
dataExportBtnEl.addEventListener('click', exportAllData);
dataImportBtnEl.addEventListener('click', () => dataImportInputEl.click());
dataImportInputEl.addEventListener('change', (e) => {
const [file] = e.target.files || [];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
importAllData(String(reader.result || ''));
};
reader.onerror = () => {
alert('파일을 읽을 수 없습니다.');
};
reader.readAsText(file, 'utf-8');
e.target.value = '';
});
sitemapCloseBtnEl.addEventListener('click', closeSitemapModal);
sitemapTeamSelectEl.addEventListener('change', () => {
sitemapFilterTeam = sitemapTeamSelectEl.value;
renderSiteMapFull();
});
sitemapSystemSelectEl.addEventListener('change', () => {
sitemapFilterSystem = sitemapSystemSelectEl.value;
renderSiteMapFull();
});
sitemapModalEl.addEventListener('click', (e) => {
if (e.target === sitemapModalEl) closeSitemapModal();
});
loadFlowModel();
loadStepMeta();
renderAll();
</script>
</body>
</html>