From 600c54c1f0ef4c8452fce16b4e5d375c9a8ab76c Mon Sep 17 00:00:00 2001 From: Taehoon Date: Wed, 11 Mar 2026 17:52:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=81=AC=EB=A1=A4=EB=9F=AC=20=EB=B6=80?= =?UTF-8?q?=EC=84=9C=20=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A7=91=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=95=ED=99=95=EB=8F=84=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getData API 가로채기 기능을 통한 부서(department) 자동 수집 구현 - 파일 0개 기준의 "데이터 없음" 분류 로직 최적화 (LEFT JOIN 적용) - 관리자 권한 인증 모달 스타일 복구 및 UI 정밀 조정 - 중복 등록 프로젝트(sm-25-032-phlinfra) DB 정리 및 테스트 파일 삭제 --- __pycache__/crawler_service.cpython-312.pyc | Bin 17206 -> 18848 bytes __pycache__/server.cpython-312.pyc | Bin 12543 -> 12696 bytes crawler_service.py | 50 +++++-- js/dashboard.js | 38 ++++-- server.py | 23 +++- style/dashboard.css | 138 ++++++++++++-------- templates/dashboard.html | 10 +- 7 files changed, 178 insertions(+), 81 deletions(-) diff --git a/__pycache__/crawler_service.cpython-312.pyc b/__pycache__/crawler_service.cpython-312.pyc index 65ae16e74d5db8b77b9530eedc799428ef204f84..676426cc6f4456ee0394682192a49427b0a775d0 100644 GIT binary patch delta 4992 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 J0{>y~_CtlP0#E<| diff --git a/crawler_service.py b/crawler_service.py index e2af8e7..0316cad 100644 --- a/crawler_service.py +++ b/crawler_service.py @@ -58,7 +58,7 @@ def crawler_thread_worker(msg_queue, user_id, password): browser = None try: msg_queue.put(json.dumps({'type': 'log', 'message': '브라우저 엔진 가동 (전 기능 복구 모드)...'})) - browser = await p.chromium.launch(headless=False, args=[ + browser = await p.chromium.launch(headless=True, args=[ "--no-sandbox", "--disable-dev-shm-usage", "--disable-blink-features=AutomationControlled" @@ -68,7 +68,7 @@ def crawler_thread_worker(msg_queue, user_id, password): user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" ) - captured_data = {"tree": None, "_is_root_archive": False, "project_list": []} + captured_data = {"tree": None, "_is_root_archive": False, "project_list": [], "last_project_data": None} async def global_interceptor(response): url = response.url @@ -84,6 +84,8 @@ def crawler_thread_worker(msg_queue, user_id, password): if is_root: captured_data["tree"] = await response.json() captured_data["_is_root_archive"] = True + elif "getData" in url and "overview" in url: + captured_data["last_project_data"] = await response.json() except: pass context.on("response", global_interceptor) @@ -144,6 +146,32 @@ def crawler_thread_worker(msg_queue, user_id, password): else: await target_el.click(force=True) await page.wait_for_selector("text=활동로그", timeout=30000) + + # [부서 정보 수집] getData 응답 대기 및 DB 업데이트 + for _ in range(10): + if captured_data.get("last_project_data"): break + await asyncio.sleep(0.5) + + last_data = captured_data.get("last_project_data") + if last_data: + if isinstance(last_data, list) and len(last_data) > 0: + last_data = last_data[0] + + if isinstance(last_data, dict): + proj_data = last_data.get("data", {}) + if isinstance(proj_data, list) and len(proj_data) > 0: + proj_data = proj_data[0] + + if isinstance(proj_data, dict): + dept = proj_data.get("department") + p_id = proj_data.get("project_id") + if dept and p_id: + with get_db_connection() as conn: + with conn.cursor() as cursor: + cursor.execute("UPDATE projects_master SET department = %s WHERE project_id = %s", (dept, p_id)) + conn.commit() + captured_data["last_project_data"] = None # 초기화 + await asyncio.sleep(2) recent_log = "데이터 없음"; file_count = 0 @@ -183,12 +211,18 @@ def crawler_thread_worker(msg_queue, user_id, password): await asyncio.sleep(0.5) if captured_data["tree"]: - tree = captured_data["tree"].get('currentTreeObject', captured_data["tree"]) - total = len(tree.get("file", {})) - folders = tree.get("folder", {}) - if isinstance(folders, dict): - for f in folders.values(): total += int(f.get("filesCount", 0)) - file_count = total + tree_data = captured_data["tree"] + if isinstance(tree_data, list) and len(tree_data) > 0: + tree_data = tree_data[0] + + if isinstance(tree_data, dict): + tree = tree_data.get('currentTreeObject', tree_data) + if isinstance(tree, dict): + total = len(tree.get("file", {})) + folders = tree.get("folder", {}) + if isinstance(folders, dict): + for f in folders.values(): total += int(f.get("filesCount", 0)) + file_count = total # 4. DB 실시간 저장 if current_p_id: diff --git a/js/dashboard.js b/js/dashboard.js index 41daaa9..a0b29a7 100644 --- a/js/dashboard.js +++ b/js/dashboard.js @@ -73,7 +73,7 @@ async function loadActivityAnalysis(date = "") {
방치 (14일 초과)
${summary.stale}
-
데이터 없음 (파일 0개 등)
${summary.unknown}
+
데이터 없음 (파일 0개)
${summary.unknown}
`; } catch (e) { console.error("분석 로드 실패:", e); } } @@ -81,16 +81,16 @@ async function loadActivityAnalysis(date = "") { // --- 렌더링 엔진 --- function renderDashboard(data) { const container = document.getElementById('projectAccordion'); - container.innerHTML = ''; + container.innerHTML = ''; const grouped = groupData(data); - Object.keys(grouped).sort((a,b) => (CONTINENT_ORDER[a]||99) - (CONTINENT_ORDER[b]||99)).forEach(continent => { + Object.keys(grouped).sort((a, b) => (CONTINENT_ORDER[a] || 99) - (CONTINENT_ORDER[b] || 99)).forEach(continent => { const continentDiv = document.createElement('div'); continentDiv.className = 'continent-group active'; let html = `
${continent}
`; Object.keys(grouped[continent]).sort().forEach(country => { html += `
${country}
프로젝트명
담당부서
담당자
파일수
최근로그
- ${grouped[continent][country].sort((a,b)=>a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}
`; + ${grouped[continent][country].sort((a, b) => a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}
`; }); html += ``; continentDiv.innerHTML = html; @@ -113,16 +113,30 @@ function createProjectHtml(p) { const [name, dept, admin, logRaw, files] = p; const recentLog = (!logRaw || logRaw === "X" || logRaw === "데이터 없음") ? "기록 없음" : logRaw; const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음"; - const statusClass = (files === 0 || files === null) ? "status-error" : (recentLog === "기록 없음") ? "status-warning" : ""; + + // 파일이 0개 또는 NULL인 경우에만 에러(붉은색) 표시 + const statusClass = (files === 0 || files === null) ? "status-error" : ""; return `
-
${name}
${dept}
${admin}
${files||0}
${recentLog}
+
${name}
${dept}
${admin}
${files || 0}
${recentLog}
-

참여 인원 상세

이름소속권한
${admin}${dept}관리자
-

최근 활동

유형내용일시
로그동기화 완료${logTime}
+
+

참여 인원 상세

+ + + +
이름소속권한
${admin}${dept}관리자
+
+
+

최근 활동

+ + + +
유형내용일시
로그동기화 완료${logTime}
+
`; @@ -132,18 +146,18 @@ function createProjectHtml(p) { function toggleGroup(h) { h.parentElement.classList.toggle('active'); } function toggleAccordion(h) { const item = h.parentElement; - item.parentElement.querySelectorAll('.accordion-item').forEach(el => { if(el!==item) el.classList.remove('active'); }); + item.parentElement.querySelectorAll('.accordion-item').forEach(el => { if (el !== item) el.classList.remove('active'); }); item.classList.toggle('active'); } function showActivityDetails(status) { const modal = document.getElementById('activityDetailModal'), tbody = document.getElementById('modalTableBody'), title = document.getElementById('modalTitle'); - const names = { active:'정상', warning:'주의', stale:'방치', unknown:'데이터 없음' }; + const names = { active: '정상', warning: '주의', stale: '방치', unknown: '데이터 없음' }; const filtered = (projectActivityDetails || []).filter(d => d.status === status); title.innerText = `${names[status]} 목록 (${filtered.length}개)`; tbody.innerHTML = filtered.map(p => { const o = rawData.find(r => r[0] === p.name); - return `${p.name}${o?o[1]:"-"}${o?o[2]:"-"}`; + return `${p.name}${o ? o[1] : "-"}${o ? o[2] : "-"}`; }).join(''); modal.style.display = 'flex'; } @@ -186,7 +200,7 @@ function closeAuthModal() { document.getElementById('authModal').style.display = async function submitAuth() { const id = document.getElementById('authId').value, pw = document.getElementById('authPw').value, err = document.getElementById('authErrorMessage'); try { - const res = await fetch('/auth/crawl', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({user_id:id, password:pw}) }); + const res = await fetch('/auth/crawl', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: id, password: pw }) }); const data = await res.json(); if (data.success) { closeAuthModal(); startCrawlProcess(); } else { err.innerText = "크롤링을 할 수 없습니다."; err.style.display = 'block'; } diff --git a/server.py b/server.py index fd96d9e..bc1c258 100644 --- a/server.py +++ b/server.py @@ -102,8 +102,8 @@ async def get_project_data(date: str = None): SELECT m.project_nm, m.short_nm, m.department, m.master, h.recent_log, h.file_count, m.continent, m.country FROM projects_master m - JOIN projects_history h ON m.project_id = h.project_id - WHERE h.crawl_date = %s ORDER BY m.project_id ASC + LEFT JOIN projects_history h ON m.project_id = h.project_id AND h.crawl_date = %s + ORDER BY m.project_id ASC """ cursor.execute(sql, (target_date,)) rows = cursor.fetchall() @@ -130,6 +130,8 @@ async def get_project_activity(date: str = None): target_date_val = datetime.strptime(date.replace(".", "-"), "%Y-%m-%d").date() target_date_dt = datetime.combine(target_date_val, datetime.min.time()) + + # 아코디언 리스트와 동일하게 마스터의 모든 프로젝트를 가져오되, 해당 날짜의 히스토리를 매칭 sql = """ SELECT m.project_id, m.project_nm, m.short_nm, h.recent_log, h.file_count FROM projects_master m @@ -142,12 +144,27 @@ async def get_project_activity(date: str = None): for r in rows: log, files = r['recent_log'], r['file_count'] status, days = "unknown", 999 - if log and log != "데이터 없음" and files and files > 0: + + # 파일 수 정수 변환 (데이터가 없거나 0이면 0) + file_val = int(files) if files else 0 + has_log = log and log != "데이터 없음" and log != "X" + + if file_val == 0: + # [핵심] 파일이 0개면 무조건 "데이터 없음" + status = "unknown" + elif has_log: + # 로그 날짜가 있는 경우 정밀 분석 match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log) if match: diff = (target_date_dt - datetime.strptime(match.group(0), "%Y.%m.%d")).days status = "active" if diff <= 7 else "warning" if diff <= 14 else "stale" days = diff + else: + status = "stale" + else: + # 파일은 있지만 로그가 없는 경우 + status = "stale" + analysis["summary"][status] += 1 analysis["details"].append({"name": r['short_nm'] or r['project_nm'], "status": status, "days_ago": days}) return analysis diff --git a/style/dashboard.css b/style/dashboard.css index 38ce943..61a6645 100644 --- a/style/dashboard.css +++ b/style/dashboard.css @@ -36,7 +36,7 @@ .portal-card h3 { font-size: 20px; color: var(--text-main); margin: 0; } .portal-card p { font-size: 14px; color: var(--text-sub); margin: 0; } -/* Dashboard Layout */ +/* Dashboard Fixed Elements */ header { position: fixed; top: var(--topbar-h); left: 0; right: 0; z-index: 1001; background: #fff; height: var(--header-h); display: flex; justify-content: space-between; align-items: center; padding: 0 32px; border-bottom: 1px solid #f5f5f5; @@ -59,50 +59,46 @@ header { .main-content { margin-top: var(--fixed-total-h); padding: 32px; max-width: 1400px; margin-left: auto; margin-right: auto; } -/* 로그 콘솔 - 초기 디자인 복구 (Sticky Terminal 스타일) */ +/* 로그 콘솔 (Terminal Style) */ .log-console { - position: sticky; - top: var(--fixed-total-h); - z-index: 999; - background: #000; - color: #0f0; - font-family: 'Consolas', 'Monaco', monospace; - padding: 15px; - margin-bottom: 20px; - border-radius: 4px; - max-height: 250px; - overflow-y: auto; - font-size: 12px; - line-height: 1.5; - box-shadow: 0 10px 20px rgba(0,0,0,0.2); + position: sticky; top: var(--fixed-total-h); z-index: 999; + background: #000; color: #0f0; font-family: 'Consolas', monospace; padding: 15px; margin-bottom: 20px; + border-radius: 4px; max-height: 250px; overflow-y: auto; font-size: 12px; line-height: 1.5; box-shadow: 0 10px 20px rgba(0,0,0,0.2); } +.log-console-header { color: #fff; border-bottom: 1px solid #333; margin-bottom: 10px; padding-bottom: 5px; font-weight: bold; } -.log-console-header { - color: #fff; - border-bottom: 1px solid #333; - margin-bottom: 10px; - padding-bottom: 5px; - font-weight: bold; -} - -/* 모달 정중앙 배치 */ +/* 인증 모달 */ .activity-modal-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px); z-index: 3000; display: flex; align-items: center; justify-content: center; padding: 20px; } +.activity-modal-content { background: #fff; width: 600px; max-height: 85vh; border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5); } +.auth-modal-content { background: #fff; width: 440px; border-radius: 16px; padding: 40px; text-align: center; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5); display: flex; flex-direction: column; gap: 32px; border: 1px solid rgba(0,0,0,0.05); } -.activity-modal-content { - background: #fff; width: 600px; max-height: 85vh; border-radius: 12px; - display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5); +.auth-header i { font-size: 32px; color: var(--primary-color); margin-bottom: 16px; } +.auth-header h3 { font-size: 20px; font-weight: 800; color: #111; margin-bottom: 8px; } +.auth-header p { font-size: 13px; color: var(--text-sub); } + +.auth-body { display: flex; flex-direction: column; gap: 20px; text-align: left; } +.input-group { display: flex; flex-direction: column; gap: 8px; } +.input-group label { font-size: 12px; font-weight: 700; color: var(--text-main); } +.input-group input { + height: 48px; padding: 0 16px; border: 1px solid var(--border-color); border-radius: 8px; + font-size: 14px; transition: 0.2s; background: #f9f9f9; } +.input-group input:focus { border-color: var(--primary-color); background: #fff; box-shadow: 0 0 0 3px var(--primary-lv-0); outline: none; } -.auth-modal-content { - background: #fff; width: 400px; border-radius: 16px; padding: 40px; - text-align: center; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5); - display: flex; flex-direction: column; gap: 25px; +.error-text { color: var(--error-color); font-size: 12px; font-weight: 600; text-align: center; margin-top: -10px; } + +.auth-footer { display: grid; grid-template-columns: 1fr 1.5fr; gap: 12px; } +.auth-footer button { + height: 48px; border-radius: 8px; font-size: 14px; font-weight: 700; cursor: pointer; border: none; transition: 0.2s; } - +.cancel-btn { background: #f1f3f5; color: #495057; } +.cancel-btn:hover { background: #e9ecef; } +.login-btn { background: var(--primary-color); color: #fff; } +.login-btn:hover { background: #153a34; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(30,81,73,0.3); } .modal-header { padding: 20px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; } .modal-header h3 { margin: 0; font-size: 16px; color: var(--primary-color); } .close-btn { background: none; border: none; font-size: 24px; cursor: pointer; color: var(--text-sub); } @@ -110,31 +106,68 @@ header { .modal-row { cursor: pointer; border-bottom: 1px solid #f5f5f5; } .modal-row:hover { background: var(--primary-lv-0); } -/* 인증 모달 내부 요소 */ -.auth-header i { font-size: 40px; color: var(--primary-color); margin-bottom: 15px; } -.auth-header h3 { font-size: 22px; color: var(--text-main); margin: 0; font-weight: 800; } -.auth-header p { font-size: 14px; color: var(--text-sub); margin-top: 8px; } -.auth-body { display: flex; flex-direction: column; gap: 15px; text-align: left; } -.input-group { display: flex; flex-direction: column; gap: 6px; } -.input-group label { font-size: 12px; font-weight: 700; color: var(--text-sub); } -.input-group input { padding: 12px 16px; border: 1px solid var(--border-color); border-radius: 8px; font-size: 14px; transition: 0.2s; } -.input-group input:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 3px var(--primary-lv-1); } -.error-text { color: var(--error-color); font-size: 13px; font-weight: 600; margin-top: 10px; text-align: center; } -.auth-footer { display: flex; gap: 10px; margin-top: 10px; } -.auth-footer button { flex: 1; padding: 12px; border-radius: 8px; font-weight: 700; cursor: pointer; transition: 0.2s; border: none; } -.cancel-btn { background: #f3f4f6; color: var(--text-sub); } -.login-btn { background: var(--primary-color); color: #fff; } +/* Accordion Layout - 정밀 정렬 개선 */ +.accordion-list-header { + position: sticky; top: var(--fixed-total-h); background: #fff; z-index: 900; + font-size: 11px; font-weight: 700; color: var(--text-sub); + padding: 12px 24px; /* 패딩 통일 */ + border-bottom: 2px solid var(--primary-color); + box-shadow: 0 4px 10px rgba(0,0,0,0.05); + display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px; align-items: center; +} -/* Accordion Layout */ -.accordion-list-header { position: sticky; top: var(--fixed-total-h); background: #fff; z-index: 900; font-size: 11px; font-weight: 700; color: var(--text-sub); padding: 14px 24px; border-bottom: 2px solid var(--primary-color); box-shadow: 0 4px 10px rgba(0,0,0,0.05); display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px; align-items: center; } -.accordion-header { display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px; padding: 16px 24px; align-items: center; cursor: pointer; border-bottom: 1px solid var(--border-color); transition: background 0.1s; } +.accordion-header { + display: grid; grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr; gap: 16px; + padding: 12px 24px; /* 패딩 통일 */ + align-items: center; cursor: pointer; border-bottom: 1px solid var(--border-color); + transition: background 0.1s; +} .accordion-item:hover .accordion-header { background: var(--primary-lv-0); } +.accordion-item.active .accordion-header { background: var(--primary-lv-0); border-bottom: none; } + .repo-title { font-weight: 700; color: var(--primary-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .repo-dept, .repo-admin { font-size: 12px; color: var(--text-main); } .repo-files { text-align: center; font-weight: 600; } .repo-log { font-size: 11px; color: var(--text-sub); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.accordion-body { display: none; padding: 24px; background: var(--bg-muted); border-bottom: 1px solid var(--border-color); } + +.accordion-body { + display: none; + padding: 24px 24px 32px 24px; /* 좌우 패딩을 행 헤더와 일치시킴 */ + background: #fff; /* 일체감을 위해 흰색 배경 사용 가능 (필요시 var(--bg-muted)) */ + border-bottom: 1px solid var(--border-color); +} .accordion-item.active .accordion-body { display: block; } + +/* 상세 표 정밀 정렬 */ +.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; } +.detail-section h4 { + font-size: 13px; margin-bottom: 12px; color: var(--text-main); + border-left: 3px solid var(--primary-color); padding-left: 10px; + font-weight: 700; +} + +.data-table { + width: 100%; border-collapse: collapse; font-size: 12px; + table-layout: fixed; /* 컬럼 너비 고정을 위해 필수 */ +} +.data-table th, .data-table td { + padding: 10px 8px; border-bottom: 1px solid var(--border-color); + text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.data-table th { color: var(--text-sub); font-weight: 600; background: #fcfcfc; } + +/* 컬럼별 너비 고정 (위아래 일치 및 정리) */ +/* 참여 인원 상세 표 비율 */ +#personnel-table th:nth-child(1), #personnel-table td:nth-child(1) { width: 25%; } +#personnel-table th:nth-child(2), #personnel-table td:nth-child(2) { width: 45%; } +#personnel-table th:nth-child(3), #personnel-table td:nth-child(3) { width: 30%; } + +/* 최근 활동 표 비율 */ +#activity-table th:nth-child(1), #activity-table td:nth-child(1) { width: 20%; } +#activity-table th:nth-child(2), #activity-table td:nth-child(2) { width: 50%; } +#activity-table th:nth-child(3), #activity-table td:nth-child(3) { width: 30%; } + +/* Status Styles */ .status-warning { background: #fffcf0; } .status-error { background: #fff5f4; } .warning-text { color: var(--error-color) !important; font-weight: 700; } @@ -146,9 +179,6 @@ header { .continent-body, .country-body { display: none; padding: 10px 0 10px 15px; } .active>.continent-body, .active>.country-body { display: block; } -.data-table { width: 100%; border-collapse: collapse; font-size: 12px; } -.data-table th, .data-table td { padding: 10px 8px; border-bottom: 1px solid var(--border-color); text-align: left; } -.data-table th { color: var(--text-sub); font-weight: 600; background: #fcfcfc; } .sync-btn { display: flex; align-items: center; gap: 8px; background-color: var(--primary-color); color: #fff; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; transition: 0.2s; } .admin-info { font-size: 12px; color: var(--text-sub); margin-left: 16px; padding: 6px 12px; background: #f8f9fa; border-radius: 4px; border: 1px solid var(--border-color); } .admin-info strong { color: var(--primary-color); font-weight: 700; } diff --git a/templates/dashboard.html b/templates/dashboard.html index 96fca23..6c0c0b2 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -36,7 +36,7 @@
기준날짜: -
접속자: 이태훈[전체관리자]
@@ -75,7 +75,8 @@
- +
@@ -86,7 +87,8 @@ -