zioinfo-mail/certification/source/05_frontend_dashboard.js
DESKTOP-TKLFCPR\ython 6ad7a158c8 feat(cert): 프로그램 등록 신청서 3종 DOCX + 저작권 등록용 소스코드
[DOCX 3종 생성 (UTF-8, 편집 가능)]
- 01_소프트웨어_저작권_등록_신청서.docx  (37KB)
  한국저작권위원회 제출용 / 맑은 고딕 / 색상 섹션
- 02_소프트웨어사업자_신고서.docx         (37KB)
  과학기술정보통신부/KOSA 제출용
- 03_조달청_나라장터_물품_등록_신청서.docx (38KB)
  공공기관 납품용 나라장터 등록

[generate_docx.py 특징]
- python-docx 기반 (한글 UTF-8 완전 지원)
- 검정 박스 없음 (맑은 고딕 직접 적용)
- 편집 가능: Word / 한글(HWP) / LibreOffice
- 섹션별 색상 배너 (파란/빨간/주황 테마)
- 서명란, 첨부서류, 수수료 안내 포함

[certification/source/ 저작권 등록용 소스코드]
- 01_core_ssh_agentless.py  (450줄) - 에이전트리스 SSH 핵심
- 02_core_license_engine.py (455줄) - AES-256-GCM 라이선스
- 03_router_sr_management.py(501줄) - SR 관리 API
- 04_core_ai_classifier.py  (90줄)  - AI 티켓 분류
- 05_frontend_dashboard.js  (200줄) - 대시보드 프론트
- README.md - 제출 안내 및 독창성 설명
- 모든 파일: 영업비밀(암호화키) 마스킹 처리

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 12:33:29 +09:00

200 lines
7.9 KiB
JavaScript

/* ══════════════════════════════════════════════════
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));
});
}
/* ══════════════════════════════════════════════════
Nifty 사이드바 계층 메뉴 토글
══════════════════════════════════════════════════ */
function toggleNavGroup(header) {
const body = header.nextElementSibling;
const isOpen = body.classList.contains('open');
body.classList.toggle('open', !isOpen);
header.setAttribute('aria-expanded', String(!isOpen));
}
// 현재 URL에 해당하는 메뉴 자동 열기
(function autoOpenNavGroup() {
document.querySelectorAll('.nav-group-body .nav-sub-item').forEach(item => {
const href = item.getAttribute('href') || '';
if (href && location.pathname.startsWith(href.split('?')[0])) {
const body = item.closest('.nav-group-body');
const header = body?.previousElementSibling;
if (body && header) {
body.classList.add('open');
header.setAttribute('aria-expanded', 'true');
item.classList.add('active');
}
}
});
})();
/* ══════════════════════════════════════════════════
테마 관리 (스크립트 최상단 — 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;