diff --git a/.env b/.env
index 3164fdb..d1fdeed 100644
--- a/.env
+++ b/.env
@@ -1,2 +1,6 @@
PM_USER_ID=b21364
-PM_PASSWORD=b21364!.`nDB_HOST=localhost`nDB_USER=root`nDB_PASSWORD=45278434`nDB_NAME=crawling
+PM_PASSWORD=b21364!.
+DB_HOST=localhost
+DB_USER=root
+DB_PASSWORD=45278434
+DB_NAME=PM_proto
diff --git a/README.md b/README.md
index 39180ec..afa860c 100644
--- a/README.md
+++ b/README.md
@@ -37,12 +37,18 @@ AI는 파일을 분류할 때 단순한 키워드 매칭이 아닌, 아래의 **
---
-# 프로젝트 관리 규칙
+# 🛠️ 개발 및 관리 규칙 (Strict Development Rules)
-1. **언어 설정**: 영어로 생각하되, 모든 답변은 한국어로 작성한다. (일본어, 중국어는 절대 사용하지 않는다.)
-2. **수정 권한 제한**: 사용자가 명시적으로 지시한 사항 외에는 **절대 절대 절대** 코드를 임의로 수정하지 않는다.
-3. **로그 기록 철저**: 모달 오픈 여부, 수집 성공/실패 여부 등 진행 상황을 실시간 로그에 상세히 표시한다.
-4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고, 사용자가 **'진행시켜'**라고 명령한 경우에만 작업을 수행한다.
+1. **언어 설정**: 영어로 생각하되, 모든 답변은 **한국어**로 작성한다.
+2. **임의 수정 절대 금지 (Zero-Arbitrary Change)**:
+ - 사용자가 명시적으로 지시한 부분 외에는 **단 한 줄의 코드도, 그 어떤 파일도 임의로 수정, 정리, 리팩토링하지 않는다.**
+ - 지시받지 않은 다른 파트의 코드는 절대 건드리지 않으며, 영향 범위가 요청 범위를 벗어나지 않도록 '외과 수술식(Surgical) 수정'을 원칙으로 한다.
+3. **개선 작업 절차 (Test-First Approach)**:
+ - 사용자가 개선(Refactoring, Optimization 등)을 지시한 경우, **수정 전 현재 시스템이 정상적으로 잘 작동하는지 먼저 전수 확인**한다.
+ - 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다.
+ - 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다.
+4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다.
+5. **로그 기록 철저**: 진행 상황(로그인, 수집, 오류 등)을 실시간 로그에 상세히 표시하여 투명성을 확보한다.
---
diff --git a/__pycache__/crawler_service.cpython-312.pyc b/__pycache__/crawler_service.cpython-312.pyc
index 73a9a5c..65ae16e 100644
Binary files a/__pycache__/crawler_service.cpython-312.pyc and b/__pycache__/crawler_service.cpython-312.pyc differ
diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc
index 34b8a2d..2f465da 100644
Binary files a/__pycache__/server.cpython-312.pyc and b/__pycache__/server.cpython-312.pyc differ
diff --git a/composition_debug.png b/composition_debug.png
deleted file mode 100644
index 5ccabb3..0000000
Binary files a/composition_debug.png and /dev/null differ
diff --git a/crawler_service.py b/crawler_service.py
index f56d3a2..e2af8e7 100644
--- a/crawler_service.py
+++ b/crawler_service.py
@@ -11,15 +11,18 @@ from datetime import datetime
from playwright.async_api import async_playwright
from dotenv import load_dotenv
-load_dotenv()
+load_dotenv(override=True)
+
+# 글로벌 중단 제어용 이벤트
+crawl_stop_event = threading.Event()
def get_db_connection():
- """MySQL 데이터베이스 연결을 반환합니다."""
+ """MySQL 데이터베이스 연결을 반환 (환경변수 기반)"""
return pymysql.connect(
- host='localhost',
- user='root',
- password='45278434',
- database='crawling',
+ host=os.getenv('DB_HOST', 'localhost'),
+ user=os.getenv('DB_USER', 'root'),
+ password=os.getenv('DB_PASSWORD', '45278434'),
+ database=os.getenv('DB_NAME', 'PM_proto'),
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
)
@@ -27,12 +30,10 @@ def get_db_connection():
def clean_date_string(date_str):
if not date_str: return ""
match = re.search(r'(\d{2})[./-](\d{2})[./-](\d{2})', date_str)
- if match:
- return f"20{match.group(1)}.{match.group(2)}.{match.group(3)}"
+ if match: return f"20{match.group(1)}.{match.group(2)}.{match.group(3)}"
return date_str[:10].replace("-", ".")
def parse_log_id(log_id):
- """ID 구조: 로그고유번호_시간_활동한 사람_활동내용_활동대상"""
if not log_id or "_" not in log_id: return log_id
try:
parts = log_id.split('_')
@@ -45,6 +46,7 @@ def parse_log_id(log_id):
return log_id
def crawler_thread_worker(msg_queue, user_id, password):
+ crawl_stop_event.clear()
if sys.platform == 'win32':
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
@@ -55,11 +57,18 @@ def crawler_thread_worker(msg_queue, user_id, password):
async with async_playwright() as p:
browser = None
try:
- msg_queue.put(json.dumps({'type': 'log', 'message': '브라우저 엔진 가동 (전 기능 완벽 복구 모드)...'}))
- browser = await p.chromium.launch(headless=False, args=["--no-sandbox"])
- context = await browser.new_context(viewport={'width': 1600, 'height': 900})
+ msg_queue.put(json.dumps({'type': 'log', 'message': '브라우저 엔진 가동 (전 기능 복구 모드)...'}))
+ browser = await p.chromium.launch(headless=False, args=[
+ "--no-sandbox",
+ "--disable-dev-shm-usage",
+ "--disable-blink-features=AutomationControlled"
+ ])
+ context = await browser.new_context(
+ viewport={'width': 1600, 'height': 900},
+ 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, "_tree_url": "", "project_list": []}
+ captured_data = {"tree": None, "_is_root_archive": False, "project_list": []}
async def global_interceptor(response):
url = response.url
@@ -68,17 +77,13 @@ def crawler_thread_worker(msg_queue, user_id, password):
data = await response.json()
captured_data["project_list"] = data.get("data", [])
elif "getTreeObject" in url:
- # [핵심 복원] 정확한 루트 경로 판별 로직
is_root = False
if "params[resourcePath]=" in url:
path_val = url.split("params[resourcePath]=")[1].split("&")[0]
if path_val in ["%2F", "/"]: is_root = True
-
if is_root:
- data = await response.json()
- captured_data["tree"] = data
- captured_data["_is_root_archive"] = "archive" in url
- captured_data["_tree_url"] = url
+ captured_data["tree"] = await response.json()
+ captured_data["_is_root_archive"] = True
except: pass
context.on("response", global_interceptor)
@@ -86,54 +91,52 @@ def crawler_thread_worker(msg_queue, user_id, password):
await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
# 로그인
- if await page.locator("#login-by-id").is_visible(timeout=5000):
+ if await page.locator("#login-by-id").is_visible(timeout=10000):
await page.click("#login-by-id")
await page.fill("#user_id", user_id)
await page.fill("#user_pw", password)
await page.click("#login-btn")
- # 리스트 로딩 대기
await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000)
await asyncio.sleep(3)
- # [Phase 1] DB 기초 정보 동기화 (마스터 테이블)
+ # [Phase 1] DB 마스터 정보 동기화
if captured_data["project_list"]:
conn = get_db_connection()
try:
with conn.cursor() as cursor:
for p_info in captured_data["project_list"]:
- try:
- sql = """
- INSERT INTO projects_master (project_id, project_nm, short_nm, master, continent, country)
- VALUES (%s, %s, %s, %s, %s, %s)
- ON DUPLICATE KEY UPDATE
- project_nm = VALUES(project_nm), short_nm = VALUES(short_nm),
- master = VALUES(master), continent = VALUES(continent), country = VALUES(country)
- """
- cursor.execute(sql, (p_info.get("project_id"), p_info.get("project_nm"),
- p_info.get("short_nm", "").strip(), p_info.get("master"),
- p_info.get("large_class"), p_info.get("mid_class")))
- except: continue
- conn.commit()
- msg_queue.put(json.dumps({'type': 'log', 'message': f'DB 마스터 정보 동기화 완료.'}))
+ sql = """
+ INSERT INTO projects_master (project_id, project_nm, short_nm, master, continent, country)
+ VALUES (%s, %s, %s, %s, %s, %s)
+ ON DUPLICATE KEY UPDATE
+ project_nm = VALUES(project_nm), short_nm = VALUES(short_nm),
+ master = VALUES(master), continent = VALUES(continent), country = VALUES(country)
+ """
+ cursor.execute(sql, (p_info.get("project_id"), p_info.get("project_nm"),
+ p_info.get("short_nm", "").strip(), p_info.get("master"),
+ p_info.get("large_class"), p_info.get("mid_class")))
+ conn.commit()
+ msg_queue.put(json.dumps({'type': 'log', 'message': 'DB 마스터 정보 동기화 완료.'}))
finally: conn.close()
- # [Phase 2] h4 태그 기반 수집 루프
+ # [Phase 2] 수집 루프
names = await page.locator("h4.list__contents_aria_group_body_list_item_label").all_inner_texts()
project_names = list(dict.fromkeys([n.strip() for n in names if n.strip()]))
count = len(project_names)
for i, project_name in enumerate(project_names):
- # 현재 프로젝트의 고유 ID 매칭 (저장용)
+ if crawl_stop_event.is_set():
+ msg_queue.put(json.dumps({'type': 'log', 'message': '>>> 중단 신호 감지: 종료합니다.'}))
+ break
+
+ msg_queue.put(json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] {project_name} 수집 시작'}))
p_match = next((p for p in captured_data["project_list"] if p.get('project_nm') == project_name or p.get('short_nm', '').strip() == project_name), None)
current_p_id = p_match.get('project_id') if p_match else None
+ captured_data["tree"] = None; captured_data["_is_root_archive"] = False
- captured_data["tree"] = None
- captured_data["_is_root_archive"] = False
- msg_queue.put(json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] {project_name} 수집 시작'}))
-
try:
- # 1. 프로젝트 진입 ([완전 복원] 좌표 클릭)
+ # 1. 프로젝트 진입 (좌표 클릭)
target_el = page.locator(f"h4.list__contents_aria_group_body_list_item_label:has-text('{project_name}')").first
await target_el.scroll_into_view_if_needed()
box = await target_el.bounding_box()
@@ -143,44 +146,34 @@ def crawler_thread_worker(msg_queue, user_id, password):
await page.wait_for_selector("text=활동로그", timeout=30000)
await asyncio.sleep(2)
- recent_log = "데이터 없음"
- file_count = 0
+ recent_log = "데이터 없음"; file_count = 0
- # 2. 활동로그 ([완전 복원] 3회 재시도 + 좌표 클릭 + 날짜 필터)
+ # 2. 활동로그 (날짜 필터 적용 버전)
modal_opened = False
for _ in range(3):
- log_btn = page.get_by_text("활동로그").first
- btn_box = await log_btn.bounding_box()
- if btn_box: await page.mouse.click(btn_box['x'] + 5, btn_box['y'] + 5)
- else: await page.evaluate("(el) => el.click()", await log_btn.element_handle())
-
+ await page.get_by_text("활동로그").first.click()
try:
await page.wait_for_selector("article.archive-modal", timeout=5000)
- modal_opened = True
- break
+ modal_opened = True; break
except: await asyncio.sleep(1)
if modal_opened:
- # 날짜 필터 입력
+ # 날짜 필터 2020-01-01 적용
inputs = await page.locator("article.archive-modal input").all()
for inp in inputs:
if (await inp.get_attribute("type")) == "date":
- await inp.fill("2020-01-01")
- break
+ await inp.fill("2020-01-01"); break
apply_btn = page.locator("article.archive-modal").get_by_text("적용").first
if await apply_btn.is_visible():
await apply_btn.click()
- await asyncio.sleep(5) # 렌더링 보장
+ await asyncio.sleep(5)
log_elements = await page.locator("article.archive-modal div[id*='_']").all()
if log_elements:
- raw_id = await log_elements[0].get_attribute("id")
- recent_log = parse_log_id(raw_id)
- msg_queue.put(json.dumps({'type': 'log', 'message': f' - [분석] 최신 로그 ID 추출 성공: {recent_log}'}))
- msg_queue.put(json.dumps({'type': 'log', 'message': f' - [최종 결과] {recent_log}'}))
+ recent_log = parse_log_id(await log_elements[0].get_attribute("id"))
await page.keyboard.press("Escape")
- # 3. 구성 수집 ([완전 복원] BaseURL fetch + 정밀 합산)
+ # 3. 구성 수집 (API Fetch 방식 - 팝업 없음)
await page.evaluate("""() => {
const baseUrl = window.location.origin + window.location.pathname.split('/').slice(0, 2).join('/');
fetch(`${baseUrl}/archive/getTreeObject?params[storageType]=CLOUD¶ms[resourcePath]=/`);
@@ -190,43 +183,26 @@ def crawler_thread_worker(msg_queue, user_id, password):
await asyncio.sleep(0.5)
if captured_data["tree"]:
- data_root = captured_data["tree"]
- tree = data_root.get('currentTreeObject', data_root) if isinstance(data_root, dict) else {}
- total = 0
- # 루트 파일 합산
- rf = tree.get("file", {})
- total += len(rf) if isinstance(rf, (dict, list)) else 0
- # 폴더별 filesCount 합산
+ 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():
- c = f.get("filesCount", "0")
- total += int(c) if str(c).isdigit() else 0
+ for f in folders.values(): total += int(f.get("filesCount", 0))
file_count = total
- msg_queue.put(json.dumps({'type': 'log', 'message': f' - [구성] 데이터 채택 성공: ...{captured_data.get("_tree_url", "")[-40:]}'}))
- msg_queue.put(json.dumps({'type': 'log', 'message': f' - [구성] 최종 정밀 합산 성공 ({file_count}개)'}))
- # 4. DB 실시간 저장 (히스토리 테이블)
+ # 4. DB 실시간 저장
if current_p_id:
- conn = get_db_connection()
- try:
+ with get_db_connection() as conn:
with conn.cursor() as cursor:
- # 오늘 날짜 히스토리 데이터 삽입 또는 업데이트
- sql = """
- INSERT INTO projects_history (project_id, crawl_date, recent_log, file_count)
- VALUES (%s, CURRENT_DATE(), %s, %s)
- ON DUPLICATE KEY UPDATE
- recent_log = VALUES(recent_log), file_count = VALUES(file_count)
- """
+ sql = "INSERT INTO projects_history (project_id, crawl_date, recent_log, file_count) VALUES (%s, CURRENT_DATE(), %s, %s) ON DUPLICATE KEY UPDATE recent_log=VALUES(recent_log), file_count=VALUES(file_count)"
cursor.execute(sql, (current_p_id, recent_log, file_count))
conn.commit()
- msg_queue.put(json.dumps({'type': 'log', 'message': f' - [DB] 히스토리 업데이트 완료 (ID: {current_p_id})'}))
- finally: conn.close()
+ msg_queue.put(json.dumps({'type': 'log', 'message': f' - [성공] 로그: {recent_log[:20]}... / 파일: {file_count}개'}))
await page.goto("https://overseas.projectmastercloud.com/dashboard", wait_until="domcontentloaded")
except Exception as e:
- msg_queue.put(json.dumps({'type': 'log', 'message': f' - [{project_name}] 건너뜀: {str(e)}'}))
+ msg_queue.put(json.dumps({'type': 'log', 'message': f' - {project_name} 실패: {str(e)}'}))
await page.goto("https://overseas.projectmastercloud.com/dashboard")
msg_queue.put(json.dumps({'type': 'done', 'data': []}))
@@ -241,10 +217,8 @@ def crawler_thread_worker(msg_queue, user_id, password):
loop.close()
async def run_crawler_service():
- user_id = os.getenv("PM_USER_ID")
- password = os.getenv("PM_PASSWORD")
msg_queue = queue.Queue()
- thread = threading.Thread(target=crawler_thread_worker, args=(msg_queue, user_id, password))
+ thread = threading.Thread(target=crawler_thread_worker, args=(msg_queue, os.getenv("PM_USER_ID"), os.getenv("PM_PASSWORD")))
thread.start()
while True:
try:
diff --git a/crawling_result 2026.03.06.csv b/crawling_result 2026.03.06.csv
deleted file mode 100644
index bd6d6c4..0000000
--- a/crawling_result 2026.03.06.csv
+++ /dev/null
@@ -1,42 +0,0 @@
-projectName,recentLog,fileCount
-ITTC 관개 교육센터,"26.01.29, 박진규, 폴더 삭제",16
-비엔티안 메콩강 관리 2차,"25.12.07, 나쉬, 파일 업로드",260
-만달레이 철도 개량 감리,"25.11.19, 이태훈, 보안참여자 권한 추가",298
-푸옥호아 양수 발전,"26.02.23, 이철호, 폴더 이름 변경",139
-아시르 지잔 고속도로,"26.03.04, 이태훈, 보안참여자 권한 추가",73
-지방 도로 복원,"26.03.04, 이태훈, 보안참여자 권한 추가",0
-타슈켄트 철도,"26.02.05, 조항언, 파일 업로드",51
-Habbaniyah Shuaiba AirBase,"26.03.04, 이태훈, 보안참여자 권한 추가",0
-시엠립 하수처리 개선,"26.02.09, 이태훈, 보안참여자 권한 추가",221
-반테 민체이 관개 홍수저감,"25.12.07, 나쉬, 파일 업로드",44
-메콩유역 수자원 관리 기후적응,"25.11.19, 이태훈, 보안참여자 권한 추가",0
-잘랄아바드 상수도 계획,"26.03.06, -, 폴더 자동 삭제 (파일 개수 미달)",58
-CAREC 도로 감리,"26.03.04, 이태훈, 보안참여자 권한 추가",0
-펀잡 홍수 방재,"25.12.08, 콰윰 아딜, 폴더 삭제",0
-KP 아보타바드 상수도,"26.02.26, 정기일, 파일 업로드",240
-홍수 복원 InFRA2,"25.12.18, -, 폴더 자동 삭제 (파일 개수 미달)",6
-PGN 해상교량 BID2,"26.03.04, 이태훈, 보안참여자 권한 추가",631
-홍수 관리 Package5B,"25.12.02, 조명훈, 폴더 이름 변경",14
-족자~바웬 도로사업,"26.03.06, 시스템관리-Savannah, 참관자 권한 추가",0
-테치만 상수도 확장,"26.02.09, 이태훈, 보안참여자 권한 추가",0
-기니 벼 재배단지,"26.01.07, -, 폴더 자동 삭제 (파일 개수 미달)",43
-부수쿠마 분뇨 자원화 2단계,"26.02.09, 이태훈, 보안참여자 권한 추가",9
-우간다 벼 재배단지,"25.12.08, 박수빈, 파일 업로드",52
-Adeaa-Becho 지하수 관개,"25.12.29, -, 폴더 자동 삭제 (파일 개수 미달)",140
-도도타군 관개,"25.12.30, -, 폴더 자동 삭제 (파일 개수 미달)",142
-지하수 관개 환경설계,"26.02.09, 이태훈, 보안참여자 권한 추가",0
-Dodoma 하수 설계감리,"26.02.09, 이태훈, 보안참여자 권한 추가",32
-Iringa 상하수도 개선,"26.02.09, 이태훈, 보안참여자 권한 추가",0
-도도마 유수율 상수도개선,"26.02.12, 서하연, 부관리자 권한 추가",35
-잔지바르 쌀 생산,"25.12.08, 박수빈, 파일 업로드",23
-SALDEORO 수력발전 28MW,"25.11.19, 이태훈, 보안참여자 권한 추가",0
-LaPaz Danli 상수도,"26.02.09, 이태훈, 보안참여자 권한 추가",60
-에스꼬마 차라짜니 도로,"26.03.06, -, 폴더 자동 삭제 (파일 개수 미달)",0
-마모레 교량도로,"26.03.04, 이태훈, 보안참여자 권한 추가",120
-Bombeo-Colomi 도로설계,"26.03.04, 이태훈, 보안참여자 권한 추가",48
-AI 폐기물,"25.11.19, 이태훈, 보안참여자 권한 추가",0
-도로 통행료 현대화,"26.02.25, 류창수, 폴더 삭제",0
-Barranca 상하수도 확장,"26.02.09, 이태훈, 보안참여자 권한 추가",44
-태평양 철도,"26.02.24, -, 폴더 자동 삭제 (파일 개수 미달)",101
-필리핀 사무소,"26.03.06, 한형남, 파일 다운로드",316
-PGN 해상교량 BID2,"26.03.04, 이태훈, 보안참여자 권한 추가",631
diff --git a/crawling_result 2026.03.09.csv b/crawling_result 2026.03.09.csv
deleted file mode 100644
index b1bc926..0000000
--- a/crawling_result 2026.03.09.csv
+++ /dev/null
@@ -1,42 +0,0 @@
-projectName,recentLog,fileCount
-비엔티안 메콩강 관리 2차,212487_25-12-07 12:22:26_나쉬_파일 업로드_/서류/06. 전문가파견/01. 파견/251031~260208_전문가 파견(이범주).zip,260
-ITTC 관개 교육센터,225728_26-01-29 09:10:21_박진규_폴더 삭제_/서류/ggg,16
-만달레이 철도 개량 감리,207041_25-11-19 16:54:36_이태훈_보안참여자 권한 추가_홍아름_김혜인,298
-푸옥호아 양수 발전,233465_26-02-23 10:24:46_이철호_폴더 이름 변경_/6 준공/3 준공도서 26년 2월 작성예정 발주처 협의중_/6 준공/3 준공도서 26년 3월 작성예정 발주처 협의중,139
-아시르 지잔 고속도로,234455_26-03-04 14:31:52_이태훈_보안참여자 권한 추가_복진훈,73
-타슈켄트 철도,228919_26-02-05 10:08:11_조항언_파일 업로드_/02_성과품/B_중간보고/BB_중간보고서/[러문] INTERIM REPORT_0115_F.pdf,51
-지방 도로 복원,234456_26-03-04 14:32:07_이태훈_보안참여자 권한 추가_복진훈,0
-Habbaniyah Shuaiba AirBase,234457_26-03-04 14:32:28_이태훈_보안참여자 권한 추가_복진훈,0
-시엠립 하수처리 개선,231205_26-02-09 11:03:05_이태훈_보안참여자 권한 추가_김창환_배형원,221
-반테 민체이 관개 홍수저감,212512_25-12-07 12:35:02_나쉬_파일 업로드_/서류/04. 기성/18차 기성금/06. 통장사본.pdf,44
-메콩유역 수자원 관리 기후적응,207047_25-11-19 17:01:19_이태훈_보안참여자 권한 추가_홍아름_김혜인,0
-잘랄아바드 상수도 계획,234860_26-03-06 10:24:27_-_폴더 자동 삭제 (파일 개수 미달)_/4. MP 성과품/3. 최종보고서/초안 제출,58
-펀잡 홍수 방재,212686_25-12-08 13:05:24_콰윰 아딜_폴더 삭제_/RFP,0
-CAREC 도로 감리,234458_26-03-04 14:32:50_이태훈_보안참여자 권한 추가_복진훈,0
-KP 아보타바드 상수도,234120_26-02-26 20:58:46_정기일_파일 업로드_/99 참고자료/99 체코 두코바니 원전/00 2025년 입찰/Engineering work instruction_241206.pdf,240
-PGN 해상교량 BID2,234454_26-03-04 14:31:31_이태훈_보안참여자 권한 추가_복진훈,631
-홍수 복원 InFRA2,214399_25-12-18 09:05:49_-_폴더 자동 삭제 (파일 개수 미달)_/서류/3.경비신청 및 정산/1. 신청,6
-홍수 관리 Package5B,211353_25-12-02 10:04:18_조명훈_폴더 이름 변경_/서류/01. 계약서/01. 계약서_/서류/01. 계약서/01. 사업계약서,14
-족자~바웬 도로사업,234908_26-03-06 13:36:39_시스템관리-Savannah_참관자 권한 추가_이호성,0
-테치만 상수도 확장,231210_26-02-09 11:06:41_이태훈_보안참여자 권한 추가_배형원_정낙훈,0
-기니 벼 재배단지,216888_26-01-07 11:07:23_-_폴더 자동 삭제 (파일 개수 미달)_/계약서류/경비 신청 및 정산/01. 신청,43
-우간다 벼 재배단지,"212622_25-12-08 11:17:57_박수빈_파일 업로드_/계약서류/경비 신청 및 정산/01. 현장경비 신청전표/11-20251029-J0401-006 (9,10월 현장운영비).pdf",52
-부수쿠마 분뇨 자원화 2단계,231215_26-02-09 11:08:34_이태훈_보안참여자 권한 추가_김창환_배형원,9
-지하수 관개 환경설계,231212_26-02-09 11:07:35_이태훈_보안참여자 권한 추가_김창환_배형원,0
-Adeaa-Becho 지하수 관개,215553_25-12-29 09:36:23_-_폴더 자동 삭제 (파일 개수 미달)_/Topographic Survey/측량조사성과품/Appendix B - List of GPS Control Points,140
-도도타군 관개,215706_25-12-30 09:17:43_-_폴더 자동 삭제 (파일 개수 미달)_/서류/03.경비신청 및 정산/02.사내정산 2차,142
-Iringa 상하수도 개선,231216_26-02-09 11:10:18_이태훈_보안참여자 권한 추가_김창환_배형원,0
-Dodoma 하수 설계감리,231217_26-02-09 11:11:06_이태훈_보안참여자 권한 추가_김창환_배형원,32
-도도마 유수율 상수도개선,232629_26-02-12 17:26:13_서하연_부관리자 권한 추가_정기일,35
-잔지바르 쌀 생산,212641_25-12-08 11:43:37_박수빈_파일 업로드_/서류/경비신청 및 정산/01. 25년/11-20250409-J0401-005 (운영경비 2분기 송금).pdf,23
-SALDEORO 수력발전 28MW,207029_25-11-19 16:44:04_이태훈_보안참여자 권한 추가_홍아름_김혜인,0
-LaPaz Danli 상수도,231219_26-02-09 11:12:37_이태훈_보안참여자 권한 추가_배형원_정낙훈,60
-에스꼬마 차라짜니 도로,234837_26-03-06 08:00:21_-_폴더 자동 삭제 (파일 개수 미달)_/EOI/001/EOI서류,0
-Bombeo-Colomi 도로설계,234463_26-03-04 14:34:24_이태훈_보안참여자 권한 추가_복진훈,48
-마모레 교량도로,234462_26-03-04 14:33:56_이태훈_보안참여자 권한 추가_복진훈,120
-AI 폐기물,207034_25-11-19 16:50:49_이태훈_보안참여자 권한 추가_홍아름_김혜인,0
-도로 통행료 현대화,233938_26-02-25 14:39:08_류창수_폴더 삭제_/필리핀다바오프로젝트,0
-Barranca 상하수도 확장,231220_26-02-09 11:13:28_이태훈_보안참여자 권한 추가_김창환_배형원,44
-태평양 철도,233798_26-02-24 17:52:33_-_폴더 자동 삭제 (파일 개수 미달)_/01_수행문서/D_보고자료/D6_최종보고,101
-필리핀 사무소,234973_26-03-09 09:35:45_한형남_파일 다운로드_/3. DPTMP(Davao Public Transportation Modernization Project)/AFCS/2.배포자료/PHI DPTMP AFCS Package PIM 04March2026.pdf_/3. DPTMP(Davao Public Transportation Modernization Project)/AFCS/2.배포자료/PAFCS PIM v04March2026.pdf,322
-PGN 해상교량 BID2,234454_26-03-04 14:31:31_이태훈_보안참여자 권한 추가_복진훈,631
diff --git a/crwaling_result.csv b/crwaling_result.csv
deleted file mode 100644
index 5560a02..0000000
--- a/crwaling_result.csv
+++ /dev/null
@@ -1,42 +0,0 @@
-projectName,recentLog,fileCount
-ITTC 관개 교육센터,"26.01.29, 박진규, 폴더 삭제",16
-비엔티안 메콩강 관리 2차,"25.12.07, 나쉬, 파일 업로드",260
-만달레이 철도 개량 감리,"25.11.19, 이태훈, 보안참여자 권한 추가",298
-푸옥호아 양수 발전,"26.02.23, 이철호, 폴더 이름 변경",139
-아시르 지잔 고속도로,"26.03.04, 이태훈, 보안참여자 권한 추가",75
-지방 도로 복원,"26.03.04, 이태훈, 보안참여자 권한 추가",0
-타슈켄트 철도,"26.02.05, 조항언, 파일 업로드",51
-Habbaniyah Shuaiba AirBase,"26.03.04, 이태훈, 보안참여자 권한 추가",0
-시엠립 하수처리 개선,"26.02.09, 이태훈, 보안참여자 권한 추가",222
-반테 민체이 관개 홍수저감,"25.12.07, 나쉬, 파일 업로드",0
-메콩유역 수자원 관리 기후적응,"25.11.19, 이태훈, 보안참여자 권한 추가",0
-잘랄아바드 상수도 계획,"26.03.06, -, 폴더 자동 삭제 (파일 개수 미달)",58
-CAREC 도로 감리,"26.03.04, 이태훈, 보안참여자 권한 추가",0
-펀잡 홍수 방재,"25.12.08, 콰윰 아딜, 폴더 삭제",0
-KP 아보타바드 상수도,"26.02.26, 정기일, 파일 업로드",240
-홍수 복원 InFRA2,"25.12.18, -, 폴더 자동 삭제 (파일 개수 미달)",6
-PGN 해상교량 BID2,"26.03.04, 이태훈, 보안참여자 권한 추가",0
-홍수 관리 Package5B,"25.12.02, 조명훈, 폴더 이름 변경",14
-족자~바웬 도로사업,"26.03.06, 시스템관리-Savannah, 참관자 권한 추가",0
-테치만 상수도 확장,"26.02.09, 이태훈, 보안참여자 권한 추가",0
-기니 벼 재배단지,"26.01.07, -, 폴더 자동 삭제 (파일 개수 미달)",44
-부수쿠마 분뇨 자원화 2단계,"26.02.09, 이태훈, 보안참여자 권한 추가",9
-우간다 벼 재배단지,"25.12.08, 박수빈, 파일 업로드",52
-Adeaa-Becho 지하수 관개,"25.12.29, -, 폴더 자동 삭제 (파일 개수 미달)",140
-도도타군 관개,"25.12.30, -, 폴더 자동 삭제 (파일 개수 미달)",0
-지하수 관개 환경설계,"26.02.09, 이태훈, 보안참여자 권한 추가",0
-Dodoma 하수 설계감리,"26.02.09, 이태훈, 보안참여자 권한 추가",32
-Iringa 상하수도 개선,"26.02.09, 이태훈, 보안참여자 권한 추가",0
-도도마 유수율 상수도개선,"26.02.12, 서하연, 부관리자 권한 추가",35
-잔지바르 쌀 생산,"25.12.08, 박수빈, 파일 업로드",23
-SALDEORO 수력발전 28MW,"25.11.19, 이태훈, 보안참여자 권한 추가",0
-LaPaz Danli 상수도,"26.02.09, 이태훈, 보안참여자 권한 추가",65
-에스꼬마 차라짜니 도로,"26.03.06, -, 폴더 자동 삭제 (파일 개수 미달)",0
-마모레 교량도로,"26.03.04, 이태훈, 보안참여자 권한 추가",120
-Bombeo-Colomi 도로설계,"26.03.04, 이태훈, 보안참여자 권한 추가",49
-AI 폐기물,"25.11.19, 이태훈, 보안참여자 권한 추가",0
-도로 통행료 현대화,"26.02.25, 류창수, 폴더 삭제",0
-Barranca 상하수도 확장,"26.02.09, 이태훈, 보안참여자 권한 추가",50
-태평양 철도,"26.02.24, -, 폴더 자동 삭제 (파일 개수 미달)",101
-필리핀 사무소,"26.03.06, 한형남, 파일 다운로드",0
-PGN 해상교량 BID2,"26.03.04, 이태훈, 보안참여자 권한 추가",637
diff --git a/db_2026.03.09.csv b/db_2026.03.09.csv
deleted file mode 100644
index 475e24e..0000000
--- a/db_2026.03.09.csv
+++ /dev/null
@@ -1,42 +0,0 @@
-[PM Overseas 프로젝트 현황],,2026.03.04,,,,,,<<활동로그가 없는 프로젝트 (8),,
-,,,,,,,,,,
-No.,프로젝트 명,담당부서,담당자,종료(예정)일,최근 활동로그,과업개요 작성 유무,파일 수,비고,,
-1,라오스 ITTC 관개 교육센터 PMC,수자원1부,방노성,2025.12.20,"2026.01.29, 폴더 삭제",O,16,2026.01.29 로그는 테스트 활동 추정,종료(예정)일 지남,진행
-2,라오스 비엔티안 메콩강 관리 2차 DD,수자원1부,방노성,2026.05.31,"2025.12.07, 파일업로드",X,260,탭 1개에 모든파일 업로드,,
-3,미얀마 만달레이 철도 개량 감리 CS,철도사업부,김태헌,2027.11.17,"2025.11.17, 폴더이름변경",O,298,,,
-4,베트남 푸옥호아 양수 발전 FS,수력부,이철호,2025.11.30,"2026.02.23, 폴더이름변경",O,139,준공도서 3월 작성예정,종료(예정)일 지남,준공
-5,사우디아라비아 아시르 지잔 고속도로 FS,도로부,공태원,2025.11.21,"2026.02.09, 파일다운로드",O,73,,종료(예정)일 지남,준공
-6,우즈베키스탄 지방 도로 복원 MP,도로부,장진영,2029.04.28,X,X,0,,,
-7,우즈베키스탄 타슈켄트 철도 FS,철도사업부,김태헌,2026.03.20,"2026.02.05, 파일업로드",O,51,,,
-8,이라크 Habbaniyah Shuaiba AirBase PD,도로부,강동구,2026.12.31,X,X,0,,,
-9,메콩유역 수자원 관리 기후적응 MP,수자원1부,정귀한,2025.12.31,X,X,0,,종료(예정)일 지남,준공
-10,캄보디아 반테 민체이 관개 홍수저감 MP,수자원1부,이대주,2026.08.28,"2025.12.07, 파일업로드",X,44,,,
-11,캄보디아 시엠립 하수처리 개선 DD,물환경사업1부,변역근,2028.12.18,"2026.02.06, AI 요약",O,221,,,
-12,키르기스스탄 잘랄아바드 상수도 계획 MP,물환경사업1부,변기상,2025.12.31,"2026.02.12, 파일업로드",X,60,,종료(예정)일 지남,준공
-13,파키스탄 펀잡 홍수 방재 PMC,수자원1부,방노성,2027.12.31,"2025.12.08, 폴더삭제",O,0,,,
-14,파키스탄 KP 아보타바드 상수도 PMC,물환경사업2부,변기상,2026.12.31,"2026.02.26, 파일업로드",O,240,,,
-15,파키스탄 CAREC 도로 감리 DD,도로부,황효섭,2026.10.26,X,X,0,,,
-16,필리핀 홍수 복원 InFRA2 DD,수자원1부,이대주,2026.08.07,"2025.12.01, 폴더삭제",O,6,최근로그 >> 폴더자동삭제(파일 개수 미달),,
-17,필리핀 홍수 관리 Package5B MP,수자원1부,이희철,2026.05.31,"2025.12.02, 폴더이름변경",O,14,,,
-18,필리핀 PGN 해상교량 BID2 IDC,구조부,이상희,2026.05.31,"2026.02.11, 파일다운로드",O,631,,,
-19,가나 테치만 상수도 확장 DS,물환경사업2부,-,2029.04.25,X,X,0,책임자 및 담당자 설정X,,
-20,기니 벼 재배단지 PMC,수자원1부,이대주,2028.12.20,"2025.12.08, 파일업로드",O,43,최근로그 >> 폴더자동삭제(파일 개수 미달),,
-21,우간다 벼 재배단지 PMC,수자원1부,방노성,2028.12.20,"2025.12.08, 파일업로드",O,52,,,
-22,우간다 부수쿠마 분뇨 자원화 2단계 PMC,물환경사업2부,변기상,2026.12.31,"2026.02.05, 파일업로드",X,9,,,
-23,에티오피아 지하수 관개 환경설계 DD,물환경사업2부,변기상,2026.06.23,X,X,0,,,
-24,에티오피아 도도타군 관개 PMC,수자원1부,방노성,2026.12.31,"2025.12.01, 폴더이름변경",O,144,탭 1개에 모든파일 업로드 // 최근로그 >> 폴더자동삭제(파일 개수 미달),,
-25,에티오피아 Adeaa-Becho 지하수 관개 MP,수자원1부,방노성,2026.07.31,"2025.11.21, 파일업로드",O,146,최근로그 >> 폴더자동삭제(파일 개수 미달),,
-26,탄자니아 Iringa 상하수도 개선 CS,물환경사업1부,백운영,2029.06.08,"2026.02.03, 폴더생성",X,0,,,
-27,탄자니아 Dodoma 하수 설계감리 DD,물환경사업2부,변기상,2027.07.08,"2026.02.04, 폴더삭제",X,32,,,
-28,탄자니아 잔지바르 쌀 생산 PMC,수자원1부,방노성,2027.12.20,"2025.12.08, 파일 업로드",O,23,,,
-29,탄자니아 도도마 유수율 상수도개선 PMC,물환경사업1부,박순석,2026.12.31,"2026.02.12, 부관리자권한추가",X,35,,,
-30,아르헨티나 SALDEORO 수력발전 28MW DD,플랜트1부,양정모,2026.01.31,X,X,0,,종료(예정)일 지남,준공
-31,온두라스 LaPaz Danli 상수도 CS,물환경사업2부,-,2027.02.23,"2026.01.29, 파일 삭제",O,60,"책임자 및 담당자 설정 X, 실 관리부서는 해외사업부, 더미파일 다수",,
-32,볼리비아 에스꼬마 차라짜니 도로 CS,도로부,전홍찬,2029.12.15,"2026.02.06, 파일업로드",X,1,,,
-33,볼리비아 마모레 교량도로 FS,도로부,황효섭,2025.10.17,"2026.02.06, 파일업로드",X,120,,종료(예정)일 지남,준공
-34,볼리비아 Bombeo-Colomi 도로설계 DD,도로부,황효섭,2026.07.24,"2025.12.05, 파일삭제",O,48,"더미파일(폴더유지용) 12개, 실 관리부서는 해외사업부",,
-35,콜롬비아 AI 폐기물 FS,플랜트1부,서재희,2026.02.27,X,X,0,,종료(예정)일 지남,
-36,파라과이 도로 통행료 현대화 MP,교통계획부,오제훈,2025.10.24,"2025.02.25, 폴더삭제",X,0,,종료(예정)일 지남,준공
-37,페루 Barranca 상하수도 확장 DD,물환경사업2부,변기상,2026.03.08,"2025.11.14, 파일업로드",O,44,"더미파일(폴더유지용) 27개, 실 관리부서는 해외사업부",,
-38,엘살바도르 태평양 철도 FS,철도사업부,김태헌,2025.12.31,"2026.02.24, 폴더자동삭제",X,101,,종료(예정)일 지남,준공
-39,필리핀 사무소,해외사업부,한형남,,"2026-03-09, 파일다운로드",과업개요 페이지 없음,323,,,
diff --git a/db_2026.03.10.csv b/db_2026.03.10.csv
deleted file mode 100644
index 4708abf..0000000
--- a/db_2026.03.10.csv
+++ /dev/null
@@ -1,42 +0,0 @@
-[PM Overseas 프로젝트 현황],,2026.03.04,,,,,,<<활동로그가 없는 프로젝트 (8),,
-,,,,,,,,,,
-No.,프로젝트 명,담당부서,담당자,종료(예정)일,최근 활동로그,과업개요 작성 유무,파일 수,비고,,
-1,라오스 ITTC 관개 교육센터 PMC,수자원1부,방노성,2025.12.20,"2026.01.29, 폴더 삭제",O,16,2026.01.29 로그는 테스트 활동 추정,종료(예정)일 지남,진행
-2,라오스 비엔티안 메콩강 관리 2차 DD,수자원1부,방노성,2026.05.31,"2025.12.07, 파일업로드",X,260,탭 1개에 모든파일 업로드,,
-3,미얀마 만달레이 철도 개량 감리 CS,철도사업부,김태헌,2027.11.17,"2025.11.17, 폴더이름변경",O,298,,,
-4,베트남 푸옥호아 양수 발전 FS,수력부,이철호,2025.11.30,"2026.02.23, 폴더이름변경",O,139,준공도서 3월 작성예정,종료(예정)일 지남,준공
-5,사우디아라비아 아시르 지잔 고속도로 FS,도로부,공태원,2025.11.21,"2026.02.09, 파일다운로드",O,73,,종료(예정)일 지남,준공
-6,우즈베키스탄 지방 도로 복원 MP,도로부,장진영,2029.04.28,X,X,0,,,
-7,우즈베키스탄 타슈켄트 철도 FS,철도사업부,김태헌,2026.03.20,"2026.02.05, 파일업로드",O,51,,,
-8,이라크 Habbaniyah Shuaiba AirBase PD,도로부,강동구,2026.12.31,X,X,0,,,
-9,메콩유역 수자원 관리 기후적응 MP,수자원1부,정귀한,2025.12.31,X,X,0,,종료(예정)일 지남,준공
-10,캄보디아 반테 민체이 관개 홍수저감 MP,수자원1부,이대주,2026.08.28,"2025.12.07, 파일업로드",X,44,,,
-11,캄보디아 시엠립 하수처리 개선 DD,물환경사업1부,변역근,2028.12.18,"2026.02.06, AI 요약",O,221,,,
-12,키르기스스탄 잘랄아바드 상수도 계획 MP,물환경사업1부,변기상,2025.12.31,"2026.02.12, 파일업로드",X,60,,종료(예정)일 지남,준공
-13,파키스탄 펀잡 홍수 방재 PMC,수자원1부,방노성,2027.12.31,"2025.12.08, 폴더삭제",O,0,,,
-14,파키스탄 KP 아보타바드 상수도 PMC,물환경사업2부,변기상,2026.12.31,"2026.02.26, 파일업로드",O,240,,,
-15,파키스탄 CAREC 도로 감리 DD,도로부,황효섭,2026.10.26,X,X,0,,,
-16,필리핀 홍수 복원 InFRA2 DD,수자원1부,이대주,2026.08.07,"2025.12.01, 폴더삭제",O,6,최근로그 >> 폴더자동삭제(파일 개수 미달),,
-17,필리핀 홍수 관리 Package5B MP,수자원1부,이희철,2026.05.31,"2025.12.02, 폴더이름변경",O,14,,,
-18,필리핀 PGN 해상교량 BID2 IDC,구조부,이상희,2026.05.31,"2026.02.11, 파일다운로드",O,631,,,
-19,가나 테치만 상수도 확장 DS,물환경사업2부,-,2029.04.25,X,X,0,책임자 및 담당자 설정X,,
-20,기니 벼 재배단지 PMC,수자원1부,이대주,2028.12.20,"2025.12.08, 파일업로드",O,43,최근로그 >> 폴더자동삭제(파일 개수 미달),,
-21,우간다 벼 재배단지 PMC,수자원1부,방노성,2028.12.20,"2025.12.08, 파일업로드",O,52,,,
-22,우간다 부수쿠마 분뇨 자원화 2단계 PMC,물환경사업2부,변기상,2026.12.31,"2026.02.05, 파일업로드",X,9,,,
-23,에티오피아 지하수 관개 환경설계 DD,물환경사업2부,변기상,2026.06.23,X,X,0,,,
-24,에티오피아 도도타군 관개 PMC,수자원1부,방노성,2026.12.31,"2025.12.01, 폴더이름변경",O,144,탭 1개에 모든파일 업로드 // 최근로그 >> 폴더자동삭제(파일 개수 미달),,
-25,에티오피아 Adeaa-Becho 지하수 관개 MP,수자원1부,방노성,2026.07.31,"2025.11.21, 파일업로드",O,146,최근로그 >> 폴더자동삭제(파일 개수 미달),,
-26,탄자니아 Iringa 상하수도 개선 CS,물환경사업1부,백운영,2029.06.08,"2026.02.03, 폴더생성",X,0,,,
-27,탄자니아 Dodoma 하수 설계감리 DD,물환경사업2부,변기상,2027.07.08,"2026.02.04, 폴더삭제",X,32,,,
-28,탄자니아 잔지바르 쌀 생산 PMC,수자원1부,방노성,2027.12.20,"2025.12.08, 파일 업로드",O,23,,,
-29,탄자니아 도도마 유수율 상수도개선 PMC,물환경사업1부,박순석,2026.12.31,"2026.02.12, 부관리자권한추가",X,35,,,
-30,아르헨티나 SALDEORO 수력발전 28MW DD,플랜트1부,양정모,2026.01.31,X,X,0,,종료(예정)일 지남,준공
-31,온두라스 LaPaz Danli 상수도 CS,물환경사업2부,-,2027.02.23,"2026.01.29, 파일 삭제",O,60,"책임자 및 담당자 설정 X, 실 관리부서는 해외사업부, 더미파일 다수",,
-32,볼리비아 에스꼬마 차라짜니 도로 CS,도로부,전홍찬,2029.12.15,"2026.02.06, 파일업로드",X,1,,,
-33,볼리비아 마모레 교량도로 FS,도로부,황효섭,2025.10.17,"2026.02.06, 파일업로드",X,120,,종료(예정)일 지남,준공
-34,볼리비아 Bombeo-Colomi 도로설계 DD,도로부,황효섭,2026.07.24,"2025.12.05, 파일삭제",O,48,"더미파일(폴더유지용) 12개, 실 관리부서는 해외사업부",,
-35,콜롬비아 AI 폐기물 FS,플랜트1부,서재희,2026.02.27,X,X,0,,종료(예정)일 지남,
-36,파라과이 도로 통행료 현대화 MP,교통계획부,오제훈,2025.10.24,"2025.02.25, 폴더삭제",X,0,,종료(예정)일 지남,준공
-37,페루 Barranca 상하수도 확장 DD,물환경사업2부,변기상,2026.03.08,"2025.11.14, 파일업로드",O,44,"더미파일(폴더유지용) 27개, 실 관리부서는 해외사업부",,
-38,엘살바도르 태평양 철도 FS,철도사업부,김태헌,2025.12.31,"2026.02.24, 폴더자동삭제",X,101,,종료(예정)일 지남,준공
-39,필리핀 사무소,해외사업부,한형남,,"2026.03.10, PDF 변환",과업개요 페이지 없음,829,,,
diff --git a/debug_modal.html b/debug_modal.html
deleted file mode 100644
index 31b4811..0000000
--- a/debug_modal.html
+++ /dev/null
@@ -1,226 +0,0 @@
-
-
-
-
- 로그필터
-
-
-
-
-
활동시간
-
- 시작
-
-
-
- 종료
-
-
-
-
-
사용자
-
-
모든 사용자
-
- 모든 사용자
- 213057 (박진규)
- 225044 (박종호)
- B21364 (이태훈)
- B22027 (김혜인)
- dev5 (시스템관리E)
- dev6 (시스템관리F)
- dev7 (시스템관리G)
- M07318 (김원기)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
프로젝트명
-
-
-
시공
-
- - 시공
- - 설계
- - 제안
- - 연구
- - 지원
- - 센터
- - 측량
-
-
-
-
-
-
PMC (실시설계)
-
- - MP (기본계획)
- - DD (실시설계)
- - FS (타당성조사)
- - PD (기본설계)
- - DS (설계감리)
- - CS (시공감리)
- - PMC (실시설계)
- - IDC (타당성조사)
- - DR (설계검토)
- - ETC (기타)
-
-
-
-
-
-
-
-
-
-
프로젝트 위치
-
위도 18.068579
-
경도 102.65966
-
-
-
-
-
-
-
-
저장공간 관련 문의: GSIM 개발팀 이호성 수석연구원
-
-
-
undefined
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/diag_folders.py b/diag_folders.py
deleted file mode 100644
index d63fa8e..0000000
--- a/diag_folders.py
+++ /dev/null
@@ -1,66 +0,0 @@
-import asyncio, os, json, re, sys
-from playwright.async_api import async_playwright
-from dotenv import load_dotenv
-
-load_dotenv()
-
-async def run_diagnostics():
- user_id = os.getenv("PM_USER_ID")
- password = os.getenv("PM_PASSWORD")
-
- async with async_playwright() as p:
- browser = await p.chromium.launch(headless=False)
- context = await browser.new_context(viewport={"width": 1600, "height": 900})
- page = await context.new_page()
-
- print(">>> 로그인 중...")
- await page.goto("https://overseas.projectmastercloud.com/dashboard")
- if await page.locator("#login-by-id").is_visible(timeout=5000):
- await page.click("#login-by-id")
- await page.fill("#user_id", user_id)
- await page.fill("#user_pw", password)
- await page.click("#login-btn")
-
- await page.wait_for_selector("h4.list__contents_aria_group_body_list_item_label", timeout=60000)
-
- project_name = "필리핀 사무소"
- print(f">>> [{project_name}] 폴더 전수 조사 시작...")
-
- target_el = page.get_by_text(project_name).first
- await target_el.scroll_into_view_if_needed()
- await target_el.click(force=True)
-
- await asyncio.sleep(10) # 충분한 로딩 대기
-
- print("\n" + "="*60)
- print(f"{'No':<4} | {'Folder Name':<40} | {'Files'}")
- print("-" * 60)
-
- # fetch 결과를 직접 리턴받음
- tree_data = await page.evaluate("""async () => {
- const resp = await fetch('/api/getTreeObject?params[storageType]=CLOUD¶ms[resourcePath]=/');
- return await resp.json();
- }""")
-
- if tree_data:
- tree = tree_data.get('currentTreeObject', {})
- folders = tree.get('folder', {})
- folder_items = list(folders.values()) if isinstance(folders, dict) else (folders if isinstance(folders, list) else [])
-
- total_sum = 0
- for i, f in enumerate(folder_items):
- name = f.get('name', 'Unknown')
- count = int(f.get('filesCount', 0))
- total_sum += count
- print(f"{i+1:<4} | {name:<40} | {count}개")
-
- print("-" * 60)
- print(f">>> 총 {len(folder_items)}개 폴더 발견 | 전체 파일 합계: {total_sum}개")
- print("="*60)
- else:
- print(">>> [오류] 데이터를 가져오지 못했습니다.")
-
- await browser.close()
-
-if __name__ == "__main__":
- asyncio.run(run_diagnostics())
diff --git a/js/common.js b/js/common.js
index 94ef6aa..accf091 100644
--- a/js/common.js
+++ b/js/common.js
@@ -1,9 +1,25 @@
-// 공통 네비게이션 및 유틸리티 로직
+/**
+ * Project Master Overseas Common JS
+ * 공통 네비게이션, 유틸리티, 전역 이벤트 관리
+ */
+
function navigateTo(path) {
location.href = path;
}
-// 상단바 클릭 시 홈으로 이동 등 공통 이벤트 설정
-document.addEventListener('DOMContentLoaded', () => {
- // 필요한 경우 공통 초기화 로직 추가
+// --- 전역 이벤트: 모든 모달창 ESC 키로 닫기 ---
+document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ // 대시보드 모달
+ if (typeof closeAuthModal === 'function') closeAuthModal();
+ if (typeof closeActivityModal === 'function') closeActivityModal();
+
+ // 메일 시스템 모달
+ if (typeof closeModal === 'function') closeModal();
+ if (typeof closeAddressBook === 'function') closeAddressBook();
+ }
+});
+
+document.addEventListener('DOMContentLoaded', () => {
+ // 공통 초기화 로직
});
diff --git a/js/dashboard.js b/js/dashboard.js
index a870fca..41daaa9 100644
--- a/js/dashboard.js
+++ b/js/dashboard.js
@@ -1,295 +1,220 @@
+/**
+ * Project Master Overseas Dashboard JS
+ * 기능: 데이터 로드, 활성도 분석, 인증 모달 제어, 크롤링 동기화 및 중단
+ */
+
+// --- 글로벌 상태 관리 ---
let rawData = [];
+let projectActivityDetails = [];
+let isCrawling = false;
-const continentMap = {
- "라오스": "아시아", "미얀마": "아시아", "베트남": "아시아", "사우디아라비아": "아시아",
- "우즈베키스탄": "아시아", "이라크": "아시아", "캄보디아": "아시아",
- "키르기스스탄": "아시아", "파키스탄": "아시아", "필리핀": "아시아",
- "아르헨티나": "아메리카", "온두라스": "아메리카", "볼리비아": "아메리카", "콜롬비아": "아메리카",
- "파라과이": "아메리카", "페루": "아메리카", "엘살바도르": "아메리카",
- "가나": "아프리카", "기니": "아프리카", "우간다": "아프리카", "에티오피아": "아프리카", "탄자니아": "아프리카"
-};
-
-const continentOrder = {
- "아시아": 1,
- "아프리카": 2,
- "아메리카": 3,
- "지사": 4
-};
+const CONTINENT_ORDER = { "아시아": 1, "아프리카": 2, "아메리카": 3, "지사": 4 };
+// --- 초기화 ---
async function init() {
+ console.log("Dashboard Initializing...");
const container = document.getElementById('projectAccordion');
- const baseDateStrong = document.getElementById('baseDate');
- if (!container) return;
+ if (!container) return;
- // 1. 가용한 날짜 목록 가져오기 및 셀렉트 박스 생성
+ await loadAvailableDates();
+ await loadDataByDate();
+}
+
+// --- 데이터 통신 및 로드 ---
+async function loadAvailableDates() {
try {
- const datesRes = await fetch('/available-dates');
- const dates = await datesRes.json();
-
- if (dates && dates.length > 0) {
- let selectHtml = ``;
- // 기준날짜 텍스트 영역을 셀렉트 박스로 교체
- const baseDateInfo = document.querySelector('.base-date-info');
- if (baseDateInfo) {
- baseDateInfo.innerHTML = `기준날짜: ${selectHtml}`;
- }
+ const response = await fetch('/available-dates');
+ const dates = await response.json();
+ if (dates?.length > 0) {
+ const selectHtml = `
+ `;
+ const baseDateStrong = document.getElementById('baseDate');
+ if (baseDateStrong) baseDateStrong.innerHTML = selectHtml;
}
- } catch (e) {
- console.error("날짜 목록 로드 실패:", e);
- }
-
- // 2. 기본 데이터 로드 (최신 날짜)
- loadDataByDate();
+ } catch (e) { console.error("날짜 로드 실패:", e); }
}
async function loadDataByDate(selectedDate = "") {
- const container = document.getElementById('projectAccordion');
-
try {
- const url = selectedDate ? `/project-data?date=${selectedDate}` : `/project-data?t=${new Date().getTime()}`;
+ await loadActivityAnalysis(selectedDate);
+ const url = selectedDate ? `/project-data?date=${selectedDate}` : `/project-data?t=${Date.now()}`;
const response = await fetch(url);
const data = await response.json();
-
if (data.error) throw new Error(data.error);
-
rawData = data.projects || [];
renderDashboard(rawData);
-
} catch (e) {
console.error("데이터 로드 실패:", e);
alert("데이터를 가져오는 데 실패했습니다.");
}
}
+async function loadActivityAnalysis(date = "") {
+ const dashboard = document.getElementById('activityDashboard');
+ if (!dashboard) return;
+ try {
+ const url = date ? `/project-activity?date=${date}` : `/project-activity`;
+ const response = await fetch(url);
+ const data = await response.json();
+ if (data.error) return;
+ const { summary, details } = data;
+ projectActivityDetails = details;
+ dashboard.innerHTML = `
+
+
정상 (7일 이내)
${summary.active}
+
+
+
주의 (14일 이내)
${summary.warning}
+
+
+
방치 (14일 초과)
${summary.stale}
+
+
+
데이터 없음 (파일 0개 등)
${summary.unknown}
+
`;
+ } catch (e) { console.error("분석 로드 실패:", e); }
+}
+
+// --- 렌더링 엔진 ---
function renderDashboard(data) {
const container = document.getElementById('projectAccordion');
- container.innerHTML = ''; // 초기화
- const groupedData = {};
-
- data.forEach((item, index) => {
- let continent = item[5] || "기기타";
- let country = item[6] || "미분류";
-
- if (!groupedData[continent]) groupedData[continent] = {};
- if (!groupedData[continent][country]) groupedData[continent][country] = [];
-
- groupedData[continent][country].push({ item, index });
- });
-
- const sortedContinents = Object.keys(groupedData).sort((a, b) => (continentOrder[a] || 99) - (continentOrder[b] || 99));
-
- sortedContinents.forEach(continent => {
- const continentGroup = document.createElement('div');
- continentGroup.className = 'continent-group';
-
- let continentHtml = `
-
-
- `;
-
- const sortedCountries = Object.keys(groupedData[continent]).sort((a, b) => a.localeCompare(b));
-
- sortedCountries.forEach(country => {
- continentHtml += `
-
-
-
-
-
- `;
-
- const sortedProjects = groupedData[continent][country].sort((a, b) => a.item[0].localeCompare(b.item[0]));
-
- sortedProjects.forEach(({ item, index }) => {
- const projectName = item[0];
- const dept = item[1];
- const admin = item[2];
- const recentLogRaw = item[3];
- const fileCount = item[4];
-
- const recentLog = recentLogRaw === "X" ? "기록 없음" : recentLogRaw;
- const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음";
-
- let statusClass = "";
- if (fileCount === 0) statusClass = "status-error";
- else if (recentLog === "기록 없음") statusClass = "status-warning";
-
- continentHtml += `
-
-
-
-
-
-
참여 인원 상세
-
- | 이름 | 소속 | 사용자권한 |
-
- | ${admin} | ${dept} | 관리자 |
- | 김철수 | ${dept} | 부관리자 |
- | 박지민 | ${dept} | 일반참여자 |
- | 최유리 | ${dept} | 참관자 |
-
-
-
-
-
최근 문의사항 및 파일 변경 로그
-
- | 유형 | 내용 | 일시 |
-
- | 로그 | 데이터 동기화 완료 | ${logTime} |
- | 문의 | 프로젝트 접근 권한 요청 | 2026-02-23 |
- | 파일 | 설계도면 v2.pdf 업로드 | 2026-02-22 |
-
-
-
-
-
-
- `;
- });
-
- continentHtml += `
-
-
-
- `;
+ container.innerHTML = '';
+ const grouped = groupData(data);
+ 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 = `
`;
+ Object.keys(grouped[continent]).sort().forEach(country => {
+ html += `
+
+ ${grouped[continent][country].sort((a,b)=>a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}
`;
});
+ html += `
`;
+ continentDiv.innerHTML = html;
+ container.appendChild(continentDiv);
+ });
+}
- continentHtml += `
+function groupData(data) {
+ const res = {};
+ data.forEach(item => {
+ const c1 = item[5] || "기타", c2 = item[6] || "미분류";
+ if (!res[c1]) res[c1] = {};
+ if (!res[c1][c2]) res[c1][c2] = [];
+ res[c1][c2].push(item);
+ });
+ return res;
+}
+
+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" : "";
+ return `
+
+
- `;
-
- continentGroup.innerHTML = continentHtml;
- container.appendChild(continentGroup);
- });
-
- const allContinents = container.querySelectorAll('.continent-group');
- allContinents.forEach(continent => {
- continent.classList.add('active');
- });
-
- const allCountries = container.querySelectorAll('.country-group');
- allCountries.forEach(country => {
- country.classList.add('active');
- });
+
+
`;
}
-function toggleGroup(header) {
- const group = header.parentElement;
- group.classList.toggle('active');
-}
-
-function toggleAccordion(header) {
- const item = header.parentElement;
- const container = item.parentElement;
-
- const allItems = container.querySelectorAll('.accordion-item');
- allItems.forEach(el => {
- if (el !== item) el.classList.remove('active');
- });
-
+// --- 이벤트 핸들러 ---
+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.classList.toggle('active');
}
-async function syncData() {
- const btn = document.getElementById('syncBtn');
- const logConsole = document.getElementById('logConsole');
- const logBody = document.getElementById('logBody');
+function showActivityDetails(status) {
+ const modal = document.getElementById('activityDetailModal'), tbody = document.getElementById('modalTableBody'), title = document.getElementById('modalTitle');
+ 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]:"-"} |
`;
+ }).join('');
+ modal.style.display = 'flex';
+}
- btn.classList.add('loading');
- btn.innerHTML = `
동기화 중 (진행 상황 확인 중...)`;
- btn.disabled = true;
+function closeActivityModal() { document.getElementById('activityDetailModal').style.display = 'none'; }
- logConsole.style.display = 'block';
- logBody.innerHTML = '';
-
- function addLog(msg) {
- const logItem = document.createElement('div');
- logItem.innerText = `[${new Date().toLocaleTimeString()}] ${msg}`;
- logBody.appendChild(logItem);
- logConsole.scrollTop = logConsole.scrollHeight;
- }
-
- try {
- console.log("Attempting to connect to /sync...");
- const response = await fetch(`/sync`);
-
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
-
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- const chunk = decoder.decode(value);
- const lines = chunk.split('\n');
-
- for (const line of lines) {
- if (line.startsWith('data: ')) {
- const payload = JSON.parse(line.substring(6));
-
- if (payload.type === 'log') {
- addLog(payload.message);
- } else if (payload.type === 'done') {
- const newData = payload.data;
- newData.forEach(scrapedItem => {
- const target = rawData.find(item =>
- item[0].replace(/\s/g, '').includes(scrapedItem.projectName.replace(/\s/g, '')) ||
- scrapedItem.projectName.replace(/\s/g, '').includes(item[0].replace(/\s/g, ''))
- );
-
- if (target) {
- if (scrapedItem.recentLog !== "기존데이터유지") {
- target[3] = scrapedItem.recentLog;
- }
- target[4] = scrapedItem.fileCount;
- }
- });
-
- document.getElementById('projectAccordion').innerHTML = '';
- init();
- addLog(">>> 모든 동기화 작업이 완료되었습니다!");
- alert(`총 ${newData.length}개 프로젝트 동기화 완료!`);
- logConsole.style.display = 'none';
- }
- }
- }
- }
- } catch (e) {
- addLog(`오류 발생: ${e.message}`);
- alert("서버 연결 실패. 백엔드 서버가 실행 중인지 확인하세요.");
- console.error(e);
- } finally {
- btn.classList.remove('loading');
- btn.innerHTML = `
데이터 동기화 (크롤링)`;
- btn.disabled = false;
+function scrollToProject(name) {
+ closeActivityModal();
+ const target = Array.from(document.querySelectorAll('.repo-title')).find(t => t.innerText.trim() === name.trim())?.closest('.accordion-header');
+ if (target) {
+ let p = target.parentElement;
+ while (p && p !== document.body) { if (p.classList.contains('continent-group') || p.classList.contains('country-group')) p.classList.add('active'); p = p.parentElement; }
+ target.parentElement.classList.add('active');
+ const pos = target.getBoundingClientRect().top + window.pageYOffset - 220;
+ window.scrollTo({ top: pos, behavior: 'smooth' });
+ target.style.backgroundColor = 'var(--primary-lv-1)';
+ setTimeout(() => target.style.backgroundColor = '', 2000);
}
}
+// --- 크롤링 및 인증 제어 ---
+async function syncData() {
+ if (isCrawling) {
+ if (confirm("크롤링을 중단하시겠습니까?")) {
+ const res = await fetch('/stop-sync');
+ if ((await res.json()).success) document.getElementById('syncBtn').innerText = "중단 요청 중...";
+ }
+ return;
+ }
+ const modal = document.getElementById('authModal');
+ if (modal) {
+ document.getElementById('authId').value = ''; document.getElementById('authPw').value = '';
+ document.getElementById('authErrorMessage').style.display = 'none';
+ modal.style.display = 'flex'; document.getElementById('authId').focus();
+ }
+}
+
+function closeAuthModal() { document.getElementById('authModal').style.display = 'none'; }
+
+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 data = await res.json();
+ if (data.success) { closeAuthModal(); startCrawlProcess(); }
+ else { err.innerText = "크롤링을 할 수 없습니다."; err.style.display = 'block'; }
+ } catch { err.innerText = "서버 연결 실패"; err.style.display = 'block'; }
+}
+
+async function startCrawlProcess() {
+ isCrawling = true;
+ const btn = document.getElementById('syncBtn'), logC = document.getElementById('logConsole'), logB = document.getElementById('logBody');
+ btn.classList.add('loading'); btn.style.backgroundColor = 'var(--error-color)'; btn.innerHTML = `
크롤링 중단`;
+ logC.style.display = 'block'; logB.innerHTML = '
>>> 엔진 초기화 중...
';
+ try {
+ const res = await fetch(`/sync`);
+ const reader = res.body.getReader(), decoder = new TextDecoder();
+ while (true) {
+ const { done, value } = await reader.read(); if (done) break;
+ decoder.decode(value).split('\n').forEach(line => {
+ if (line.startsWith('data: ')) {
+ const p = JSON.parse(line.substring(6));
+ if (p.type === 'log') {
+ const div = document.createElement('div'); div.innerText = `[${new Date().toLocaleTimeString()}] ${p.message}`;
+ logB.appendChild(div); logC.scrollTop = logC.scrollHeight;
+ } else if (p.type === 'done') { init(); alert(`동기화 종료`); logC.style.display = 'none'; }
+ }
+ });
+ }
+ } catch { alert("스트림 끊김"); }
+ finally { isCrawling = false; btn.classList.remove('loading'); btn.style.backgroundColor = ''; btn.innerHTML = `
데이터 동기화 (크롤링)`; }
+}
+
document.addEventListener('DOMContentLoaded', init);
diff --git a/log_debug.png b/log_debug.png
deleted file mode 100644
index dcb29f1..0000000
Binary files a/log_debug.png and /dev/null differ
diff --git a/migrate_db_history.py b/migrate_db_history.py
index 054d09b..b77629e 100644
--- a/migrate_db_history.py
+++ b/migrate_db_history.py
@@ -4,7 +4,7 @@ import os
def get_db():
return pymysql.connect(
host='localhost', user='root', password='45278434',
- database='crawling', charset='utf8mb4'
+ database=os.getenv('DB_NAME', 'PM_proto'), charset='utf8mb4'
)
def migrate_to_timeseries():
diff --git a/migrate_normalized.py b/migrate_normalized.py
index 4649db4..b8cc930 100644
--- a/migrate_normalized.py
+++ b/migrate_normalized.py
@@ -4,7 +4,7 @@ import os
def get_db():
return pymysql.connect(
host='localhost', user='root', password='45278434',
- database='crawling', charset='utf8mb4',
+ database=os.getenv('DB_NAME', 'PM_proto'), charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
)
diff --git a/server.py b/server.py
index de2b8d0..fd96d9e 100644
--- a/server.py
+++ b/server.py
@@ -1,37 +1,33 @@
import os
import sys
-
-# 한글 환경 및 Tesseract 경로 강제 설정
-os.environ["PYTHONIOENCODING"] = "utf-8"
-os.environ["TESSDATA_PREFIX"] = r"C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata"
-
-from fastapi import FastAPI
+import re
+import asyncio
+import pymysql
+from datetime import datetime
+from pydantic import BaseModel
+from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
+
from analyze import analyze_file_content
-from crawler_service import run_crawler_service
-import asyncio
-from fastapi import Request
+from crawler_service import run_crawler_service, crawl_stop_event
+
+# --- 환경 설정 ---
+os.environ["PYTHONIOENCODING"] = "utf-8"
+# Tesseract 경로는 환경에 따라 다를 수 있으므로 환경변수 우선 사용 권장
+TESSDATA_PREFIX = os.getenv("TESSDATA_PREFIX", r"C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tessdata")
+os.environ["TESSDATA_PREFIX"] = TESSDATA_PREFIX
app = FastAPI(title="Project Master Overseas API")
templates = Jinja2Templates(directory="templates")
-# --- 유틸리티: 동기 함수를 스레드 풀에서 실행 ---
-async def run_in_threadpool(func, *args):
- loop = asyncio.get_event_loop()
- return await loop.run_in_executor(None, func, *args)
-
-# 정적 파일 및 미들웨어 설정
+# 정적 파일 마운트
app.mount("/style", StaticFiles(directory="style"), name="style")
app.mount("/js", StaticFiles(directory="js"), name="js")
app.mount("/sample_files", StaticFiles(directory="sample"), name="sample_files")
-@app.get("/sample.png")
-async def get_sample_img():
- return FileResponse("sample.png")
-
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -40,88 +36,29 @@ app.add_middleware(
allow_headers=["*"],
)
+# --- 데이터 모델 ---
+class AuthRequest(BaseModel):
+ user_id: str
+ password: str
-# --- HTML 라우팅 ---
-import pymysql
-
+# --- 유틸리티 함수 ---
def get_db_connection():
+ """MySQL 데이터베이스 연결을 반환 (환경변수 기반)"""
return pymysql.connect(
- host='localhost',
- user='root',
- password='45278434',
- database='crawling',
+ host=os.getenv('DB_HOST', 'localhost'),
+ user=os.getenv('DB_USER', 'root'),
+ password=os.getenv('DB_PASSWORD', '45278434'),
+ database=os.getenv('DB_NAME', 'PM_proto'),
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
)
-@app.get("/available-dates")
-async def get_available_dates():
- """
- 히스토리 테이블에서 유니크한 크롤링 날짜 목록을 반환
- """
- try:
- conn = get_db_connection()
- try:
- with conn.cursor() as cursor:
- cursor.execute("SELECT DISTINCT crawl_date FROM projects_history ORDER BY crawl_date DESC")
- rows = cursor.fetchall()
- dates = [row['crawl_date'].strftime("%Y.%m.%d") for row in rows if row['crawl_date']]
- return dates
- finally:
- conn.close()
- except Exception as e:
- return {"error": str(e)}
-
-@app.get("/project-data")
-async def get_project_data(date: str = None):
- """
- 특정 날짜의 데이터를 JOIN하여 반환
- """
- try:
- conn = get_db_connection()
- try:
- with conn.cursor() as cursor:
- if not date or date == "-":
- cursor.execute("SELECT MAX(crawl_date) as last_date FROM projects_history")
- target_date_row = cursor.fetchone()
- target_date = target_date_row['last_date']
- else:
- target_date = date.replace(".", "-")
-
- if not target_date:
- return {"projects": [], "last_updated": "-"}
-
- # 마스터 정보와 히스토리 정보를 JOIN
- sql = """
- 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
- """
- cursor.execute(sql, (target_date,))
- rows = cursor.fetchall()
-
- projects = []
- for row in rows:
- display_name = row['short_nm'] if row['short_nm'] and row['short_nm'].strip() else row['project_nm']
- projects.append([
- display_name,
- row['department'],
- row['master'],
- row['recent_log'],
- row['file_count'],
- row['continent'],
- row['country']
- ])
-
- return {"projects": projects, "last_updated": target_date.strftime("%Y.%m.%d") if hasattr(target_date, 'strftime') else str(target_date).replace("-", ".")}
- finally:
- conn.close()
- except Exception as e:
- return {"error": str(e)}
+async def run_in_threadpool(func, *args):
+ """동기 함수를 비차단 방식으로 실행"""
+ loop = asyncio.get_event_loop()
+ return await loop.run_in_executor(None, func, *args)
+# --- HTML 라우팅 ---
@app.get("/")
async def root(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@@ -131,37 +68,119 @@ async def get_dashboard(request: Request):
return templates.TemplateResponse("dashboard.html", {"request": request})
@app.get("/mailTest")
-@app.get("/mailTest.html")
async def get_mail_test(request: Request):
return templates.TemplateResponse("mailTest.html", {"request": request})
-# --- 데이터 API ---
-@app.get("/attachments")
-async def get_attachments():
- sample_path = "sample"
- if not os.path.exists(sample_path):
- os.makedirs(sample_path)
- files = []
- for f in os.listdir(sample_path):
- f_path = os.path.join(sample_path, f)
- if os.path.isfile(f_path):
- files.append({
- "name": f,
- "size": f"{os.path.getsize(f_path) / 1024:.1f} KB"
- })
- return files
+# --- 분석 및 수집 API ---
+@app.get("/available-dates")
+async def get_available_dates():
+ """히스토리 날짜 목록 반환"""
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ cursor.execute("SELECT DISTINCT crawl_date FROM projects_history ORDER BY crawl_date DESC")
+ rows = cursor.fetchall()
+ return [row['crawl_date'].strftime("%Y.%m.%d") for row in rows if row['crawl_date']]
+ except Exception as e:
+ return {"error": str(e)}
-@app.get("/analyze-file")
-async def analyze_file(filename: str):
- """
- 분석 서비스(analyze.py) 호출 - 스레드 풀에서 비차단 방식으로 실행
- """
- return await run_in_threadpool(analyze_file_content, filename)
+@app.get("/project-data")
+async def get_project_data(date: str = None):
+ """특정 날짜의 프로젝트 정보 JOIN 반환"""
+ try:
+ target_date = date.replace(".", "-") if date and date != "-" else None
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ if not target_date:
+ cursor.execute("SELECT MAX(crawl_date) as last_date FROM projects_history")
+ res = cursor.fetchone()
+ target_date = res['last_date']
+
+ if not target_date: return {"projects": []}
+
+ sql = """
+ 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
+ """
+ cursor.execute(sql, (target_date,))
+ rows = cursor.fetchall()
+
+ projects = []
+ for r in rows:
+ name = r['short_nm'] if r['short_nm'] and r['short_nm'].strip() else r['project_nm']
+ projects.append([name, r['department'], r['master'], r['recent_log'], r['file_count'], r['continent'], r['country']])
+ return {"projects": projects}
+ except Exception as e:
+ return {"error": str(e)}
+
+@app.get("/project-activity")
+async def get_project_activity(date: str = None):
+ """활성도 분석 API"""
+ try:
+ with get_db_connection() as conn:
+ with conn.cursor() as cursor:
+ if not date or date == "-":
+ cursor.execute("SELECT MAX(crawl_date) as last_date FROM projects_history")
+ res = cursor.fetchone()
+ target_date_val = res['last_date'] if res['last_date'] else datetime.now().date()
+ else:
+ 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
+ LEFT JOIN projects_history h ON m.project_id = h.project_id AND h.crawl_date = %s
+ """
+ cursor.execute(sql, (target_date_val,))
+ rows = cursor.fetchall()
+
+ analysis = {"summary": {"active": 0, "warning": 0, "stale": 0, "unknown": 0}, "details": []}
+ 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:
+ 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
+ analysis["summary"][status] += 1
+ analysis["details"].append({"name": r['short_nm'] or r['project_nm'], "status": status, "days_ago": days})
+ return analysis
+ except Exception as e:
+ return {"error": str(e)}
+
+@app.post("/auth/crawl")
+async def auth_crawl(req: AuthRequest):
+ """크롤링 인증"""
+ if req.user_id == os.getenv("PM_USER_ID") and req.password == os.getenv("PM_PASSWORD"):
+ return {"success": True}
+ return {"success": False, "message": "크롤링을 할 수 없습니다."}
@app.get("/sync")
async def sync_data():
- """
- 크롤링 서비스(crawler_service.py) 호출
- """
- print(">>> /sync request received")
return StreamingResponse(run_crawler_service(), media_type="text_event-stream")
+
+@app.get("/stop-sync")
+async def stop_sync():
+ crawl_stop_event.set()
+ return {"success": True}
+
+@app.get("/attachments")
+async def get_attachments():
+ path = "sample"
+ if not os.path.exists(path): os.makedirs(path)
+ return [{"name": f, "size": f"{os.path.getsize(os.path.join(path, f))/1024:.1f} KB"}
+ for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))]
+
+@app.get("/analyze-file")
+async def analyze_file(filename: str):
+ return await run_in_threadpool(analyze_file_content, filename)
+
+@app.get("/sample.png")
+async def get_sample_img():
+ return FileResponse("sample.png")
diff --git a/server_reboot.log b/server_reboot.log
deleted file mode 100644
index 8a8f47f..0000000
Binary files a/server_reboot.log and /dev/null differ
diff --git a/server_revert.log b/server_revert.log
deleted file mode 100644
index 9554606..0000000
Binary files a/server_revert.log and /dev/null differ
diff --git a/server_startup.log b/server_startup.log
deleted file mode 100644
index 6432ab7..0000000
Binary files a/server_startup.log and /dev/null differ
diff --git a/sheet.csv b/sheet.csv
deleted file mode 100644
index 775627a..0000000
--- a/sheet.csv
+++ /dev/null
@@ -1,42 +0,0 @@
-[PM Overseas 프로젝트 현황],,2026.03.04,,,,,,<<활동로그가 없는 프로젝트 (8),,
-,,,,,,,,,,
-No.,프로젝트 명,담당부서,담당자,종료(예정)일,최근 활동로그,과업개요 작성 유무,파일 수,비고,,
-1,라오스 ITTC 관개 교육센터 PMC,수자원1부,방노성,2025.12.20,"2026.01.29, 폴더 삭제",O,16,2026.01.29 로그는 테스트 활동 추정,종료(예정)일 지남,진행
-2,라오스 비엔티안 메콩강 관리 2차 DD,수자원1부,방노성,2026.05.31,"2025.12.07, 파일업로드",X,260,탭 1개에 모든파일 업로드,,
-3,미얀마 만달레이 철도 개량 감리 CS,철도사업부,김태헌,2027.11.17,"2025.11.17, 폴더이름변경",O,298,,,
-4,베트남 푸옥호아 양수 발전 FS,수력부,이철호,2025.11.30,"2026.02.23, 폴더이름변경",O,139,준공도서 3월 작성예정,종료(예정)일 지남,준공
-5,사우디아라비아 아시르 지잔 고속도로 FS,도로부,공태원,2025.11.21,"2026.02.09, 파일다운로드",O,73,,종료(예정)일 지남,준공
-6,우즈베키스탄 지방 도로 복원 MP,도로부,장진영,2029.04.28,X,X,0,,,
-7,우즈베키스탄 타슈켄트 철도 FS,철도사업부,김태헌,2026.03.20,"2026.02.05, 파일업로드",O,51,,,
-8,이라크 Habbaniyah Shuaiba AirBase PD,도로부,강동구,2026.12.31,X,X,0,,,
-9,메콩유역 수자원 관리 기후적응 MP,수자원1부,정귀한,2025.12.31,X,X,0,,종료(예정)일 지남,준공
-10,캄보디아 반테 민체이 관개 홍수저감 MP,수자원1부,이대주,2026.08.28,"2025.12.07, 파일업로드",X,44,,,
-11,캄보디아 시엠립 하수처리 개선 DD,물환경사업1부,변역근,2028.12.18,"2026.02.06, AI 요약",O,221,,,
-12,키르기스스탄 잘랄아바드 상수도 계획 MP,물환경사업1부,변기상,2025.12.31,"2026.02.12, 파일업로드",X,60,,종료(예정)일 지남,준공
-13,파키스탄 펀잡 홍수 방재 PMC,수자원1부,방노성,2027.12.31,"2025.12.08, 폴더삭제",O,0,,,
-14,파키스탄 KP 아보타바드 상수도 PMC,물환경사업2부,변기상,2026.12.31,"2026.02.26, 파일업로드",O,240,,,
-15,파키스탄 CAREC 도로 감리 DD,도로부,황효섭,2026.10.26,X,X,0,,,
-16,필리핀 홍수 복원 InFRA2 DD,수자원1부,이대주,2026.08.07,"2025.12.01, 폴더삭제",O,6,최근로그 >> 폴더자동삭제(파일 개수 미달),,
-17,필리핀 홍수 관리 Package5B MP,수자원1부,이희철,2026.05.31,"2025.12.02, 폴더이름변경",O,14,,,
-18,필리핀 PGN 해상교량 BID2 IDC,구조부,이상희,2026.05.31,"2026.02.11, 파일다운로드",O,631,,,
-19,가나 테치만 상수도 확장 DS,물환경사업2부,-,2029.04.25,X,X,0,책임자 및 담당자 설정X,,
-20,기니 벼 재배단지 PMC,수자원1부,이대주,2028.12.20,"2025.12.08, 파일업로드",O,43,최근로그 >> 폴더자동삭제(파일 개수 미달),,
-21,우간다 벼 재배단지 PMC,수자원1부,방노성,2028.12.20,"2025.12.08, 파일업로드",O,52,,,
-22,우간다 부수쿠마 분뇨 자원화 2단계 PMC,물환경사업2부,변기상,2026.12.31,"2026.02.05, 파일업로드",X,9,,,
-23,에티오피아 지하수 관개 환경설계 DD,물환경사업2부,변기상,2026.06.23,X,X,0,,,
-24,에티오피아 도도타군 관개 PMC,수자원1부,방노성,2026.12.31,"2025.12.01, 폴더이름변경",O,144,탭 1개에 모든파일 업로드 // 최근로그 >> 폴더자동삭제(파일 개수 미달),,
-25,에티오피아 Adeaa-Becho 지하수 관개 MP,수자원1부,방노성,2026.07.31,"2025.11.21, 파일업로드",O,146,최근로그 >> 폴더자동삭제(파일 개수 미달),,
-26,탄자니아 Iringa 상하수도 개선 CS,물환경사업1부,백운영,2029.06.08,"2026.02.03, 폴더생성",X,0,,,
-27,탄자니아 Dodoma 하수 설계감리 DD,물환경사업2부,변기상,2027.07.08,"2026.02.04, 폴더삭제",X,32,,,
-28,탄자니아 잔지바르 쌀 생산 PMC,수자원1부,방노성,2027.12.20,"2025.12.08, 파일 업로드",O,23,,,
-29,탄자니아 도도마 유수율 상수도개선 PMC,물환경사업1부,박순석,2026.12.31,"2026.02.12, 부관리자권한추가",X,35,,,
-30,아르헨티나 SALDEORO 수력발전 28MW DD,플랜트1부,양정모,2026.01.31,X,X,0,,종료(예정)일 지남,준공
-31,온두라스 LaPaz Danli 상수도 CS,물환경사업2부,-,2027.02.23,"2026.01.29, 파일 삭제",O,60,"책임자 및 담당자 설정 X, 실 관리부서는 해외사업부, 더미파일 다수",,
-32,볼리비아 에스꼬마 차라짜니 도로 CS,도로부,전홍찬,2029.12.15,"2026.02.06, 파일업로드",X,1,,,
-33,볼리비아 마모레 교량도로 FS,도로부,황효섭,2025.10.17,"2026.02.06, 파일업로드",X,120,,종료(예정)일 지남,준공
-34,볼리비아 Bombeo-Colomi 도로설계 DD,도로부,황효섭,2026.07.24,"2025.12.05, 파일삭제",O,48,"더미파일(폴더유지용) 12개, 실 관리부서는 해외사업부",,
-35,콜롬비아 AI 폐기물 FS,플랜트1부,서재희,2026.02.27,X,X,0,,종료(예정)일 지남,
-36,파라과이 도로 통행료 현대화 MP,교통계획부,오제훈,2025.10.24,"2025.02.25, 폴더삭제",X,0,,종료(예정)일 지남,준공
-37,페루 Barranca 상하수도 확장 DD,물환경사업2부,변기상,2026.03.08,"2025.11.14, 파일업로드",O,44,"더미파일(폴더유지용) 27개, 실 관리부서는 해외사업부",,
-38,엘살바도르 태평양 철도 FS,철도사업부,김태헌,2025.12.31,"2026.02.24, 폴더자동삭제",X,101,,종료(예정)일 지남,준공
-39,필리핀 사무소,해외사업부,한형남,,"2026.03.04, 파일다운로드",과업개요 페이지 없음,817,,,
\ No newline at end of file
diff --git a/style/dashboard.css b/style/dashboard.css
index 611bbef..38ce943 100644
--- a/style/dashboard.css
+++ b/style/dashboard.css
@@ -1,91 +1,72 @@
+:root {
+ --topbar-h: 36px;
+ --header-h: 56px;
+ --activity-h: 110px;
+ --fixed-total-h: calc(var(--topbar-h) + var(--header-h) + var(--activity-h));
+
+ --primary-color: #1E5149;
+ --primary-lv-0: #f0f7f4;
+ --primary-lv-1: #e1eee9;
+ --border-color: #e5e7eb;
+ --bg-muted: #F9FAFB;
+ --text-main: #111827;
+ --text-sub: #6B7280;
+ --error-color: #F21D0D;
+}
+
/* Portal (Index) */
.portal-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
- height: calc(100vh - 36px);
+ height: calc(100vh - var(--topbar-h));
background: var(--bg-muted);
- padding: var(--space-lg);
- margin-top: 36px;
+ padding: 32px;
+ margin-top: var(--topbar-h);
}
-.portal-header {
- text-align: center;
- margin-bottom: 50px;
-}
+.portal-header { text-align: center; margin-bottom: 50px; }
+.portal-header h1 { font-size: 28px; color: var(--primary-color); margin-bottom: 10px; font-weight: 800; }
+.portal-header p { color: var(--text-sub); font-size: 15px; }
+.button-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 30px; width: 100%; max-width: 800px; }
+.portal-card { background: #fff; border: 1px solid var(--border-color); border-radius: 12px; padding: 40px; text-align: center; transition: all 0.3s ease; width: 100%; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); display: flex; flex-direction: column; align-items: center; gap: 20px; cursor: pointer; text-decoration: none; }
+.portal-card:hover { transform: translateY(-8px); border-color: var(--primary-color); box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); }
+.portal-card i { font-size: 48px; color: var(--primary-color); }
+.portal-card h3 { font-size: 20px; color: var(--text-main); margin: 0; }
+.portal-card p { font-size: 14px; color: var(--text-sub); margin: 0; }
-.portal-header h1 {
- font-size: 28px;
- color: var(--primary-color);
- margin-bottom: 10px;
-}
-
-.portal-header p {
- color: var(--text-sub);
- font-size: 15px;
-}
-
-.button-grid {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 30px;
- width: 100%;
- max-width: 800px;
-}
-
-.portal-card {
- background: #fff;
- border: 1px solid var(--border-color);
- border-radius: 12px;
- padding: 40px;
- text-align: center;
- transition: 0.3s;
- width: 100%;
- box-shadow: var(--box-shadow);
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 20px;
-}
-
-.portal-card:hover {
- transform: translateY(-5px);
- border-color: var(--primary-color);
- box-shadow: var(--box-shadow-lg);
-}
-
-/* Dashboard List & Console */
+/* Dashboard Layout */
header {
- position: fixed;
- top: 36px;
- left: 0;
- right: 0;
- z-index: 1000;
- background: #fff;
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: var(--space-md) var(--space-lg);
- border-bottom: 1px solid var(--border-color);
- box-shadow: 0 2px 4px rgba(0,0,0,0.05);
+ 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;
}
-.main-content {
- margin-top: 100px;
- padding: var(--space-lg);
- max-width: 1400px;
- margin-left: auto;
- margin-right: auto;
+.activity-dashboard-wrapper {
+ position: fixed; top: calc(var(--topbar-h) + var(--header-h)); left: 0; right: 0; z-index: 1000;
+ background: #fff; height: var(--activity-h); border-bottom: 1px solid var(--border-color); box-shadow: 0 4px 6px rgba(0,0,0,0.03);
}
+.activity-dashboard { max-width: 1200px; margin: 0 auto; height: 100%; display: flex; gap: 15px; padding: 10px 32px 20px 32px; }
+.activity-card { flex: 1; padding: 12px 15px; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; display: flex; flex-direction: column; justify-content: center; gap: 2px; border-left: 5px solid transparent; }
+.activity-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
+.activity-card.active { background: #e8f5e9; border-left-color: #4DB251; }
+.activity-card.warning { background: #fff8e1; border-left-color: #FFBF00; }
+.activity-card.stale { background: #ffebee; border-left-color: #F21D0D; }
+.activity-card.unknown { background: #f5f5f5; border-left-color: #9e9e9e; }
+.activity-card .label { font-size: 11px; font-weight: 600; opacity: 0.7; }
+.activity-card .count { font-size: 20px; font-weight: 800; }
+
+.main-content { margin-top: var(--fixed-total-h); padding: 32px; max-width: 1400px; margin-left: auto; margin-right: auto; }
+
+/* 로그 콘솔 - 초기 디자인 복구 (Sticky Terminal 스타일) */
.log-console {
position: sticky;
- top: 100px;
+ top: var(--fixed-total-h);
z-index: 999;
background: #000;
color: #0f0;
- font-family: monospace;
+ font-family: 'Consolas', 'Monaco', monospace;
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
@@ -104,212 +85,72 @@ header {
font-weight: bold;
}
-.accordion-container {
- border-top: 1px solid var(--border-color);
+/* 모달 정중앙 배치 */
+.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;
}
-.accordion-list-header,
-.accordion-header {
- display: grid;
- grid-template-columns: 2.5fr 1fr 1fr 0.8fr 2fr;
- gap: var(--space-md);
- padding: var(--space-md) var(--space-lg);
- align-items: center;
- cursor: pointer;
+.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);
}
-.accordion-list-header {
- position: sticky;
- top: 100px;
- background: var(--bg-muted);
- z-index: 10;
- font-size: 11px;
- font-weight: 700;
- color: var(--text-sub);
- border-bottom: 1px solid var(--text-main);
- cursor: default;
+.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;
}
-.accordion-item {
- border-bottom: 1px solid var(--border-color);
-}
+.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); }
+.modal-body { padding: 20px; overflow-y: auto; }
+.modal-row { cursor: pointer; border-bottom: 1px solid #f5f5f5; }
+.modal-row:hover { background: var(--primary-lv-0); }
-.accordion-item: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; }
-.repo-title {
- font-weight: 700;
- color: var(--primary-color);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
+/* 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-item:hover .accordion-header { background: var(--primary-lv-0); }
+.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-item.active .accordion-body { display: block; }
+.status-warning { background: #fffcf0; }
+.status-error { background: #fff5f4; }
+.warning-text { color: var(--error-color) !important; font-weight: 700; }
-.repo-files {
- text-align: center;
- font-weight: 600;
-}
+.continent-group, .country-group { margin-bottom: 15px; }
+.continent-header, .country-header { background: #fff; padding: 14px 20px; border: 1px solid var(--border-color); border-radius: 8px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; font-weight: 700; }
+.continent-header { background: var(--primary-color); color: white; border: none; font-size: 15px; }
+.country-header { font-size: 14px; color: var(--text-main); margin-top: 8px; }
+.continent-body, .country-body { display: none; padding: 10px 0 10px 15px; }
+.active>.continent-body, .active>.country-body { display: block; }
-.repo-log {
- font-size: 11px;
- color: var(--text-sub);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.accordion-body {
- display: none;
- padding: var(--space-lg);
- background: var(--bg-muted);
- border-top: 1px solid var(--border-color);
-}
-
-.accordion-item.active .accordion-body {
- display: block;
-}
-
-.status-warning {
- background: #fff9e6;
-}
-
-.status-error {
- background: #fee9e7;
-}
-
-.warning-text {
- color: #f21d0d !important;
- font-weight: 700;
-}
-
-/* Multi-level Groups */
-.continent-group,
-.country-group {
- margin-bottom: 10px;
-}
-
-.continent-header,
-.country-header {
- background: #fff;
- padding: 12px 20px;
- border: 1px solid var(--border-color);
- border-radius: 8px;
- display: flex;
- justify-content: space-between;
- align-items: center;
- cursor: pointer;
- font-weight: 700;
- transition: all 0.2s;
-}
-
-.continent-header {
- background: var(--primary-color);
- color: white;
- border: none;
- font-size: 15px;
-}
-
-.country-header {
- font-size: 14px;
- color: var(--text-main);
- margin-top: 5px;
-}
-
-.continent-body,
-.country-body {
- display: none;
- padding: 10px 0 10px 20px;
-}
-
-.active>.continent-body,
-.active>.country-body {
- display: block;
-}
-
-/* Detail Views */
-.detail-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 20px;
-}
-
-.detail-section h4 {
- font-size: 13px;
- margin-bottom: 10px;
- color: var(--text-main);
- border-left: 3px solid var(--primary-color);
- padding-left: 8px;
-}
-
-.data-table {
- width: 100%;
- border-collapse: collapse;
- font-size: 12px;
-}
-
-.data-table th,
-.data-table td {
- padding: 8px;
- border-bottom: 1px solid var(--border-color);
- text-align: left;
-}
-
-.data-table th {
- color: var(--text-sub);
- font-weight: 600;
-}
-
-/* Sync Button & Admin Info */
-.sync-btn {
- display: flex;
- align-items: center;
- gap: var(--space-sm);
- background-color: var(--primary-color);
- color: #fff;
- padding: 8px 16px;
- border-radius: var(--radius-lg);
- font-size: 13px;
- font-weight: 600;
- cursor: pointer;
- border: none;
- box-shadow: var(--box-shadow);
-}
-
-.sync-btn:hover {
- background-color: var(--primary-lv-8);
-}
-
-.sync-btn.loading .spinner {
- display: inline-block;
-}
-
-.admin-info {
- font-size: 13px;
- color: var(--text-sub);
- margin-left: var(--space-md);
- padding: 6px 12px;
- background: var(--bg-muted);
- border-radius: var(--radius-sm);
- border: 1px solid var(--border-color);
-}
-
-.admin-info strong {
- color: var(--primary-color);
- font-weight: 700;
-}
-
-.base-date-info {
- font-size: 13px;
- color: var(--text-sub);
- background: #f8f9fa;
- padding: 6px 15px;
- border-radius: 6px;
- border: 1px solid var(--border-color);
-}
-
-.base-date-info strong {
- color: #333;
- font-weight: 700;
- margin-left: 5px;
-}
+.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; }
+.base-date-info { font-size: 13px; color: var(--text-sub); background: #fdfdfd; padding: 6px 15px; border-radius: 6px; border: 1px solid var(--border-color); }
+.base-date-info strong { color: #333; font-weight: 700; }
diff --git a/templates/dashboard.html b/templates/dashboard.html
index debd3a6..96fca23 100644
--- a/templates/dashboard.html
+++ b/templates/dashboard.html
@@ -42,7 +42,14 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
크롤링을 할 수 없습니다.
+
+
+
+
+
+
+
+
+
+
+
+
+ | 프로젝트명 |
+ 담당부서 |
+ 담당자 |
+
+
+
+
+
+
+
+
+
+