G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현 G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건) G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST) G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직 G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트 G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트 G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트 G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트 G-10: core/push_notify.py + routers/push.py + PushSubscription 모델 G-11: approvals 다중승인 (위임/서명/기한초과/마감연장) G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh 하네스: guardia-orchestrator 확장기능 Phase 반영 봇명령어: /sr /status /license /bulk 슬래시 명령어 추가 설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1243 lines
64 KiB
HTML
1243 lines
64 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>GUARDiA — SI 프로젝트 관리</title>
|
||
<link rel="stylesheet" href="/static/style.css">
|
||
<style>
|
||
:root {
|
||
--bg: var(--main-bg);
|
||
--bg-card: var(--card-bg);
|
||
--bg-input: var(--input-bg);
|
||
--bg-hover: var(--sidebar-hover-bg);
|
||
--text: var(--text-primary);
|
||
}
|
||
.page-wrap { display:flex; flex-direction:column; height:100vh; overflow:hidden; }
|
||
.topnav { display:flex; align-items:center; gap:16px; padding:0 24px; height:52px; background:var(--bg-card); border-bottom:1px solid var(--border); flex-shrink:0; }
|
||
.topnav-logo { font-weight:700; font-size:16px; color:var(--accent); text-decoration:none; }
|
||
.topnav-links { display:flex; gap:4px; flex-wrap:nowrap; }
|
||
.topnav-link { padding:5px 10px; font-size:12px; color:var(--text-muted); text-decoration:none; border-radius:6px; transition:background .15s,color .15s; white-space:nowrap; }
|
||
.topnav-link:hover,.topnav-link.active { background:var(--bg-hover); color:var(--text); }
|
||
.topnav-right { margin-left:auto; display:flex; align-items:center; gap:12px; }
|
||
|
||
/* ── 레이아웃: 사이드바 + 메인 ── */
|
||
.si-layout { display:flex; flex:1; overflow:hidden; }
|
||
.si-sidebar { width:260px; min-width:220px; background:var(--bg-card); border-right:1px solid var(--border); display:flex; flex-direction:column; overflow:hidden; }
|
||
.si-sidebar-header { padding:14px 16px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; }
|
||
.si-sidebar-header h3 { font-size:14px; font-weight:600; color:var(--text); }
|
||
.si-proj-list { flex:1; overflow-y:auto; padding:8px; }
|
||
.si-proj-item { padding:10px 12px; border-radius:8px; cursor:pointer; transition:background .15s; margin-bottom:4px; border:1px solid transparent; }
|
||
.si-proj-item:hover { background:var(--bg-hover); }
|
||
.si-proj-item.active { background:var(--bg-hover); border-color:var(--accent); }
|
||
.si-proj-name { font-size:13px; font-weight:600; color:var(--text); }
|
||
.si-proj-phase { font-size:11px; color:var(--text-muted); margin-top:2px; }
|
||
.si-proj-progress { margin-top:6px; background:var(--border); border-radius:4px; height:4px; }
|
||
.si-proj-progress-bar { height:4px; border-radius:4px; background:var(--accent); transition:width .4s; }
|
||
|
||
/* ── 메인 영역 ── */
|
||
.si-main { flex:1; display:flex; flex-direction:column; overflow:hidden; }
|
||
.si-main-header { padding:16px 24px; border-bottom:1px solid var(--border); background:var(--bg-card); flex-shrink:0; }
|
||
.si-main-title { font-size:18px; font-weight:700; color:var(--text); }
|
||
.si-main-meta { font-size:12px; color:var(--text-muted); margin-top:4px; }
|
||
.si-tabs { display:flex; gap:0; padding:0 24px; background:var(--bg-card); border-bottom:1px solid var(--border); flex-shrink:0; overflow-x:auto; }
|
||
.si-tab-btn { padding:10px 16px; font-size:13px; background:none; border:none; color:var(--text-muted); cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-1px; white-space:nowrap; }
|
||
.si-tab-btn.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
|
||
.si-tab-content { display:none; flex:1; overflow-y:auto; padding:24px; }
|
||
.si-tab-content.active { display:block; }
|
||
|
||
/* ── 공통 ── */
|
||
.toolbar { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:16px; align-items:center; }
|
||
.search-box { background:var(--bg-input); border:1px solid var(--border); border-radius:6px; padding:7px 12px; color:var(--text); font-size:13px; min-width:180px; }
|
||
.filter-select { background:var(--bg-input); border:1px solid var(--border); border-radius:6px; padding:7px 10px; color:var(--text); font-size:13px; }
|
||
.btn { padding:7px 16px; border-radius:6px; border:none; font-size:13px; font-weight:600; cursor:pointer; }
|
||
.btn-primary { background:var(--accent); color:#fff; }
|
||
.btn-sm { padding:5px 12px; font-size:12px; }
|
||
.btn-ghost { background:transparent; border:1px solid var(--border); color:var(--text-muted); }
|
||
.btn-danger { background:#dc2626; color:#fff; }
|
||
.sr-table { width:100%; border-collapse:collapse; }
|
||
.sr-table th { background:var(--bg-card); color:var(--text-muted); font-size:12px; font-weight:600; padding:10px 12px; text-align:left; border-bottom:1px solid var(--border); }
|
||
.sr-table td { padding:10px 12px; border-bottom:1px solid var(--border); font-size:13px; color:var(--text); vertical-align:top; }
|
||
.sr-table tr:hover td { background:var(--bg-hover); cursor:pointer; }
|
||
.badge { display:inline-block; font-size:11px; font-weight:600; padding:2px 8px; border-radius:4px; }
|
||
.modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.5); z-index:100; display:none; align-items:center; justify-content:center; }
|
||
.modal-overlay.open { display:flex; }
|
||
.modal-box { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:28px; width:600px; max-width:95vw; max-height:88vh; overflow-y:auto; position:relative; }
|
||
.modal-box h2 { font-size:18px; margin-bottom:18px; color:var(--text); }
|
||
.modal-close { position:absolute; top:14px; right:16px; background:none; border:none; font-size:22px; cursor:pointer; color:var(--text-muted); }
|
||
label { display:flex; flex-direction:column; gap:4px; font-size:13px; color:var(--text-muted); margin-bottom:12px; }
|
||
label input, label select, label textarea { background:var(--bg-input); border:1px solid var(--border); border-radius:6px; padding:8px 10px; color:var(--text); font-size:13px; }
|
||
.form-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||
.info-grid { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:20px; }
|
||
.info-card { background:var(--bg-card); border:1px solid var(--border); border-radius:8px; padding:14px 16px; }
|
||
.info-card-lbl { font-size:11px; color:var(--text-muted); margin-bottom:4px; }
|
||
.info-card-val { font-size:20px; font-weight:700; color:var(--accent); }
|
||
.empty-state { text-align:center; padding:60px 20px; color:var(--text-muted); }
|
||
.detail-section { background:var(--bg-card); border:1px solid var(--border); border-radius:8px; padding:16px; margin-bottom:16px; }
|
||
.detail-section h4 { font-size:13px; font-weight:600; color:var(--text); margin-bottom:12px; }
|
||
|
||
/* ── 프로젝트 단계 배지 ── */
|
||
.phase-INITIATION { background:#1e3a5f; color:#60a5fa; }
|
||
.phase-ANALYSIS { background:#164e63; color:#22d3ee; }
|
||
.phase-DESIGN { background:#14532d; color:#4ade80; }
|
||
.phase-IMPLEMENTATION { background:#713f12; color:#fbbf24; }
|
||
.phase-DEPLOYMENT { background:#4c1d95; color:#c084fc; }
|
||
.phase-STABILIZATION { background:#1e3a5f; color:#93c5fd; }
|
||
.phase-CLOSED { background:#1f2937; color:#6b7280; }
|
||
|
||
/* ── WBS 트리 ── */
|
||
.wbs-tree { font-size:13px; }
|
||
.wbs-row { display:flex; align-items:center; padding:7px 10px; border-radius:6px; gap:8px; margin:1px 0; }
|
||
.wbs-row:hover { background:var(--bg-hover); }
|
||
.wbs-indent { width:20px; flex-shrink:0; }
|
||
.wbs-code { font-family:monospace; color:var(--text-muted); min-width:80px; font-size:12px; }
|
||
.wbs-name { flex:1; color:var(--text); }
|
||
.wbs-progress-wrap { width:100px; background:var(--border); border-radius:4px; height:6px; }
|
||
.wbs-progress-bar { height:6px; border-radius:4px; background:var(--accent); }
|
||
.wbs-status { font-size:11px; color:var(--text-muted); min-width:70px; text-align:right; }
|
||
.wbs-level-1 { font-weight:700; background:var(--bg-card); }
|
||
.wbs-level-2 { font-weight:600; }
|
||
|
||
/* ── 리스크 매트릭스 ── */
|
||
.risk-matrix { display:grid; grid-template-columns:40px repeat(5,1fr); grid-template-rows:repeat(5,1fr) 40px; gap:2px; width:340px; height:340px; }
|
||
.risk-cell { border-radius:4px; display:flex; align-items:center; justify-content:center; font-size:11px; cursor:default; position:relative; }
|
||
.risk-axis-lbl { font-size:10px; color:var(--text-muted); display:flex; align-items:center; justify-content:center; }
|
||
.risk-LOW { background:#14532d44; }
|
||
.risk-MEDIUM { background:#71380f44; }
|
||
.risk-HIGH { background:#7c2d1244; }
|
||
.risk-CRITICAL { background:#7f1d1d44; }
|
||
.risk-dot { width:8px; height:8px; border-radius:50%; background:var(--accent); position:absolute; }
|
||
|
||
/* ── 마일스톤 타임라인 ── */
|
||
.milestone-timeline { position:relative; padding-left:24px; }
|
||
.milestone-timeline::before { content:''; position:absolute; left:8px; top:0; bottom:0; width:2px; background:var(--border); }
|
||
.milestone-item { position:relative; padding:12px 0 12px 20px; }
|
||
.milestone-item::before { content:''; position:absolute; left:-20px; top:20px; width:10px; height:10px; border-radius:50%; background:var(--accent); border:2px solid var(--bg-card); }
|
||
.milestone-item.done::before { background:#4ade80; }
|
||
.milestone-item.overdue::before { background:#f87171; }
|
||
.milestone-date { font-size:11px; color:var(--text-muted); }
|
||
.milestone-name { font-size:14px; font-weight:600; color:var(--text); margin:2px 0; }
|
||
.milestone-deliverables { font-size:12px; color:var(--text-muted); }
|
||
|
||
/* ── 테스트 서브탭 ── */
|
||
.sub-tabs { display:flex; gap:4px; margin-bottom:16px; }
|
||
.sub-tab-btn { padding:6px 14px; font-size:12px; background:var(--bg-card); border:1px solid var(--border); border-radius:6px; color:var(--text-muted); cursor:pointer; }
|
||
.sub-tab-btn.active { background:var(--accent); color:#fff; border-color:var(--accent); }
|
||
.sub-tab-content { display:none; }
|
||
.sub-tab-content.active { display:block; }
|
||
|
||
/* ── 단계 전환 프로그레스 ── */
|
||
.phase-steps { display:flex; align-items:center; gap:0; margin-bottom:20px; overflow-x:auto; padding-bottom:4px; }
|
||
.phase-step { display:flex; align-items:center; }
|
||
.phase-step-dot { width:28px; height:28px; border-radius:50%; border:2px solid var(--border); background:var(--bg-card); display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:600; color:var(--text-muted); flex-shrink:0; }
|
||
.phase-step-dot.done { background:#4ade80; border-color:#4ade80; color:#000; }
|
||
.phase-step-dot.current { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||
.phase-step-lbl { font-size:10px; color:var(--text-muted); text-align:center; margin-top:4px; white-space:nowrap; }
|
||
.phase-step-line { width:32px; height:2px; background:var(--border); flex-shrink:0; }
|
||
.phase-step-line.done { background:#4ade80; }
|
||
.phase-step-wrap { display:flex; flex-direction:column; align-items:center; }
|
||
|
||
/* status badges */
|
||
.st-OPEN,.st-TODO { background:#1e3a5f22; color:#60a5fa; }
|
||
.st-IN_PROGRESS,.st-WIP { background:#71380f22; color:#fbbf24; }
|
||
.st-DONE,.st-CLOSED,.st-RESOLVED { background:#14532d22; color:#4ade80; }
|
||
.st-BLOCKED,.st-CRITICAL { background:#7f1d1d22; color:#f87171; }
|
||
.st-MITIGATED { background:#14532d22; color:#86efac; }
|
||
.pri-HIGH { background:#7f1d1d22; color:#f87171; }
|
||
.pri-MEDIUM { background:#71380f22; color:#fbbf24; }
|
||
.pri-LOW { background:#14532d22; color:#4ade80; }
|
||
.cr-PENDING { background:#1e3a5f22; color:#93c5fd; }
|
||
.cr-APPROVED { background:#14532d22; color:#4ade80; }
|
||
.cr-REJECTED { background:#7f1d1d22; color:#f87171; }
|
||
.cr-IMPLEMENTED { background:#4c1d9522; color:#c084fc; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page-wrap">
|
||
|
||
<!-- 상단 네비게이션 -->
|
||
<nav class="topnav">
|
||
<a class="topnav-logo" href="/">GUARDiA ITSM</a>
|
||
<div class="topnav-links">
|
||
<a class="topnav-link" href="/">대시보드</a>
|
||
<a class="topnav-link" href="/incidents">장애관리</a>
|
||
<a class="topnav-link" href="/ssl">SSL</a>
|
||
<a class="topnav-link" href="/pm">PM점검</a>
|
||
<a class="topnav-link" href="/oncall">온콜</a>
|
||
<a class="topnav-link" href="/batch">배치</a>
|
||
<a class="topnav-link" href="/vibe">바이브</a>
|
||
<a class="topnav-link active" href="/si">SI</a>
|
||
<a class="topnav-link" href="/agents">AI에이전트</a>
|
||
</div>
|
||
<div class="topnav-right">
|
||
<span id="userLabel" style="font-size:13px;color:var(--text-muted)"></span>
|
||
<button class="btn btn-ghost btn-sm" onclick="logout()">로그아웃</button>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- 메인 레이아웃 -->
|
||
<div class="si-layout">
|
||
|
||
<!-- 사이드바: 프로젝트 목록 -->
|
||
<aside class="si-sidebar">
|
||
<div class="si-sidebar-header">
|
||
<h3>SI 프로젝트</h3>
|
||
<button class="btn btn-primary btn-sm" onclick="openCreateProject()">+ 새 프로젝트</button>
|
||
</div>
|
||
<div style="padding:8px 12px;">
|
||
<input type="text" class="search-box" style="width:100%;box-sizing:border-box;" placeholder="🔍 프로젝트 검색..." oninput="filterProjects(this.value)">
|
||
</div>
|
||
<div class="si-proj-list" id="projList">
|
||
<div class="empty-state" style="padding:30px 10px;font-size:12px;">프로젝트를 불러오는 중...</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- 메인 영역 -->
|
||
<div class="si-main">
|
||
<div class="si-main-header" id="projHeader">
|
||
<div class="si-main-title">← 왼쪽에서 프로젝트를 선택하세요</div>
|
||
<div class="si-main-meta">SI 프로젝트를 선택하면 상세 정보를 확인할 수 있습니다.</div>
|
||
</div>
|
||
|
||
<!-- 탭 버튼 -->
|
||
<div class="si-tabs" id="siTabs" style="display:none;">
|
||
<button class="si-tab-btn active" onclick="switchTab('overview')">📋 개요</button>
|
||
<button class="si-tab-btn" onclick="switchTab('wbs')">📊 WBS</button>
|
||
<button class="si-tab-btn" onclick="switchTab('requirements')">📝 요구사항</button>
|
||
<button class="si-tab-btn" onclick="switchTab('issues')">⚠️ 이슈</button>
|
||
<button class="si-tab-btn" onclick="switchTab('risks')">🎯 리스크</button>
|
||
<button class="si-tab-btn" onclick="switchTab('milestones')">🏁 마일스톤</button>
|
||
<button class="si-tab-btn" onclick="switchTab('cr')">🔄 변경요청</button>
|
||
<button class="si-tab-btn" onclick="switchTab('tests')">🧪 테스트</button>
|
||
</div>
|
||
|
||
<!-- 탭 컨텐츠들 -->
|
||
<div id="tabContents" style="flex:1;overflow:hidden;display:flex;flex-direction:column;">
|
||
|
||
<!-- ──────── 개요 탭 ──────── -->
|
||
<div id="tab-overview" class="si-tab-content active">
|
||
<div id="overviewContent"><div class="empty-state">프로젝트를 선택하세요.</div></div>
|
||
</div>
|
||
|
||
<!-- ──────── WBS 탭 ──────── -->
|
||
<div id="tab-wbs" class="si-tab-content">
|
||
<div class="toolbar">
|
||
<button class="btn btn-primary btn-sm" onclick="openAddWbs()">+ WBS 항목</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="loadWbs()">↻ 새로고침</button>
|
||
<span style="font-size:12px;color:var(--text-muted);margin-left:auto;" id="wbsCompletionLabel"></span>
|
||
</div>
|
||
<div id="wbsTree"><div class="empty-state">WBS 데이터를 불러오는 중...</div></div>
|
||
</div>
|
||
|
||
<!-- ──────── 요구사항 탭 ──────── -->
|
||
<div id="tab-requirements" class="si-tab-content">
|
||
<div class="toolbar">
|
||
<input type="text" class="search-box" placeholder="🔍 요구사항 검색..." oninput="filterTable('reqTable',this.value)">
|
||
<select class="filter-select" onchange="filterReqs(this.value)">
|
||
<option value="">전체 유형</option>
|
||
<option value="FUNCTIONAL">기능적</option>
|
||
<option value="NON_FUNCTIONAL">비기능적</option>
|
||
<option value="CONSTRAINT">제약사항</option>
|
||
</select>
|
||
<button class="btn btn-primary btn-sm" onclick="openAddReq()">+ 요구사항 등록</button>
|
||
</div>
|
||
<table class="sr-table" id="reqTable">
|
||
<thead><tr><th>코드</th><th>제목</th><th>유형</th><th>우선순위</th><th>확정여부</th><th>WBS 연결</th></tr></thead>
|
||
<tbody id="reqBody"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- ──────── 이슈 탭 ──────── -->
|
||
<div id="tab-issues" class="si-tab-content">
|
||
<div class="toolbar">
|
||
<input type="text" class="search-box" placeholder="🔍 이슈 검색..." oninput="filterTable('issueTable',this.value)">
|
||
<select class="filter-select" onchange="filterIssues(this.value)">
|
||
<option value="">전체 상태</option>
|
||
<option value="OPEN">OPEN</option>
|
||
<option value="IN_PROGRESS">진행중</option>
|
||
<option value="RESOLVED">해결</option>
|
||
<option value="CLOSED">종료</option>
|
||
</select>
|
||
<button class="btn btn-primary btn-sm" onclick="openAddIssue()">+ 이슈 등록</button>
|
||
</div>
|
||
<table class="sr-table" id="issueTable">
|
||
<thead><tr><th>번호</th><th>제목</th><th>유형</th><th>심각도</th><th>상태</th><th>담당자</th><th>기한</th></tr></thead>
|
||
<tbody id="issueBody"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- ──────── 리스크 탭 ──────── -->
|
||
<div id="tab-risks" class="si-tab-content">
|
||
<div style="display:flex;gap:24px;flex-wrap:wrap;">
|
||
<!-- 위험 매트릭스 -->
|
||
<div>
|
||
<div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:12px;">위험 매트릭스 (확률 × 영향도)</div>
|
||
<div style="display:flex;gap:8px;align-items:flex-start;">
|
||
<div style="writing-mode:vertical-rl;transform:rotate(180deg);font-size:11px;color:var(--text-muted);text-align:center;padding:10px 0;">← 확률(Probability)</div>
|
||
<div>
|
||
<div id="riskMatrix" class="risk-matrix"></div>
|
||
<div style="display:flex;gap:0;margin-top:4px;padding-left:40px;">
|
||
<div style="flex:1;text-align:center;font-size:10px;color:var(--text-muted);">1</div>
|
||
<div style="flex:1;text-align:center;font-size:10px;color:var(--text-muted);">2</div>
|
||
<div style="flex:1;text-align:center;font-size:10px;color:var(--text-muted);">3</div>
|
||
<div style="flex:1;text-align:center;font-size:10px;color:var(--text-muted);">4</div>
|
||
<div style="flex:1;text-align:center;font-size:10px;color:var(--text-muted);">5</div>
|
||
</div>
|
||
<div style="text-align:center;font-size:11px;color:var(--text-muted);margin-top:2px;">영향도(Impact) →</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 리스크 목록 -->
|
||
<div style="flex:1;min-width:300px;">
|
||
<div class="toolbar">
|
||
<button class="btn btn-primary btn-sm" onclick="openAddRisk()">+ 리스크 등록</button>
|
||
</div>
|
||
<table class="sr-table" id="riskTable">
|
||
<thead><tr><th>제목</th><th>확률</th><th>영향</th><th>점수</th><th>등급</th><th>상태</th></tr></thead>
|
||
<tbody id="riskBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ──────── 마일스톤 탭 ──────── -->
|
||
<div id="tab-milestones" class="si-tab-content">
|
||
<div class="toolbar">
|
||
<button class="btn btn-primary btn-sm" onclick="openAddMilestone()">+ 마일스톤 등록</button>
|
||
</div>
|
||
<div id="milestoneTimeline" class="milestone-timeline"></div>
|
||
</div>
|
||
|
||
<!-- ──────── CR 탭 ──────── -->
|
||
<div id="tab-cr" class="si-tab-content">
|
||
<div class="toolbar">
|
||
<input type="text" class="search-box" placeholder="🔍 변경요청 검색..." oninput="filterTable('crTable',this.value)">
|
||
<select class="filter-select" onchange="filterCRs(this.value)">
|
||
<option value="">전체 상태</option>
|
||
<option value="PENDING">검토중</option>
|
||
<option value="APPROVED">승인</option>
|
||
<option value="REJECTED">반려</option>
|
||
<option value="IMPLEMENTED">구현완료</option>
|
||
</select>
|
||
<button class="btn btn-primary btn-sm" onclick="openAddCR()">+ CR 등록</button>
|
||
</div>
|
||
<table class="sr-table" id="crTable">
|
||
<thead><tr><th>번호</th><th>제목</th><th>유형</th><th>영향도</th><th>상태</th><th>요청일</th><th>요청자</th></tr></thead>
|
||
<tbody id="crBody"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- ──────── 테스트 탭 ──────── -->
|
||
<div id="tab-tests" class="si-tab-content">
|
||
<div class="sub-tabs">
|
||
<button class="sub-tab-btn active" onclick="switchSubTab('testPlan')">테스트 계획</button>
|
||
<button class="sub-tab-btn" onclick="switchSubTab('testCase')">테스트 케이스</button>
|
||
<button class="sub-tab-btn" onclick="switchSubTab('defect')">결함 관리</button>
|
||
</div>
|
||
|
||
<div id="sub-testPlan" class="sub-tab-content active">
|
||
<div class="toolbar">
|
||
<button class="btn btn-primary btn-sm" onclick="openAddTestPlan()">+ 테스트 계획 등록</button>
|
||
</div>
|
||
<table class="sr-table" id="testPlanTable">
|
||
<thead><tr><th>계획명</th><th>유형</th><th>시작일</th><th>종료일</th><th>담당자</th><th>상태</th></tr></thead>
|
||
<tbody id="testPlanBody"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div id="sub-testCase" class="sub-tab-content">
|
||
<div class="toolbar">
|
||
<select class="filter-select" id="tcPlanFilter" onchange="loadTestCases()">
|
||
<option value="">테스트 계획 선택</option>
|
||
</select>
|
||
<button class="btn btn-primary btn-sm" onclick="openAddTestCase()">+ 케이스 등록</button>
|
||
</div>
|
||
<table class="sr-table" id="testCaseTable">
|
||
<thead><tr><th>코드</th><th>제목</th><th>우선순위</th><th>상태</th><th>실행결과</th></tr></thead>
|
||
<tbody id="testCaseBody"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div id="sub-defect" class="sub-tab-content">
|
||
<div class="toolbar">
|
||
<button class="btn btn-primary btn-sm" onclick="openAddDefect()">+ 결함 등록</button>
|
||
</div>
|
||
<table class="sr-table" id="defectTable">
|
||
<thead><tr><th>번호</th><th>제목</th><th>심각도</th><th>상태</th><th>연결 케이스</th><th>발견일</th></tr></thead>
|
||
<tbody id="defectBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div><!-- /tabContents -->
|
||
</div><!-- /si-main -->
|
||
</div><!-- /si-layout -->
|
||
</div><!-- /page-wrap -->
|
||
|
||
<!-- ───────────── 모달들 ───────────── -->
|
||
|
||
<!-- 프로젝트 등록/수정 모달 -->
|
||
<div id="projModal" class="modal-overlay">
|
||
<div class="modal-box">
|
||
<button class="modal-close" onclick="closeProjModal()">×</button>
|
||
<h2 id="projModalTitle">SI 프로젝트 등록</h2>
|
||
<input type="hidden" id="projId">
|
||
<div class="form-row">
|
||
<label>프로젝트명 *<input id="proj_name" placeholder="예: 홈페이지 재구축 SI"></label>
|
||
<label>발주기관<input id="proj_client" placeholder="발주기관명"></label>
|
||
</div>
|
||
<div class="form-row">
|
||
<label>시작일<input type="date" id="proj_start"></label>
|
||
<label>종료일<input type="date" id="proj_end"></label>
|
||
</div>
|
||
<div class="form-row">
|
||
<label>계약금액(원)<input type="number" id="proj_budget" placeholder="0"></label>
|
||
<label>PM<input id="proj_pm" placeholder="PM 이름 또는 이메일"></label>
|
||
</div>
|
||
<label>설명<textarea id="proj_desc" rows="3" placeholder="프로젝트 개요..."></textarea></label>
|
||
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px;">
|
||
<button class="btn btn-ghost" onclick="closeProjModal()">취소</button>
|
||
<button class="btn btn-primary" onclick="submitProject()">저장</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WBS 등록 모달 -->
|
||
<div id="wbsModal" class="modal-overlay">
|
||
<div class="modal-box">
|
||
<button class="modal-close" onclick="closeModal('wbsModal')">×</button>
|
||
<h2>WBS 항목 등록</h2>
|
||
<div class="form-row">
|
||
<label>WBS 코드<input id="wbs_code" placeholder="예: 1.1.1"></label>
|
||
<label>부모 항목 ID<input type="number" id="wbs_parent" placeholder="없으면 빈칸"></label>
|
||
</div>
|
||
<label>항목명 *<input id="wbs_name" placeholder="작업명"></label>
|
||
<div class="form-row">
|
||
<label>시작일<input type="date" id="wbs_start"></label>
|
||
<label>종료일<input type="date" id="wbs_end"></label>
|
||
</div>
|
||
<div class="form-row">
|
||
<label>담당자<input id="wbs_assignee" placeholder="담당자"></label>
|
||
<label>초기 진척률 (%)<input type="number" id="wbs_progress" value="0" min="0" max="100"></label>
|
||
</div>
|
||
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px;">
|
||
<button class="btn btn-ghost" onclick="closeModal('wbsModal')">취소</button>
|
||
<button class="btn btn-primary" onclick="submitWbs()">저장</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 요구사항 등록 모달 -->
|
||
<div id="reqModal" class="modal-overlay">
|
||
<div class="modal-box">
|
||
<button class="modal-close" onclick="closeModal('reqModal')">×</button>
|
||
<h2>요구사항 등록</h2>
|
||
<div class="form-row">
|
||
<label>코드<input id="req_code" placeholder="예: REQ-001"></label>
|
||
<label>유형<select id="req_type">
|
||
<option value="FUNCTIONAL">기능적</option>
|
||
<option value="NON_FUNCTIONAL">비기능적</option>
|
||
<option value="CONSTRAINT">제약사항</option>
|
||
</select></label>
|
||
</div>
|
||
<label>제목 *<input id="req_title" placeholder="요구사항 제목"></label>
|
||
<label>설명<textarea id="req_desc" rows="3" placeholder="상세 설명..."></textarea></label>
|
||
<div class="form-row">
|
||
<label>우선순위<select id="req_priority">
|
||
<option value="HIGH">HIGH</option>
|
||
<option value="MEDIUM" selected>MEDIUM</option>
|
||
<option value="LOW">LOW</option>
|
||
</select></label>
|
||
<label>출처<input id="req_source" placeholder="예: RFP 3.1절"></label>
|
||
</div>
|
||
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px;">
|
||
<button class="btn btn-ghost" onclick="closeModal('reqModal')">취소</button>
|
||
<button class="btn btn-primary" onclick="submitReq()">저장</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 이슈 등록 모달 -->
|
||
<div id="issueModal" class="modal-overlay">
|
||
<div class="modal-box">
|
||
<button class="modal-close" onclick="closeModal('issueModal')">×</button>
|
||
<h2>이슈 등록</h2>
|
||
<label>제목 *<input id="iss_title" placeholder="이슈 제목"></label>
|
||
<div class="form-row">
|
||
<label>유형<select id="iss_type">
|
||
<option value="SCOPE">범위 변경</option>
|
||
<option value="SCHEDULE">일정 지연</option>
|
||
<option value="RESOURCE">자원 부족</option>
|
||
<option value="TECHNICAL">기술적 문제</option>
|
||
<option value="COMMUNICATION">커뮤니케이션</option>
|
||
<option value="OTHER">기타</option>
|
||
</select></label>
|
||
<label>심각도<select id="iss_severity">
|
||
<option value="CRITICAL">CRITICAL</option>
|
||
<option value="HIGH">HIGH</option>
|
||
<option value="MEDIUM" selected>MEDIUM</option>
|
||
<option value="LOW">LOW</option>
|
||
</select></label>
|
||
</div>
|
||
<div class="form-row">
|
||
<label>담당자<input id="iss_assignee" placeholder="담당자"></label>
|
||
<label>기한<input type="date" id="iss_due"></label>
|
||
</div>
|
||
<label>설명<textarea id="iss_desc" rows="3"></textarea></label>
|
||
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px;">
|
||
<button class="btn btn-ghost" onclick="closeModal('issueModal')">취소</button>
|
||
<button class="btn btn-primary" onclick="submitIssue()">저장</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 리스크 등록 모달 -->
|
||
<div id="riskModal" class="modal-overlay">
|
||
<div class="modal-box">
|
||
<button class="modal-close" onclick="closeModal('riskModal')">×</button>
|
||
<h2>리스크 등록</h2>
|
||
<label>제목 *<input id="risk_title" placeholder="리스크 제목"></label>
|
||
<label>설명<textarea id="risk_desc" rows="2"></textarea></label>
|
||
<div class="form-row">
|
||
<label>확률 (1~5)<input type="number" id="risk_prob" min="1" max="5" value="3"></label>
|
||
<label>영향도 (1~5)<input type="number" id="risk_impact" min="1" max="5" value="3"></label>
|
||
</div>
|
||
<div class="form-row">
|
||
<label>담당자<input id="risk_owner" placeholder="리스크 담당자"></label>
|
||
<label>대응전략<select id="risk_strategy">
|
||
<option value="AVOID">회피(Avoid)</option>
|
||
<option value="TRANSFER">전가(Transfer)</option>
|
||
<option value="MITIGATE">경감(Mitigate)</option>
|
||
<option value="ACCEPT">수용(Accept)</option>
|
||
</select></label>
|
||
</div>
|
||
<label>대응방안<textarea id="risk_response" rows="2"></textarea></label>
|
||
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px;">
|
||
<button class="btn btn-ghost" onclick="closeModal('riskModal')">취소</button>
|
||
<button class="btn btn-primary" onclick="submitRisk()">저장</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 마일스톤 등록 모달 -->
|
||
<div id="msModal" class="modal-overlay">
|
||
<div class="modal-box">
|
||
<button class="modal-close" onclick="closeModal('msModal')">×</button>
|
||
<h2>마일스톤 등록</h2>
|
||
<label>마일스톤명 *<input id="ms_name" placeholder="예: 설계 완료"></label>
|
||
<div class="form-row">
|
||
<label>목표일<input type="date" id="ms_due"></label>
|
||
<label>WBS 연결 ID<input id="ms_wbs" placeholder="WBS ID (선택)"></label>
|
||
</div>
|
||
<label>산출물 목록<textarea id="ms_deliverables" rows="2" placeholder="콤마(,)로 구분: 설계서.pptx, ERD.png"></textarea></label>
|
||
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px;">
|
||
<button class="btn btn-ghost" onclick="closeModal('msModal')">취소</button>
|
||
<button class="btn btn-primary" onclick="submitMilestone()">저장</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CR 등록 모달 -->
|
||
<div id="crModal" class="modal-overlay">
|
||
<div class="modal-box">
|
||
<button class="modal-close" onclick="closeModal('crModal')">×</button>
|
||
<h2>변경요청 등록</h2>
|
||
<label>제목 *<input id="cr_title" placeholder="변경 요청 제목"></label>
|
||
<label>변경 내용<textarea id="cr_desc" rows="3" placeholder="변경 내용 상세 설명..."></textarea></label>
|
||
<div class="form-row">
|
||
<label>유형<select id="cr_type">
|
||
<option value="SCOPE">범위 변경</option>
|
||
<option value="SCHEDULE">일정 변경</option>
|
||
<option value="BUDGET">예산 변경</option>
|
||
<option value="REQUIREMENT">요구사항 변경</option>
|
||
</select></label>
|
||
<label>영향도<select id="cr_impact">
|
||
<option value="HIGH">HIGH</option>
|
||
<option value="MEDIUM" selected>MEDIUM</option>
|
||
<option value="LOW">LOW</option>
|
||
</select></label>
|
||
</div>
|
||
<label>요청자<input id="cr_requester" placeholder="요청자 이름"></label>
|
||
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px;">
|
||
<button class="btn btn-ghost" onclick="closeModal('crModal')">취소</button>
|
||
<button class="btn btn-primary" onclick="submitCR()">저장</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 테스트 계획 등록 모달 -->
|
||
<div id="tpModal" class="modal-overlay">
|
||
<div class="modal-box">
|
||
<button class="modal-close" onclick="closeModal('tpModal')">×</button>
|
||
<h2>테스트 계획 등록</h2>
|
||
<label>계획명 *<input id="tp_name" placeholder="예: 단위 테스트 계획"></label>
|
||
<div class="form-row">
|
||
<label>유형<select id="tp_type">
|
||
<option value="UNIT">단위(Unit)</option>
|
||
<option value="INTEGRATION">통합(Integration)</option>
|
||
<option value="SYSTEM">시스템(System)</option>
|
||
<option value="ACCEPTANCE">인수(Acceptance)</option>
|
||
</select></label>
|
||
<label>담당자<input id="tp_owner" placeholder="담당자"></label>
|
||
</div>
|
||
<div class="form-row">
|
||
<label>시작일<input type="date" id="tp_start"></label>
|
||
<label>종료일<input type="date" id="tp_end"></label>
|
||
</div>
|
||
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px;">
|
||
<button class="btn btn-ghost" onclick="closeModal('tpModal')">취소</button>
|
||
<button class="btn btn-primary" onclick="submitTestPlan()">저장</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 결함 등록 모달 -->
|
||
<div id="defectModal" class="modal-overlay">
|
||
<div class="modal-box">
|
||
<button class="modal-close" onclick="closeModal('defectModal')">×</button>
|
||
<h2>결함 등록</h2>
|
||
<label>제목 *<input id="def_title" placeholder="결함 제목"></label>
|
||
<label>재현 절차<textarea id="def_steps" rows="3" placeholder="재현 절차..."></textarea></label>
|
||
<div class="form-row">
|
||
<label>심각도<select id="def_severity">
|
||
<option value="CRITICAL">CRITICAL</option>
|
||
<option value="HIGH">HIGH</option>
|
||
<option value="MEDIUM" selected>MEDIUM</option>
|
||
<option value="LOW">LOW</option>
|
||
</select></label>
|
||
<label>연결 테스트케이스 ID<input type="number" id="def_tc" placeholder="선택"></label>
|
||
</div>
|
||
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px;">
|
||
<button class="btn btn-ghost" onclick="closeModal('defectModal')">취소</button>
|
||
<button class="btn btn-primary" onclick="submitDefect()">저장</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 단계 전환 모달 -->
|
||
<div id="phaseModal" class="modal-overlay">
|
||
<div class="modal-box">
|
||
<button class="modal-close" onclick="closeModal('phaseModal')">×</button>
|
||
<h2>단계 전환</h2>
|
||
<p style="font-size:13px;color:var(--text-muted);margin-bottom:16px;">현재 단계를 다음 단계로 전환합니다. 단계별 체크리스트가 자동 생성됩니다.</p>
|
||
<div style="background:var(--bg-input);border-radius:8px;padding:12px;margin-bottom:16px;font-size:13px;color:var(--text);" id="phaseTransInfo"></div>
|
||
<div style="display:flex;justify-content:flex-end;gap:10px;">
|
||
<button class="btn btn-ghost" onclick="closeModal('phaseModal')">취소</button>
|
||
<button class="btn btn-primary" onclick="confirmPhaseTransition()">전환 확인</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const token = localStorage.getItem('guardia_token');
|
||
if (!token) { location.href = '/login'; }
|
||
document.body.dataset.theme = localStorage.getItem('guardia_theme') || 'dark';
|
||
|
||
async function api(method, path, body) {
|
||
const res = await fetch(path, {
|
||
method,
|
||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||
body: body ? JSON.stringify(body) : undefined
|
||
});
|
||
if (res.status === 401) { localStorage.removeItem('guardia_token'); location.href = '/login'; }
|
||
return res;
|
||
}
|
||
|
||
function logout() {
|
||
localStorage.removeItem('guardia_token');
|
||
location.href = '/login';
|
||
}
|
||
|
||
// 상태
|
||
let allProjects = [];
|
||
let currentProject = null;
|
||
const PHASES = ['INITIATION','ANALYSIS','DESIGN','IMPLEMENTATION','DEPLOYMENT','STABILIZATION','CLOSED'];
|
||
const PHASE_LABELS = { INITIATION:'착수', ANALYSIS:'분석', DESIGN:'설계', IMPLEMENTATION:'구현', DEPLOYMENT:'배포', STABILIZATION:'안정화', CLOSED:'종료' };
|
||
|
||
// ── 초기화 ───────────────────────────────────────────
|
||
async function init() {
|
||
try {
|
||
const me = await api('GET','/api/auth/me');
|
||
if (me.ok) { const u = await me.json(); document.getElementById('userLabel').textContent = u.username || u.email; }
|
||
} catch(e) {}
|
||
await loadProjects();
|
||
}
|
||
|
||
// ── 프로젝트 목록 ─────────────────────────────────────
|
||
async function loadProjects() {
|
||
const res = await api('GET', '/api/si/projects?is_active=true&limit=200');
|
||
if (!res.ok) return;
|
||
allProjects = await res.json();
|
||
renderProjList(allProjects);
|
||
}
|
||
|
||
function renderProjList(list) {
|
||
const el = document.getElementById('projList');
|
||
if (!list.length) { el.innerHTML = '<div class="empty-state" style="padding:30px 10px;font-size:12px;">프로젝트가 없습니다.</div>'; return; }
|
||
el.innerHTML = list.map(p => `
|
||
<div class="si-proj-item${currentProject?.id===p.id?' active':''}" onclick="selectProject(${p.id})">
|
||
<div class="si-proj-name">${esc(p.project_name||p.name||'')}</div>
|
||
<div class="si-proj-phase"><span class="badge phase-${p.phase||p.current_phase||'INITIATION'}">${PHASE_LABELS[p.phase||p.current_phase]||p.phase||''}</span></div>
|
||
<div class="si-proj-progress"><div class="si-proj-progress-bar" style="width:${p.completion_rate||0}%"></div></div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function filterProjects(q) {
|
||
const kw = q.toLowerCase();
|
||
renderProjList(allProjects.filter(p => (p.project_name||p.name||'').toLowerCase().includes(kw)));
|
||
}
|
||
|
||
async function selectProject(id) {
|
||
const res = await api('GET', `/api/si/projects/${id}`);
|
||
if (!res.ok) return;
|
||
currentProject = await res.json();
|
||
renderProjList(allProjects);
|
||
document.getElementById('siTabs').style.display = '';
|
||
updateProjHeader();
|
||
switchTab('overview');
|
||
}
|
||
|
||
function updateProjHeader() {
|
||
const p = currentProject;
|
||
if (!p) return;
|
||
document.getElementById('projHeader').innerHTML = `
|
||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||
<div class="si-main-title">${esc(p.project_name||p.name||'')}</div>
|
||
<span class="badge phase-${p.phase||p.current_phase||'INITIATION'}">${PHASE_LABELS[p.phase||p.current_phase]||''}</span>
|
||
<button class="btn btn-ghost btn-sm" onclick="openPhaseModal()">단계 전환</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="openEditProject()">✏️ 수정</button>
|
||
</div>
|
||
<div class="si-main-meta">
|
||
${p.project_code||''} | ${p.client_name||p.client||''} | ${p.planned_start_date||p.start_date||''} ~ ${p.planned_end_date||p.end_date||''}
|
||
| 완료율 ${p.completion_rate||0}%
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ── 탭 전환 ───────────────────────────────────────────
|
||
function switchTab(name) {
|
||
document.querySelectorAll('.si-tab-btn').forEach((b,i) => {
|
||
const tabs = ['overview','wbs','requirements','issues','risks','milestones','cr','tests'];
|
||
b.classList.toggle('active', tabs[i] === name);
|
||
});
|
||
document.querySelectorAll('.si-tab-content').forEach(c => c.classList.remove('active'));
|
||
const el = document.getElementById('tab-' + name);
|
||
if (el) el.classList.add('active');
|
||
|
||
if (!currentProject) return;
|
||
const loaders = { overview: loadOverview, wbs: loadWbs, requirements: loadRequirements,
|
||
issues: loadIssues, risks: loadRisks, milestones: loadMilestones, cr: loadCRs, tests: loadTests };
|
||
if (loaders[name]) loaders[name]();
|
||
}
|
||
|
||
function switchSubTab(name) {
|
||
document.querySelectorAll('.sub-tab-btn').forEach((b,i) => {
|
||
const tabs = ['testPlan','testCase','defect'];
|
||
b.classList.toggle('active', tabs[i] === name);
|
||
});
|
||
document.querySelectorAll('.sub-tab-content').forEach(c => c.classList.remove('active'));
|
||
const el = document.getElementById('sub-' + name);
|
||
if (el) el.classList.add('active');
|
||
}
|
||
|
||
// ── 개요 ──────────────────────────────────────────────
|
||
async function loadOverview() {
|
||
const pid = currentProject.id;
|
||
const res = await api('GET', `/api/si/projects/${pid}/summary`);
|
||
const p = currentProject;
|
||
|
||
let sumData = null;
|
||
if (res.ok) sumData = await res.json();
|
||
|
||
const phaseIdx = PHASES.indexOf(p.phase||p.current_phase||'INITIATION');
|
||
|
||
document.getElementById('overviewContent').innerHTML = `
|
||
<!-- 진척 단계 -->
|
||
<div class="phase-steps">
|
||
${PHASES.map((ph,i) => `
|
||
<div class="phase-step-wrap">
|
||
<div class="phase-step">
|
||
<div class="phase-step-dot ${i<phaseIdx?'done':i===phaseIdx?'current':''}">${i+1}</div>
|
||
</div>
|
||
<div class="phase-step-lbl">${PHASE_LABELS[ph]}</div>
|
||
</div>
|
||
${i<PHASES.length-1 ? `<div class="phase-step-line ${i<phaseIdx?'done':''}"></div>` : ''}
|
||
`).join('')}
|
||
</div>
|
||
|
||
<!-- 요약 카드 -->
|
||
<div class="info-grid">
|
||
<div class="info-card"><div class="info-card-lbl">완료율</div><div class="info-card-val">${p.completion_rate||0}%</div></div>
|
||
<div class="info-card"><div class="info-card-lbl">WBS 항목</div><div class="info-card-val">${sumData?.wbs_total||'—'}</div></div>
|
||
<div class="info-card"><div class="info-card-lbl">오픈 이슈</div><div class="info-card-val">${sumData?.issue_open||'—'}</div></div>
|
||
<div class="info-card"><div class="info-card-lbl">미처리 리스크</div><div class="info-card-val">${sumData?.risk_open||'—'}</div></div>
|
||
</div>
|
||
|
||
<!-- 상세 정보 -->
|
||
<div class="detail-section">
|
||
<h4>프로젝트 정보</h4>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:13px;">
|
||
<div><span style="color:var(--text-muted)">코드: </span>${esc(p.project_code||'-')}</div>
|
||
<div><span style="color:var(--text-muted)">발주기관: </span>${esc(p.client_name||p.client||'-')}</div>
|
||
<div><span style="color:var(--text-muted)">PM: </span>${esc(p.pm_name||p.pm||'-')}</div>
|
||
<div><span style="color:var(--text-muted)">계약금액: </span>${fmtMoney(p.contract_amount||p.budget||0)}원</div>
|
||
<div><span style="color:var(--text-muted)">계획 시작: </span>${p.planned_start_date||p.start_date||'-'}</div>
|
||
<div><span style="color:var(--text-muted)">계획 종료: </span>${p.planned_end_date||p.end_date||'-'}</div>
|
||
</div>
|
||
</div>
|
||
|
||
${p.description ? `<div class="detail-section"><h4>프로젝트 설명</h4><p style="font-size:13px;color:var(--text-muted);line-height:1.6;">${esc(p.description)}</p></div>` : ''}
|
||
`;
|
||
}
|
||
|
||
// ── WBS ───────────────────────────────────────────────
|
||
async function loadWbs() {
|
||
if (!currentProject) return;
|
||
const res = await api('GET', `/api/si/projects/${currentProject.id}/wbs`);
|
||
if (!res.ok) { document.getElementById('wbsTree').innerHTML = '<div class="empty-state">불러오기 실패</div>'; return; }
|
||
const tree = await res.json();
|
||
renderWbsTree(Array.isArray(tree) ? tree : (tree.children || []));
|
||
}
|
||
|
||
function renderWbsTree(nodes, depth=0) {
|
||
if (!nodes || !nodes.length) {
|
||
document.getElementById('wbsTree').innerHTML = '<div class="empty-state">WBS 데이터가 없습니다.<br>항목을 추가해주세요.</div>';
|
||
return;
|
||
}
|
||
let html = '<div class="wbs-tree">';
|
||
function walk(items, d) {
|
||
for (const item of items) {
|
||
const pct = item.completion_rate || item.progress_pct || 0;
|
||
html += `<div class="wbs-row wbs-level-${Math.min(d+1,4)}" onclick="openWbsProgress(${item.id},${pct})">
|
||
<div style="width:${d*20}px;flex-shrink:0;"></div>
|
||
<div class="wbs-code">${esc(item.wbs_code||'')}</div>
|
||
<div class="wbs-name">${esc(item.title||item.name||'')}</div>
|
||
<div class="wbs-progress-wrap"><div class="wbs-progress-bar" style="width:${pct}%"></div></div>
|
||
<div class="wbs-status">${pct}%</div>
|
||
<div style="font-size:11px;color:var(--text-muted);min-width:80px;">${item.assignee||''}</div>
|
||
</div>`;
|
||
if (item.children && item.children.length) walk(item.children, d+1);
|
||
}
|
||
}
|
||
walk(Array.isArray(nodes) ? nodes : [nodes], depth);
|
||
html += '</div>';
|
||
document.getElementById('wbsTree').innerHTML = html;
|
||
}
|
||
|
||
async function openWbsProgress(id, current) {
|
||
const pct = prompt(`진척률 업데이트 (현재: ${current}%) — 0~100 입력:`);
|
||
if (pct === null) return;
|
||
const val = parseInt(pct);
|
||
if (isNaN(val)||val<0||val>100) { alert('0~100 사이 숫자를 입력하세요.'); return; }
|
||
const res = await api('PATCH', `/api/si/projects/${currentProject.id}/wbs/${id}/progress`, { progress_pct: val });
|
||
if (res.ok) loadWbs(); else alert('업데이트 실패');
|
||
}
|
||
|
||
// ── 요구사항 ──────────────────────────────────────────
|
||
async function loadRequirements() {
|
||
const res = await api('GET', `/api/si/projects/${currentProject.id}/requirements`);
|
||
if (!res.ok) return;
|
||
const list = await res.json();
|
||
document.getElementById('reqBody').innerHTML = list.map(r => `
|
||
<tr>
|
||
<td>${esc(r.requirement_code||r.code||'')}</td>
|
||
<td>${esc(r.title||'')}</td>
|
||
<td>${r.req_type||r.type||''}</td>
|
||
<td><span class="badge pri-${r.priority||'MEDIUM'}">${r.priority||''}</span></td>
|
||
<td>${r.is_confirmed?'<span class="badge st-DONE">확정</span>':'<span class="badge st-OPEN">미확정</span>'}</td>
|
||
<td>${r.wbs_item_id||'—'}</td>
|
||
</tr>
|
||
`).join('') || '<tr><td colspan="6" style="text-align:center;color:var(--text-muted);">요구사항 없음</td></tr>';
|
||
}
|
||
|
||
// ── 이슈 ──────────────────────────────────────────────
|
||
async function loadIssues() {
|
||
const res = await api('GET', `/api/si/projects/${currentProject.id}/issues`);
|
||
if (!res.ok) return;
|
||
const list = await res.json();
|
||
document.getElementById('issueBody').innerHTML = list.map(i => `
|
||
<tr onclick="openIssueDetail(${i.id})">
|
||
<td>#${i.id}</td>
|
||
<td>${esc(i.title||'')}</td>
|
||
<td>${i.issue_type||i.type||''}</td>
|
||
<td><span class="badge pri-${i.severity||'MEDIUM'}">${i.severity||''}</span></td>
|
||
<td><span class="badge st-${i.status||'OPEN'}">${i.status||''}</span></td>
|
||
<td>${esc(i.assignee||'—')}</td>
|
||
<td>${i.due_date||'—'}</td>
|
||
</tr>
|
||
`).join('') || '<tr><td colspan="7" style="text-align:center;color:var(--text-muted);">이슈 없음</td></tr>';
|
||
}
|
||
|
||
async function openIssueDetail(id) {
|
||
const res = await api('GET', `/api/si/projects/${currentProject.id}/issues/${id}`);
|
||
if (!res.ok) return;
|
||
const i = await res.json();
|
||
const newStatus = prompt(`상태 변경 (현재: ${i.status})\nOPEN / IN_PROGRESS / RESOLVED / CLOSED 중 입력:`);
|
||
if (!newStatus) return;
|
||
await api('PATCH', `/api/si/projects/${currentProject.id}/issues/${id}`, { status: newStatus.toUpperCase() });
|
||
loadIssues();
|
||
}
|
||
|
||
// ── 리스크 ────────────────────────────────────────────
|
||
async function loadRisks() {
|
||
const res = await api('GET', `/api/si/projects/${currentProject.id}/risks`);
|
||
if (!res.ok) return;
|
||
const list = await res.json();
|
||
renderRiskMatrix(list);
|
||
document.getElementById('riskBody').innerHTML = list.map(r => `
|
||
<tr>
|
||
<td>${esc(r.title||'')}</td>
|
||
<td>${r.probability||r.prob||0}</td>
|
||
<td>${r.impact||0}</td>
|
||
<td>${r.risk_score||((r.probability||0)*(r.impact||0))}</td>
|
||
<td><span class="badge risk-${r.risk_level||'LOW'}">${r.risk_level||''}</span></td>
|
||
<td><span class="badge st-${r.status||'OPEN'}">${r.status||''}</span></td>
|
||
</tr>
|
||
`).join('') || '<tr><td colspan="6" style="text-align:center;color:var(--text-muted);">리스크 없음</td></tr>';
|
||
}
|
||
|
||
function renderRiskMatrix(risks) {
|
||
const el = document.getElementById('riskMatrix');
|
||
let html = '';
|
||
for (let p = 5; p >= 1; p--) {
|
||
html += `<div class="risk-axis-lbl" style="font-size:10px;">${p}</div>`;
|
||
for (let im = 1; im <= 5; im++) {
|
||
const score = p * im;
|
||
const level = score>=15?'CRITICAL':score>=8?'HIGH':score>=4?'MEDIUM':'LOW';
|
||
const cellRisks = risks.filter(r => (r.probability||r.prob||0)==p && (r.impact||0)==im);
|
||
html += `<div class="risk-cell risk-${level}" title="${level} (${p}×${im}=${score})">
|
||
${cellRisks.map(()=>'<div class="risk-dot"></div>').join('')}
|
||
</div>`;
|
||
}
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
// ── 마일스톤 ─────────────────────────────────────────
|
||
async function loadMilestones() {
|
||
const res = await api('GET', `/api/si/projects/${currentProject.id}/milestones`);
|
||
if (!res.ok) return;
|
||
const list = await res.json();
|
||
if (!list.length) { document.getElementById('milestoneTimeline').innerHTML = '<div class="empty-state">마일스톤이 없습니다.</div>'; return; }
|
||
const today = new Date().toISOString().slice(0,10);
|
||
document.getElementById('milestoneTimeline').innerHTML = list.map(ms => {
|
||
const due = ms.due_date||ms.target_date||'';
|
||
const done = ms.is_completed||ms.status==='DONE';
|
||
const overdue = !done && due < today;
|
||
return `<div class="milestone-item ${done?'done':overdue?'overdue':''}">
|
||
<div class="milestone-date">${due||'—'}</div>
|
||
<div class="milestone-name">${esc(ms.title||ms.name||'')}</div>
|
||
<div class="milestone-deliverables">${(ms.deliverables||[]).join(', ')||ms.deliverable_list||''}</div>
|
||
${done?'<span class="badge st-DONE" style="margin-top:4px;">완료</span>':overdue?'<span class="badge pri-HIGH" style="margin-top:4px;">지연</span>':''}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ── CR ───────────────────────────────────────────────
|
||
async function loadCRs() {
|
||
const res = await api('GET', `/api/si/projects/${currentProject.id}/change-requests`);
|
||
if (!res.ok) return;
|
||
const list = await res.json();
|
||
document.getElementById('crBody').innerHTML = list.map(cr => `
|
||
<tr>
|
||
<td>#${cr.id}</td>
|
||
<td>${esc(cr.title||'')}</td>
|
||
<td>${cr.change_type||cr.type||''}</td>
|
||
<td><span class="badge pri-${cr.impact_level||cr.impact||'MEDIUM'}">${cr.impact_level||cr.impact||''}</span></td>
|
||
<td><span class="badge cr-${cr.status||'PENDING'}">${cr.status||''}</span></td>
|
||
<td>${cr.requested_at||cr.created_at||'—'}</td>
|
||
<td>${esc(cr.requested_by||cr.requester||'—')}</td>
|
||
</tr>
|
||
`).join('') || '<tr><td colspan="7" style="text-align:center;color:var(--text-muted);">변경요청 없음</td></tr>';
|
||
}
|
||
|
||
// ── 테스트 ────────────────────────────────────────────
|
||
async function loadTests() {
|
||
await loadTestPlans();
|
||
}
|
||
|
||
async function loadTestPlans() {
|
||
const res = await api('GET', `/api/si/projects/${currentProject.id}/test-plans`);
|
||
if (!res.ok) return;
|
||
const list = await res.json();
|
||
// tc plan filter 갱신
|
||
const sel = document.getElementById('tcPlanFilter');
|
||
sel.innerHTML = '<option value="">테스트 계획 선택</option>' + list.map(tp => `<option value="${tp.id}">${esc(tp.plan_name||tp.name||'')}</option>`).join('');
|
||
|
||
document.getElementById('testPlanBody').innerHTML = list.map(tp => `
|
||
<tr onclick="selectTestPlan(${tp.id})">
|
||
<td>${esc(tp.plan_name||tp.name||'')}</td>
|
||
<td>${tp.test_type||tp.type||''}</td>
|
||
<td>${tp.planned_start||tp.start_date||'—'}</td>
|
||
<td>${tp.planned_end||tp.end_date||'—'}</td>
|
||
<td>${esc(tp.test_owner||tp.owner||'—')}</td>
|
||
<td><span class="badge st-${tp.status||'TODO'}">${tp.status||''}</span></td>
|
||
</tr>
|
||
`).join('') || '<tr><td colspan="6" style="text-align:center;color:var(--text-muted);">테스트 계획 없음</td></tr>';
|
||
|
||
await loadDefects();
|
||
}
|
||
|
||
function selectTestPlan(id) {
|
||
document.getElementById('tcPlanFilter').value = id;
|
||
switchSubTab('testCase');
|
||
loadTestCases();
|
||
}
|
||
|
||
async function loadTestCases() {
|
||
const planId = document.getElementById('tcPlanFilter').value;
|
||
if (!planId) { document.getElementById('testCaseBody').innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);">계획을 선택하세요</td></tr>'; return; }
|
||
const res = await api('GET', `/api/si/projects/${currentProject.id}/test-plans/${planId}/cases`);
|
||
if (!res.ok) return;
|
||
const list = await res.json();
|
||
document.getElementById('testCaseBody').innerHTML = list.map(tc => `
|
||
<tr>
|
||
<td>${esc(tc.case_code||tc.code||'')}</td>
|
||
<td>${esc(tc.title||'')}</td>
|
||
<td><span class="badge pri-${tc.priority||'MEDIUM'}">${tc.priority||''}</span></td>
|
||
<td><span class="badge st-${tc.status||'TODO'}">${tc.status||''}</span></td>
|
||
<td><span class="badge ${tc.execution_result==='PASS'?'st-DONE':tc.execution_result==='FAIL'?'st-BLOCKED':'st-OPEN'}">${tc.execution_result||'미실행'}</span></td>
|
||
</tr>
|
||
`).join('') || '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);">테스트 케이스 없음</td></tr>';
|
||
}
|
||
|
||
async function loadDefects() {
|
||
const res = await api('GET', `/api/si/projects/${currentProject.id}/defects`);
|
||
if (!res.ok) return;
|
||
const list = await res.json();
|
||
document.getElementById('defectBody').innerHTML = list.map(d => `
|
||
<tr>
|
||
<td>#${d.id}</td>
|
||
<td>${esc(d.title||'')}</td>
|
||
<td><span class="badge pri-${d.severity||'MEDIUM'}">${d.severity||''}</span></td>
|
||
<td><span class="badge st-${d.status||'OPEN'}">${d.status||''}</span></td>
|
||
<td>${d.test_case_id||'—'}</td>
|
||
<td>${(d.found_at||d.created_at||'').slice(0,10)||'—'}</td>
|
||
</tr>
|
||
`).join('') || '<tr><td colspan="6" style="text-align:center;color:var(--text-muted);">결함 없음</td></tr>';
|
||
}
|
||
|
||
// ── 테이블 공통 필터 ──────────────────────────────────
|
||
function filterTable(tableId, q) {
|
||
const kw = q.toLowerCase();
|
||
document.querySelectorAll(`#${tableId} tbody tr`).forEach(tr => {
|
||
tr.style.display = tr.textContent.toLowerCase().includes(kw) ? '' : 'none';
|
||
});
|
||
}
|
||
function filterReqs(type) { filterByAttr('reqBody', 2, type); }
|
||
function filterIssues(status) { filterByAttr('issueBody', 4, status); }
|
||
function filterCRs(status) { filterByAttr('crBody', 4, status); }
|
||
function filterByAttr(tbodyId, colIdx, val) {
|
||
document.querySelectorAll(`#${tbodyId} tr`).forEach(tr => {
|
||
const td = tr.querySelectorAll('td')[colIdx];
|
||
tr.style.display = (!val || (td && td.textContent.includes(val))) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
// ── 모달 열기/닫기 ────────────────────────────────────
|
||
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
||
function openModal(id) { document.getElementById(id).classList.add('open'); }
|
||
|
||
function openCreateProject() {
|
||
document.getElementById('projId').value = '';
|
||
document.getElementById('projModalTitle').textContent = 'SI 프로젝트 등록';
|
||
['proj_name','proj_client','proj_pm','proj_desc'].forEach(id => document.getElementById(id).value = '');
|
||
['proj_start','proj_end','proj_budget'].forEach(id => document.getElementById(id).value = '');
|
||
openModal('projModal');
|
||
}
|
||
|
||
function openEditProject() {
|
||
const p = currentProject;
|
||
document.getElementById('projId').value = p.id;
|
||
document.getElementById('projModalTitle').textContent = '프로젝트 수정';
|
||
document.getElementById('proj_name').value = p.project_name||p.name||'';
|
||
document.getElementById('proj_client').value = p.client_name||p.client||'';
|
||
document.getElementById('proj_pm').value = p.pm_name||p.pm||'';
|
||
document.getElementById('proj_desc').value = p.description||'';
|
||
document.getElementById('proj_start').value = (p.planned_start_date||p.start_date||'').slice(0,10);
|
||
document.getElementById('proj_end').value = (p.planned_end_date||p.end_date||'').slice(0,10);
|
||
document.getElementById('proj_budget').value = p.contract_amount||p.budget||'';
|
||
openModal('projModal');
|
||
}
|
||
|
||
function closeProjModal() { closeModal('projModal'); }
|
||
|
||
async function submitProject() {
|
||
const id = document.getElementById('projId').value;
|
||
const body = {
|
||
project_name: document.getElementById('proj_name').value,
|
||
client_name: document.getElementById('proj_client').value,
|
||
pm_name: document.getElementById('proj_pm').value,
|
||
description: document.getElementById('proj_desc').value,
|
||
planned_start_date: document.getElementById('proj_start').value || undefined,
|
||
planned_end_date: document.getElementById('proj_end').value || undefined,
|
||
contract_amount: parseFloat(document.getElementById('proj_budget').value)||undefined,
|
||
};
|
||
const res = id
|
||
? await api('PATCH', `/api/si/projects/${id}`, body)
|
||
: await api('POST', '/api/si/projects', body);
|
||
if (res.ok) {
|
||
closeProjModal();
|
||
await loadProjects();
|
||
if (id) { currentProject = await (await api('GET',`/api/si/projects/${id}`)).json(); updateProjHeader(); loadOverview(); }
|
||
} else alert('저장 실패');
|
||
}
|
||
|
||
function openAddWbs() { openModal('wbsModal'); }
|
||
async function submitWbs() {
|
||
const res = await api('POST', `/api/si/projects/${currentProject.id}/wbs`, {
|
||
wbs_code: document.getElementById('wbs_code').value,
|
||
title: document.getElementById('wbs_name').value,
|
||
parent_id: parseInt(document.getElementById('wbs_parent').value)||null,
|
||
planned_start_date: document.getElementById('wbs_start').value||undefined,
|
||
planned_end_date: document.getElementById('wbs_end').value||undefined,
|
||
assignee: document.getElementById('wbs_assignee').value||undefined,
|
||
completion_rate: parseInt(document.getElementById('wbs_progress').value)||0,
|
||
});
|
||
if (res.ok) { closeModal('wbsModal'); loadWbs(); } else alert('저장 실패');
|
||
}
|
||
|
||
function openAddReq() { openModal('reqModal'); }
|
||
async function submitReq() {
|
||
const res = await api('POST', `/api/si/projects/${currentProject.id}/requirements`, {
|
||
requirement_code: document.getElementById('req_code').value,
|
||
title: document.getElementById('req_title').value,
|
||
description: document.getElementById('req_desc').value,
|
||
req_type: document.getElementById('req_type').value,
|
||
priority: document.getElementById('req_priority').value,
|
||
source: document.getElementById('req_source').value||undefined,
|
||
});
|
||
if (res.ok) { closeModal('reqModal'); loadRequirements(); } else alert('저장 실패');
|
||
}
|
||
|
||
function openAddIssue() { openModal('issueModal'); }
|
||
async function submitIssue() {
|
||
const res = await api('POST', `/api/si/projects/${currentProject.id}/issues`, {
|
||
title: document.getElementById('iss_title').value,
|
||
issue_type: document.getElementById('iss_type').value,
|
||
severity: document.getElementById('iss_severity').value,
|
||
assignee: document.getElementById('iss_assignee').value||undefined,
|
||
due_date: document.getElementById('iss_due').value||undefined,
|
||
description: document.getElementById('iss_desc').value||undefined,
|
||
});
|
||
if (res.ok) { closeModal('issueModal'); loadIssues(); } else alert('저장 실패');
|
||
}
|
||
|
||
function openAddRisk() { openModal('riskModal'); }
|
||
async function submitRisk() {
|
||
const res = await api('POST', `/api/si/projects/${currentProject.id}/risks`, {
|
||
title: document.getElementById('risk_title').value,
|
||
description: document.getElementById('risk_desc').value||undefined,
|
||
probability: parseInt(document.getElementById('risk_prob').value),
|
||
impact: parseInt(document.getElementById('risk_impact').value),
|
||
risk_owner: document.getElementById('risk_owner').value||undefined,
|
||
strategy: document.getElementById('risk_strategy').value,
|
||
response_plan: document.getElementById('risk_response').value||undefined,
|
||
});
|
||
if (res.ok) { closeModal('riskModal'); loadRisks(); } else alert('저장 실패');
|
||
}
|
||
|
||
function openAddMilestone() { openModal('msModal'); }
|
||
async function submitMilestone() {
|
||
const res = await api('POST', `/api/si/projects/${currentProject.id}/milestones`, {
|
||
title: document.getElementById('ms_name').value,
|
||
due_date: document.getElementById('ms_due').value||undefined,
|
||
wbs_item_id: parseInt(document.getElementById('ms_wbs').value)||undefined,
|
||
deliverables: document.getElementById('ms_deliverables').value.split(',').map(s=>s.trim()).filter(Boolean),
|
||
});
|
||
if (res.ok) { closeModal('msModal'); loadMilestones(); } else alert('저장 실패');
|
||
}
|
||
|
||
function openAddCR() { openModal('crModal'); }
|
||
async function submitCR() {
|
||
const res = await api('POST', `/api/si/projects/${currentProject.id}/change-requests`, {
|
||
title: document.getElementById('cr_title').value,
|
||
description: document.getElementById('cr_desc').value,
|
||
change_type: document.getElementById('cr_type').value,
|
||
impact_level: document.getElementById('cr_impact').value,
|
||
requested_by: document.getElementById('cr_requester').value||undefined,
|
||
});
|
||
if (res.ok) { closeModal('crModal'); loadCRs(); } else alert('저장 실패');
|
||
}
|
||
|
||
function openAddTestPlan() { openModal('tpModal'); }
|
||
async function submitTestPlan() {
|
||
const res = await api('POST', `/api/si/projects/${currentProject.id}/test-plans`, {
|
||
plan_name: document.getElementById('tp_name').value,
|
||
test_type: document.getElementById('tp_type').value,
|
||
test_owner: document.getElementById('tp_owner').value||undefined,
|
||
planned_start: document.getElementById('tp_start').value||undefined,
|
||
planned_end: document.getElementById('tp_end').value||undefined,
|
||
});
|
||
if (res.ok) { closeModal('tpModal'); loadTestPlans(); } else alert('저장 실패');
|
||
}
|
||
|
||
function openAddTestCase() {
|
||
const planId = document.getElementById('tcPlanFilter').value;
|
||
if (!planId) { alert('먼저 테스트 계획을 선택하세요.'); return; }
|
||
const code = prompt('테스트 케이스 코드 (예: TC-001):'); if (!code) return;
|
||
const title = prompt('제목:'); if (!title) return;
|
||
api('POST', `/api/si/projects/${currentProject.id}/test-plans/${planId}/cases`, {
|
||
case_code: code, title: title, priority: 'MEDIUM'
|
||
}).then(res => { if (res.ok) loadTestCases(); else alert('저장 실패'); });
|
||
}
|
||
|
||
function openAddDefect() { openModal('defectModal'); }
|
||
async function submitDefect() {
|
||
const res = await api('POST', `/api/si/projects/${currentProject.id}/defects`, {
|
||
title: document.getElementById('def_title').value,
|
||
description: document.getElementById('def_steps').value||undefined,
|
||
severity: document.getElementById('def_severity').value,
|
||
test_case_id: parseInt(document.getElementById('def_tc').value)||undefined,
|
||
});
|
||
if (res.ok) { closeModal('defectModal'); loadDefects(); } else alert('저장 실패');
|
||
}
|
||
|
||
// ── 단계 전환 ─────────────────────────────────────────
|
||
let nextPhase = null;
|
||
function openPhaseModal() {
|
||
const p = currentProject;
|
||
const curr = p.phase || p.current_phase || 'INITIATION';
|
||
const idx = PHASES.indexOf(curr);
|
||
if (idx >= PHASES.length - 1) { alert('이미 마지막 단계입니다.'); return; }
|
||
nextPhase = PHASES[idx + 1];
|
||
document.getElementById('phaseTransInfo').textContent =
|
||
`${PHASE_LABELS[curr]} → ${PHASE_LABELS[nextPhase]}`;
|
||
openModal('phaseModal');
|
||
}
|
||
|
||
async function confirmPhaseTransition() {
|
||
if (!nextPhase) return;
|
||
const res = await api('PATCH', `/api/si/projects/${currentProject.id}/phase`, { phase: nextPhase });
|
||
if (res.ok) {
|
||
closeModal('phaseModal');
|
||
currentProject = await (await api('GET', `/api/si/projects/${currentProject.id}`)).json();
|
||
updateProjHeader();
|
||
await loadProjects();
|
||
loadOverview();
|
||
} else alert('단계 전환 실패');
|
||
}
|
||
|
||
// ── 유틸 ─────────────────────────────────────────────
|
||
function esc(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||
function fmtMoney(n) { return Number(n||0).toLocaleString(); }
|
||
|
||
// 자동 새로고침
|
||
setInterval(() => { if (currentProject) {
|
||
const active = document.querySelector('.si-tab-btn.active');
|
||
if (active) {
|
||
const tabs = ['overview','wbs','requirements','issues','risks','milestones','cr','tests'];
|
||
const idx = Array.from(document.querySelectorAll('.si-tab-btn')).indexOf(active);
|
||
if (idx >= 0) {
|
||
const loaders = { overview: loadOverview, wbs: loadWbs, requirements: loadRequirements,
|
||
issues: loadIssues, risks: loadRisks, milestones: loadMilestones, cr: loadCRs, tests: loadTests };
|
||
if (loaders[tabs[idx]]) loaders[tabs[idx]]();
|
||
}
|
||
}
|
||
}}, 30000);
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|