zioinfo-mail/workspace/guardia-itsm/static/si.html
DESKTOP-TKLFCPR\ython cfe2901a55 refactor(structure): consolidate all projects under workspace/
- itsm/    -> workspace/guardia-itsm/
- manager/ -> workspace/guardia-manager/
- app/     -> workspace/guardia-messenger/
- manual/  -> workspace/guardia-docs/

workspace/zioinfo-web/ unchanged.
git mv preserves full commit history.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:50:56 +09:00

1243 lines
64 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>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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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>