feat: 프로젝트 활성도 분석 시스템 및 크롤링 인증/중단 기능 구현 - DB 연결 최적화, 활성도 위젯 및 내비게이션, 관리자 인증 모달, 중단 기능, UI 레이아웃 최적화, 코드 리팩토링 및 파일 정리
This commit is contained in:
6
.env
6
.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
|
||||
|
||||
16
README.md
16
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. **로그 기록 철저**: 진행 상황(로그인, 수집, 오류 등)을 실시간 로그에 상세히 표시하여 투명성을 확보한다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
@@ -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,23 +91,21 @@ 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)
|
||||
@@ -113,27 +116,27 @@ def crawler_thread_worker(msg_queue, user_id, password):
|
||||
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 마스터 정보 동기화 완료.'}))
|
||||
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
|
||||
msg_queue.put(json.dumps({'type': 'log', 'message': f'[{i+1}/{count}] {project_name} 수집 시작'}))
|
||||
captured_data["tree"] = None; captured_data["_is_root_archive"] = False
|
||||
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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,,,
|
||||
|
@@ -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,,,
|
||||
|
226
debug_modal.html
226
debug_modal.html
@@ -1,226 +0,0 @@
|
||||
|
||||
<div class="wrap">
|
||||
<article class="log-filter">
|
||||
<div class="head">
|
||||
<span class="title _h3">로그필터</span>
|
||||
<button class="_button-xsmall reset">초기화</button>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="log-date">
|
||||
<span class="subtitle">활동시간</span>
|
||||
<div class="log-date-wrap">
|
||||
<span class="category">시작</span>
|
||||
<input type="date" value="">
|
||||
</div>
|
||||
<div class="log-date-wrap">
|
||||
<span class="category">종료</span>
|
||||
<input type="date" value="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-user">
|
||||
<span class="subtitle">사용자</span>
|
||||
<div class="custom-select-wrap">
|
||||
<div class="custom-select-display">모든 사용자</div>
|
||||
<ul class="custom-select-list" style="display: none;"><li data-value="allUser">모든 사용자</li><li data-value="213057">213057 (박진규)</li><li data-value="225044">225044 (박종호)</li><li data-value="B21364">B21364 (이태훈)</li><li data-value="B22027">B22027 (김혜인)</li><li data-value="dev5">dev5 (시스템관리E)</li><li data-value="dev6">dev6 (시스템관리F)</li><li data-value="dev7">dev7 (시스템관리G)</li><li data-value="M07318">M07318 (김원기)</li></ul>
|
||||
<select id="log-user-select" name="log-user-select" hidden=""><option value="allUser">모든 사용자</option><option value="213057">213057 (박진규)</option><option value="225044">225044 (박종호)</option><option value="B21364">B21364 (이태훈)</option><option value="B22027">B22027 (김혜인)</option><option value="dev5">dev5 (시스템관리E)</option><option value="dev6">dev6 (시스템관리F)</option><option value="dev7">dev7 (시스템관리G)</option><option value="M07318">M07318 (김원기)</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-activity">
|
||||
<div class="head-group">
|
||||
<span class="subtitle">활동유형</span>
|
||||
<div class="button-wrap">
|
||||
<button class="_button-xsmall select-all">전체선택</button>
|
||||
<button class="_button-xsmall clear-all">전체해제</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="category">파일 / 폴더관련</span>
|
||||
<label>
|
||||
<input type="checkbox" value="uploadData_file" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>파일 업로드</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="renameTarget" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>이름 변경</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="removeTarget" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>삭제</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="downloadTarget" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>다운로드</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="relocateTarget" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>파일 이동</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="createFolder" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>새 폴더 생성</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="setDataPermission_folder" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>폴더 권한 설정</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="convertPdf" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>PDF 변환</span>
|
||||
</label>
|
||||
<span class="category">유저관련</span>
|
||||
<label>
|
||||
<input type="checkbox" value="editAuthor" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>작성자 변경</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="deletePermission" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>권한 삭제</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="addPermission" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>권한 추가</span>
|
||||
</label>
|
||||
<span class="category">기타</span>
|
||||
<label>
|
||||
<input type="checkbox" value="summarizeAI" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>AI 요약</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot">
|
||||
<button class="_button-medium">적용</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="modal-wrap">
|
||||
<div class="modal-header narrow-area">
|
||||
<div class="title">
|
||||
<div class="left-wrap">
|
||||
<div class="title-wrap">
|
||||
<div class="text">활동로그</div>
|
||||
<div class="users-count" style="display: none;">1 명</div>
|
||||
</div>
|
||||
<div class="btn set-user-permission-btn permission-min-sub-master" style="display: none;">
|
||||
<div class="text">유저 권한 설정</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="close"></div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="connected-users-wrap" style="display: none;">
|
||||
<div class="user-item-wrap scrollbar"><div class="user-item me" data-user-id="B21364"><img class="profile-image" src="/main/img/archive/empty-profile.svg" style="outline: rgb(24, 114, 89) solid 2px;"><div class="wrap"><div class="top-wrap"><div class="name">이태훈 선임연구원</div><div class="user-permission-sub-master"><h6>부관리자</h6></div><div class="me-badge"><h6>나</h6></div></div><div class="bottom-wrap"><div class="cur-path">현재 위치: /과업개요</div></div></div></div></div>
|
||||
<div class="project-setting-wrap">
|
||||
<div class="project-name-wrap">
|
||||
<div>프로젝트명</div>
|
||||
<div class="project-type-wrap" id="project-type-wrap" style="display: none;">
|
||||
<button class="project-type" id="project-type-btn">
|
||||
<h5 class="project-type__label --type__support">지원</h5>
|
||||
<i class="project-type__icon"></i>
|
||||
</button>
|
||||
<h5 class="--type-capsule" id="project-type-capsule">시공</h5>
|
||||
<ul class="project-type__list">
|
||||
<li class="project-type__list_item --type__construction">시공</li>
|
||||
<li class="project-type__list_item --type__design">설계</li>
|
||||
<li class="project-type__list_item --type__surgest">제안</li>
|
||||
<li class="project-type__list_item --type__research">연구</li>
|
||||
<li class="project-type__list_item --type__support">지원</li>
|
||||
<li class="project-type__list_item --type__center">센터</li>
|
||||
<li class="project-type__list_item --type__survey">측량</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="project-type-wrap" id="project-type-wrap-overseas" style="display: flex;">
|
||||
<button class="project-type" id="project-type-btn-overseas" style="min-width: 107.523px; text-align: center; justify-content: center; display: none; align-items: center;">
|
||||
<h5 class="project-type__label --type__MP">MP (기본계획)</h5>
|
||||
<i class="project-type__icon"></i>
|
||||
</button>
|
||||
<h5 class="--type-capsule" id="project-type-capsule-overseas" style="min-width: 107.523px; text-align: center; justify-content: center; display: flex; align-items: center;">PMC (실시설계)</h5>
|
||||
<ul class="project-type__list" style="min-width: 107.523px;">
|
||||
<li class="project-type__list_item --type__MP" style="padding-left: 5px; padding-right: 5px;">MP (기본계획)</li>
|
||||
<li class="project-type__list_item --type__DD" style="padding-left: 5px; padding-right: 5px;">DD (실시설계)</li>
|
||||
<li class="project-type__list_item --type__FS" style="padding-left: 5px; padding-right: 5px;">FS (타당성조사)</li>
|
||||
<li class="project-type__list_item --type__PD" style="padding-left: 5px; padding-right: 5px;">PD (기본설계)</li>
|
||||
<li class="project-type__list_item --type__DS" style="padding-left: 5px; padding-right: 5px;">DS (설계감리)</li>
|
||||
<li class="project-type__list_item --type__CS" style="padding-left: 5px; padding-right: 5px;">CS (시공감리)</li>
|
||||
<li class="project-type__list_item --type__PMC" style="padding-left: 5px; padding-right: 5px;">PMC (실시설계)</li>
|
||||
<li class="project-type__list_item --type__IDC" style="padding-left: 5px; padding-right: 5px;">IDC (타당성조사)</li>
|
||||
<li class="project-type__list_item --type__DR" style="padding-left: 5px; padding-right: 5px;">DR (설계검토)</li>
|
||||
<li class="project-type__list_item --type__ETC" style="padding-left: 5px; padding-right: 5px;">ETC (기타)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="project-input-wrap" style="display: flex; gap:1rem;">
|
||||
<div class="project-setting-name" id="project-name-view"> ITTC 관개 교육센터</div>
|
||||
<input type="text" class="project-setting-name" id="project-name-input" style="display: none; border: 1px solid black;">
|
||||
</div>
|
||||
<div class="project-step-wrap">
|
||||
<button class="project-step" id="project-step-btn" style="display: none;">
|
||||
<h5 class="project-step__label --step__active">진행</h5>
|
||||
<i class="project-step__icon"></i>
|
||||
</button>
|
||||
<h5 class="project-step-capsule --step-capsule__active" id="project-step-capsule" style="display: flex;">진행</h5>
|
||||
<ul class="project-step__list">
|
||||
<li class="project-step__list_item --step__active">진행</li>
|
||||
<li class="project-step__list_item --step__stop">중지</li>
|
||||
<li class="project-step__list_item --step__done">완료</li>
|
||||
<li class="project-step__list_item --step__wait">대기</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="peoject-save-wrap">
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-manager-wrap">
|
||||
<div class="project-manager-title">프로젝트 관리자</div>
|
||||
<div class="project-manager-name">방노성 전무이사</div>
|
||||
</div>
|
||||
<div class="project-location-wrap">
|
||||
<div class="project-location-title">프로젝트 위치</div>
|
||||
<div class="project-location-lat">위도 18.068579</div>
|
||||
<div class="project-location-lon">경도 102.65966</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-wrap">
|
||||
<div class="logout-btn">
|
||||
<div class="image"></div>
|
||||
<div class="text">로그아웃</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="manual-wrap" style="display: none;"></div>
|
||||
<div class="size-wrap" style="display: none;">
|
||||
<div class="chart" style="user-select: none; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); position: relative;"><div style="position: relative; width: 1152px; height: 720px; padding: 0px; margin: 0px; border-width: 0px;"><canvas data-zr-dom-id="zr_0" width="1152" height="720" style="position: absolute; left: 0px; top: 0px; width: 1152px; height: 720px; user-select: none; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); padding: 0px; margin: 0px; border-width: 0px;"></canvas></div><div class=""></div></div>
|
||||
<div class="text">저장공간 관련 문의: GSIM 개발팀 이호성 수석연구원</div>
|
||||
</div>
|
||||
<div class="log-wrap" style="opacity: 1; display: flex;">
|
||||
<div class="log-item-wrap log-header">
|
||||
<div class="log-item">
|
||||
<div class="date"><div class="text">활동시간</div></div>
|
||||
<div class="user"><div class="text">사용자</div></div>
|
||||
<div class="activity"><div class="text">활동유형</div></div>
|
||||
<div class="log"><div class="text">활동내용</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item-wrap log-body scrollbar scroll-container"></div>
|
||||
</div>
|
||||
<div class="text-wrap" style="display: none;">undefined</div>
|
||||
<div class="project-list-wrap" style="display: none;"></div>
|
||||
<div class="input-wrap" style="display: none;"></div>
|
||||
<div class="user-list-wrap" style="display: none;">
|
||||
<div class="user-item-wrap scrollbar"></div>
|
||||
</div>
|
||||
<div class="btn-wrap" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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())
|
||||
24
js/common.js
24
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', () => {
|
||||
// 공통 초기화 로직
|
||||
});
|
||||
|
||||
417
js/dashboard.js
417
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;
|
||||
|
||||
// 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 = `<select id="dateSelector" onchange="loadDataByDate(this.value)" style="margin-left:10px; border:none; background:none; font-weight:700; cursor:pointer;">`;
|
||||
dates.forEach(d => {
|
||||
selectHtml += `<option value="${d}">${d}</option>`;
|
||||
});
|
||||
selectHtml += `</select>`;
|
||||
// 기준날짜 텍스트 영역을 셀렉트 박스로 교체
|
||||
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 = `
|
||||
<select id="dateSelector" onchange="loadDataByDate(this.value)"
|
||||
style="margin-left:10px; border:none; background:none; font-weight:700; cursor:pointer; font-family:inherit; color:inherit;">
|
||||
${dates.map(d => `<option value="${d}">${d}</option>`).join('')}
|
||||
</select>`;
|
||||
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 = `
|
||||
<div class="activity-card active" onclick="showActivityDetails('active')">
|
||||
<div class="label">정상 (7일 이내)</div><div class="count">${summary.active}</div>
|
||||
</div>
|
||||
<div class="activity-card warning" onclick="showActivityDetails('warning')">
|
||||
<div class="label">주의 (14일 이내)</div><div class="count">${summary.warning}</div>
|
||||
</div>
|
||||
<div class="activity-card stale" onclick="showActivityDetails('stale')">
|
||||
<div class="label">방치 (14일 초과)</div><div class="count">${summary.stale}</div>
|
||||
</div>
|
||||
<div class="activity-card unknown" onclick="showActivityDetails('unknown')">
|
||||
<div class="label">데이터 없음 (파일 0개 등)</div><div class="count">${summary.unknown}</div>
|
||||
</div>`;
|
||||
} 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 });
|
||||
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 = `<div class="continent-header" onclick="toggleGroup(this)"><span>${continent}</span><span class="toggle-icon">▼</span></div><div class="continent-body">`;
|
||||
Object.keys(grouped[continent]).sort().forEach(country => {
|
||||
html += `<div class="country-group active"><div class="country-header" onclick="toggleGroup(this)"><span>${country}</span><span class="toggle-icon">▼</span></div><div class="country-body"><div class="accordion-container">
|
||||
<div class="accordion-list-header"><div>프로젝트명</div><div>담당부서</div><div>담당자</div><div style="text-align:center;">파일수</div><div>최근로그</div></div>
|
||||
${grouped[continent][country].sort((a,b)=>a[0].localeCompare(b[0])).map(p => createProjectHtml(p)).join('')}</div></div></div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
continentDiv.innerHTML = html;
|
||||
container.appendChild(continentDiv);
|
||||
});
|
||||
}
|
||||
|
||||
const sortedContinents = Object.keys(groupedData).sort((a, b) => (continentOrder[a] || 99) - (continentOrder[b] || 99));
|
||||
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;
|
||||
}
|
||||
|
||||
sortedContinents.forEach(continent => {
|
||||
const continentGroup = document.createElement('div');
|
||||
continentGroup.className = 'continent-group';
|
||||
|
||||
let continentHtml = `
|
||||
<div class="continent-header" onclick="toggleGroup(this)">
|
||||
<span>${continent}</span>
|
||||
<span class="toggle-icon">▼</span>
|
||||
</div>
|
||||
<div class="continent-body">
|
||||
`;
|
||||
|
||||
const sortedCountries = Object.keys(groupedData[continent]).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
sortedCountries.forEach(country => {
|
||||
continentHtml += `
|
||||
<div class="country-group">
|
||||
<div class="country-header" onclick="toggleGroup(this)">
|
||||
<span>${country}</span>
|
||||
<span class="toggle-icon">▼</span>
|
||||
</div>
|
||||
<div class="country-body">
|
||||
<div class="accordion-container">
|
||||
<div class="accordion-list-header">
|
||||
<div>프로젝트명</div>
|
||||
<div>담당부서</div>
|
||||
<div>담당자</div>
|
||||
<div style="text-align:center;">파일수</div>
|
||||
<div>최근로그</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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;
|
||||
function createProjectHtml(p) {
|
||||
const [name, dept, admin, logRaw, files] = p;
|
||||
const recentLog = (!logRaw || logRaw === "X" || logRaw === "데이터 없음") ? "기록 없음" : logRaw;
|
||||
const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음";
|
||||
|
||||
let statusClass = "";
|
||||
if (fileCount === 0) statusClass = "status-error";
|
||||
else if (recentLog === "기록 없음") statusClass = "status-warning";
|
||||
|
||||
continentHtml += `
|
||||
const statusClass = (files === 0 || files === null) ? "status-error" : (recentLog === "기록 없음") ? "status-warning" : "";
|
||||
return `
|
||||
<div class="accordion-item ${statusClass}">
|
||||
<div class="accordion-header" onclick="toggleAccordion(this)">
|
||||
<div class="repo-title" title="${projectName}">${projectName}</div>
|
||||
<div class="repo-dept">${dept}</div>
|
||||
<div class="repo-admin">${admin}</div>
|
||||
<div class="repo-files ${fileCount === 0 ? 'warning-text' : ''}">${fileCount}</div>
|
||||
<div class="repo-log ${recentLog === '기록 없음' ? 'warning-text' : ''}" title="${recentLog}">${recentLog}</div>
|
||||
<div class="repo-title" title="${name}">${name}</div><div class="repo-dept">${dept}</div><div class="repo-admin">${admin}</div><div class="repo-files ${statusClass==='status-error'?'warning-text':''}">${files||0}</div><div class="repo-log ${recentLog==='기록 없음'?'warning-text':''}" title="${recentLog}">${recentLog}</div>
|
||||
</div>
|
||||
<div class="accordion-body">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-section">
|
||||
<h4>참여 인원 상세</h4>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>이름</th><th>소속</th><th>사용자권한</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>${admin}</td><td>${dept}</td><td>관리자</td></tr>
|
||||
<tr><td>김철수</td><td>${dept}</td><td>부관리자</td></tr>
|
||||
<tr><td>박지민</td><td>${dept}</td><td>일반참여자</td></tr>
|
||||
<tr><td>최유리</td><td>${dept}</td><td>참관자</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h4>최근 문의사항 및 파일 변경 로그</h4>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>유형</th><th>내용</th><th>일시</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="badge">로그</span></td><td>데이터 동기화 완료</td><td>${logTime}</td></tr>
|
||||
<tr><td><span class="badge" style="background:var(--hover-bg); border: 1px solid var(--border-color); color:var(--primary-color);">문의</span></td><td>프로젝트 접근 권한 요청</td><td>2026-02-23</td></tr>
|
||||
<tr><td><span class="badge" style="background:var(--primary-color); color:white;">파일</span></td><td>설계도면 v2.pdf 업로드</td><td>2026-02-22</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="detail-section"><h4>참여 인원 상세</h4><table class="data-table"><thead><tr><th>이름</th><th>소속</th><th>권한</th></tr></thead><tbody><tr><td>${admin}</td><td>${dept}</td><td>관리자</td></tr></tbody></table></div>
|
||||
<div class="detail-section"><h4>최근 활동</h4><table class="data-table"><thead><tr><th>유형</th><th>내용</th><th>일시</th></tr></thead><tbody><tr><td><span class="badge">로그</span></td><td>동기화 완료</td><td>${logTime}</td></tr></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
continentHtml += `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
continentHtml += `
|
||||
</div>
|
||||
`;
|
||||
|
||||
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');
|
||||
});
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 `<tr class="modal-row" onclick="scrollToProject('${p.name}')"><td><strong>${p.name}</strong></td><td>${o?o[1]:"-"}</td><td>${o?o[2]:"-"}</td></tr>`;
|
||||
}).join('');
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
btn.classList.add('loading');
|
||||
btn.innerHTML = `<span class="spinner"></span> 동기화 중 (진행 상황 확인 중...)`;
|
||||
btn.disabled = true;
|
||||
|
||||
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, ''))
|
||||
);
|
||||
function closeActivityModal() { document.getElementById('activityDetailModal').style.display = 'none'; }
|
||||
|
||||
function scrollToProject(name) {
|
||||
closeActivityModal();
|
||||
const target = Array.from(document.querySelectorAll('.repo-title')).find(t => t.innerText.trim() === name.trim())?.closest('.accordion-header');
|
||||
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 = `<span class="spinner"></span> 데이터 동기화 (크롤링)`;
|
||||
btn.disabled = false;
|
||||
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 = `<span class="spinner"></span> 크롤링 중단`;
|
||||
logC.style.display = 'block'; logB.innerHTML = '<div style="color:#aaa; margin-bottom:10px;">>>> 엔진 초기화 중...</div>';
|
||||
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 = `<span class="spinner"></span> 데이터 동기화 (크롤링)`; }
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
BIN
log_debug.png
BIN
log_debug.png
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB |
@@ -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():
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
253
server.py
253
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):
|
||||
@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
|
||||
"""
|
||||
분석 서비스(analyze.py) 호출 - 스레드 풀에서 비차단 방식으로 실행
|
||||
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
|
||||
"""
|
||||
return await run_in_threadpool(analyze_file_content, filename)
|
||||
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")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
42
sheet.csv
42
sheet.csv
@@ -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,,,
|
||||
|
@@ -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; }
|
||||
|
||||
@@ -42,7 +42,14 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 실시간 로그 콘솔 추가 -->
|
||||
<!-- 프로젝트 활성도 대시보드 (전체 너비 래퍼) -->
|
||||
<div class="activity-dashboard-wrapper">
|
||||
<div id="activityDashboard" class="activity-dashboard">
|
||||
<!-- JS에서 동적 삽입 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 실시간 로그 콘솔 (본문 내부로 복구) -->
|
||||
<div id="logConsole" class="log-console" style="display:none;">
|
||||
<div class="log-console-header">실시간 수집 로그 [PM Overseas]</div>
|
||||
<div id="logBody"></div>
|
||||
@@ -53,6 +60,55 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 모달 레이어 (최외각 유지) -->
|
||||
<div id="authModal" class="activity-modal-overlay" style="display:none;">
|
||||
<div class="auth-modal-content">
|
||||
<div class="auth-header">
|
||||
<i class="fas fa-lock"></i>
|
||||
<h3>크롤링 권한 인증</h3>
|
||||
<p>시스템 동기화를 위해 관리자 계정으로 로그인하세요.</p>
|
||||
</div>
|
||||
<div class="auth-body">
|
||||
<div class="input-group">
|
||||
<label>관리자 아이디</label>
|
||||
<input type="text" id="authId" placeholder="아이디를 입력하세요">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>비밀번호</label>
|
||||
<input type="password" id="authPw" placeholder="비밀번호를 입력하세요" onkeyup="if(event.key==='Enter') submitAuth()">
|
||||
</div>
|
||||
<div id="authErrorMessage" class="error-text" style="display:none;">크롤링을 할 수 없습니다.</div>
|
||||
</div>
|
||||
<div class="auth-footer">
|
||||
<button class="cancel-btn" onclick="closeAuthModal()">취소</button>
|
||||
<button class="login-btn" onclick="submitAuth()">인증 및 실행</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="activityDetailModal" class="activity-modal-overlay" style="display:none;" onclick="closeActivityModal(event)">
|
||||
<div class="activity-modal-content" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalTitle">상세 목록</h3>
|
||||
<button class="close-btn" onclick="closeActivityModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>프로젝트명</th>
|
||||
<th>담당부서</th>
|
||||
<th>담당자</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="modalTableBody">
|
||||
<!-- JS에서 동적 삽입 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/common.js"></script>
|
||||
<script src="js/dashboard.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import asyncio, os, json, queue, threading
|
||||
from crawler_service import crawler_thread_worker
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
async def run_main_test():
|
||||
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.start()
|
||||
|
||||
print(">>> 메인 워커 실행 중 (필리핀 사무소)...")
|
||||
try:
|
||||
while True:
|
||||
msg_raw = await asyncio.to_thread(msg_queue.get, timeout=300)
|
||||
if msg_raw is None: break
|
||||
|
||||
msg = json.loads(msg_raw)
|
||||
if msg["type"] == "log":
|
||||
print(f"[LOG] {msg['message']}")
|
||||
elif msg["type"] == "done":
|
||||
print(f"\n[DONE] 최종 결과: {msg['data']}")
|
||||
break
|
||||
except queue.Empty:
|
||||
print(">>> 타임아웃")
|
||||
finally:
|
||||
thread.join()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_main_test())
|
||||
Reference in New Issue
Block a user