/* ══════════════════════════════════════════════════ F-4: PWA Service Worker 등록 ══════════════════════════════════════════════════ */ if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/static/sw.js', { scope: '/' }) .then(reg => { console.log('[GUARDiA PWA] SW 등록 성공:', reg.scope); // 새 버전 감지 시 사용자에게 알림 reg.addEventListener('updatefound', () => { const newSW = reg.installing; newSW.addEventListener('statechange', () => { if (newSW.state === 'installed' && navigator.serviceWorker.controller) { console.log('[GUARDiA PWA] 새 버전 사용 가능 — 새로고침하면 업데이트됩니다.'); } }); }); }) .catch(err => console.warn('[GUARDiA PWA] SW 등록 실패:', err)); }); } /* ══════════════════════════════════════════════════ 테마 관리 (스크립트 최상단 — FOUC 방지) ══════════════════════════════════════════════════ */ function applyTheme(key) { document.body.dataset.theme = key; localStorage.setItem("guardia_theme", key); document.querySelectorAll(".theme-swatch").forEach(b => b.classList.toggle("active", b.dataset.theme === key) ); } /* ─── Auth ───────────────────────────────────────── */ const _token = localStorage.getItem("guardia_token"); const _userInfo = JSON.parse(localStorage.getItem("guardia_userinfo") || "{}"); // 로그인 안 된 경우 → /login으로 if (!_token) { window.location.replace("/login"); } // 비밀번호 변경 필요 → /change-password로 if (_userInfo.must_change_pw) { window.location.replace("/change-password"); } /** * Bearer 토큰을 자동으로 포함하는 fetch 래퍼. * 401 응답 시 로그인 페이지로 리다이렉트. */ async function authFetch(url, opts = {}) { const headers = { "Content-Type": "application/json", ...(opts.headers || {}) }; if (_token) headers["Authorization"] = `Bearer ${_token}`; const res = await fetch(url, { ...opts, headers }); if (res.status === 401) { localStorage.removeItem("guardia_token"); localStorage.removeItem("guardia_userinfo"); window.location.replace("/login"); throw new Error("Unauthorized"); } return res; } /** 로그아웃 */ function logout() { localStorage.removeItem("guardia_token"); localStorage.removeItem("guardia_userinfo"); window.location.replace("/login"); } /* ─── State ─────────────────────────────────────── */ let currentView = "dashboard"; let srCache = []; let statsCache = {}; let workloadCache = []; let dashCache = {}; // 역할별 대시보드 데이터 /* ─── Status labels ─────────────────────────────── */ const STATUS_LABEL = { RECEIVED: "접수", PARSED: "파싱 완료", PENDING_APPROVAL: "승인 대기", APPROVED: "승인됨", IN_PROGRESS: "진행 중", PENDING_PM_VALIDATION: "PM 검증 대기", COMPLETED: "완료", FAILED_ROLLBACK: "롤백 실패", REJECTED: "반려", }; const TYPE_LABEL = { DEPLOY: "배포", RESTART: "재기동", LOG: "로그", INQUIRY: "문의", OTHER: "기타", }; const PRIORITY_LABEL = { CRITICAL: "긴급", HIGH: "높음", MEDIUM: "보통", LOW: "낮음" }; const KANBAN_COLS = [ { key: "RECEIVED", label: "접수" }, { key: "PENDING_APPROVAL", label: "승인 대기" }, { key: "APPROVED", label: "승인됨" }, { key: "IN_PROGRESS", label: "진행 중" }, { key: "PENDING_PM_VALIDATION", label: "PM 검증" }, { key: "COMPLETED", label: "완료" }, { key: "FAILED_ROLLBACK", label: "롤백 실패" }, { key: "REJECTED", label: "반려" }, ]; const STATUS_COLORS = { RECEIVED: "#8b949e", PARSED: "#79c0ff", PENDING_APPROVAL: "#e3b341", APPROVED: "#56d364", IN_PROGRESS: "#58a6ff", PENDING_PM_VALIDATION: "#bc8cff", COMPLETED: "#3fb950", FAILED_ROLLBACK: "#f85149", REJECTED: "#da3633", }; /* ─── Init ──────────────────────────────────────── */ window.addEventListener("DOMContentLoaded", async () => { // 사용자 정보 표시 const roleLabel = { ADMIN:"관리자", ENGINEER:"엔지니어", PM:"PM", CUSTOMER:"고객" }; const roleColor = { ADMIN:"#818cf8", ENGINEER:"#34d399", PM:"#fbbf24", CUSTOMER:"#a78bfa" }; const role = _userInfo.role || ""; document.getElementById("user-display-name").textContent = _userInfo.display_name || _userInfo.username || "사용자"; const roleBadge = document.getElementById("user-role-badge"); roleBadge.textContent = roleLabel[role] || role; roleBadge.style.background = (roleColor[role] || "#64748b") + "22"; roleBadge.style.color = roleColor[role] || "#64748b"; // 테마 버튼 active 상태 동기화 applyTheme(document.body.dataset.theme || "dark"); setupNav(); setupNewSR(); setupListFilters(); initChat(); await loadAll(); initSSE(); // 실시간 이벤트 연결 startPoll(); // 30초 폴백 폴링 }); async function loadAll() { await Promise.all([loadDashboardMe(), loadSRs(), loadWorkload()]); renderCurrentView(); } /* ══════════════════════════════════════════════════ SSE 실시간 연결 ══════════════════════════════════════════════════ */ let _sseSource = null; let _sseRetry = 0; let _refreshPending = false; let _pollTimer = null; function initSSE() { if (!_token) return; if (_sseSource) { _sseSource.close(); _sseSource = null; } const url = `/api/dashboard/events?token=${encodeURIComponent(_token)}`; _sseSource = new EventSource(url); _sseSource.onopen = () => { _sseRetry = 0; setSseDot(true); }; _sseSource.onmessage = (e) => { if (!e.data || e.data.trim() === "") return; try { handleSSEEvent(JSON.parse(e.data)); } catch {} }; _sseSource.onerror = () => { setSseDot(false); _sseSource.close(); _sseSource = null; _sseRetry++; const delay = Math.min(3000 * _sseRetry, 60000); setTimeout(initSSE, delay); }; } function setSseDot(connected) { const dot = document.getElementById("sse-dot"); const lbl = document.getElementById("sse-label"); if (!dot) return; dot.className = `sse-dot ${connected ? "green" : "red"}`; if (lbl) lbl.textContent = connected ? "실시간 연결" : "재연결 중…"; } async function handleSSEEvent(evt) { switch (evt.type) { case "sr_created": showToast(`🆕 새 SR 접수: ${evt.title || evt.sr_id}`, "info"); debouncedRefresh(); break; case "sr_updated": showToast(`📋 ${evt.sr_id} → ${STATUS_LABEL[evt.new_status] || evt.new_status}`, "info"); debouncedRefresh(); break; case "approval_done": { const icon = evt.result === "APPROVED" ? "✅" : "❌"; showToast(`${icon} ${evt.sr_id} 결재 완료 (${evt.approver})`, evt.result === "APPROVED" ? "success" : "warning"); debouncedRefresh(); break; } case "stats": await loadDashboardMe(); if (currentView === "dashboard") renderDashboard(); break; } } function debouncedRefresh() { if (_refreshPending) return; _refreshPending = true; setTimeout(async () => { await loadAll(); if (["ADMIN","PM"].includes(_userInfo.role)) loadTrend(); _refreshPending = false; showRefreshIndicator(); }, 600); } function showRefreshIndicator() { const el = document.getElementById("topbar-refresh-indicator"); if (!el) return; el.classList.remove("hidden"); setTimeout(() => el.classList.add("hidden"), 2500); } /* 폴백 폴링 (SSE 장애 대비, 30초) */ function startPoll() { if (_pollTimer) clearInterval(_pollTimer); _pollTimer = setInterval(async () => { if (_sseSource && _sseSource.readyState === EventSource.OPEN) return; await loadDashboardMe(); if (currentView === "dashboard") renderDashboard(); }, 30000); } /* ══════════════════════════════════════════════════ 토스트 알림 ══════════════════════════════════════════════════ */ function showToast(msg, type = "info") { const el = document.createElement("div"); el.className = `toast toast-${type}`; el.textContent = msg; document.body.appendChild(el); requestAnimationFrame(() => { requestAnimationFrame(() => el.classList.add("show")); }); setTimeout(() => { el.classList.remove("show"); setTimeout(() => el.remove(), 350); }, 3500); } async function loadDashboardMe() { try { const r = await authFetch("/api/dashboard/me"); dashCache = await r.json(); // stats도 함께 채워두기 (호환성) if (dashCache.kpi) { statsCache = { total: dashCache.kpi.total, by_status: {}, by_type: {}, }; } } catch { dashCache = {}; } } /* ─── Nav ───────────────────────────────────────── */ function setupNav() { document.querySelectorAll(".nav-item").forEach(el => { el.addEventListener("click", () => { const view = el.dataset.view; switchView(view); }); }); } function switchView(view) { currentView = view; document.querySelectorAll(".nav-item").forEach(el => el.classList.toggle("active", el.dataset.view === view) ); document.querySelectorAll(".view").forEach(el => el.classList.toggle("active", el.id === `view-${view}`) ); const titles = { dashboard: "대시보드", board: "칸반 보드", list: "SR 목록", audit: "감사 로그", cmdb: "CMDB", kb: "기술 문서 KB", institutions: "기관 관리", scripts: "스크립트 관리", timetable: "작업 타임테이블", }; document.getElementById("page-title").textContent = titles[view] || view; renderCurrentView(); } function renderCurrentView() { if (currentView === "dashboard") renderDashboard(); else if (currentView === "board") renderKanban(); else if (currentView === "list") renderList(); else if (currentView === "audit") loadAudit(); else if (currentView === "cmdb") loadCmdb(); else if (currentView === "kb") loadKBView(); else if (currentView === "institutions") loadInstitutions(); else if (currentView === "scripts") loadScripts(); else if (currentView === "timetable") loadTimetable(); } /* ─── Data loading ──────────────────────────────── */ async function loadWorkload() { try { const r = await authFetch("/api/assign/workload"); workloadCache = await r.json(); renderWorkload(); } catch { workloadCache = []; } } async function loadStats() { try { const r = await authFetch("/api/tasks/stats"); statsCache = await r.json(); } catch { statsCache = {}; } } async function loadSRs(params = {}) { const qs = new URLSearchParams(params).toString(); const r = await authFetch(`/api/tasks?limit=200${qs ? "&" + qs : ""}`); srCache = await r.json(); } /* ══════════════════════════════════════════════════ 7일 추이 차트 (순수 SVG) ══════════════════════════════════════════════════ */ async function loadTrend() { try { const r = await authFetch("/api/dashboard/stats/trend"); const data = await r.json(); renderTrendChart(data); const lbl = document.getElementById("trend-last-updated"); if (lbl) lbl.textContent = new Date().toLocaleTimeString("ko-KR", { hour:"2-digit", minute:"2-digit" }) + " 기준"; } catch {} } function renderTrendChart(days) { const card = document.getElementById("trend-chart-card"); const el = document.getElementById("trend-chart"); if (!card || !el || !days?.length) return; card.style.display = ""; const W = el.clientWidth || 520; const H = 130; const pad = { t: 16, r: 16, b: 32, l: 36 }; const cw = W - pad.l - pad.r; const ch = H - pad.t - pad.b; const maxVal = Math.max(...days.flatMap(d => [d.created, d.completed]), 1); const step = cw / days.length; const bw = Math.floor(step * 0.32); // Y 격자선 + 레이블 const yLines = [0, .5, 1].map(f => { const y = pad.t + ch * (1 - f); const v = Math.round(maxVal * f); return ` ${v}`; }).join(""); // 막대 + X 레이블 const bars = days.map((d, i) => { const cx = pad.l + i * step + step * 0.5; const x1 = cx - bw - 1; const x2 = cx + 1; const h1 = Math.max(d.created / maxVal * ch, d.created ? 2 : 0); const h2 = Math.max(d.completed / maxVal * ch, d.completed ? 2 : 0); const y1 = pad.t + ch - h1; const y2 = pad.t + ch - h2; return ` ${d.label}`; }).join(""); el.innerHTML = ` ${yLines} ${bars} `; } /* ─── Dashboard (역할별 분기) ─────────────────────── */ function renderDashboard() { const role = _userInfo.role || "ADMIN"; const d = dashCache || {}; const view = document.getElementById("view-dashboard"); if (!view) return; // 역할 전환 시 이전 역할 전용 동적 요소 정리 document.getElementById("eng-wl-bar")?.remove(); document.getElementById("workload-mini-card")?.remove(); if (role === "ADMIN") renderDashboardAdmin(d); else if (role === "ENGINEER") renderDashboardEngineer(d); else if (role === "PM") renderDashboardPM(d); else if (role === "CUSTOMER") renderDashboardCustomer(d); else renderDashboardAdmin(d); } /* ── 공통 헬퍼 ─────────────────────────────────── */ function _srRow(sr, extra = "") { return `
${sr.sr_id} ${esc(sr.title)} ${STATUS_LABEL[sr.status] || sr.status} ${extra}
`; } function _statCard(value, label, color = "accent", sub = "", icon = "") { return `
${icon ? `
${icon}
` : ""}
${value}
${label}
${sub ? `
${sub}
` : ""}
`; } function _statusBarChart(byStatus) { const total = Object.values(byStatus).reduce((a, b) => a + b, 0) || 1; return `
` + Object.entries(byStatus).sort((a,b) => b[1]-a[1]).map(([k,v]) => `
${STATUS_LABEL[k]||k}${v}
`).join("") + `
`; } /* ── ADMIN 대시보드 ─────────────────────────────── */ function renderDashboardAdmin(d) { const kpi = d.kpi || {}; document.getElementById("dash-greeting")?.remove(); document.getElementById("stats-row").innerHTML = ` ${_statCard(kpi.total || 0, "전체 SR", "accent", "", "📋")} ${_statCard(kpi.pending_approval || 0, "승인 대기", "yellow", "", "🔔")} ${_statCard(kpi.in_progress || 0, "진행 중", "cyan", "", "⚙️")} ${_statCard(kpi.completed_today || 0, "오늘 완료", "green", "", "✅")} ${_statCard(kpi.failed || 0, "롤백 실패", "red", "", "⚠️")} ${_statCard((kpi.auto_rate || 0) + "%", "AI 자동처리율","purple", "", "🤖")} `; // 최근 SR const recent = (d.recent_srs || []).slice(0, 10); document.getElementById("recent-list").innerHTML = recent.map(sr => _srRow(sr, `${fmtDate(sr.created_at)}` )).join("") || '
SR이 없습니다.
'; // 상태별 차트 (srCache 기반) const bs = {}; srCache.forEach(sr => { bs[sr.status] = (bs[sr.status]||0)+1; }); document.getElementById("status-chart").innerHTML = _statusBarChart(bs); // 7일 추이 차트 loadTrend(); // 승인 대기 목록 (workload 카드에 표시) const pendEl = document.getElementById("workload-body"); const pendList = (d.pending_srs || []); const pendHTML = pendList.length ? pendList.map(sr => `
${sr.sr_id} ${esc(sr.title)} ${PRIORITY_LABEL[sr.priority]||sr.priority} ${esc(sr.requested_by||"")}
`).join("") : '
승인 대기 SR 없음
'; // workload 카드 헤더 변경 const wlCard = document.getElementById("workload-card"); if (wlCard) { wlCard.querySelector(".card-header").innerHTML = ` 🟡 승인 대기 (${pendList.length}건) 클릭하여 상세 확인`; if (pendEl) pendEl.innerHTML = pendHTML; } // 엔지니어 워크로드 mini (status-chart 하단에 추가) renderWorkloadMini(d.workload || workloadCache); } function renderWorkloadMini(wl) { // 기존 mini 제거 document.getElementById("workload-mini-card")?.remove(); const main = document.getElementById("view-dashboard"); if (!main || !wl?.length) return; const div = document.createElement("div"); div.id = "workload-mini-card"; div.className = "card"; div.style.marginTop = "16px"; div.innerHTML = `
👷 엔지니어 워크로드
${wl.map(e => { const pct = e.utilization || 0; const color = pct >= 80 ? "#f85149" : pct >= 50 ? "#e3b341" : "#3fb950"; return `
${(e.display_name||"?").charAt(0)}
${esc(e.display_name||e.username)}
${(e.skill_types||"").split(",").filter(Boolean).join("·")}
${e.active}/${e.max_workload}
`; }).join("")}
`; main.appendChild(div); } /* ── ENGINEER 대시보드 ──────────────────────────── */ function renderDashboardEngineer(d) { const wl = d.my_workload || {}; const pct = wl.utilization || 0; const barColor = pct >= 80 ? "#f85149" : pct >= 50 ? "#e3b341" : "#3fb950"; const skills = (wl.skills || "").split(",").filter(Boolean) .map(s => ({ DEPLOY:"배포", RESTART:"재기동", LOG:"로그", INQUIRY:"문의" }[s]||s)); document.getElementById("stats-row").innerHTML = `
${(_userInfo.display_name||"E").charAt(0)}
${esc(d.greeting||"")}
${skills.map(s=>`${s}`).join("")}
${_statCard(wl.active||0, "담당 중", "accent", `최대 ${wl.max||5}건`)} ${_statCard(wl.completed_month||0, "이번 달 완료", "green")} ${_statCard(wl.completed_total||0, "누적 완료", "purple")} `; // 워크로드 바 (중복 방지) document.getElementById("eng-wl-bar")?.remove(); const statsRowEl = document.getElementById("stats-row"); statsRowEl.insertAdjacentHTML("afterend", `
현재 워크로드${wl.active||0}/${wl.max||5}건 (${pct}%)
`); // 처리 대기 (APPROVED — 바로 시작 가능) const ready = d.action_required || []; document.getElementById("recent-list").innerHTML = `
⚡ 즉시 실행 가능 (APPROVED, ${ready.length}건)
` + (ready.map(sr => `
${sr.sr_id} ${esc(sr.title)} ${PRIORITY_LABEL[sr.priority]||sr.priority}
`).join("") || '
처리 대기 SR 없음
'); // 진행 중 const inprog = d.in_progress || []; document.getElementById("status-chart").innerHTML = `
🔄 진행 중 (${inprog.length}건)
` + (inprog.map(sr => _srRow(sr, `${fmtDate(sr.updated_at)}`)).join("") || '
진행 중 SR 없음
'); // 워크로드 카드: 최근 완료 const done = d.recent_completed || []; const wlEl = document.getElementById("workload-card"); if (wlEl) { wlEl.querySelector(".card-header").innerHTML = `✅ 최근 완료 (${done.length}건)`; const body = document.getElementById("workload-body"); if (body) body.innerHTML = done.map(sr => _srRow(sr, `${fmtDate(sr.updated_at)}`) ).join("") || '
완료 이력 없음
'; } } /* ── PM 대시보드 ────────────────────────────────── */ function renderDashboardPM(d) { const pendCnt = d.pending_count || 0; document.getElementById("stats-row").innerHTML = `
${pendCnt}
승인 대기
${pendCnt > 0 ? '
즉시 처리 필요
' : ""}
${(d.inst_stats||[]).map(i => _statCard(i.total, i.inst_name, "accent", `진행 ${i.active} · 완료 ${i.completed}`)).join("")} `; // 승인 대기 큐 (메인 카드) const pend = d.pending_srs || []; document.getElementById("recent-list").innerHTML = `
🔔 승인 대기 큐 — 결재 필요
` + pend.map(sr => `
${sr.sr_id} ${esc(sr.title)} ${PRIORITY_LABEL[sr.priority]||sr.priority} ${esc(sr.requested_by||"")}
`).join("") || '
승인 대기 SR 없음 ✨
'; // 엔지니어 워크로드 const wlEl = document.getElementById("workload-card"); if (wlEl) { wlEl.querySelector(".card-header").innerHTML = `👷 엔지니어 워크로드`; const body = document.getElementById("workload-body"); if (body) body.innerHTML = `
` + (d.workload||[]).map(e => { const pct = e.utilization||0; const color = pct>=80?"#f85149":pct>=50?"#e3b341":"#3fb950"; return `
${(e.display_name||"?").charAt(0)}
${esc(e.display_name||e.username)}
${e.active}/${e.max_workload}
`; }).join("") + `
`; } // 상태 차트 const bs = {}; srCache.forEach(sr => { bs[sr.status] = (bs[sr.status]||0)+1; }); document.getElementById("status-chart").innerHTML = _statusBarChart(bs); // PM도 추이 차트 표시 loadTrend(); } /* ── CUSTOMER 대시보드 ──────────────────────────── */ function renderDashboardCustomer(d) { const st = d.stats || {}; document.getElementById("stats-row").innerHTML = `
소속 기관
${esc(d.inst_name||d.inst_code||"—")}
${_statCard(st.total||0, "전체 SR")} ${_statCard(st.active||0, "진행 중", "accent")} ${_statCard(st.completed||0, "완료", "green")} ${d.avg_rating != null ? _statCard("★".repeat(Math.round(d.avg_rating||0)) + ` ${d.avg_rating}`, "평균 만족도", "yellow") : ""} `; // 진행 중 SR const activeSRs = d.active_srs || []; document.getElementById("recent-list").innerHTML = `
🔄 진행 중인 요청 (${activeSRs.length}건)
` + activeSRs.map(sr => `
${sr.sr_id} ${esc(sr.title)} ${STATUS_LABEL[sr.status]||sr.status} ${esc(sr.assigned_to||"처리 중")}
`).join("") || '
진행 중인 요청이 없습니다.
'; // 상태 현황 const bs = {}; srCache.forEach(sr => { bs[sr.status] = (bs[sr.status]||0)+1; }); document.getElementById("status-chart").innerHTML = _statusBarChart(bs); // 워크로드 카드: 최근 완료 const done = d.recent_completed || []; const wlEl = document.getElementById("workload-card"); if (wlEl) { wlEl.querySelector(".card-header").innerHTML = `✅ 최근 완료된 요청`; const body = document.getElementById("workload-body"); if (body) body.innerHTML = done.map(sr => `
${sr.sr_id} ${esc(sr.title)} 완료 ${fmtDate(sr.updated_at)}
`).join("") || '
완료된 요청 없음
'; } } /* ── PM 빠른 승인/반려 ──────────────────────────── */ async function quickApprove(srId, result) { const actor = _userInfo.display_name || _userInfo.username || "PM"; const r = await authFetch(`/api/approvals/${srId}`, { method: "POST", body: JSON.stringify({ approver: actor, result, comment: "대시보드 빠른 처리" }), }); if (r.ok) { await loadAll(); } else { const e = await r.json().catch(()=>({})); alert(e.detail||"처리 실패"); } } /* ─── 엔지니어 워크로드 패널 ─────────────────────── */ function renderWorkload() { const el = document.getElementById("workload-body"); if (!el) return; if (!workloadCache.length) { el.innerHTML = '
등록된 엔지니어 프로필 없음
'; return; } const skillLabel = { DEPLOY:"배포", RESTART:"재기동", LOG:"로그", INQUIRY:"문의", OTHER:"기타" }; const canAssign = ["ADMIN","PM"].includes(_userInfo.role || ""); el.innerHTML = `
${workloadCache.map(eng => { const pct = eng.utilization; const color = pct >= 80 ? "#f85149" : pct >= 50 ? "#e3b341" : "#3fb950"; const skills = (eng.skill_types || "").split(",").filter(Boolean) .map(s => `${skillLabel[s]||s}`).join(""); const aff = (eng.inst_affinity || "").split(",").filter(Boolean) .map(a => `${a}`).join(""); const assignBtn = canAssign ? `` : ""; return `
${eng.display_name.charAt(0)}
${esc(eng.display_name)}
${eng.username}
${eng.active}/${eng.max_workload}
${skills}${aff}
완료 ${eng.completed}건
${assignBtn}
`; }).join("")}
`; } function openAutoAssignModal(e, username) { e.stopPropagation(); const eng = workloadCache.find(e => e.username === username); if (!eng) return; const active = srCache.filter(sr => sr.assigned_to === username && !["COMPLETED","FAILED_ROLLBACK","REJECTED"].includes(sr.status) ); const html = active.length ? active.map(sr => `
${sr.sr_id} ${esc(sr.title)} ${STATUS_LABEL[sr.status]||sr.status}
`).join("") : '
현재 담당 중인 SR 없음
'; document.getElementById("modal-body").innerHTML = `
활성 ${eng.active}건 | 완료 ${eng.completed}건 | 이용률 ${eng.utilization}%
${html}`; document.getElementById("modal-overlay").classList.remove("hidden"); } /* ─── Kanban ────────────────────────────────────── */ function renderKanban() { const board = document.getElementById("kanban-board"); board.innerHTML = ""; KANBAN_COLS.forEach(col => { const cards = srCache.filter(sr => sr.status === col.key); const colEl = document.createElement("div"); colEl.className = "kanban-col"; colEl.innerHTML = `
${col.label} ${cards.length}
`; board.appendChild(colEl); const cardsEl = colEl.querySelector(`#col-${col.key}`); cards.forEach(sr => { const card = document.createElement("div"); card.className = "kanban-card"; card.innerHTML = `
${sr.sr_id}
${esc(sr.title)}
${TYPE_LABEL[sr.sr_type] || sr.sr_type} ${PRIORITY_LABEL[sr.priority] || sr.priority}
`; card.addEventListener("click", () => openDetail(sr.sr_id)); cardsEl.appendChild(card); }); }); } /* ─── SR List ───────────────────────────────────── */ function setupListFilters() { document.getElementById("search-input").addEventListener("input", renderList); document.getElementById("filter-status").addEventListener("change", renderList); document.getElementById("filter-type").addEventListener("change", renderList); } function renderList() { const keyword = document.getElementById("search-input").value.toLowerCase(); const fStatus = document.getElementById("filter-status").value; const fType = document.getElementById("filter-type").value; let rows = srCache; if (keyword) rows = rows.filter(r => r.title.toLowerCase().includes(keyword) || r.sr_id.toLowerCase().includes(keyword)); if (fStatus) rows = rows.filter(r => r.status === fStatus); if (fType) rows = rows.filter(r => r.sr_type === fType); document.getElementById("sr-tbody").innerHTML = rows.map(sr => { const engInfo = workloadCache.find(e => e.username === sr.assigned_to); const engChip = sr.assigned_to ? `${esc(engInfo?.display_name || sr.assigned_to)}` : `미배정`; return ` ${sr.sr_id} ${TYPE_LABEL[sr.sr_type] || sr.sr_type} ${esc(sr.title)} ${STATUS_LABEL[sr.status] || sr.status} ${PRIORITY_LABEL[sr.priority] || sr.priority} ${engChip} ${esc(sr.requested_by || "")} ${fmtDate(sr.created_at)} `; }).join("") || `결과 없음`; } const WORK_ACTION_LABEL = { CMDB_CHECK:"🔍 자산 확인", SSH_CONNECT:"🔗 SSH 접속", SSH_EXEC:"⚡ 명령 실행", SOURCE_MOD:"📦 파일 배포", HEALTH_CHECK:"💓 헬스체크", RESULT:"📋 결과 기록", COMPLETE:"✅ 완료 처리", }; /* ─── 첨부파일 헬퍼 ────────────────────────────── */ function _fileIcon(name) { const ext = (name.split(".").pop() || "").toLowerCase(); return { pdf:"📄", png:"🖼️", jpg:"🖼️", jpeg:"🖼️", gif:"🖼️", webp:"🖼️", xlsx:"📊", xls:"📊", docx:"📝", doc:"📝", pptx:"📊", ppt:"📊", zip:"🗜️", tar:"🗜️", gz:"🗜️", sh:"⚙️", sql:"🗄️", json:"📋", yaml:"📋", yml:"📋", md:"📋", log:"📜", txt:"📜", csv:"📊", }[ext] || "📎"; } function _fmtSize(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } function _renderAttachments(atts, srId) { if (!atts || atts.length === 0) return `
첨부파일 없음
`; return `
` + atts.map(a => `
${_fileIcon(a.original_name)} ${esc(a.original_name)} ${_fmtSize(a.file_size)} ${esc(a.uploaded_by)} ${fmtDate(a.created_at)}
`).join("") + `
`; } async function uploadAttachments(srId, input) { if (!input.files.length) return; const ffd = new FormData(); for (const f of input.files) ffd.append("files", f); const r = await authFetch(`/api/tasks/${srId}/attachments`, { method: "POST", body: ffd }); if (r.ok) { const newAtts = await authFetch(`/api/tasks/${srId}/attachments`).then(x => x.json()).catch(() => []); const el = document.getElementById(`att-list-${srId}`); if (el) el.innerHTML = _renderAttachments(newAtts, srId); } else { const err = await r.json().catch(() => ({})); alert(err.detail || "파일 업로드 실패"); } input.value = ""; } async function deleteAttachment(srId, attId, btn) { if (!confirm("첨부파일을 삭제하시겠습니까?")) return; const r = await authFetch(`/api/tasks/${srId}/attachments/${attId}`, { method: "DELETE" }); if (r.ok) { const newAtts = await authFetch(`/api/tasks/${srId}/attachments`).then(x => x.json()).catch(() => []); const el = document.getElementById(`att-list-${srId}`); if (el) el.innerHTML = _renderAttachments(newAtts, srId); } else { const err = await r.json().catch(() => ({})); alert(err.detail || "삭제 실패"); } } /* ─── SR Detail Modal ───────────────────────────── */ async function openDetail(srId) { const sr = srCache.find(s => s.sr_id === srId); if (!sr) return; const [approvalsRes, auditRes, workRes, ratingRes, attachmentsRes] = await Promise.all([ authFetch(`/api/approvals/${srId}`).then(r => r.json()).catch(() => []), authFetch(`/api/audit?sr_id=${srId}`).then(r => r.json()).catch(() => []), authFetch(`/api/work/${srId}`).then(r => r.json()).catch(() => []), authFetch(`/api/rating/${srId}`).then(r => r.ok ? r.json() : null).catch(() => null), authFetch(`/api/tasks/${srId}/attachments`).then(r => r.json()).catch(() => []), ]); const approvalHTML = approvalsRes.length ? approvalsRes.map(a => `
${esc(a.approver)} ${a.result === "APPROVED" ? "승인" : a.result === "REJECTED" ? "반려" : "대기"} ${a.comment ? `${esc(a.comment)}` : ""}
`).join("") : '
승인 기록 없음
'; const auditHTML = auditRes.slice(0, 10).map(log => `
${esc(log.action)} by ${esc(log.actor || "system")}
${esc(log.detail || "")}  #${(log.log_hash || "").slice(0, 12)}
`).join("") || '
기록 없음
'; const canApprove = sr.status === "PENDING_APPROVAL"; const canSimulate = !["COMPLETED","FAILED_ROLLBACK","REJECTED"].includes(sr.status); const canAssign = ["ADMIN","PM"].includes(_userInfo.role || ""); /* 작업 로그 HTML */ const workHTML = workRes.length ? `
` + workRes.map(w => `
${WORK_ACTION_LABEL[w.action_type] || w.action_type} by ${esc(w.engineer||"AI")}
${esc(w.content||"")} ${w.result ? `
${esc(w.result.slice(0,120))}` : ""}
`).join("") + `
` : `
작업 이력 없음
`; /* 별점 HTML */ const ratingHTML = ratingRes ? `
${"★".repeat(ratingRes.stars)}${"☆".repeat(5-ratingRes.stars)} ${esc(ratingRes.customer||"")} ${ratingRes.comment ? `— ${esc(ratingRes.comment)}` : ""}
` : sr.status === "COMPLETED" ? `
고객 만족도 평가
${[1,2,3,4,5].map(n=>``).join("")}
` : ""; document.getElementById("modal-body").innerHTML = ` ${sr.description ? ` ` : ""} ${ratingHTML ? ` ` : ""} ${canApprove ? ` ` : ""} `; document.getElementById("modal-overlay").classList.remove("hidden"); // KB 추천 비동기 로드 (모달 렌더 후) setTimeout(() => loadKBSuggestForSR(srId), 100); } async function doApproval(srId, result) { const approver = document.getElementById("approver-name").value.trim(); const comment = document.getElementById("approver-comment").value.trim(); if (!approver) { alert("승인자 이름을 입력하세요."); return; } const r = await authFetch(`/api/approvals/${srId}`, { method: "POST", body: JSON.stringify({ approver, result, comment: comment || null }), }); if (r.ok) { document.getElementById("modal-overlay").classList.add("hidden"); await loadAll(); } else { const err = await r.json().catch(() => ({})); alert(err.detail || "처리 중 오류 발생"); } } /* ─── Simulate + Rating from modal ─────────────── */ async function runSimulate(srId) { const btn = document.getElementById(`btn-simulate-${srId}`); const lbl = document.getElementById(`sim-status-${srId}`); if (btn) { btn.disabled = true; btn.textContent = "⏳ 실행 중…"; } if (lbl) lbl.textContent = "CMDB 확인 → SSH 접속 → 작업 수행 중…"; const r = await authFetch(`/api/work/${srId}/simulate`, { method: "POST" }); if (r.ok) { if (lbl) lbl.textContent = "✅ 완료! 메신저에 알림이 전송됩니다."; await loadAll(); setTimeout(() => openDetail(srId), 600); } else { const err = await r.json().catch(() => ({})); if (lbl) lbl.textContent = "❌ " + (err.detail || "오류 발생"); if (btn) { btn.disabled = false; btn.textContent = "⚡ AI 작업 실행 시뮬레이션"; } } } async function rateFromModal(srId, stars) { const customer = prompt("평가자 이름을 입력하세요:", "고객"); if (!customer) return; const r = await authFetch(`/api/rating/${srId}`, { method: "POST", body: JSON.stringify({ customer, stars, comment: null }), }); if (r.ok) { const widget = document.getElementById("star-widget"); if (widget) widget.innerHTML = `
${"★".repeat(stars)}${"☆".repeat(5-stars)} — 평가 감사합니다!
`; } } document.getElementById("modal-close-btn").addEventListener("click", () => document.getElementById("modal-overlay").classList.add("hidden") ); document.getElementById("modal-overlay").addEventListener("click", e => { if (e.target === e.currentTarget) e.currentTarget.classList.add("hidden"); }); /* ─── 엔지니어 재배정 패널 ───────────────────────── */ async function openReassignPanel(srId) { let engineers = []; try { const r = await authFetch("/api/assign/engineers"); engineers = await r.json(); } catch {} const panel = document.createElement("div"); panel.id = "reassign-panel"; panel.style.cssText = "margin-top:10px;padding:10px;background:var(--surface-2);border-radius:6px;border:1px solid var(--border)"; panel.innerHTML = `
담당 엔지니어 변경
`; // 기존 패널 있으면 제거 document.getElementById("reassign-panel")?.remove(); const assignCell = document.getElementById("assign-cell"); if (assignCell) assignCell.appendChild(panel); } async function submitReassign(srId) { const sel = document.getElementById("reassign-select"); const eng = sel ? sel.value : ""; // "" → 자동 배정 const url = `/api/assign/${srId}${eng ? `?engineer=${encodeURIComponent(eng)}` : ""}`; const r = await authFetch(url, { method: "POST" }); if (r.ok) { const data = await r.json(); document.getElementById("reassign-panel")?.remove(); await loadWorkload(); await loadAll(); openDetail(srId); } else { const err = await r.json().catch(() => ({})); alert(err.detail || "배정 실패"); } } /* ─── New SR Modal ──────────────────────────────── */ function setupNewSR() { document.getElementById("btn-new-sr").addEventListener("click", () => document.getElementById("new-sr-overlay").classList.remove("hidden") ); document.getElementById("new-sr-close").addEventListener("click", () => document.getElementById("new-sr-overlay").classList.add("hidden") ); document.getElementById("new-sr-overlay").addEventListener("click", e => { if (e.target === e.currentTarget) e.currentTarget.classList.add("hidden"); }); // 파일 선택 미리보기 document.getElementById("sr-file-input").addEventListener("change", e => { const files = Array.from(e.target.files || []); const txt = document.getElementById("file-upload-text"); const prev = document.getElementById("file-preview-list"); txt.textContent = files.length ? `${files.length}개 파일 선택됨` : "첨부파일 선택 (선택사항, 최대 10개 · 파일당 20 MB)"; prev.innerHTML = files.map(f => `
${_fileIcon(f.name)} ${esc(f.name)} ${_fmtSize(f.size)}
`).join(""); }); document.getElementById("new-sr-form").addEventListener("submit", async e => { e.preventDefault(); const fd = new FormData(e.target); const payload = Object.fromEntries(fd.entries()); // remove empty optional fields if (!payload.description) delete payload.description; if (!payload.target_server) delete payload.target_server; if (!payload.inst_code) delete payload.inst_code; const r = await authFetch("/api/tasks", { method: "POST", body: JSON.stringify(payload), }); if (!r.ok) { const err = await r.json().catch(() => ({})); alert(err.detail || "SR 생성 실패"); return; } const sr = await r.json(); // 파일 업로드 (선택된 경우) const fileInput = document.getElementById("sr-file-input"); if (fileInput.files.length > 0) { const ffd = new FormData(); for (const f of fileInput.files) ffd.append("files", f); const ur = await authFetch(`/api/tasks/${sr.sr_id}/attachments`, { method: "POST", body: ffd, }); if (!ur.ok) { const uerr = await ur.json().catch(() => ({})); alert(`SR이 생성되었으나 파일 업로드 실패: ${uerr.detail || "알 수 없는 오류"}`); } } document.getElementById("new-sr-overlay").classList.add("hidden"); e.target.reset(); document.getElementById("file-upload-text").textContent = "첨부파일 선택 (선택사항, 최대 10개 · 파일당 20 MB)"; document.getElementById("file-preview-list").innerHTML = ""; await loadAll(); }); } /* ─── Audit ─────────────────────────────────────── */ async function loadAudit() { const data = await authFetch("/api/audit?limit=100").then(r => r.json()).catch(() => []); document.getElementById("audit-tbody").innerHTML = data.map((log, i) => ` ${i + 1} ${esc(log.sr_id || "—")} ${esc(log.actor || "system")} ${esc(log.action)} ${esc(log.detail || "")} ${(log.log_hash || "").slice(0, 12)} ${fmtDate(log.created_at)} `).join("") || `기록 없음`; document.getElementById("btn-verify").addEventListener("click", async () => { const res = await authFetch("/api/audit/verify").then(r => r.json()); const el = document.getElementById("verify-result"); if (res.intact) { el.textContent = "✅ 체인 무결성 확인됨"; el.className = "ok"; } else { el.textContent = `❌ 변조 감지 (ID: ${res.broken_at_id})`; el.className = "fail"; } }); } /* ─── CMDB ───────────────────────────────────────── */ async function loadCmdb() { const institutions = await authFetch("/api/cmdb/institutions").then(r => r.json()).catch(() => []); const grid = document.getElementById("cmdb-grid"); grid.innerHTML = ""; await Promise.all(institutions.map(async inst => { const servers = await authFetch(`/api/cmdb/institutions/${inst.inst_code}/servers`) .then(r => r.json()).catch(() => []); const card = document.createElement("div"); card.className = "cmdb-card"; card.innerHTML = `
${esc(inst.inst_name)} ${esc(inst.inst_code)}
${servers.map(s => `
${s.server_role} ${esc(s.server_name)} ${esc(s.os_type || "")} ${s.is_active ? "● 정상" : "● 비활성"}
`).join("") || '
서버 없음
'}
${inst.contact_pm ? `
PM: ${esc(inst.contact_pm)}
` : ""} `; grid.appendChild(card); })); } /* ─── Knowledge Base ────────────────────────────── */ const KB_CAT_COLOR = { JAVA: "#f0883e", MIDDLEWARE: "#58a6ff", DB: "#e3b341", WEB: "#3fb950", OS: "#bc8cff", SECURITY: "#f85149", }; async function loadKBView() { // 초기 로드: 전체 목록 표시 const cat = document.getElementById("kb-category-filter")?.value || ""; const url = `/api/kb/list${cat ? `?category=${encodeURIComponent(cat)}` : ""}`; try { const docs = await authFetch(url).then(r => r.json()); renderKBDocs(docs.map(d => ({ doc: d, score: null, matched_keywords: [] }))); } catch { document.getElementById("kb-results").innerHTML = '
로드 실패
'; } // 검색 이벤트 연결 (한 번만) const inp = document.getElementById("kb-search-input"); if (inp && !inp._bound) { inp._bound = true; inp.addEventListener("keydown", e => { if (e.key === "Enter") searchKB(); }); } } async function searchKB() { const q = document.getElementById("kb-search-input")?.value.trim(); const cat = document.getElementById("kb-category-filter")?.value || ""; const el = document.getElementById("kb-results"); if (!q) { loadKBView(); return; } el.innerHTML = '
검색 중…
'; try { const url = `/api/kb?q=${encodeURIComponent(q)}&limit=10`; let hits = await authFetch(url).then(r => r.json()); if (cat) hits = hits.filter(h => h.doc.category === cat); renderKBDocs(hits); } catch { el.innerHTML = '
검색 실패
'; } } function renderKBDocs(hits) { const el = document.getElementById("kb-results"); if (!hits.length) { el.innerHTML = '
관련 문서 없음
'; return; } el.innerHTML = hits.map(h => renderKBCard(h, false)).join(""); } function renderKBCard(h, compact = false) { const d = h.doc; const color = KB_CAT_COLOR[d.category] || "#8b949e"; const scoreHTML = h.score !== null ? `관련도 ${Math.round(h.score * 100)}%` : ""; const kwHTML = h.matched_keywords?.length ? `
${h.matched_keywords.map(k => `${esc(k)}`).join(" ")}
` : ""; if (compact) { // 모달 내 축약형 return `
${d.category} ${esc(d.title)} ${scoreHTML}
${kwHTML}
`; } // 전체 카드 return `
${d.category} ${esc(d.title)}
${scoreHTML}
${kwHTML ? `
${kwHTML}
` : ""}
`; } function toggleKBCard(docId) { const body = document.getElementById(`kb-body-${docId}`); const icon = document.querySelector(`#kb-card-${docId} .kb-toggle-icon`); if (!body) return; const open = body.style.display === "none"; body.style.display = open ? "block" : "none"; if (icon) icon.textContent = open ? "▲" : "▼"; } async function openKBDetail(docId) { // KB 뷰로 이동 후 해당 카드 펼치기 switchView("kb"); await loadKBView(); setTimeout(() => { const card = document.getElementById(`kb-card-${docId}`); if (card) { card.scrollIntoView({ behavior: "smooth", block: "center" }); const body = document.getElementById(`kb-body-${docId}`); if (body && body.style.display === "none") toggleKBCard(docId); } }, 400); } /* SR 모달 내 KB 추천 렌더링 */ async function loadKBSuggestForSR(srId) { const el = document.getElementById("kb-suggest-section"); if (!el) return; el.innerHTML = '
분석 중…
'; try { const hits = await authFetch(`/api/kb/suggest/${srId}`).then(r => r.json()); if (!hits.length) { el.innerHTML = '
관련 문서 없음
'; return; } el.innerHTML = hits.map(h => renderKBCard(h, true)).join(""); } catch { el.innerHTML = '
추천 로드 실패
'; } } /* ─── AI 채팅 어시스턴트 ─────────────────────────── */ const _CHAT_GREET = [ "안녕하세요! GUARDiA AI 어시스턴트입니다.", "자연어로 ITSM 업무를 처리할 수 있습니다.", "예시 명령을 클릭하거나 직접 입력해 보세요.", ]; const _CHAT_EXAMPLES = [ "승인 대기 SR 목록 보여줘", "엔지니어 워크로드 현황", "KB에서 OOM 검색해줘", "전체 현황 요약해줘", "긴급 SR 있어?", ]; let _chatHistory = []; function initChat() { const fab = document.getElementById("ai-chat-fab"); const panel = document.getElementById("ai-chat-panel"); const close = document.getElementById("ai-chat-close"); const inp = document.getElementById("ai-chat-input"); const send = document.getElementById("ai-chat-send"); fab.addEventListener("click", () => { const open = panel.classList.toggle("hidden"); if (!open && _chatHistory.length === 0) { // 첫 오픈 시 인사 메시지 appendChatMsg("ai", _CHAT_GREET.join("\n")); renderSuggestions(_CHAT_EXAMPLES); } }); close.addEventListener("click", () => panel.classList.add("hidden")); send.addEventListener("click", sendChat); inp.addEventListener("keydown", e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendChat(); } }); } function appendChatMsg(role, text, data) { const el = document.getElementById("ai-chat-messages"); const div = document.createElement("div"); div.className = role === "user" ? "chat-msg chat-user" : "chat-msg chat-ai"; // 마크다운-라이크 렌더링 (bold, bullet) const rendered = text .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/`(.+?)`/g, '$1') .replace(/^• /gm, ' ') .replace(/\n/g, '
'); div.innerHTML = rendered; // 데이터 링크 (SR 클릭 가능) if (data?.length) { const links = data.filter(d => d.sr_id).map(d => `${d.sr_id}` ); if (links.length) { const linksDiv = document.createElement("div"); linksDiv.className = "chat-links"; linksDiv.innerHTML = links.join(""); div.appendChild(linksDiv); } } el.appendChild(div); el.scrollTop = el.scrollHeight; _chatHistory.push({ role, text }); } function renderSuggestions(items) { const el = document.getElementById("ai-chat-suggestions"); el.innerHTML = items.map(s => `` ).join(""); } function sendChatText(text) { document.getElementById("ai-chat-input").value = text; sendChat(); } async function sendChat() { const inp = document.getElementById("ai-chat-input"); const text = inp.value.trim(); if (!text) return; inp.value = ""; document.getElementById("ai-chat-suggestions").innerHTML = ""; appendChatMsg("user", text); // 로딩 표시 const loadId = "chat-loading-" + Date.now(); const el = document.getElementById("ai-chat-messages"); el.insertAdjacentHTML("beforeend", `
⏳ 처리 중…
` ); el.scrollTop = el.scrollHeight; try { const r = await authFetch("/api/nlcmd", { method: "POST", body: JSON.stringify({ text }), }); const data = await r.json(); document.getElementById(loadId)?.remove(); appendChatMsg("ai", data.response, data.data); // 액션 수행 시 데이터 갱신 if (data.action_taken) { await loadAll(); } // 후속 제안 if (data.suggestions?.length) { renderSuggestions(data.suggestions); } else if (data.intent === "QUERY_SR_LIST" && data.data?.length) { const sample = data.data[0]; renderSuggestions([ `${sample.sr_id} 상태 알려줘`, `${sample.sr_id} 자동 배정해줘`, ]); } else if (data.intent === "SEARCH_KB" && data.data?.length) { renderSuggestions(["KB 뷰에서 전체 문서 보기"]); } } catch { document.getElementById(loadId)?.remove(); appendChatMsg("ai", "❌ 명령 처리 중 오류가 발생했습니다."); } } /* ══════════════════════════════════════════════════ 기관 관리 뷰 ══════════════════════════════════════════════════ */ let _instCache = []; let _instCurrentCode = null; async function loadInstitutions() { try { const r = await authFetch("/api/institutions"); _instCache = await r.json(); renderInstitutionTable(_instCache); } catch { _instCache = []; } } function filterInstitutions() { const kw = (document.getElementById("inst-search")?.value || "").toLowerCase(); const region = document.getElementById("inst-region-filter")?.value || ""; const filtered = _instCache.filter(i => { const matchKw = !kw || i.inst_name?.toLowerCase().includes(kw) || i.inst_code?.toLowerCase().includes(kw); const matchRegion = !region || i.region === region; return matchKw && matchRegion; }); renderInstitutionTable(filtered); } function renderInstitutionTable(list) { const tbody = document.getElementById("inst-tbody"); if (!tbody) return; // CUSTOMER 역할이면 버튼 숨김 const canEdit = ["ADMIN", "PM"].includes(_userInfo.role || ""); if (!list.length) { tbody.innerHTML = '등록된 기관이 없습니다.'; return; } tbody.innerHTML = list.map(inst => { const expiry = inst.contract_end ? new Date(inst.contract_end) : null; const today = new Date(); const daysLeft = expiry ? Math.ceil((expiry - today) / 86400000) : null; let expiryBadge = expiry ? `${inst.contract_end}` : "-"; if (daysLeft !== null && daysLeft <= 30 && daysLeft > 7) expiryBadge = `D-${daysLeft} ⚠`; if (daysLeft !== null && daysLeft <= 7) expiryBadge = `D-${daysLeft} 🔴`; return ` ${esc(inst.inst_code)} ${esc(inst.inst_name)} ${esc(inst.region || "-")} ${expiryBadge} ${inst.sla_hours}h ${inst.server_count ?? "-"}대 ${inst.contact_count ?? "-"}명 ${inst.is_active ? "활성" : "비활성"} ${canEdit ? `` : ""} `; }).join(""); } async function openInstDetail(instCode) { _instCurrentCode = instCode; const inst = _instCache.find(i => i.inst_code === instCode); if (!inst) return; // 담당자 목록 로드 후 상세 모달 생성 let contacts = []; try { const r = await authFetch(`/api/institutions/${instCode}/contacts`); contacts = await r.json(); } catch {} // 상세는 SR 상세 모달 재활용 const canEdit = ["ADMIN", "PM"].includes(_userInfo.role || ""); const roleLabel = {MANAGER:"담당자",ENGINEER:"엔지니어",PM:"PM",SECURITY:"보안",HELPDESK:"헬프데스크"}; const html = `
지역${esc(inst.region||"-")}
SLA${inst.sla_hours}시간
전화${esc(inst.phone||"-")}
계약 기간${inst.contract_start||"?"} ~ ${inst.contract_end||"?"}
주소${esc(inst.address||"-")}
비고${esc(inst.note||"-")}
${contacts.length ? ` ${contacts.map(c => ``).join("")}
이름역할부서이메일전화주담
${esc(c.contact_name)} ${roleLabel[c.role]||c.role} ${esc(c.dept||"-")} ${esc(c.email||"-")} ${esc(c.phone||c.mobile||"-")} ${c.is_primary ? "★" : ""}
` : `
등록된 담당자가 없습니다.
`} `; document.getElementById("modal-body").innerHTML = html; document.getElementById("modal-overlay").classList.remove("hidden"); } // 기관 등록/수정 모달 let _instEditCode = null; function openInstModal(instCode = null) { _instEditCode = instCode; const overlay = document.getElementById("inst-modal-overlay"); const form = document.getElementById("inst-form"); form.reset(); document.getElementById("inst-modal-title").textContent = instCode ? "기관 수정" : "기관 등록"; if (instCode) { const inst = _instCache.find(i => i.inst_code === instCode); if (inst) { Object.keys(inst).forEach(k => { const el = form.elements[k]; if (el) el.value = inst[k] ?? ""; }); } } overlay.classList.remove("hidden"); } function closeInstModal() { document.getElementById("inst-modal-overlay").classList.add("hidden"); } async function submitInstForm(e) { e.preventDefault(); const form = e.target; const data = Object.fromEntries(new FormData(form)); // 숫자 변환 if (data.sla_hours) data.sla_hours = parseInt(data.sla_hours); // 빈 문자열 null로 Object.keys(data).forEach(k => { if (data[k] === "") data[k] = null; }); try { let r; if (_instEditCode) { r = await authFetch(`/api/institutions/${_instEditCode}`, { method: "PATCH", body: JSON.stringify(data), }); } else { r = await authFetch("/api/institutions", { method: "POST", body: JSON.stringify(data), }); } if (!r.ok) { const err = await r.json(); showToast(err.detail || "저장 실패", "error"); return; } showToast(_instEditCode ? "기관 정보가 수정됐습니다." : "기관이 등록됐습니다.", "success"); closeInstModal(); loadInstitutions(); } catch { showToast("저장 중 오류가 발생했습니다.", "error"); } } // 담당자 등록 모달 let _contactInstCode = null; function openContactModal(instCode) { _contactInstCode = instCode; document.getElementById("contact-form").reset(); document.getElementById("contact-modal-overlay").classList.remove("hidden"); } function closeContactModal() { document.getElementById("contact-modal-overlay").classList.add("hidden"); } async function submitContactForm(e) { e.preventDefault(); const form = e.target; const data = Object.fromEntries(new FormData(form)); data.is_primary = form.elements.is_primary?.checked || false; Object.keys(data).forEach(k => { if (data[k] === "") data[k] = null; }); try { const r = await authFetch(`/api/institutions/${_contactInstCode}/contacts`, { method: "POST", body: JSON.stringify(data), }); if (!r.ok) { const err = await r.json(); showToast(err.detail || "저장 실패", "error"); return; } showToast("담당자가 등록됐습니다.", "success"); closeContactModal(); openInstDetail(_contactInstCode); } catch { showToast("저장 중 오류가 발생했습니다.", "error"); } } /* ══════════════════════════════════════════════════ 스크립트 관리 뷰 ══════════════════════════════════════════════════ */ let _scriptCache = []; const SCRIPT_CATEGORY_KO = { SM: "SM", REGULAR: "정기점검", ADHOC: "수시점검", DEPLOY: "배포", SECURITY: "보안", MONITORING: "모니터링", }; const SCRIPT_CATEGORY_COLOR = { SM: "#818cf8", REGULAR: "#34d399", ADHOC: "#fbbf24", DEPLOY: "#38bdf8", SECURITY: "#f87171", MONITORING: "#a78bfa", }; async function loadScripts() { try { const r = await authFetch("/api/shell-scripts?limit=200"); _scriptCache = await r.json(); renderScriptList(_scriptCache); } catch { _scriptCache = []; } } function filterScripts() { const kw = (document.getElementById("script-search")?.value || "").toLowerCase(); const cat = document.getElementById("script-category-filter")?.value || ""; const layer = document.getElementById("script-layer-filter")?.value || ""; const filtered = _scriptCache.filter(s => { const matchKw = !kw || s.script_name?.toLowerCase().includes(kw) || s.description?.toLowerCase().includes(kw) || (s.tags||"").toLowerCase().includes(kw); const matchCat = !cat || s.category === cat; const matchLayer = !layer || s.target_layer === layer || s.target_layer === "ALL"; return matchKw && matchCat && matchLayer; }); renderScriptList(filtered); } function renderScriptList(list) { const body = document.getElementById("script-list-body"); if (!body) return; const canEdit = ["ADMIN", "PM", "ENGINEER"].includes(_userInfo.role || ""); if (!list.length) { body.innerHTML = '
등록된 스크립트가 없습니다.
'; return; } body.innerHTML = list.map(s => { const catColor = SCRIPT_CATEGORY_COLOR[s.category] || "#818cf8"; const dangerBadge = s.is_dangerous ? `⚠ 위험` : ""; const approvalBadge = s.requires_approval ? `승인필요` : ""; return `
${esc(s.script_name)}
${SCRIPT_CATEGORY_KO[s.category]||s.category} ${esc(s.target_layer)} ${dangerBadge}${approvalBadge} ${canEdit ? `` : ""}
${esc(s.description)}
버전 ${esc(s.version)} 사용 ${s.use_count}회 ${s.tags ? `${esc(s.tags).split(",").map(t=>`#${t.trim()}`).join(" ")}` : ""}
`; }).join(""); } function openScriptDetail(scriptId) { const s = _scriptCache.find(x => x.id === scriptId); if (!s) return; const catColor = SCRIPT_CATEGORY_COLOR[s.category] || "#818cf8"; const html = `
${SCRIPT_CATEGORY_KO[s.category]||s.category} ${esc(s.target_layer)} ${esc(s.os_type)} ${s.is_dangerous ? `⚠ 위험 명령 포함` : ""} ${s.requires_approval ? `실행 전 승인 필요` : ""}
설명${esc(s.description)}
버전${esc(s.version)}
작성자${esc(s.author||"-")}
사용 횟수${s.use_count}회
${esc(s.script_body)}
${s.sample_output ? `
${esc(s.sample_output)}
` : ""} `; document.getElementById("modal-body").innerHTML = html; document.getElementById("modal-overlay").classList.remove("hidden"); } let _scriptEditId = null; function openScriptModal(scriptId = null) { _scriptEditId = scriptId; const overlay = document.getElementById("script-modal-overlay"); const form = document.getElementById("script-form"); form.reset(); document.getElementById("script-modal-title").textContent = scriptId ? "스크립트 수정" : "스크립트 등록"; if (scriptId) { const s = _scriptCache.find(x => x.id === scriptId); if (s) { Object.keys(s).forEach(k => { const el = form.elements[k]; if (el) el.value = s[k] ?? ""; }); if (s.is_dangerous) form.elements.is_dangerous.checked = true; if (s.requires_approval) form.elements.requires_approval.checked = true; } } overlay.classList.remove("hidden"); } function closeScriptModal() { document.getElementById("script-modal-overlay").classList.add("hidden"); } async function submitScriptForm(e) { e.preventDefault(); const form = e.target; const data = Object.fromEntries(new FormData(form)); data.is_dangerous = form.elements.is_dangerous?.checked || false; data.requires_approval = form.elements.requires_approval?.checked || false; Object.keys(data).forEach(k => { if (data[k] === "") data[k] = null; }); try { let r; if (_scriptEditId) { r = await authFetch(`/api/shell-scripts/${_scriptEditId}`, { method: "PATCH", body: JSON.stringify(data), }); } else { r = await authFetch("/api/shell-scripts", { method: "POST", body: JSON.stringify(data), }); } if (!r.ok) { const err = await r.json(); showToast(err.detail || "저장 실패", "error"); return; } showToast("스크립트가 저장됐습니다.", "success"); closeScriptModal(); loadScripts(); } catch { showToast("저장 중 오류가 발생했습니다.", "error"); } } /* ══════════════════════════════════════════════════ 작업 타임테이블 뷰 ══════════════════════════════════════════════════ */ let _ttCache = []; const WORK_TYPE_KO = { REGULAR_CHECK: "정기점검", PM: "예방정비", SR: "SR작업", ADHOC: "수시점검", DEPLOY: "배포", EMERGENCY: "긴급대응", }; const WORK_TYPE_COLOR = { REGULAR_CHECK: "#34d399", PM: "#818cf8", SR: "#38bdf8", ADHOC: "#fbbf24", DEPLOY: "#a78bfa", EMERGENCY: "#f87171", }; const RESULT_STATUS_KO = { PENDING: "예정", SUCCESS: "완료", FAILED: "실패", PARTIAL: "부분완료", CANCELLED: "취소", }; const RESULT_STATUS_BADGE = { PENDING: "badge-PARSED", SUCCESS: "badge-COMPLETED", FAILED: "badge-FAILED_ROLLBACK", PARTIAL: "badge-PENDING_APPROVAL", CANCELLED: "badge-REJECTED", }; async function loadTimetable() { try { const r = await authFetch("/api/timetable?limit=200"); _ttCache = await r.json(); // 기관·스크립트 목록 로드 (모달 select 채우기) _populateTimetableSelects(); renderTimetableTable(_ttCache); } catch { _ttCache = []; } } async function _populateTimetableSelects() { // 기관 select const instSel = document.getElementById("tt-inst-select"); if (instSel && instSel.options.length <= 1) { if (!_instCache.length) { try { const r = await authFetch("/api/institutions"); _instCache = await r.json(); } catch {} } _instCache.forEach(i => { const opt = document.createElement("option"); opt.value = i.id; opt.textContent = `${i.inst_code} ${i.inst_name}`; instSel.appendChild(opt); }); } // 스크립트 select const scriptSel = document.getElementById("tt-script-select"); if (scriptSel && scriptSel.options.length <= 1) { if (!_scriptCache.length) { try { const r = await authFetch("/api/shell-scripts?limit=200"); _scriptCache = await r.json(); } catch {} } _scriptCache.forEach(s => { const opt = document.createElement("option"); opt.value = s.id; opt.textContent = `[${s.category}] ${s.script_name}`; opt.dataset.body = s.script_body; scriptSel.appendChild(opt); }); } } function fillScriptBody(sel) { const opt = sel.options[sel.selectedIndex]; const ta = document.querySelector("#tt-form textarea[name='command_or_shell']"); if (ta && opt?.dataset.body) ta.value = opt.dataset.body; else if (ta && !opt?.dataset.body) ta.value = ""; } function filterTimetable() { const kw = (document.getElementById("tt-search")?.value || "").toLowerCase(); const type = document.getElementById("tt-type-filter")?.value || ""; const status = document.getElementById("tt-status-filter")?.value || ""; const filtered = _ttCache.filter(t => { const matchKw = !kw || t.title?.toLowerCase().includes(kw) || (t.content||"").toLowerCase().includes(kw); const matchType = !type || t.work_type === type; const matchStatus = !status || t.result_status === status; return matchKw && matchType && matchStatus; }); renderTimetableTable(filtered); } function renderTimetableTable(list) { const tbody = document.getElementById("tt-tbody"); if (!tbody) return; const canEdit = ["ADMIN", "PM", "ENGINEER"].includes(_userInfo.role || ""); if (!list.length) { tbody.innerHTML = '등록된 작업이 없습니다.'; return; } const instMap = {}; _instCache.forEach(i => { instMap[i.id] = i.inst_name; }); tbody.innerHTML = list.map(t => { const typeColor = WORK_TYPE_COLOR[t.work_type] || "#818cf8"; const instName = t.inst_id ? (instMap[t.inst_id] || "-") : "-"; return ` ${WORK_TYPE_KO[t.work_type]||t.work_type} ${esc(t.title)} ${esc(instName)} ${fmtDate(t.scheduled_at)} ${t.completed_at ? fmtDate(t.completed_at) : "-"} ${RESULT_STATUS_KO[t.result_status]||t.result_status} ${esc(t.assignee||"-")} ${t.sr_id ? `${esc(t.sr_id)}` : "-"} ${canEdit ? `` : ""} `; }).join(""); } function openTimetableDetail(id) { const t = _ttCache.find(x => x.id === id); if (!t) return; const typeColor = WORK_TYPE_COLOR[t.work_type] || "#818cf8"; const instMap = {}; _instCache.forEach(i => { instMap[i.id] = i.inst_name; }); const instName = t.inst_id ? (instMap[t.inst_id] || "-") : "-"; const duration = (t.started_at && t.completed_at) ? `${Math.ceil((new Date(t.completed_at) - new Date(t.started_at)) / 60000)}분` : "-"; const html = `
${WORK_TYPE_KO[t.work_type]||t.work_type} ${RESULT_STATUS_KO[t.result_status]||t.result_status}
기관${esc(instName)}
처리예정${fmtDate(t.scheduled_at)}
시작${t.started_at ? fmtDate(t.started_at) : "-"}
완료${t.completed_at ? fmtDate(t.completed_at) : "-"}
소요${duration}
담당자${esc(t.assignee||"-")}
검토자${esc(t.reviewer||"-")}
${t.sr_id ? `
SR${esc(t.sr_id)}
` : ""}
${esc(t.content)}
${t.command_or_shell ? `
${esc(t.command_or_shell)}
` : ""} ${t.result ? `
${esc(t.result)}
` : ""} ${t.note ? `
${esc(t.note)}
` : ""} `; document.getElementById("modal-body").innerHTML = html; document.getElementById("modal-overlay").classList.remove("hidden"); } let _ttEditId = null; function openTimetableModal(ttId = null) { _ttEditId = ttId; const overlay = document.getElementById("tt-modal-overlay"); const form = document.getElementById("tt-form"); form.reset(); document.getElementById("tt-modal-title").textContent = ttId ? "작업 수정" : "작업 등록"; _populateTimetableSelects(); if (ttId) { const t = _ttCache.find(x => x.id === ttId); if (t) { Object.keys(t).forEach(k => { const el = form.elements[k]; if (!el) return; if (k === "scheduled_at" || k === "started_at" || k === "completed_at") { el.value = t[k] ? t[k].slice(0,16) : ""; } else { el.value = t[k] ?? ""; } }); } } else { // 기본값: 지금부터 1시간 후 const dt = new Date(Date.now() + 3600000); const iso = dt.toISOString().slice(0,16); const el = form.elements.scheduled_at; if (el) el.value = iso; } overlay.classList.remove("hidden"); } function closeTimetableModal() { document.getElementById("tt-modal-overlay").classList.add("hidden"); } async function submitTimetableForm(e) { e.preventDefault(); const form = e.target; const data = Object.fromEntries(new FormData(form)); // 빈 문자열 → null, 숫자 변환 Object.keys(data).forEach(k => { if (data[k] === "") data[k] = null; }); if (data.inst_id) data.inst_id = parseInt(data.inst_id); if (data.script_id) data.script_id = parseInt(data.script_id); try { let r; if (_ttEditId) { r = await authFetch(`/api/timetable/${_ttEditId}`, { method: "PATCH", body: JSON.stringify(data), }); } else { r = await authFetch("/api/timetable", { method: "POST", body: JSON.stringify(data), }); } if (!r.ok) { const err = await r.json(); showToast(err.detail || "저장 실패", "error"); return; } showToast("작업이 저장됐습니다.", "success"); closeTimetableModal(); loadTimetable(); } catch { showToast("저장 중 오류가 발생했습니다.", "error"); } } async function exportTimetableExcel() { const type = document.getElementById("tt-type-filter")?.value || ""; const status = document.getElementById("tt-status-filter")?.value || ""; const params = new URLSearchParams(); if (type) params.set("work_type", type); if (status) params.set("result_status", status); try { const r = await authFetch(`/api/timetable/export/excel?${params.toString()}`); if (!r.ok) { showToast("Excel 생성 실패", "error"); return; } const blob = await r.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); const cd = r.headers.get("Content-Disposition") || ""; const match = cd.match(/filename\*=UTF-8''(.+)/); a.download = match ? decodeURIComponent(match[1]) : "GUARDiA_작업이력.xlsx"; a.href = url; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); showToast("Excel 다운로드 완료", "success"); } catch { showToast("다운로드 중 오류가 발생했습니다.", "error"); } } /* ─── Helpers ───────────────────────────────────── */ function esc(s) { return String(s ?? "") .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function fmtDate(iso) { if (!iso) return ""; try { return new Date(iso).toLocaleString("ko-KR", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); } catch { return iso; } }