feat: 메일 관리 UI 개편 및 시스템 구조 최적화

- UI/UX: 메일 관리 레이아웃 고도화 및 미리보기 토글 핸들 도입
- 기능: 주소록 CRUD 기능 추가 및 모달 인터페이스 개선
- 구조: CSS 파일 기능별 분리 및 Jinja2 템플릿 엔진 도입
- 백엔드: OCR 비동기 처리 및 CSV 파싱(BOM) 안정화
- 데이터: 2026.03.04 기준 최신 프로젝트 현황 업데이트
This commit is contained in:
2026-03-04 17:58:54 +09:00
parent ff9146cfee
commit d246b08799
20 changed files with 2074 additions and 1138 deletions

View File

@@ -142,7 +142,8 @@ def analyze_file_content(filename: str):
if images: if images:
ocr_result = pytesseract.image_to_string(images[0], lang='kor+eng') ocr_result = pytesseract.image_to_string(images[0], lang='kor+eng')
page_text += "\n" + ocr_result page_text += "\n" + ocr_result
except: pass except Exception as ocr_err:
print(f"OCR Error on page {i+1}: {ocr_err}")
text_by_pages.append(page_text) text_by_pages.append(page_text)
elif filename.lower().endswith(('.xlsx', '.xls')): elif filename.lower().endswith(('.xlsx', '.xls')):
import pandas as pd import pandas as pd

View File

@@ -21,19 +21,20 @@ async def run_crawler_service():
results = [] results = []
async with async_playwright() as p: async with async_playwright() as p:
yield f"data: {json.dumps({'type': 'log', 'message': '브라우저 실행 중...'})}\n\n" browser = None
browser = await p.chromium.launch(headless=True, args=[
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-blink-features=AutomationControlled"
])
context = await browser.new_context(
viewport={'width': 1920, 'height': 1080},
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"
)
page = await context.new_page()
try: try:
yield f"data: {json.dumps({'type': 'log', 'message': '브라우저 실행 중...'})}\n\n"
browser = await p.chromium.launch(headless=True, args=[
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-blink-features=AutomationControlled"
])
context = await browser.new_context(
viewport={'width': 1920, 'height': 1080},
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"
)
page = await context.new_page()
yield f"data: {json.dumps({'type': 'log', 'message': '사이트 접속 및 로그인 중...'})}\n\n" yield f"data: {json.dumps({'type': 'log', 'message': '사이트 접속 및 로그인 중...'})}\n\n"
await page.goto("https://overseas.projectmastercloud.com/", wait_until="domcontentloaded") await page.goto("https://overseas.projectmastercloud.com/", wait_until="domcontentloaded")
@@ -131,7 +132,10 @@ async def run_crawler_service():
yield f"data: {json.dumps({'type': 'done', 'data': results})}\n\n" yield f"data: {json.dumps({'type': 'done', 'data': results})}\n\n"
except GeneratorExit:
# SSE 연결이 클라이언트 측에서 먼저 끊겼을 때 실행
if browser: await browser.close()
except Exception as e: except Exception as e:
yield f"data: {json.dumps({'type': 'log', 'message': f'치명적 오류: {str(e)}'})}\n\n" yield f"data: {json.dumps({'type': 'log', 'message': f'치명적 오류: {str(e)}'})}\n\n"
finally: finally:
await browser.close() if browser: await browser.close()

View File

@@ -1,360 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Master Overseas 관리자</title>
<link rel="stylesheet" as="style" crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="stylesheet" href="style/style.css">
</head>
<body>
<nav class="topbar">
<div class="topbar-header">
<a href="/">
<h2>Project Master Test</h2>
</a>
</div>
<ul class="nav-list">
<li class="nav-item active" onclick="location.href='/dashboard'">대시보드</li>
<li class="nav-item" onclick="alert('준비 중입니다.')">문의사항</li>
<li class="nav-item" onclick="alert('준비 중입니다.')">로그관리</li>
<li class="nav-item" onclick="alert('준비 중입니다.')">파일관리</li>
<li class="nav-item" onclick="alert('준비 중입니다.')">인원관리</li>
<li class="nav-item" onclick="alert('준비 중입니다.')">공지사항</li>
</ul>
</nav>
<main class="main-content">
<header>
<div style="display:flex; align-items:center;">
<h1>프로젝트 현황</h1>
</div>
<div style="display:flex; align-items:center;">
<button id="syncBtn" class="sync-btn" onclick="syncData()">
<span class="spinner"></span>
데이터 동기화 (크롤링)
</button>
<div class="admin-info">접속자: <strong>이태훈[전체관리자]</strong></div>
</div>
</header>
<!-- 실시간 로그 콘솔 추가 -->
<div id="logConsole"
style="display:none; background:#000; color:#0f0; font-family:monospace; padding:15px; margin-bottom:20px; border-radius:4px; max-height:200px; overflow-y:auto; font-size:12px; line-height:1.5;">
<div
style="color:#fff; border-bottom:1px solid #333; margin-bottom:10px; padding-bottom:5px; font-weight:bold;">
실시간 수집 로그 [PM Overseas]</div>
<div id="logBody"></div>
</div>
<div id="projectAccordion">
<!-- Multi-level Accordion items will be generated here -->
</div>
</main>
<script>
const rawData = [
["라오스 ITTC 관개 교육센터 PMC", "수자원1부", "방노성", "2026.01.29, 폴더 삭제", 16],
["라오스 비엔티안 메콩강 관리 2차 DD", "수자원1부", "방노성", "2025.12.07, 파일업로드", 260],
["미얀마 만달레이 철도 개량 감리 CS", "철도사업부", "김태헌", "2025.11.17, 폴더이름변경", 298],
["베트남 푸옥호아 양수 발전 FS", "수력부", "이철호", "2026.02.23, 폴더이름변경", 139],
["사우디아라비아 아시르 지잔 고속도로 FS", "도로부", "공태원", "2026.02.09, 파일다운로드", 73],
["우즈베키스탄 타슈켄트 철도 FS", "철도사업부", "김태헌", "2026.02.05, 파일업로드", 51],
["우즈베키스탄 지방 도로 복원 MP", "도로부", "장진영", "X", 0],
["이라크 Habbaniyah Shuaiba AirBase PD", "도로부", "강동구", "X", 0],
["캄보디아 반테 민체이 관개 홍수저감 MP", "수자원1부", "이대주", "2025.12.07, 파일업로드", 44],
["캄보디아 시엠립 하수처리 개선 DD", "물환경사업1부", "변역근", "2026.02.06, AI 요약", 221],
["메콩유역 수자원 관리 기후적응 MP", "수자원1부", "정귀한", "X", 0],
["키르기스스탄 잘랄아바드 상수도 계획 MP", "물환경사업1부", "변기상", "2026.02.12, 파일업로드", 60],
["파키스탄 CAREC 도로 감리 DD", "도로부", "황효섭", "X", 0],
["파키스탄 펀잡 홍수 방재 PMC", "수자원1부", "방노성", "2025.12.08, 폴더삭제", 0],
["파키스탄 KP 아보타바드 상수도 PMC", "물환경사업2부", "변기상", "2026.02.12, 파일업로드", 234],
["필리핀 홍수 관리 Package5B MP", "수자원1부", "이희철", "2025.12.02, 폴더이름변경", 14],
["필리핀 PGN 해상교량 BID2 IDC", "구조부", "이상희", "2026.02.11, 파일다운로드", 631],
["필리핀 홍수 복원 InFRA2 DD", "수자원1부", "이대주", "2025.12.01, 폴더삭제", 6],
["가나 테치만 상수도 확장 DS", "물환경사업2부", "-", "X", 0],
["기니 벼 재배단지 PMC", "수자원1부", "이대주", "2025.12.08, 파일업로드", 43],
["우간다 벼 재배단지 PMC", "수자원1부", "방노성", "2025.12.08, 파일업로드", 52],
["우간다 부수쿠마 분뇨 자원화 2단계 PMC", "물환경사업2부", "변기상", "2026.02.05, 파일업로드", 9],
["에티오피아 지하수 관개 환경설계 DD", "물환경사업2부", "변기상", "X", 0],
["에티오피아 도도타군 관개 PMC", "수자원1부", "방노성", "2025.12.01, 폴더이름변경", 144],
["에티오피아 Adeaa-Becho 지하수 관개 MP", "수자원1부", "방노성", "2025.11.21, 파일업로드", 146],
["탄자니아 Iringa 상하수도 개선 CS", "물환경사업1부", "백운영", "2026.02.03, 폴더 생성", 0],
["탄자니아 Dodoma 하수 설계감리 DD", "물환경사업2부", "변기상", "2026.02.04, 폴더삭제", 32],
["탄자니아 잔지바르 쌀 생산 PMC", "수자원1부", "방노성", "2025.12.08, 파일 업로드", 23],
["탄자니아 도도마 유수율 상수도개선 PMC", "물환경사업1부", "박순석", "2026.02.12, 부관리자권한추가", 35],
["아르헨티나 SALDEORO 수력발전 28MW DD", "플랜트1부", "양정모", "X", 0],
["온두라스 LaPaz Danli 상수도 CS", "물환경사업2부", "-", "2026.01.29, 파일 삭제", 60],
["볼리비아 에스꼬마 차라짜니 도로 CS", "도로부", "전홍찬", "2026.02.06, 파일업로드", 1],
["볼리비아 마모레 교량도로 FS", "도로부", "황효섭", "2026.02.06, 파일업로드", 120],
["볼리비아 Bombeo-Colomi 도로설계 DD", "도로부", "황효섭", "2025.12.05, 파일삭제", 48],
["콜롬비아 AI 폐기물 FS", "플랜트1부", "서재희", "X", 0],
["파라과이 도로 통행료 현대화 MP", "교통계획부", "오제훈", "X", 0],
["페루 Barranca 상하수도 확장 DD", "물환경사업2부", "변기상", "2025.11.14, 파일업로드", 44],
["엘살바도르 태평양 철도 FS", "철도사업부", "김태헌", "2026.02.04, 파일이름변경", 102],
["필리핀 사무소", "해외사업부", "한형남", "2026.02.23, 파일업로드", 813]
];
const continentMap = {
"라오스": "아시아", "미얀마": "아시아", "베트남": "아시아", "사우디아라비아": "아시아",
"우즈베키스탄": "아시아", "이라크": "아시아", "캄보디아": "아시아",
"키르기스스탄": "아시아", "파키스탄": "아시아", "필리핀": "아시아",
"아르헨티나": "아메리카", "온두라스": "아메리카", "볼리비아": "아메리카", "콜롬비아": "아메리카",
"파라과이": "아메리카", "페루": "아메리카", "엘살바도르": "아메리카",
"가나": "아프리카", "기니": "아프리카", "우간다": "아프리카", "에티오피아": "아프리카", "탄자니아": "아프리카"
};
const continentOrder = {
"아시아": 1,
"아프리카": 2,
"아메리카": 3,
"지사": 4
};
function init() {
const container = document.getElementById('projectAccordion');
const groupedData = {};
// 1. 데이터 파싱 및 그룹화
rawData.forEach((item, index) => {
const projectName = item[0];
let continent = "";
let country = "";
if (projectName.endsWith("사무소")) {
continent = "지사";
country = projectName.split(" ")[0];
} else if (projectName.startsWith("메콩유역")) {
country = "캄보디아";
continent = "아시아";
} else {
country = projectName.split(" ")[0];
continent = continentMap[country] || "기타";
}
if (!groupedData[continent]) groupedData[continent] = {};
if (!groupedData[continent][country]) groupedData[continent][country] = [];
groupedData[continent][country].push({ item, index });
});
// 2. 대륙 정렬 (아시아 - 아프리카 - 아메리카 - 지사)
const sortedContinents = Object.keys(groupedData).sort((a, b) => (continentOrder[a] || 99) - (continentOrder[b] || 99));
// 3. HTML 생성
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;
const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음";
// 상태 클래스 결정
let statusClass = "";
if (fileCount === 0) statusClass = "status-error";
else if (recentLog === "기록 없음") statusClass = "status-warning";
continentHtml += `
<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>
<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>
</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');
});
}
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');
});
item.classList.toggle('active');
}
async function syncData() {
const btn = document.getElementById('syncBtn');
const logConsole = document.getElementById('logConsole');
const logBody = document.getElementById('logBody');
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 {
const response = await fetch(`/sync`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const payload = JSON.parse(line.substring(6));
if (payload.type === 'log') {
addLog(payload.message);
} else if (payload.type === 'done') {
const newData = payload.data;
newData.forEach(scrapedItem => {
const target = rawData.find(item =>
item[0].replace(/\s/g, '').includes(scrapedItem.projectName.replace(/\s/g, '')) ||
scrapedItem.projectName.replace(/\s/g, '').includes(item[0].replace(/\s/g, ''))
);
if (target) {
// 기존 데이터 유지 마커 확인
if (scrapedItem.recentLog !== "기존데이터유지") {
target[3] = scrapedItem.recentLog;
}
target[4] = scrapedItem.fileCount;
}
});
document.getElementById('projectAccordion').innerHTML = '';
init();
addLog(">>> 모든 동기화 작업이 완료되었습니다!");
alert(`${newData.length}개 프로젝트 동기화 완료!`);
logConsole.style.display = 'none'; // 성공 시 콘솔 숨김
}
}
}
}
} catch (e) {
addLog(`오류 발생: ${e.message}`);
alert("서버 연결 실패. 백엔드 서버가 실행 중인지 확인하세요.");
console.error(e);
} finally {
btn.classList.remove('loading');
btn.innerHTML = `<span class="spinner"></span> 데이터 동기화 (크롤링)`;
btn.disabled = false;
}
}
init();
</script>
</body>
</html>

View File

@@ -1,6 +1,6 @@
<div class="wrap"> <div class="wrap">
<article class="log-filter" style="display: flex;"> <article class="log-filter">
<div class="head"> <div class="head">
<span class="title _h3">로그필터</span> <span class="title _h3">로그필터</span>
<button class="_button-xsmall reset">초기화</button> <button class="_button-xsmall reset">초기화</button>
@@ -10,11 +10,11 @@
<span class="subtitle">활동시간</span> <span class="subtitle">활동시간</span>
<div class="log-date-wrap"> <div class="log-date-wrap">
<span class="category">시작</span> <span class="category">시작</span>
<input type="date" value="" style=""> <input type="date" value="">
</div> </div>
<div class="log-date-wrap"> <div class="log-date-wrap">
<span class="category">종료</span> <span class="category">종료</span>
<input type="date" value="" style=""> <input type="date" value="">
</div> </div>
</div> </div>
<div class="log-user"> <div class="log-user">
@@ -35,64 +35,64 @@
</div> </div>
<span class="category">파일 / 폴더관련</span> <span class="category">파일 / 폴더관련</span>
<label> <label>
<input type="checkbox" value="uploadData_file" checked="" style=""> <input type="checkbox" value="uploadData_file" checked="">
<span class="--checkbox"></span> <span class="--checkbox"></span>
<span>파일 업로드</span> <span>파일 업로드</span>
</label> </label>
<label> <label>
<input type="checkbox" value="renameTarget" checked="" style=""> <input type="checkbox" value="renameTarget" checked="">
<span class="--checkbox"></span> <span class="--checkbox"></span>
<span>이름 변경</span> <span>이름 변경</span>
</label> </label>
<label> <label>
<input type="checkbox" value="removeTarget" checked="" style=""> <input type="checkbox" value="removeTarget" checked="">
<span class="--checkbox"></span> <span class="--checkbox"></span>
<span>삭제</span> <span>삭제</span>
</label> </label>
<label> <label>
<input type="checkbox" value="downloadTarget" checked="" style=""> <input type="checkbox" value="downloadTarget" checked="">
<span class="--checkbox"></span> <span class="--checkbox"></span>
<span>다운로드</span> <span>다운로드</span>
</label> </label>
<label> <label>
<input type="checkbox" value="relocateTarget" checked="" style=""> <input type="checkbox" value="relocateTarget" checked="">
<span class="--checkbox"></span> <span class="--checkbox"></span>
<span>파일 이동</span> <span>파일 이동</span>
</label> </label>
<label> <label>
<input type="checkbox" value="createFolder" checked="" style=""> <input type="checkbox" value="createFolder" checked="">
<span class="--checkbox"></span> <span class="--checkbox"></span>
<span>새 폴더 생성</span> <span>새 폴더 생성</span>
</label> </label>
<label> <label>
<input type="checkbox" value="setDataPermission_folder" checked="" style=""> <input type="checkbox" value="setDataPermission_folder" checked="">
<span class="--checkbox"></span> <span class="--checkbox"></span>
<span>폴더 권한 설정</span> <span>폴더 권한 설정</span>
</label> </label>
<label> <label>
<input type="checkbox" value="convertPdf" checked="" style=""> <input type="checkbox" value="convertPdf" checked="">
<span class="--checkbox"></span> <span class="--checkbox"></span>
<span>PDF 변환</span> <span>PDF 변환</span>
</label> </label>
<span class="category">유저관련</span> <span class="category">유저관련</span>
<label> <label>
<input type="checkbox" value="editAuthor" checked="" style=""> <input type="checkbox" value="editAuthor" checked="">
<span class="--checkbox"></span> <span class="--checkbox"></span>
<span>작성자 변경</span> <span>작성자 변경</span>
</label> </label>
<label> <label>
<input type="checkbox" value="deletePermission" checked="" style=""> <input type="checkbox" value="deletePermission" checked="">
<span class="--checkbox"></span> <span class="--checkbox"></span>
<span>권한 삭제</span> <span>권한 삭제</span>
</label> </label>
<label> <label>
<input type="checkbox" value="addPermission" checked="" style=""> <input type="checkbox" value="addPermission" checked="">
<span class="--checkbox"></span> <span class="--checkbox"></span>
<span>권한 추가</span> <span>권한 추가</span>
</label> </label>
<span class="category">기타</span> <span class="category">기타</span>
<label> <label>
<input type="checkbox" value="summarizeAI" checked="" style=""> <input type="checkbox" value="summarizeAI" checked="">
<span class="--checkbox"></span> <span class="--checkbox"></span>
<span>AI 요약</span> <span>AI 요약</span>
</label> </label>
@@ -114,46 +114,15 @@
<div class="btn set-user-permission-btn permission-min-sub-master" style="display: none;"> <div class="btn set-user-permission-btn permission-min-sub-master" style="display: none;">
<div class="text">유저 권한 설정</div> <div class="text">유저 권한 설정</div>
</div> </div>
<!-- <div class="btn dev-menu-btn permission-min-dev">
<div class="text">개발자 메뉴</div>
</div> -->
</div> </div>
<!-- <div class="right-wrap">
<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>
</ul>
<button class="project-step" id="project-step-btn">
<h5 class="project-step__label --step__active">진행</h5>
<i class="project-step__icon"></i>
</button>
<h5 class="" id="project-step-capsule">진행</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 class="project-manager-title">프로젝트 관리자</div>
<div class="project-manager-name"></div>
</div> -->
</div> </div>
<div class="close"></div> <div class="close"></div>
</div> </div>
<div class="modal-body" style=""> <div class="modal-body">
<div class="connected-users-wrap" style="display: none;"> <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="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" style="display: flex; flex-direction: column;"> <div class="project-setting-wrap">
<div class="project-name-wrap" style="display: flex; gap:1rem;"> <div class="project-name-wrap">
<div>프로젝트명</div> <div>프로젝트명</div>
<div class="project-type-wrap" id="project-type-wrap" style="display: none;"> <div class="project-type-wrap" id="project-type-wrap" style="display: none;">
<button class="project-type" id="project-type-btn"> <button class="project-type" id="project-type-btn">
@@ -192,7 +161,7 @@
</ul> </ul>
</div> </div>
<div class="project-input-wrap" style="display: flex; gap:1rem;"> <div class="project-input-wrap" style="display: flex; gap:1rem;">
<div class="project-setting-name" id="project-name-view" style="display: flex;"> ITTC 관개 교육센터</div> <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;"> <input type="text" class="project-setting-name" id="project-name-input" style="display: none; border: 1px solid black;">
</div> </div>
<div class="project-step-wrap"> <div class="project-step-wrap">
@@ -209,19 +178,16 @@
</ul> </ul>
</div> </div>
<div class="peoject-save-wrap"> <div class="peoject-save-wrap">
</div> </div>
</div> </div>
<div class="project-manager-wrap" style="display: flex; gap:1rem;"> <div class="project-manager-wrap">
<div class="project-manager-title">프로젝트 관리자</div> <div class="project-manager-title">프로젝트 관리자</div>
<div class="project-manager-name">방노성 전무이사</div> <div class="project-manager-name">방노성 전무이사</div>
</div> </div>
<div class="project-location-wrap" style="display: flex; gap:1rem;"> <div class="project-location-wrap">
<div class="project-location-title">프로젝트 위치</div> <div class="project-location-title">프로젝트 위치</div>
<div class="project-location-lat"><div class="project-location-lat">위도 18.068579</div></div> <div class="project-location-lat">위도 18.068579</div>
<div class="project-location-lon"><div class="project-location-lon">경도 102.65966</div></div> <div class="project-location-lon">경도 102.65966</div>
</div> </div>
</div> </div>
<div class="btn-wrap"> <div class="btn-wrap">
@@ -233,7 +199,7 @@
</div> </div>
<div class="manual-wrap" style="display: none;"></div> <div class="manual-wrap" style="display: none;"></div>
<div class="size-wrap" style="display: none;"> <div class="size-wrap" style="display: none;">
<div class="chart" _echarts_instance_="ec_1772068581031" 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="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 class="text">저장공간 관련 문의: GSIM 개발팀 이호성 수석연구원</div>
</div> </div>
<div class="log-wrap" style="opacity: 1; display: flex;"> <div class="log-wrap" style="opacity: 1; display: flex;">
@@ -248,16 +214,10 @@
<div class="log-item-wrap log-body scrollbar scroll-container"></div> <div class="log-item-wrap log-body scrollbar scroll-container"></div>
</div> </div>
<div class="text-wrap" style="display: none;">undefined</div> <div class="text-wrap" style="display: none;">undefined</div>
<!-- <div class="input-wrap"></div> -->
<div class="project-list-wrap" style="display: none;"></div> <div class="project-list-wrap" style="display: none;"></div>
<div class="input-wrap" style="display: none;"></div> <div class="input-wrap" style="display: none;"></div>
<div class="user-list-wrap" style="display: none;"> <div class="user-list-wrap" style="display: none;">
<div class="user-item-wrap scrollbar"></div> <div class="user-item-wrap scrollbar"></div>
<!-- 작성자 변경 선택 결과 숨김 -->
<!-- <div class="selected-user-item-wrap">
<div class="text">선택 결과</div>
<div class="selected-user-item"></div>
</div> -->
</div> </div>
<div class="btn-wrap" style="display: none;"></div> <div class="btn-wrap" style="display: none;"></div>
</div> </div>

View File

@@ -1,142 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Master Portal</title>
<link rel="stylesheet" as="style" crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="stylesheet" href="style/style.css">
<style>
body {
display: block;
/* style.css의 flex: min-height 100vh 해제 */
background-color: var(--hover-bg);
}
.portal-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: calc(100vh - 36px);
margin-top: 36px;
padding: 20px;
}
.portal-header {
text-align: center;
margin-bottom: 50px;
}
.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: white;
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 40px;
text-align: center;
text-decoration: none;
color: var(--text-main);
transition: all 0.3s ease;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.portal-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
border-color: var(--primary-color);
}
.portal-card .icon {
width: 64px;
height: 64px;
background-color: var(--hover-bg);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: var(--primary-color);
transition: background-color 0.3s;
}
.portal-card:hover .icon {
background-color: var(--primary-color);
color: white;
}
.portal-card h2 {
font-size: 20px;
font-weight: 700;
}
.portal-card p {
color: var(--text-sub);
font-size: 14px;
line-height: 1.5;
}
@media screen and (max-width: 600px) {
.button-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<nav class="topbar">
<div class="topbar-header">
<a href="/"><h2>Project Master Test</h2></a>
</div>
</nav>
<div class="portal-container">
<div class="portal-header">
<h1>Project Master 테스트</h1>
<p>원하시는 서비스에 접속하려면 아래 버튼을 클릭하세요.</p>
</div>
<div class="button-grid">
<a href="/dashboard" class="portal-card">
<div class="icon">📊</div>
<h2>관리자 페이지 테스트</h2>
<p>관리자 페이지 테스트 입니다.</p>
</a>
<a href="/mailTest" class="portal-card">
<div class="icon">✉️</div>
<h2>메일 테스트</h2>
<p>메일 기능 테스트 페이지입니다.</p>
</a>
</div>
</div>
</body>
</html>

9
js/common.js Normal file
View File

@@ -0,0 +1,9 @@
// 공통 네비게이션 및 유틸리티 로직
function navigateTo(path) {
location.href = path;
}
// 상단바 클릭 시 홈으로 이동 등 공통 이벤트 설정
document.addEventListener('DOMContentLoaded', () => {
// 필요한 경우 공통 초기화 로직 추가
});

267
js/dashboard.js Normal file
View File

@@ -0,0 +1,267 @@
let rawData = [];
const continentMap = {
"라오스": "아시아", "미얀마": "아시아", "베트남": "아시아", "사우디아라비아": "아시아",
"우즈베키스탄": "아시아", "이라크": "아시아", "캄보디아": "아시아",
"키르기스스탄": "아시아", "파키스탄": "아시아", "필리핀": "아시아",
"아르헨티나": "아메리카", "온두라스": "아메리카", "볼리비아": "아메리카", "콜롬비아": "아메리카",
"파라과이": "아메리카", "페루": "아메리카", "엘살바도르": "아메리카",
"가나": "아프리카", "기니": "아프리카", "우간다": "아프리카", "에티오피아": "아프리카", "탄자니아": "아프리카"
};
const continentOrder = {
"아시아": 1,
"아프리카": 2,
"아메리카": 3,
"지사": 4
};
async function init() {
const container = document.getElementById('projectAccordion');
if (!container) return;
// 서버에서 최신 sheet.csv 데이터 가져오기 (캐시 방지 위해 timestamp 추가)
try {
const response = await fetch(`/project-data?t=${new Date().getTime()}`);
rawData = await response.json();
console.log("Loaded rawData:", rawData);
if (rawData.error) throw new Error(rawData.error);
} catch (e) {
console.error("데이터 로드 실패:", e);
alert("데이터를 가져오는 데 실패했습니다.");
return;
}
container.innerHTML = ''; // 초기화
const groupedData = {};
rawData.forEach((item, index) => {
const projectName = item[0];
let continent = "";
let country = "";
if (projectName.endsWith("사무소")) {
continent = "지사";
country = projectName.split(" ")[0];
} else if (projectName.startsWith("메콩유역")) {
country = "캄보디아";
continent = "아시아";
} else {
country = projectName.split(" ")[0];
continent = continentMap[country] || "기타";
}
if (!groupedData[continent]) groupedData[continent] = {};
if (!groupedData[continent][country]) groupedData[continent][country] = [];
groupedData[continent][country].push({ item, index });
});
const sortedContinents = Object.keys(groupedData).sort((a, b) => (continentOrder[a] || 99) - (continentOrder[b] || 99));
sortedContinents.forEach(continent => {
const continentGroup = document.createElement('div');
continentGroup.className = 'continent-group';
let continentHtml = `
<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;
const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음";
let statusClass = "";
if (fileCount === 0) statusClass = "status-error";
else if (recentLog === "기록 없음") statusClass = "status-warning";
continentHtml += `
<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>
<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>
</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');
});
}
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');
});
item.classList.toggle('active');
}
async function syncData() {
const btn = document.getElementById('syncBtn');
const logConsole = document.getElementById('logConsole');
const logBody = document.getElementById('logBody');
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 {
const response = await fetch(`/sync`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const payload = JSON.parse(line.substring(6));
if (payload.type === 'log') {
addLog(payload.message);
} else if (payload.type === 'done') {
const newData = payload.data;
newData.forEach(scrapedItem => {
const target = rawData.find(item =>
item[0].replace(/\s/g, '').includes(scrapedItem.projectName.replace(/\s/g, '')) ||
scrapedItem.projectName.replace(/\s/g, '').includes(item[0].replace(/\s/g, ''))
);
if (target) {
if (scrapedItem.recentLog !== "기존데이터유지") {
target[3] = scrapedItem.recentLog;
}
target[4] = scrapedItem.fileCount;
}
});
document.getElementById('projectAccordion').innerHTML = '';
init();
addLog(">>> 모든 동기화 작업이 완료되었습니다!");
alert(`${newData.length}개 프로젝트 동기화 완료!`);
logConsole.style.display = 'none';
}
}
}
}
} catch (e) {
addLog(`오류 발생: ${e.message}`);
alert("서버 연결 실패. 백엔드 서버가 실행 중인지 확인하세요.");
console.error(e);
} finally {
btn.classList.remove('loading');
btn.innerHTML = `<span class="spinner"></span> 데이터 동기화 (크롤링)`;
btn.disabled = false;
}
}
document.addEventListener('DOMContentLoaded', init);

403
js/mail.js Normal file
View File

@@ -0,0 +1,403 @@
let currentFiles = [];
let editingIndex = -1;
const HIERARCHY = {
"행정": {
"계약": ["계약관리", "기성관리", "업무지시서", "인원관리"],
"업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"]
},
"설계성과품": {
"시방서": ["공사시방서", "장비 반입허가 검토서"],
"설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"],
"수량산출서": ["토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"],
"내역서": ["단가산출서"],
"보고서": ["실시설계보고서", "지반조사보고서", "구조계산서", "수리 및 전기계산서", "기타보고서", "기술자문 및 심의"],
"측량계산부": ["측량계산부"],
"설계단계 수행협의": ["회의·협의"]
},
"시공성과품": {
"설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"]
},
"시공검측": {
"토공": ["검측 (깨기)", "검측 (연약지반)", "검측 (발파)", "검측 (노체)", "검측 (노상)", "검측 (토취장)"],
"배수공": ["검측 (V형측구)", "검측 (산마루측구)", "검측 (U형측구)", "검측 (U형측구)(안)", "검측 (L형측구, J형측구)", "검측 (도수로)", "검측 (도수로)(안)", "검측 (횡배수관)", "검측 (종배수관)", "검측 (맹암거)", "검측 (통로암거)", "검측 (수로암거)", "검측 (호안공)", "검측 (옹벽공)", "검측 (용수개거)"],
"구조물공": ["검측 (평목교-거더, 부대공)", "검측 (평목교)(안)", "검측 (개착터널, 생태통로)"],
"포장공": ["검측 (기층, 보조기층)"],
"부대공": ["검측 (환경)", "검측 (지장가옥,건물 철거)", "검측 (방음벽 등)"],
"비탈면안전공": ["검측 (식생보호공)", "검측 (구조물보호공)"],
"교통안전시설공": ["검측 (낙석방지책)"],
"검측 양식서류": ["검측 양식서류"]
},
"설계변경": {
"실정보고(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물공", "포장공", "교통안전공", "부대공", "전기공사", "미확정공", "안전관리", "환경관리", "품질관리", "자재관리", "지장물", "기타"],
"실정보고(대술~정안)": ["토공", "배수공", "비탈면안전공", "포장공", "부대공", "안전관리", "환경관리", "자재관리", "기타"],
"기술지원 검토": ["토공", "배수공", "교량공(평목교)", "구조물&부대공", "기타"],
"시공계획(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물&부대&포장&교통안전공", "환경 및 품질관리"]
},
"공사관리": {
"공정·일정": ["공정표", "월간 공정보고", "작업일보"],
"품질 관리": ["품질시험계획서", "품질시험 실적보고", "콘크리트 타설현황[어천~공주(4차)]", "품질관리비 사용내역", "균열관리", "품질관리 양식서류"],
"안전 관리": ["안전관리계획서", "안전관리 실적보고", "위험성 평가", "사전작업허가서", "안전관리비 사용내역", "안전관리수준평가", "안전관리 양식서류"],
"환경 관리": ["환경영향평가", "사전재해영향성검토", "유지관리 및 보수점검", "환경보전비 사용내역", "건설폐기물 관리"],
"자재 관리 (관급)": ["자재구매요청 (레미콘, 철근)", "자재구매요청 (그 외)", "납품기한", "계약 변경", "자재 반입·수불 관리", "자재관리 양식서류"],
"자재 관리 (사급)": ["자재공급원 승인", "자재 반입·수불 관리", "자재 검수·확인"],
"점검 (정리중)": ["내부점검", "외부점검"],
"공문": ["접수(수신)", "발송(발신)", "하도급", "인력", "방침"]
},
"민원관리": {
"민원(어천~공주)": ["처리대장", "보상", "공사일반", "환경분쟁"],
"실정보고(어천~공주)": ["민원"],
"실정보고(대술~정안)": ["민원"]
}
};
async function loadAttachments() {
try {
const res = await fetch('/attachments');
currentFiles = await res.json();
renderFiles();
} catch (e) {
console.error("Failed to load attachments:", e);
}
}
function renderFiles() {
const isAiActive = document.getElementById('aiToggle').checked;
const container = document.getElementById('attachmentList');
if (!container) return;
container.innerHTML = '';
currentFiles.forEach((file, index) => {
const item = document.createElement('div');
item.className = 'attachment-item-wrap';
item.style.marginBottom = "8px";
let pathText = "경로를 선택해주세요";
let modeClass = "manual-mode";
if (file.analysis) {
const prefix = file.analysis.isManual ? "선택 경로: " : "추천: ";
pathText = `${prefix}${file.analysis.suggested_path}`;
modeClass = file.analysis.isManual ? "manual-mode" : "smart-mode";
} else if (isAiActive) {
pathText = "AI 분석 대기 중...";
modeClass = "smart-mode";
}
item.innerHTML = `
<div class="attachment-item" onclick="showPreview(${index}, event)">
<span class="file-icon">📄</span>
<div class="file-details">
<div class="file-name" title="${file.name}">${file.name}</div>
<div class="file-size">${file.size}</div>
</div>
<div class="btn-group">
<span id="recommend-${index}" class="ai-recommend path-display ${modeClass}" onclick="openPathModal(${index}, event)">${pathText}</span>
${isAiActive ? `<button class="btn-upload btn-ai" onclick="startAnalysis(${index}, event)">AI 분석</button>` : ''}
<button class="btn-upload btn-normal" onclick="confirmUpload(${index}, event)">파일 업로드</button>
</div>
</div>
<div id="log-area-${index}" class="file-log-area">
<div id="log-content-${index}"></div>
</div>
`;
container.appendChild(item);
});
}
function switchMailTab(el, tabType) {
document.querySelectorAll('.mail-tab').forEach(tab => tab.classList.remove('active'));
el.classList.add('active');
console.log(`Switched to ${tabType}`);
// 실제 데이터 필터링 로직이 있다면 여기에 추가
}
function togglePreview(show) {
const previewArea = document.getElementById('mailPreviewArea');
if (show) {
previewArea.classList.add('active');
} else {
previewArea.classList.remove('active');
}
}
function showPreview(index, event) {
// 버튼이나 경로 클릭 시 미리보기 방지
if (event.target.closest('.btn-group') || event.target.closest('.path-display')) return;
const file = currentFiles[index];
const previewContainer = document.getElementById('previewContainer');
const fullViewBtn = document.getElementById('fullViewBtn');
// 이전에 active 상태인 아이템 해제
document.querySelectorAll('.attachment-item').forEach(item => item.classList.remove('active'));
// 현재 클릭한 아이템 active
event.currentTarget.classList.add('active');
togglePreview(true);
const isPdf = file.name.toLowerCase().endsWith('.pdf');
const fileUrl = `/sample_files/${encodeURIComponent(file.name)}`;
// 전체보기 버튼 설정
if (fullViewBtn) {
fullViewBtn.style.display = 'block';
fullViewBtn.onclick = () => {
window.open(fileUrl, 'PMFullView', 'width=1000,height=800,scrollbars=yes');
};
}
if (isPdf) {
// PDF의 경우 #page=1 옵션을 사용하여 첫 페이지부터 노출 (10페이지 제한은 UI 문구로 처리)
// 실제 브라우저 뷰어 사양에 따라 다르지만 보통 전체가 로드되므로, 안내 문구와 함께 제공
previewContainer.innerHTML = `
<iframe src="${fileUrl}#page=1" style="width:100%; height:100%; border:none;"></iframe>
`;
} else {
previewContainer.innerHTML = `
<div style="width:100%; height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:20px; text-align:center;">
<img src="/sample.png" class="preview-image" onerror="this.src='https://via.placeholder.com/400x560?text=File+Preview'">
<div style="margin-top:20px; font-weight:700; color:var(--primary-color);">${file.name}</div>
<div style="font-size:12px; color:var(--text-sub); margin-top:8px;">본 화면은 미리보기 예시입니다.</div>
</div>
`;
}
}
function openPathModal(index, event) {
if (event) event.stopPropagation();
editingIndex = index;
const modal = document.getElementById('pathModal');
const tabSelect = document.getElementById('tabSelect');
if (!tabSelect) return;
tabSelect.innerHTML = Object.keys(HIERARCHY).map(tab => `<option value="${tab}">${tab}</option>`).join('');
updateCategories();
modal.style.display = 'flex';
}
function updateCategories() {
const tabSelect = document.getElementById('tabSelect');
const catSelect = document.getElementById('categorySelect');
if (!tabSelect || !catSelect) return;
const tab = tabSelect.value;
const cats = Object.keys(HIERARCHY[tab]);
catSelect.innerHTML = cats.map(cat => `<option value="${cat}">${cat}</option>`).join('');
updateSubs();
}
function updateSubs() {
const tabSelect = document.getElementById('tabSelect');
const catSelect = document.getElementById('categorySelect');
const subSelect = document.getElementById('subSelect');
if (!tabSelect || !catSelect || !subSelect) return;
const tab = tabSelect.value;
const cat = catSelect.value;
const subs = HIERARCHY[tab][cat];
subSelect.innerHTML = subs.map(sub => `<option value="${sub}">${sub}</option>`).join('');
}
function applyPathSelection() {
const tabSelect = document.getElementById('tabSelect');
const catSelect = document.getElementById('categorySelect');
const subSelect = document.getElementById('subSelect');
if (!tabSelect || !catSelect || !subSelect) return;
const tab = tabSelect.value;
const cat = catSelect.value;
const sub = subSelect.value;
const fullPath = `${tab} > ${cat} > ${sub}`;
if (!currentFiles[editingIndex].analysis) {
currentFiles[editingIndex].analysis = {};
}
currentFiles[editingIndex].analysis.suggested_path = fullPath;
currentFiles[editingIndex].analysis.isManual = true;
renderFiles();
closeModal();
}
function closeModal() {
const modal = document.getElementById('pathModal');
if (modal) modal.style.display = 'none';
}
async function startAnalysis(index, event) {
if (event) event.stopPropagation();
const file = currentFiles[index];
const logArea = document.getElementById(`log-area-${index}`);
const logContent = document.getElementById(`log-content-${index}`);
const recLabel = document.getElementById(`recommend-${index}`);
if (!logArea || !logContent || !recLabel) return;
logArea.classList.add('active');
logContent.innerHTML = '<div class="log-line log-info">>>> 3중 레이어 AI 분석 엔진 가동...</div>';
recLabel.innerText = '분석 중...';
try {
const res = await fetch(`/analyze-file?filename=${encodeURIComponent(file.name)}`);
const analysis = await res.json();
if (analysis.error) throw new Error(analysis.error);
const result = analysis.final_result;
const steps = [
`1. 파일 포맷 분석: ${file.name.split('.').pop().toUpperCase()} 감지`,
`2. 페이지 스캔: 총 ${analysis.total_pages}페이지 분석 완료`,
`3. 문맥 추론: ${result.reason}`
];
steps.forEach(step => {
const line = document.createElement('div');
line.className = 'log-line';
line.innerText = " " + step;
logContent.appendChild(line);
});
const resultLine = document.createElement('div');
resultLine.className = 'log-line log-success';
resultLine.style.marginTop = "8px";
resultLine.innerHTML = `[최종 결과] ${result.suggested_path}<br>└ 신뢰도: 100%`;
logContent.appendChild(resultLine);
const snippetArea = document.createElement('div');
snippetArea.style.cssText = "margin-top:10px; padding:10px; background:#1a202c; color:#a0aec0; font-size:11px; border-radius:4px; border-left:3px solid #63b3ed; max-height:100px; overflow-y:auto;";
snippetArea.innerHTML = `<strong>[AI가 읽은 핵심 내용]</strong><br>${result.snippet || "텍스트 추출 불가"}`;
logContent.appendChild(snippetArea);
currentFiles[index].analysis = {
suggested_path: result.suggested_path,
isManual: false
};
renderFiles();
} catch (e) {
logContent.innerHTML += `<div class="log-line" style="color:red;">ERR: ${e.message}</div>`;
recLabel.innerText = '분석 실패';
}
}
function confirmUpload(index, event) {
if (event) event.stopPropagation();
const file = currentFiles[index];
if (!file.analysis || !file.analysis.suggested_path) {
alert("경로를 설정해주세요.");
return;
}
const path = file.analysis.suggested_path;
const message = `정해진 위치로 업로드하시겠습니까?\n\n위치: ${path}`;
if (confirm(message)) alert("업로드가 완료되었습니다.");
}
let addressBookData = [
{ name: "이태훈", dept: "PM Overseas / 선임연구원", email: "th.lee@projectmaster.com", phone: "010-1234-5678" },
{ name: "Pany S.", dept: "라오스 농림부 / 국장", email: "pany.s@lao.gov.la", phone: "+856-20-1234-5678" },
{ name: "김철수", dept: "현대건설 / 현장소장", email: "cs.kim@hdec.co.kr", phone: "010-9876-5432" },
{ name: "Nguyen Van A", dept: "베트남 전력청 / 팀장", email: "nva@evn.com.vn", phone: "+84-90-1234-5678" }
];
let contactEditingIndex = -1;
function openAddressBook() {
const modal = document.getElementById('addressBookModal');
if (modal) {
renderAddressBook();
modal.style.display = 'flex';
}
}
function closeAddressBook() {
const modal = document.getElementById('addressBookModal');
if (modal) {
modal.style.display = 'none';
document.getElementById('addContactForm').style.display = 'none';
contactEditingIndex = -1;
}
}
function renderAddressBook() {
const body = document.getElementById('addressBookBody');
if (!body) return;
body.innerHTML = addressBookData.map((c, idx) => `
<tr>
<td><strong>${c.name}</strong></td>
<td>${c.dept}</td>
<td>${c.email}</td>
<td>${c.phone}</td>
<td style="text-align:right; white-space:nowrap;">
<button class="_button-xsmall" style="background:#edf2f7; color:var(--text-main); margin-right:4px;" onclick="editContact(${idx})">수정</button>
<button class="_button-xsmall" style="background:#fee2e2; color:#e53e3e;" onclick="deleteContact(${idx})">삭제</button>
</td>
</tr>
`).join('');
}
function toggleAddContactForm() {
const form = document.getElementById('addContactForm');
if (form.style.display === 'none') {
form.style.display = 'block';
} else {
form.style.display = 'none';
contactEditingIndex = -1;
// 필드 초기화
document.getElementById('newContactName').value = '';
document.getElementById('newContactDept').value = '';
document.getElementById('newContactEmail').value = '';
document.getElementById('newContactPhone').value = '';
}
}
function editContact(index) {
const contact = addressBookData[index];
contactEditingIndex = index;
document.getElementById('newContactName').value = contact.name;
document.getElementById('newContactDept').value = contact.dept;
document.getElementById('newContactEmail').value = contact.email;
document.getElementById('newContactPhone').value = contact.phone;
document.getElementById('addContactForm').style.display = 'block';
}
function deleteContact(index) {
if (confirm(`'${addressBookData[index].name}'님을 주소록에서 삭제하시겠습니까?`)) {
addressBookData.splice(index, 1);
renderAddressBook();
}
}
function addContact() {
const name = document.getElementById('newContactName').value;
const dept = document.getElementById('newContactDept').value;
const email = document.getElementById('newContactEmail').value;
const phone = document.getElementById('newContactPhone').value;
if (!name) {
alert("이름을 입력해주세요.");
return;
}
const newData = { name, dept, email, phone };
if (contactEditingIndex > -1) {
addressBookData[contactEditingIndex] = newData;
contactEditingIndex = -1;
} else {
addressBookData.push(newData);
}
renderAddressBook();
toggleAddContactForm();
}
function togglePreviewAuto() {
const area = document.getElementById('mailPreviewArea');
const icon = document.getElementById('previewToggleIcon');
const isActive = area.classList.toggle('active');
if (icon) {
icon.innerText = isActive ? '▶' : '◀';
}
}
document.addEventListener('DOMContentLoaded', loadAttachments);

View File

@@ -1,410 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Mail Manager</title>
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="stylesheet" href="style/style.css">
<style>
/* 모달 스타일 */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.6);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-content {
background: white;
padding: 24px;
border-radius: 12px;
width: 90%;
max-width: 500px;
box-shadow: var(--box-shadow);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 12px;
}
.select-group {
margin-bottom: 16px;
}
.select-group label {
display: block;
font-size: 12px;
font-weight: 700;
color: var(--text-sub);
margin-bottom: 6px;
}
.modal-select {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 14px;
}
.btn-confirm {
width: 100%;
padding: 12px;
background: var(--primary-color);
color: white;
border: none;
border-radius: 8px;
font-weight: 700;
cursor: pointer;
margin-top: 10px;
}
/* AI 추천 스타일 (Smart Mode) */
.ai-recommend.smart-mode {
display: inline-block;
background: linear-gradient(135deg, #f6f8ff 0%, #f0f4ff 100%);
color: #4a69bd;
border: 1px solid #d1d9ff;
font-weight: 600;
}
/* 수동 선택 스타일 (Manual Mode) */
.ai-recommend.manual-mode {
display: inline-block;
background: var(--hover-bg);
color: var(--text-sub);
border: 1px dashed var(--border-color);
font-weight: 400;
font-size: 11px;
}
.path-display {
cursor: pointer;
padding: 4px 10px;
border-radius: 6px;
transition: all 0.2s ease;
}
.path-display:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
</style>
</head>
<body>
<!-- 경로 선택 모달 -->
<div id="pathModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h3 style="margin:0; font-size:16px;">파일 보관 경로 선택</h3>
<span style="cursor:pointer; font-size:20px;" onclick="closeModal()">&times;</span>
</div>
<div class="select-group">
<label>1단계: 탭 (Tab)</label>
<select id="tabSelect" class="modal-select" onchange="updateCategories()"></select>
</div>
<div class="select-group">
<label>2단계: 카테고리 (Category)</label>
<select id="categorySelect" class="modal-select" onchange="updateSubs()"></select>
</div>
<div class="select-group">
<label>3단계: 서브카테고리 (Sub-Category)</label>
<select id="subSelect" class="modal-select"></select>
</div>
<button class="btn-confirm" onclick="applyPathSelection()">경로 확정하기</button>
</div>
</div>
<nav class="topbar">
<div class="topbar-header">
<a href="/"><h2>Project Master Test</h2></a>
</div>
<ul class="nav-list">
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
<li class="nav-item active" onclick="location.href='/mailTest'">메일관리</li>
<li class="nav-item">로그관리</li>
<li class="nav-item">파일관리</li>
</ul>
</nav>
<div class="mail-wrapper">
<aside class="mail-sidebar">
<select class="project-select">
<option>라오스 ITTC 관개 교육센터</option>
<option>베트남 푸옥호아 발전소</option>
</select>
<ul class="folder-list">
<li class="folder-item active" style="list-style:none;"><span>📥 수신함</span><span>12</span></li>
<li class="folder-item" style="list-style:none;"><span>📤 발신함</span></li>
<li class="folder-item" style="list-style:none;"><span>📁 중요메일</span></li>
</ul>
</aside>
<section class="mail-list-area">
<div class="search-bar">
<input type="text" placeholder="제목, 내용, 기관 검색...">
<div style="display:flex; gap:4px;">
<select style="flex:1; padding:4px; font-size:11px;"><option>모든 상대기관</option></select>
<select style="flex:1; padding:4px; font-size:11px;"><option>전체 기간</option></select>
</div>
</div>
<div class="mail-items-container">
<div class="mail-item active">
<div style="display:flex; justify-content:space-between; margin-bottom:4px;">
<span style="font-weight:700; color:var(--primary-color);">라오스 농림부</span>
<span style="font-size:11px; color:var(--text-sub);">오후 2:30</span>
</div>
<div style="font-weight:600; font-size:12px; margin-bottom:4px;">ITTC 교육센터 착공식 일정 협의</div>
<div style="font-size:11px; color:var(--text-sub); display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">안녕하세요. 착공식 관련하여 첨부 드리는 공문을...</div>
</div>
</div>
</section>
<section class="mail-content-area">
<div class="mail-content-header">
<h2 style="font-size:18px; color:var(--primary-color); margin-bottom:8px;">ITTC 교육센터 착공식 일정 협의 요청</h2>
<div style="font-size:12px; color:var(--text-sub);"><strong>보낸사람</strong> pany.s@lao.gov.la (라오스 농림부)</div>
<div style="font-size:12px; color:var(--text-sub);"><strong>날짜</strong> 2026년 2월 26일 14:30</div>
</div>
<div class="mail-body">
안녕하세요. 이태훈 선임연구원님.<br><br>
라오스 ITTC 관개 교육센터 착공식과 관련하여 정부 측 인사의 일정을 반영한 최종 공문을 송부합니다.<br>
첨부파일의 세부 계획안을 검토하신 후, 프로젝트 대시보드의 '과업계획' 탭에 업로드 및 공유 부탁드립니다.
</div>
<div class="attachment-area">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
<div style="font-weight:700; font-size:13px;">첨부파일 리스트</div>
<div class="ai-toggle-wrap">
<span class="ai-label">AI 판단</span>
<label class="switch">
<input type="checkbox" id="aiToggle" onchange="renderFiles()">
<span class="slider"></span>
</label>
</div>
</div>
<div id="attachmentList"></div>
</div>
</section>
</div>
<script>
let currentFiles = [];
let editingIndex = -1;
const HIERARCHY = {
"행정": {
"계약": ["계약관리", "기성관리", "업무지시서", "인원관리"],
"업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"]
},
"설계성과품": {
"시방서": ["공사시방서", "장비 반입허가 검토서"],
"설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"],
"수량산출서": ["토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"],
"내역서": ["단가산출서"],
"보고서": ["실시설계보고서", "지반조사보고서", "구조계산서", "수리 및 전기계산서", "기타보고서", "기술자문 및 심의"],
"측량계산부": ["측량계산부"],
"설계단계 수행협의": ["회의·협의"]
},
"시공성과품": {
"설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"]
},
"시공검측": {
"토공": ["검측 (깨기)", "검측 (연약지반)", "검측 (발파)", "검측 (노체)", "검측 (노상)", "검측 (토취장)"],
"배수공": ["검측 (V형측구)", "검측 (산마루측구)", "검측 (U형측구)", "검측 (U형측구)(안)", "검측 (L형측구, J형측구)", "검측 (도수로)", "검측 (도수로)(안)", "검측 (횡배수관)", "검측 (종배수관)", "검측 (맹암거)", "검측 (통로암거)", "검측 (수로암거)", "검측 (호안공)", "검측 (옹벽공)", "검측 (용수개거)"],
"구조물공": ["검측 (평목교-거더, 부대공)", "검측 (평목교)(안)", "검측 (개착터널, 생태통로)"],
"포장공": ["검측 (기층, 보조기층)"],
"부대공": ["검측 (환경)", "검측 (지장가옥,건물 철거)", "검측 (방음벽 등)"],
"비탈면안전공": ["검측 (식생보호공)", "검측 (구조물보호공)"],
"교통안전시설공": ["검측 (낙석방지책)"],
"검측 양식서류": ["검측 양식서류"]
},
"설계변경": {
"실정보고(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물공", "포장공", "교통안전공", "부대공", "전기공사", "미확정공", "안전관리", "환경관리", "품질관리", "자재관리", "지장물", "기타"],
"실정보고(대술~정안)": ["토공", "배수공", "비탈면안전공", "포장공", "부대공", "안전관리", "환경관리", "자재관리", "기타"],
"기술지원 검토": ["토공", "배수공", "교량공(평목교)", "구조물&부대공", "기타"],
"시공계획(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물&부대&포장&교통안전공", "환경 및 품질관리"]
},
"공사관리": {
"공정·일정": ["공정표", "월간 공정보고", "작업일보"],
"품질 관리": ["품질시험계획서", "품질시험 실적보고", "콘크리트 타설현황[어천~공주(4차)]", "품질관리비 사용내역", "균열관리", "품질관리 양식서류"],
"안전 관리": ["안전관리계획서", "안전관리 실적보고", "위험성 평가", "사전작업허가서", "안전관리비 사용내역", "안전관리수준평가", "안전관리 양식서류"],
"환경 관리": ["환경영향평가", "사전재해영향성검토", "유지관리 및 보수점검", "환경보전비 사용내역", "건설폐기물 관리"],
"자재 관리 (관급)": ["자재구매요청 (레미콘, 철근)", "자재구매요청 (그 외)", "납품기한", "계약 변경", "자재 반입·수불 관리", "자재관리 양식서류"],
"자재 관리 (사급)": ["자재공급원 승인", "자재 반입·수불 관리", "자재 검수·확인"],
"점검 (정리중)": ["내부점검", "외부점검"],
"공문": ["접수(수신)", "발송(발신)", "하도급", "인력", "방침"]
},
"민원관리": {
"민원(어천~공주)": ["처리대장", "보상", "공사일반", "환경분쟁"],
"실정보고(어천~공주)": ["민원"],
"실정보고(대술~정안)": ["민원"]
}
};
async function loadAttachments() {
try {
const res = await fetch('/attachments');
currentFiles = await res.json();
renderFiles();
} catch (e) {
console.error("Failed to load attachments:", e);
}
}
function renderFiles() {
const isAiActive = document.getElementById('aiToggle').checked;
const container = document.getElementById('attachmentList');
container.innerHTML = '';
currentFiles.forEach((file, index) => {
const item = document.createElement('div');
item.className = 'attachment-item-wrap';
item.style.marginBottom = "8px";
let pathText = "경로를 선택해주세요";
let modeClass = "manual-mode";
if (file.analysis) {
const prefix = file.analysis.isManual ? "선택 경로: " : "추천: ";
pathText = `${prefix}${file.analysis.suggested_path}`;
modeClass = file.analysis.isManual ? "manual-mode" : "smart-mode";
} else if (isAiActive) {
pathText = "AI 분석 대기 중...";
modeClass = "smart-mode";
}
item.innerHTML = `
<div class="attachment-item">
<div class="file-info">
<span style="font-size:20px;">📄</span>
<div>
<div style="font-size:12px; font-weight:700;">${file.name}</div>
<div style="font-size:10px; color:var(--text-sub);">${file.size}</div>
</div>
<span id="recommend-${index}" class="ai-recommend path-display ${modeClass}" onclick="openPathModal(${index})">${pathText}</span>
</div>
<div class="btn-group">
${isAiActive ? `<button class="btn-upload btn-ai" onclick="startAnalysis(${index})">AI 분석</button>` : ''}
<button class="btn-upload btn-normal" onclick="confirmUpload(${index})">파일 업로드</button>
</div>
</div>
<div id="log-area-${index}" class="file-log-area">
<div id="log-content-${index}"></div>
</div>
`;
container.appendChild(item);
});
}
function openPathModal(index) {
editingIndex = index;
const modal = document.getElementById('pathModal');
const tabSelect = document.getElementById('tabSelect');
tabSelect.innerHTML = Object.keys(HIERARCHY).map(tab => `<option value="${tab}">${tab}</option>`).join('');
updateCategories();
modal.style.display = 'flex';
}
function updateCategories() {
const tab = document.getElementById('tabSelect').value;
const catSelect = document.getElementById('categorySelect');
const cats = Object.keys(HIERARCHY[tab]);
catSelect.innerHTML = cats.map(cat => `<option value="${cat}">${cat}</option>`).join('');
updateSubs();
}
function updateSubs() {
const tab = document.getElementById('tabSelect').value;
const cat = document.getElementById('categorySelect').value;
const subSelect = document.getElementById('subSelect');
const subs = HIERARCHY[tab][cat];
subSelect.innerHTML = subs.map(sub => `<option value="${sub}">${sub}</option>`).join('');
}
function applyPathSelection() {
const tab = document.getElementById('tabSelect').value;
const cat = document.getElementById('categorySelect').value;
const sub = document.getElementById('subSelect').value;
const fullPath = `${tab} > ${cat} > ${sub}`;
if (!currentFiles[editingIndex].analysis) {
currentFiles[editingIndex].analysis = {};
}
currentFiles[editingIndex].analysis.suggested_path = fullPath;
currentFiles[editingIndex].analysis.isManual = true;
renderFiles();
closeModal();
}
function closeModal() {
document.getElementById('pathModal').style.display = 'none';
}
async function startAnalysis(index) {
const file = currentFiles[index];
const logArea = document.getElementById(`log-area-${index}`);
const logContent = document.getElementById(`log-content-${index}`);
const recLabel = document.getElementById(`recommend-${index}`);
logArea.classList.add('active');
logContent.innerHTML = '<div class="log-line log-info">>>> 3중 레이어 AI 분석 엔진 가동...</div>';
recLabel.innerText = '분석 중...';
try {
const res = await fetch(`/analyze-file?filename=${encodeURIComponent(file.name)}`);
const analysis = await res.json();
if (analysis.error) throw new Error(analysis.error);
const result = analysis.final_result;
const steps = [
`1. 파일 포맷 분석: ${file.name.split('.').pop().toUpperCase()} 감지`,
`2. 페이지 스캔: 총 ${analysis.total_pages}페이지 분석 완료`,
`3. 문맥 추론: ${result.reason}`
];
steps.forEach(step => {
const line = document.createElement('div');
line.className = 'log-line';
line.innerText = " " + step;
logContent.appendChild(line);
});
const resultLine = document.createElement('div');
resultLine.className = 'log-line log-success';
resultLine.style.marginTop = "8px";
resultLine.innerHTML = `[최종 결과] ${result.suggested_path}<br>└ 신뢰도: 100%`;
logContent.appendChild(resultLine);
const snippetArea = document.createElement('div');
snippetArea.style.cssText = "margin-top:10px; padding:10px; background:#1a202c; color:#a0aec0; font-size:11px; border-radius:4px; border-left:3px solid #63b3ed; max-height:100px; overflow-y:auto;";
snippetArea.innerHTML = `<strong>[AI가 읽은 핵심 내용]</strong><br>${result.snippet || "텍스트 추출 불가"}`;
logContent.appendChild(snippetArea);
currentFiles[index].analysis = {
suggested_path: result.suggested_path,
isManual: false
};
renderFiles();
} catch (e) {
logContent.innerHTML += `<div class="log-line" style="color:red;">ERR: ${e.message}</div>`;
recLabel.innerText = '분석 실패';
}
}
function confirmUpload(index) {
const file = currentFiles[index];
const path = file.analysis ? file.analysis.suggested_path : "선택한 탭";
let message = `[${file.name}] 파일을 업로드하시겠습니까?`;
if(file.analysis) message = `정해진 위치로 업로드하시겠습니까?\n\n위치: ${path}`;
if (confirm(message)) alert("업로드가 완료되었습니다.");
}
loadAttachments();
</script>
</body>
</html>

View File

@@ -9,13 +9,29 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, FileResponse from fastapi.responses import StreamingResponse, FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from analyze import analyze_file_content from analyze import analyze_file_content
from crawler_service import run_crawler_service from crawler_service import run_crawler_service
import asyncio
from fastapi import Request
app = FastAPI(title="Project Master Overseas API") 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("/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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
@@ -25,18 +41,54 @@ app.add_middleware(
) )
# --- HTML 라우팅 --- # --- HTML 라우팅 ---
import csv
@app.get("/project-data")
async def get_project_data():
"""
sheet.csv 파일을 읽어서 프로젝트 현황 데이터를 반환
"""
projects = []
try:
with open("sheet.csv", mode="r", encoding="utf-8-sig") as f:
reader = csv.reader(f)
rows = [row for row in reader if row] # 빈 행 제외
# 실제 데이터가 시작되는 지점 찾기 (No. 로 시작하는 행 다음부터)
start_idx = -1
for i, row in enumerate(rows):
if row and "No." in row[0]:
start_idx = i + 1
break
if start_idx != -1:
for row in rows[start_idx:]:
if len(row) >= 8:
# [프로젝트명, 담당부서, 담당자, 최근활동로그, 파일수] 형식으로 추출
projects.append([
row[1], # 프로젝트 명
row[2], # 담당부서
row[3], # 담당자
row[5], # 최근 활동로그
int(row[7]) if row[7].isdigit() else 0 # 파일 수
])
except Exception as e:
return {"error": str(e)}
return projects
@app.get("/") @app.get("/")
async def root(): async def root(request: Request):
return FileResponse("index.html") return templates.TemplateResponse("index.html", {"request": request})
@app.get("/dashboard") @app.get("/dashboard")
async def get_dashboard(): async def get_dashboard(request: Request):
return FileResponse("dashboard.html") return templates.TemplateResponse("dashboard.html", {"request": request})
@app.get("/mailTest") @app.get("/mailTest")
@app.get("/mailTest.html") @app.get("/mailTest.html")
async def get_mail_test(): async def get_mail_test(request: Request):
return FileResponse("mailTest.html") return templates.TemplateResponse("mailTest.html", {"request": request})
# --- 데이터 API --- # --- 데이터 API ---
@app.get("/attachments") @app.get("/attachments")
@@ -57,9 +109,9 @@ async def get_attachments():
@app.get("/analyze-file") @app.get("/analyze-file")
async def analyze_file(filename: str): async def analyze_file(filename: str):
""" """
분석 서비스(analyze.py) 호출 분석 서비스(analyze.py) 호출 - 스레드 풀에서 비차단 방식으로 실행
""" """
return analyze_file_content(filename) return await run_in_threadpool(analyze_file_content, filename)
@app.get("/sync") @app.get("/sync")
async def sync_data(): async def sync_data():

View File

@@ -1,4 +1,4 @@
[PM Overseas 프로젝트 현황],,2026.02.24,,,,,,<<활동로그가 없는 프로젝트 (9),, [PM Overseas 프로젝트 현황],,2026.03.04,,,,,,<<활동로그가 없는 프로젝트 (8),,
,,,,,,,,,, ,,,,,,,,,,
No.,프로젝트 명,담당부서,담당자,종료(예정)일,최근 활동로그,과업개요 작성 유무,파일 수,비고,, No.,프로젝트 명,담당부서,담당자,종료(예정)일,최근 활동로그,과업개요 작성 유무,파일 수,비고,,
1,라오스 ITTC 관개 교육센터 PMC,수자원1부,방노성,2025.12.20,"2026.01.29, 폴더 삭제",O,16,2026.01.29 로그는 테스트 활동 추정,종료(예정)일 지남,진행 1,라오스 ITTC 관개 교육센터 PMC,수자원1부,방노성,2025.12.20,"2026.01.29, 폴더 삭제",O,16,2026.01.29 로그는 테스트 활동 추정,종료(예정)일 지남,진행
@@ -6,19 +6,19 @@ No.,프로젝트 명,담당부서,담당자,종료(예정)일,최근 활동로
3,미얀마 만달레이 철도 개량 감리 CS,철도사업부,김태헌,2027.11.17,"2025.11.17, 폴더이름변경",O,298,,, 3,미얀마 만달레이 철도 개량 감리 CS,철도사업부,김태헌,2027.11.17,"2025.11.17, 폴더이름변경",O,298,,,
4,베트남 푸옥호아 양수 발전 FS,수력부,이철호,2025.11.30,"2026.02.23, 폴더이름변경",O,139,준공도서 3월 작성예정,종료(예정)일 지남,준공 4,베트남 푸옥호아 양수 발전 FS,수력부,이철호,2025.11.30,"2026.02.23, 폴더이름변경",O,139,준공도서 3월 작성예정,종료(예정)일 지남,준공
5,사우디아라비아 아시르 지잔 고속도로 FS,도로부,공태원,2025.11.21,"2026.02.09, 파일다운로드",O,73,,종료(예정)일 지남,준공 5,사우디아라비아 아시르 지잔 고속도로 FS,도로부,공태원,2025.11.21,"2026.02.09, 파일다운로드",O,73,,종료(예정)일 지남,준공
6,우즈베키스탄 타슈켄트 철도 FS,철도사업,김태헌,2026.03.20,"2026.02.05, 파일업로드",O,51,,, 6,우즈베키스탄 지방 도로 복원 MP,도로,장진영,2029.04.28,X,X,0,,,
7,우즈베키스탄 지방 도로 복원 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,,, 8,이라크 Habbaniyah Shuaiba AirBase PD,도로부,강동구,2026.12.31,X,X,0,,,
9,캄보디아 반테 민체이 관개 홍수저감 MP,수자원1부,이대주,2026.08.28,"2025.12.07, 파일업로드",X,44,,, 9,메콩유역 수자원 관리 기후적응 MP,수자원1부,정귀한,2025.12.31,X,X,0,,종료(예정)일 지남,준공
10,캄보디아 시엠립 하수처리 개선 DD,물환경사업1부,변역근,2028.12.18,"2026.02.06, AI 요약",O,221,,, 10,캄보디아 반테 민체이 관개 홍수저감 MP,수자원1부,이대주,2026.08.28,"2025.12.07, 파일업로드",X,44,,,
11,메콩유역 수자원 관리 기후적응 MP,수자원1부,정귀한,2025.12.31,X,X,0,,종료(예정)일 지남,준공 11,캄보디아 시엠립 하수처리 개선 DD,물환경사업1부,변역근,2028.12.18,"2026.02.06, AI 요약",O,221,,,
12,키르기스스탄 잘랄아바드 상수도 계획 MP,물환경사업1부,변기상,2025.12.31,"2026.02.12, 파일업로드",X,60,,종료(예정)일 지남,준공 12,키르기스스탄 잘랄아바드 상수도 계획 MP,물환경사업1부,변기상,2025.12.31,"2026.02.12, 파일업로드",X,60,,종료(예정)일 지남,준공
13,파키스탄 CAREC 도로 감리 DD,도로,황효섭,2026.10.26,X,X,0,,, 13,파키스탄 펀잡 홍수 방재 PMC,수자원1,방노성,2027.12.31,"2025.12.08, 폴더삭제",O,0,,,
14,파키스탄 펀잡 홍수 방재 PMC,수자원1,방노성,2027.12.31,"2025.12.08, 폴더삭제",O,0,,, 14,파키스탄 KP 아보타바드 상수도 PMC,물환경사업2,변기상,2026.12.31,"2026.02.26, 파일업로드",O,240,,,
15,파키스탄 KP 아보타바드 상수도 PMC,물환경사업2,변기상,2026.12.31,"2026.02.12, 파일업로드",O,234,,, 15,파키스탄 CAREC 도로 감리 DD,도로,황효섭,2026.10.26,X,X,0,,,
16,필리핀 홍수 관리 Package5B MP,수자원1부,희철,2026.05.31,"2025.12.02, 폴더이름변경",O,14,,, 16,필리핀 홍수 복원 InFRA2 DD,수자원1부,대주,2026.08.07,"2025.12.01, 폴더삭제",O,6,최근로그 >> 폴더자동삭제(파일 개수 미달),,
17,필리핀 PGN 해상교량 BID2 IDC,구조,,2026.05.31,"2026.02.11, 파일다운로드",O,631,,, 17,필리핀 홍수 관리 Package5B MP,수자원1,이희,2026.05.31,"2025.12.02, 폴더이름변경",O,14,,,
18,필리핀 홍수 복원 InFRA2 DD,수자원1,대주,2026.08.07,"2025.12.01, 폴더삭제",O,6,최근로그 >> 폴더자동삭제(파일 개수 미달),, 18,필리핀 PGN 해상교량 BID2 IDC,구조,상희,2026.05.31,"2026.02.11, 파일다운로드",O,631,,,
19,가나 테치만 상수도 확장 DS,물환경사업2부,-,2029.04.25,X,X,0,책임자 및 담당자 설정X,, 19,가나 테치만 상수도 확장 DS,물환경사업2부,-,2029.04.25,X,X,0,책임자 및 담당자 설정X,,
20,기니 벼 재배단지 PMC,수자원1부,이대주,2028.12.20,"2025.12.08, 파일업로드",O,43,최근로그 >> 폴더자동삭제(파일 개수 미달),, 20,기니 벼 재배단지 PMC,수자원1부,이대주,2028.12.20,"2025.12.08, 파일업로드",O,43,최근로그 >> 폴더자동삭제(파일 개수 미달),,
21,우간다 벼 재배단지 PMC,수자원1부,방노성,2028.12.20,"2025.12.08, 파일업로드",O,52,,, 21,우간다 벼 재배단지 PMC,수자원1부,방노성,2028.12.20,"2025.12.08, 파일업로드",O,52,,,
@@ -26,7 +26,7 @@ No.,프로젝트 명,담당부서,담당자,종료(예정)일,최근 활동로
23,에티오피아 지하수 관개 환경설계 DD,물환경사업2부,변기상,2026.06.23,X,X,0,,, 23,에티오피아 지하수 관개 환경설계 DD,물환경사업2부,변기상,2026.06.23,X,X,0,,,
24,에티오피아 도도타군 관개 PMC,수자원1부,방노성,2026.12.31,"2025.12.01, 폴더이름변경",O,144,탭 1개에 모든파일 업로드 // 최근로그 >> 폴더자동삭제(파일 개수 미달),, 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,최근로그 >> 폴더자동삭제(파일 개수 미달),, 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,,, 26,탄자니아 Iringa 상하수도 개선 CS,물환경사업1부,백운영,2029.06.08,"2026.02.03, 폴더생성",X,0,,,
27,탄자니아 Dodoma 하수 설계감리 DD,물환경사업2부,변기상,2027.07.08,"2026.02.04, 폴더삭제",X,32,,, 27,탄자니아 Dodoma 하수 설계감리 DD,물환경사업2부,변기상,2027.07.08,"2026.02.04, 폴더삭제",X,32,,,
28,탄자니아 잔지바르 쌀 생산 PMC,수자원1부,방노성,2027.12.20,"2025.12.08, 파일 업로드",O,23,,, 28,탄자니아 잔지바르 쌀 생산 PMC,수자원1부,방노성,2027.12.20,"2025.12.08, 파일 업로드",O,23,,,
29,탄자니아 도도마 유수율 상수도개선 PMC,물환경사업1부,박순석,2026.12.31,"2026.02.12, 부관리자권한추가",X,35,,, 29,탄자니아 도도마 유수율 상수도개선 PMC,물환경사업1부,박순석,2026.12.31,"2026.02.12, 부관리자권한추가",X,35,,,
@@ -35,8 +35,8 @@ No.,프로젝트 명,담당부서,담당자,종료(예정)일,최근 활동로
32,볼리비아 에스꼬마 차라짜니 도로 CS,도로부,전홍찬,2029.12.15,"2026.02.06, 파일업로드",X,1,,, 32,볼리비아 에스꼬마 차라짜니 도로 CS,도로부,전홍찬,2029.12.15,"2026.02.06, 파일업로드",X,1,,,
33,볼리비아 마모레 교량도로 FS,도로부,황효섭,2025.10.17,"2026.02.06, 파일업로드",X,120,,종료(예정)일 지남,준공 33,볼리비아 마모레 교량도로 FS,도로부,황효섭,2025.10.17,"2026.02.06, 파일업로드",X,120,,종료(예정)일 지남,준공
34,볼리비아 Bombeo-Colomi 도로설계 DD,도로부,황효섭,2026.07.24,"2025.12.05, 파일삭제",O,48,"더미파일(폴더유지용) 12개, 실 관리부서는 해외사업부",, 34,볼리비아 Bombeo-Colomi 도로설계 DD,도로부,황효섭,2026.07.24,"2025.12.05, 파일삭제",O,48,"더미파일(폴더유지용) 12개, 실 관리부서는 해외사업부",,
35,콜롬비아 AI 폐기물 FS,플랜트1부,서재희,2026.02.27,X,X,0,,, 35,콜롬비아 AI 폐기물 FS,플랜트1부,서재희,2026.02.27,X,X,0,,종료(예정)일 지남,
36,파라과이 도로 통행료 현대화 MP,교통계획부,오제훈,2025.10.24,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개, 실 관리부서는 해외사업부",, 37,페루 Barranca 상하수도 확장 DD,물환경사업2부,변기상,2026.03.08,"2025.11.14, 파일업로드",O,44,"더미파일(폴더유지용) 27개, 실 관리부서는 해외사업부",,
38,엘살바도르 태평양 철도 FS,철도사업부,김태헌,2025.12.31,"2026.02.04, 파일이름변경",X,102,,종료(예정)일 지남,준공 38,엘살바도르 태평양 철도 FS,철도사업부,김태헌,2025.12.31,"2026.02.24, 폴더자동삭제",X,101,,종료(예정)일 지남,준공
39,필리핀 사무소,해외사업부,한형남,,"2026.02.23, 파일로드",과업개요 페이지 없음,813,,, 39,필리핀 사무소,해외사업부,한형남,,"2026.03.04, 파일다운로드",과업개요 페이지 없음,817,,,
1 [PM Overseas 프로젝트 현황] 2026.02.24 2026.03.04 <<활동로그가 없는 프로젝트 (9) <<활동로그가 없는 프로젝트 (8)
2
3 No. 프로젝트 명 담당부서 담당부서 담당자 종료(예정)일 최근 활동로그 과업개요 작성 유무 파일 수 비고
4 1 라오스 ITTC 관개 교육센터 PMC 수자원1부 수자원1부 방노성 2025.12.20 2026.01.29, 폴더 삭제 O 16 2026.01.29 로그는 테스트 활동 추정 종료(예정)일 지남 진행
6 3 미얀마 만달레이 철도 개량 감리 CS 철도사업부 철도사업부 김태헌 2027.11.17 2025.11.17, 폴더이름변경 O 298
7 4 베트남 푸옥호아 양수 발전 FS 수력부 수력부 이철호 2025.11.30 2026.02.23, 폴더이름변경 O 139 준공도서 3월 작성예정 종료(예정)일 지남 준공
8 5 사우디아라비아 아시르 지잔 고속도로 FS 도로부 도로부 공태원 2025.11.21 2026.02.09, 파일다운로드 O 73 종료(예정)일 지남 준공
9 6 우즈베키스탄 타슈켄트 철도 FS 우즈베키스탄 지방 도로 복원 MP 철도사업부 도로부 김태헌 장진영 2026.03.20 2029.04.28 2026.02.05, 파일업로드 X O X 51 0
10 7 우즈베키스탄 지방 도로 복원 MP 우즈베키스탄 타슈켄트 철도 FS 도로부 철도사업부 장진영 김태헌 2029.04.28 2026.03.20 X 2026.02.05, 파일업로드 X O 0 51
11 8 이라크 Habbaniyah Shuaiba AirBase PD 도로부 도로부 강동구 2026.12.31 X X 0
12 9 캄보디아 반테 민체이 관개 홍수저감 MP 메콩유역 수자원 관리 기후적응 MP 수자원1부 수자원1부 이대주 정귀한 2026.08.28 2025.12.31 2025.12.07, 파일업로드 X X 44 0 종료(예정)일 지남 준공
13 10 캄보디아 시엠립 하수처리 개선 DD 캄보디아 반테 민체이 관개 홍수저감 MP 물환경사업1부 수자원1부 변역근 이대주 2028.12.18 2026.08.28 2026.02.06, AI 요약 2025.12.07, 파일업로드 O X 221 44
14 11 메콩유역 수자원 관리 기후적응 MP 캄보디아 시엠립 하수처리 개선 DD 수자원1부 물환경사업1부 정귀한 변역근 2025.12.31 2028.12.18 X 2026.02.06, AI 요약 X O 0 221 종료(예정)일 지남 준공
15 12 키르기스스탄 잘랄아바드 상수도 계획 MP 물환경사업1부 물환경사업1부 변기상 2025.12.31 2026.02.12, 파일업로드 X 60 종료(예정)일 지남 준공
16 13 파키스탄 CAREC 도로 감리 DD 파키스탄 펀잡 홍수 방재 PMC 도로부 수자원1부 황효섭 방노성 2026.10.26 2027.12.31 X 2025.12.08, 폴더삭제 X O 0
17 14 파키스탄 펀잡 홍수 방재 PMC 파키스탄 KP 아보타바드 상수도 PMC 수자원1부 물환경사업2부 방노성 변기상 2027.12.31 2026.12.31 2025.12.08, 폴더삭제 2026.02.26, 파일업로드 O 0 240
18 15 파키스탄 KP 아보타바드 상수도 PMC 파키스탄 CAREC 도로 감리 DD 물환경사업2부 도로부 변기상 황효섭 2026.12.31 2026.10.26 2026.02.12, 파일업로드 X O X 234 0
19 16 필리핀 홍수 관리 Package5B MP 필리핀 홍수 복원 InFRA2 DD 수자원1부 수자원1부 이희철 이대주 2026.05.31 2026.08.07 2025.12.02, 폴더이름변경 2025.12.01, 폴더삭제 O 14 6 최근로그 >> 폴더자동삭제(파일 개수 미달)
20 17 필리핀 PGN 해상교량 BID2 IDC 필리핀 홍수 관리 Package5B MP 구조부 수자원1부 이상희 이희철 2026.05.31 2026.02.11, 파일다운로드 2025.12.02, 폴더이름변경 O 631 14
21 18 필리핀 홍수 복원 InFRA2 DD 필리핀 PGN 해상교량 BID2 IDC 수자원1부 구조부 이대주 이상희 2026.08.07 2026.05.31 2025.12.01, 폴더삭제 2026.02.11, 파일다운로드 O 6 631 최근로그 >> 폴더자동삭제(파일 개수 미달)
22 19 가나 테치만 상수도 확장 DS 물환경사업2부 물환경사업2부 - 2029.04.25 X X 0 책임자 및 담당자 설정X
23 20 기니 벼 재배단지 PMC 수자원1부 수자원1부 이대주 2028.12.20 2025.12.08, 파일업로드 O 43 최근로그 >> 폴더자동삭제(파일 개수 미달)
24 21 우간다 벼 재배단지 PMC 수자원1부 수자원1부 방노성 2028.12.20 2025.12.08, 파일업로드 O 52
26 23 에티오피아 지하수 관개 환경설계 DD 물환경사업2부 물환경사업2부 변기상 2026.06.23 X X 0
27 24 에티오피아 도도타군 관개 PMC 수자원1부 수자원1부 방노성 2026.12.31 2025.12.01, 폴더이름변경 O 144 탭 1개에 모든파일 업로드 // 최근로그 >> 폴더자동삭제(파일 개수 미달)
28 25 에티오피아 Adeaa-Becho 지하수 관개 MP 수자원1부 수자원1부 방노성 2026.07.31 2025.11.21, 파일업로드 O 146 최근로그 >> 폴더자동삭제(파일 개수 미달)
29 26 탄자니아 Iringa 상하수도 개선 CS 물환경사업1부 물환경사업1부 백운영 2029.06.08 2026.02.03, 폴더 생성 2026.02.03, 폴더생성 X 0
30 27 탄자니아 Dodoma 하수 설계감리 DD 물환경사업2부 물환경사업2부 변기상 2027.07.08 2026.02.04, 폴더삭제 X 32
31 28 탄자니아 잔지바르 쌀 생산 PMC 수자원1부 수자원1부 방노성 2027.12.20 2025.12.08, 파일 업로드 O 23
32 29 탄자니아 도도마 유수율 상수도개선 PMC 물환경사업1부 물환경사업1부 박순석 2026.12.31 2026.02.12, 부관리자권한추가 X 35
35 32 볼리비아 에스꼬마 차라짜니 도로 CS 도로부 도로부 전홍찬 2029.12.15 2026.02.06, 파일업로드 X 1
36 33 볼리비아 마모레 교량도로 FS 도로부 도로부 황효섭 2025.10.17 2026.02.06, 파일업로드 X 120 종료(예정)일 지남 준공
37 34 볼리비아 Bombeo-Colomi 도로설계 DD 도로부 도로부 황효섭 2026.07.24 2025.12.05, 파일삭제 O 48 더미파일(폴더유지용) 12개, 실 관리부서는 해외사업부
38 35 콜롬비아 AI 폐기물 FS 플랜트1부 플랜트1부 서재희 2026.02.27 X X 0 종료(예정)일 지남
39 36 파라과이 도로 통행료 현대화 MP 교통계획부 교통계획부 오제훈 2025.10.24 X 2025.02.25, 폴더삭제 X 0 종료(예정)일 지남 준공
40 37 페루 Barranca 상하수도 확장 DD 물환경사업2부 물환경사업2부 변기상 2026.03.08 2025.11.14, 파일업로드 O 44 더미파일(폴더유지용) 27개, 실 관리부서는 해외사업부
41 38 엘살바도르 태평양 철도 FS 철도사업부 철도사업부 김태헌 2025.12.31 2026.02.04, 파일이름변경 2026.02.24, 폴더자동삭제 X 102 101 종료(예정)일 지남 준공
42 39 필리핀 사무소 해외사업부 해외사업부 한형남 2026.02.23, 파일업로드 2026.03.04, 파일다운로드 과업개요 페이지 없음 813 817

281
style/common.css Normal file
View File

@@ -0,0 +1,281 @@
:root {
/* Design Tokens */
--primary-color: #1E5149;
--primary-lv-0: #e9eeed;
--primary-lv-1: #D2DCDB;
--primary-lv-8: #193833;
--bg-default: #FFFFFF;
--bg-muted: #F9FAFB;
--text-main: #2D3748;
--text-sub: #718096;
--border-color: #E2E8F0;
--hover-bg: #F7FAFC;
--header-gradient: linear-gradient(90deg, #193833 0%, #1e5149 100%);
--ai-gradient: linear-gradient(180deg, #da8cf1 0%, #8bb1f2 100%);
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 32px;
--space-xl: 64px;
--radius-sm: 4px;
--radius-lg: 8px;
--fz-h1: 20px;
--fz-h2: 16px;
--fz-body: 13px;
--box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
--box-shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.1);
}
/* Base Reset */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Pretendard', sans-serif;
font-size: var(--fz-body);
color: var(--text-main);
background: var(--bg-default);
min-height: 100vh;
}
/* 메일 관리자 전용: 전체 스크롤 방지 */
body:has(.mail-wrapper) {
height: 100vh;
overflow: hidden;
}
input, select, textarea, button {
font-family: 'Pretendard', sans-serif;
}
a {
text-decoration: none;
color: inherit;
}
button {
cursor: pointer;
border: none;
font-family: inherit;
transition: all 0.2s ease;
}
/* Layout Utilities */
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
/* Components: Topbar */
.topbar {
width: 100%;
background: var(--header-gradient);
color: #fff;
padding: 0 var(--space-lg);
position: fixed;
top: 0;
height: 36px;
display: flex;
align-items: center;
z-index: 1000;
}
.topbar-header {
margin-right: 60px;
font-weight: 700;
}
.topbar-header h2 {
font-size: 16px;
color: white;
}
.nav-list {
display: flex;
list-style: none;
gap: var(--space-sm);
}
.nav-item {
padding: 4px 8px;
border-radius: 4px;
color: rgba(255, 255, 255, 0.8);
transition: 0.2s;
font-size: 14px;
cursor: pointer;
}
.nav-item:hover {
background: var(--primary-lv-1);
color: #fff;
}
.nav-item.active {
background: var(--primary-lv-0);
color: var(--primary-color) !important;
font-weight: 700;
}
/* Modals */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
z-index: 2000;
justify-content: center;
align-items: center;
}
.modal-content {
background: white;
padding: 24px;
border-radius: 12px;
width: 90%;
max-width: 500px;
box-shadow: var(--box-shadow-lg);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 12px;
}
.modal-header h3 {
margin: 0;
font-size: 16px;
}
.modal-close {
cursor: pointer;
font-size: 20px;
}
/* Modal Form Elements */
.select-group {
margin-bottom: 16px;
text-align: left;
}
.select-group label {
display: block;
font-size: 12px;
font-weight: 700;
color: var(--text-sub);
margin-bottom: 6px;
}
.modal-select {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 14px;
background: #fff;
outline: none;
}
.modal-select:focus {
border-color: var(--primary-color);
}
/* Data Tables inside Modals */
.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: var(--bg-muted);
}
.data-table tr:hover {
background: var(--hover-bg);
}
/* Utils: Buttons */
._button-xsmall {
padding: 2px 8px;
font-size: 11px;
border-radius: 4px;
background: var(--bg-muted);
border: 1px solid var(--border-color);
}
._button-small {
padding: 6px 14px;
font-size: 12px;
border-radius: 6px;
background: var(--primary-color);
color: #fff;
font-weight: 600;
}
._button-medium {
padding: 10px 20px;
background: var(--primary-color);
color: #fff;
border-radius: 6px;
font-weight: 700;
}
.btn-confirm {
width: 100%;
padding: 12px;
background: var(--primary-color);
color: white;
border: none;
border-radius: 8px;
font-weight: 700;
cursor: pointer;
margin-top: 10px;
}
/* Spinner */
.spinner {
display: none;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, .3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.badge {
background: #eee;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
}

264
style/dashboard.css Normal file
View File

@@ -0,0 +1,264 @@
/* Portal (Index) */
.portal-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: calc(100vh - 36px);
background: var(--bg-muted);
padding: var(--space-lg);
margin-top: 36px;
}
.portal-header {
text-align: center;
margin-bottom: 50px;
}
.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);
}
.portal-card .icon {
font-size: 32px;
width: 64px;
height: 64px;
background: var(--bg-muted);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: 0.3s;
}
.portal-card:hover .icon {
background: var(--primary-color);
color: #fff;
}
.portal-card h2 {
font-size: 20px;
font-weight: 700;
}
.portal-card p {
color: var(--text-sub);
font-size: 14px;
line-height: 1.5;
}
@media screen and (max-width: 600px) {
.button-grid {
grid-template-columns: 1fr;
}
}
/* Dashboard List & Console */
.log-console {
background: #000;
color: #0f0;
font-family: monospace;
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
font-size: 12px;
line-height: 1.5;
}
.log-console-header {
color: #fff;
border-bottom: 1px solid #333;
margin-bottom: 10px;
padding-bottom: 5px;
font-weight: bold;
}
.accordion-container {
border-top: 1px solid var(--border-color);
}
.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;
}
.accordion-list-header {
font-size: 11px;
font-weight: 700;
color: var(--text-sub);
border-bottom: 1px solid var(--text-main);
cursor: default;
}
.accordion-item {
border-bottom: 1px solid var(--border-color);
}
.accordion-item:hover {
background: var(--primary-lv-0);
}
.repo-title {
font-weight: 700;
color: var(--primary-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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: 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;
}
/* Accordion Multi-level (Continent/Country) */
.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;
}

404
style/mail.css Normal file
View File

@@ -0,0 +1,404 @@
/* Mail Manager Layout */
.mail-wrapper {
display: flex;
height: calc(100vh - 36px);
margin-top: 36px;
background: #fff;
overflow: hidden;
}
.mail-sidebar {
display: none; /* 사이드바 삭제 */
}
.mail-list-area {
width: 320px;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
}
.mail-tabs {
display: flex;
border-bottom: 1px solid var(--border-color);
background: #f8f9fa;
flex-shrink: 0;
width: 100%;
}
.mail-tab {
flex: 1;
padding: 12px 0;
text-align: center;
cursor: pointer;
font-weight: 700;
color: #a0aec0;
font-size: 11px;
transition: all 0.2s ease;
border-bottom: 2px solid transparent;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
}
.mail-tab:hover {
background: #edf2f7;
color: var(--primary-color);
}
.mail-tab.active {
color: var(--primary-color);
border-bottom: 2px solid var(--primary-color);
background: #fff;
}
.search-bar {
padding: var(--space-md);
border-bottom: 1px solid var(--border-color);
background: #fff;
flex-shrink: 0;
margin-bottom: 8px; /* 하단 리스트와 간격 추가 */
}
.mail-items-container {
flex: 1;
overflow-y: auto;
}
.mail-item {
padding: var(--space-md);
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: 0.2s;
}
.mail-item:hover {
background: var(--bg-muted);
}
.mail-item.active {
background: #E9EEED;
border-left: 4px solid var(--primary-color);
}
.address-book-footer {
padding: 12px;
border-top: 1px solid var(--border-color);
background: #fff;
flex-shrink: 0;
}
.mail-content-area {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
border-right: 1px solid var(--border-color);
}
/* Mail Preview & Toggle Handle */
.mail-preview-area {
width: 0;
background: #f1f3f5;
display: flex;
flex-direction: column;
transition: all 0.3s ease;
overflow: visible;
position: relative;
border-left: 0px solid transparent;
}
.mail-preview-area.active {
width: 500px;
border-left: 1px solid var(--border-color);
}
/* 닫혔을 때 내부 내용이 비치는 것 방지 */
.mail-preview-area > *:not(.preview-toggle-handle) {
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.mail-preview-area.active > * {
opacity: 1;
pointer-events: auto;
}
.preview-toggle-handle {
position: absolute;
left: -20px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 60px;
background: var(--primary-color);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 8px 0 0 8px;
font-size: 10px;
box-shadow: -2px 0 5px rgba(0,0,0,0.1);
z-index: 10;
}
.preview-toggle-handle:hover {
background: var(--primary-lv-8);
}
.preview-header {
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.preview-header h3 {
font-size: 14px;
font-weight: 700;
color: var(--primary-color);
}
.preview-content {
flex: 1;
padding: 20px;
overflow-y: auto;
display: flex;
justify-content: center;
align-items: flex-start;
}
.a4-container {
width: 100%;
background: #fff;
box-shadow: 0 0 20px rgba(0,0,0,0.1);
position: relative;
aspect-ratio: 1 / 1.414; /* A4 Ratio */
display: flex;
align-items: center;
justify-content: center;
}
.preview-placeholder {
color: var(--text-sub);
font-size: 13px;
text-align: center;
padding: 20px;
}
.preview-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.mail-content-header {
padding: var(--space-lg);
border-bottom: 1px solid var(--border-color);
}
.mail-body {
padding: var(--space-lg);
line-height: 1.6;
min-height: 200px;
}
/* Attachments & AI Analysis */
.attachment-area {
padding: var(--space-lg);
border-top: 1px solid var(--border-color);
background: var(--bg-muted);
}
.attachment-item {
display: flex;
align-items: center;
gap: var(--space-md);
background: #fff;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
margin-bottom: var(--space-sm);
cursor: pointer;
transition: 0.2s;
}
.attachment-item:hover {
border-color: var(--primary-color);
box-shadow: var(--box-shadow);
}
.attachment-item.active {
background: var(--primary-lv-0);
border-color: var(--primary-color);
}
.file-icon {
font-size: 20px;
flex-shrink: 0;
}
.file-details {
flex: 1;
overflow: hidden;
}
.file-name {
font-size: 12px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 10px;
color: var(--text-sub);
}
.btn-group {
display: flex;
gap: var(--space-xs);
flex: 1;
justify-content: flex-end;
}
.btn-upload {
padding: 6px 12px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
color: #fff;
}
.btn-ai {
background: var(--ai-gradient);
}
.btn-normal {
background: var(--primary-color);
}
.ai-recommend {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 700;
margin-left: 10px;
}
.ai-recommend.smart-mode {
background: linear-gradient(135deg, #f6f8ff 0%, #f0f4ff 100%);
color: #4a69bd;
border: 1px solid #d1d9ff;
}
.ai-recommend.manual-mode {
background: var(--hover-bg);
color: var(--text-sub);
border: 1px dashed var(--border-color);
font-weight: 400;
}
.path-display {
cursor: pointer;
padding: 4px 10px;
border-radius: 6px;
transition: all 0.2s ease;
}
.file-log-area {
display: none;
width: 100%;
margin-top: 10px;
background: #1a202c;
border-radius: 4px;
padding: 12px;
font-family: monospace;
font-size: 11px;
color: #cbd5e0;
}
.file-log-area.active {
display: block;
}
.log-line {
margin-bottom: 2px;
}
.log-success {
color: #48bb78;
font-weight: 700;
}
.log-info {
color: #63b3ed;
}
/* Toggle Switch */
.switch {
position: relative;
display: inline-block;
width: 34px;
height: 20px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 20px;
}
.slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked+.slider {
background: var(--ai-gradient);
}
input:checked+.slider:before {
transform: translateX(14px);
}
.ai-toggle-wrap {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 12px;
font-weight: 600;
color: var(--text-sub);
}
input:checked~.ai-label {
color: #6d3dc2;
}

View File

@@ -1,123 +1,3 @@
:root { @import url('common.css');
/* Design Tokens */ @import url('dashboard.css');
--primary-color: #1E5149; --primary-lv-0: #e9eeed; --primary-lv-1: #D2DCDB; --primary-lv-8: #193833; @import url('mail.css');
--bg-default: #FFFFFF; --bg-muted: #F9FAFB;
--text-main: #2D3748; --text-sub: #718096; --border-color: #E2E8F0;
--header-gradient: linear-gradient(90deg, #193833 0%, #1e5149 100%);
--ai-gradient: linear-gradient(180deg, #da8cf1 0%, #8bb1f2 100%);
--space-xs: 4px; --space-sm: 8px; --space-md: 16px; --space-lg: 32px; --space-xl: 64px;
--radius-sm: 4px; --radius-lg: 8px;
--fz-h1: 20px; --fz-h2: 16px; --fz-body: 13px;
}
/* Base Reset */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Pretendard', sans-serif; font-size: var(--fz-body); color: var(--text-main); background: var(--bg-default); min-height: 100vh; }
a { text-decoration: none; color: inherit; }
button { cursor: pointer; border: none; font-family: inherit; }
/* Components: Topbar */
.topbar { width: 100%; background: var(--header-gradient); color: #fff; padding: 0 var(--space-lg); position: fixed; top: 0; height: 36px; display: flex; align-items: center; z-index: 1000; }
.topbar-header { margin-right: 60px; font-weight: 700; }
.nav-list { display: flex; list-style: none; gap: var(--space-sm); }
.nav-item { padding: 4px 8px; border-radius: 4px; color: rgba(255,255,255,0.8); transition: 0.2s; font-size: 14px; }
.nav-item:hover { background: var(--primary-lv-1); color: #fff; }
.nav-item.active { background: var(--primary-lv-0); color: var(--primary-color) !important; font-weight: 700; }
/* Global Layout */
.main-content { margin-top: 36px; padding: var(--space-lg) var(--space-xl); max-width: 1400px; margin-left: auto; margin-right: auto; }
header { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: var(--space-lg); padding-bottom: var(--space-sm); border-bottom: 1px solid var(--border-color); }
header h1 { font-size: var(--fz-h1); color: var(--primary-color); }
/* Portal (Index) */
.portal-container { display: flex; flex-direction: column; align-items: center; justify-content: center; height: calc(100vh - 36px); background: var(--bg-muted); padding: var(--space-lg); }
.portal-card { background: #fff; border: 1px solid var(--border-color); border-radius: 12px; padding: 40px; text-align: center; transition: 0.3s; width: 100%; max-width: 380px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); }
.portal-card:hover { transform: translateY(-5px); border-color: var(--primary-color); box-shadow: 0 10px 20px rgba(0,0,0,0.1); }
.portal-card .icon { font-size: 32px; margin-bottom: 20px; width: 64px; height: 64px; background: var(--bg-muted); border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: 0.3s; margin-left: auto; margin-right: auto; }
.portal-card:hover .icon { background: var(--primary-color); color: #fff; }
/* Dashboard List & Console */
.log-console { background:#000; color:#0f0; font-family:monospace; padding:15px; margin-bottom:20px; border-radius:4px; max-height:200px; overflow-y:auto; font-size:12px; }
.accordion-container { border-top: 1px solid var(--border-color); }
.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; }
.accordion-list-header { font-size: 11px; font-weight: 700; color: var(--text-sub); border-bottom: 1px solid var(--text-main); }
.accordion-item { border-bottom: 1px solid var(--border-color); }
.accordion-item:hover { background: var(--primary-lv-0); }
.repo-title { font-weight: 700; color: var(--primary-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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: 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; }
/* Mail Manager Refined */
.mail-wrapper { display: flex; height: calc(100vh - 36px); margin-top: 36px; background: #fff; overflow: hidden; }
.mail-sidebar { width: 240px; border-right: 1px solid var(--border-color); padding: var(--space-md); background: var(--bg-muted); }
.project-select { width: 100%; padding: var(--space-sm); border-radius: var(--radius-sm); border: 1px solid var(--border-color); margin-bottom: var(--space-lg); font-weight: 700; background: #fff; }
.folder-item { padding: var(--space-sm) var(--space-md); border-radius: 4px; cursor: pointer; margin-bottom: 2px; font-weight: 500; display: flex; justify-content: space-between; }
.folder-item:hover { background: var(--primary-lv-0); }
.folder-item.active { background: var(--primary-color); color: #fff; }
.mail-list-area { width: 380px; border-right: 1px solid var(--border-color); display: flex; flex-direction: column; }
.search-bar { padding: var(--space-md); border-bottom: 1px solid var(--border-color); background: var(--bg-muted); }
.search-bar input { width: 100%; padding: 8px; border-radius: 4px; border: 1px solid var(--border-color); margin-bottom: var(--space-sm); }
.mail-item { padding: var(--space-md); border-bottom: 1px solid var(--border-color); cursor: pointer; transition: 0.2s; }
.mail-item:hover { background: var(--bg-muted); }
.mail-item.active { background: #E9EEED; border-left: 4px solid var(--primary-color); }
.mail-content-area { flex: 1; display: flex; flex-direction: column; overflow-y: auto; }
.mail-content-header { padding: var(--space-lg); border-bottom: 1px solid var(--border-color); }
.mail-body { padding: var(--space-lg); line-height: 1.6; min-height: 200px; }
.attachment-area { padding: var(--space-lg); border-top: 1px solid var(--border-color); background: var(--bg-muted); }
.attachment-item { display: flex; align-items: center; justify-content: space-between; background: #fff; padding: var(--space-sm) var(--space-md); border-radius: var(--radius-lg); border: 1px solid var(--border-color); margin-bottom: var(--space-sm); }
.file-info { display: flex; align-items: center; gap: var(--space-sm); flex: 1; }
.ai-recommend { font-size: 11px; color: #6d3dc2; background: #f1ecf9; padding: 2px 6px; border-radius: 4px; font-weight: 700; margin-left: 10px; }
.ai-log-console {
margin-top: var(--space-md);
background: #1a202c; /* dark gray */
color: #a0aec0;
padding: var(--space-md);
border-radius: var(--radius-lg);
font-family: 'Courier New', Courier, monospace;
font-size: 11px;
line-height: 1.5;
max-height: 150px;
overflow-y: auto;
}
.ai-log-console .log-entry { margin-bottom: 4px; border-left: 2px solid #4a5568; padding-left: 8px; }
.ai-log-console .highlight { color: #63b3ed; font-weight: 700; }
.ai-log-console .success { color: #48bb78; }
.btn-group { display: flex; gap: var(--space-xs); }
.btn-upload { padding: 6px 12px; border-radius: 4px; font-size: 11px; font-weight: 700; color: #fff; }
.btn-ai { background: var(--ai-gradient); }
.btn-normal { background: var(--primary-color); }
.btn-upload:hover { opacity: 0.9; transform: translateY(-1px); }
/* File-specific Log Accordion */
.file-log-area { display: none; width: 100%; margin-top: 10px; background: #1a202c; border-radius: 4px; padding: 12px; font-family: monospace; font-size: 11px; color: #cbd5e0; }
.file-log-area.active { display: block; }
.log-line { margin-bottom: 2px; }
.log-success { color: #48bb78; font-weight: 700; }
.log-info { color: #63b3ed; }
/* Toggle Switch */
.switch { position: relative; display: inline-block; width: 34px; height: 20px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 20px; }
.slider:before { position: absolute; content: ""; height: 14px; width: 14px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
input:checked + .slider { background: var(--ai-gradient); }
input:checked + .slider:before { transform: translateX(14px); }
.ai-toggle-wrap { display: flex; align-items: center; gap: var(--space-sm); font-size: 12px; font-weight: 600; color: var(--text-sub); }
input:checked ~ .ai-label { color: #6d3dc2; }
/* Utils */
.sync-btn { background: var(--primary-color); color: #fff; padding: 8px 16px; border-radius: var(--radius-lg); font-weight: 700; }
.badge { background: #eee; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
.toggle-icon { transition: 0.2s; }
.active > .continent-header .toggle-icon, .active > .country-header .toggle-icon { transform: rotate(180deg); }

61
templates/dashboard.html Normal file
View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Master Overseas 관리자</title>
<link rel="stylesheet" as="style" crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="stylesheet" href="style/common.css">
<link rel="stylesheet" href="style/dashboard.css">
</head>
<body>
<nav class="topbar">
<div class="topbar-header">
<a href="/">
<h2>Project Master Test</h2>
</a>
</div>
<ul class="nav-list">
<li class="nav-item active" onclick="location.href='/dashboard'">대시보드</li>
<li class="nav-item" onclick="alert('준비 중입니다.')">문의사항</li>
<li class="nav-item" onclick="alert('준비 중입니다.')">로그관리</li>
<li class="nav-item" onclick="alert('준비 중입니다.')">파일관리</li>
<li class="nav-item" onclick="alert('준비 중입니다.')">인원관리</li>
<li class="nav-item" onclick="alert('준비 중입니다.')">공지사항</li>
</ul>
</nav>
<main class="main-content">
<header>
<div class="flex-center">
<h1>프로젝트 현황</h1>
</div>
<div class="flex-center">
<button id="syncBtn" class="sync-btn" onclick="syncData()">
<span class="spinner"></span>
데이터 동기화 (크롤링)
</button>
<div class="admin-info">접속자: <strong>이태훈[전체관리자]</strong></div>
</div>
</header>
<!-- 실시간 로그 콘솔 추가 -->
<div id="logConsole" class="log-console" style="display:none;">
<div class="log-console-header">실시간 수집 로그 [PM Overseas]</div>
<div id="logBody"></div>
</div>
<div id="projectAccordion">
<!-- Multi-level Accordion items will be generated here -->
</div>
</main>
<script src="js/common.js"></script>
<script src="js/dashboard.js"></script>
</body>
</html>

46
templates/index.html Normal file
View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Master Portal</title>
<link rel="stylesheet" as="style" crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="stylesheet" href="style/common.css">
<link rel="stylesheet" href="style/dashboard.css">
</head>
<body>
<nav class="topbar">
<div class="topbar-header">
<a href="/"><h2>Project Master Test</h2></a>
</div>
</nav>
<div class="portal-container">
<div class="portal-header">
<h1>Project Master 테스트</h1>
<p>원하시는 서비스에 접속하려면 아래 버튼을 클릭하세요.</p>
</div>
<div class="button-grid">
<a href="/dashboard" class="portal-card">
<div class="icon">📊</div>
<h2>관리자 페이지 테스트</h2>
<p>관리자 페이지 테스트 입니다.</p>
</a>
<a href="/mailTest" class="portal-card">
<div class="icon">✉️</div>
<h2>메일 테스트</h2>
<p>메일 기능 테스트 페이지입니다.</p>
</a>
</div>
</div>
<script src="js/common.js"></script>
</body>
</html>

140
templates/mailTest.html Normal file
View File

@@ -0,0 +1,140 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Mail Manager</title>
<link rel="stylesheet" as="style" crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="stylesheet" href="style/common.css">
<link rel="stylesheet" href="style/mail.css">
</head>
<body>
{% include 'modals/path_selector.html' %}
<nav class="topbar">
<div class="topbar-header">
<a href="/">
<h2>Project Master Test</h2>
</a>
</div>
<ul class="nav-list">
<li class="nav-item" onclick="location.href='/dashboard'">대시보드</li>
<li class="nav-item active" onclick="location.href='/mailTest'">메일관리</li>
<li class="nav-item">로그관리</li>
<li class="nav-item">파일관리</li>
</ul>
</nav>
<div class="mail-wrapper">
<!-- 메일 리스트 영역 (사이드바 삭제됨) -->
<section class="mail-list-area">
<div class="mail-tabs">
<div class="mail-tab active" onclick="switchMailTab(this, 'inbound')">📥 수신</div>
<div class="mail-tab" onclick="switchMailTab(this, 'outbound')">📤 발신</div>
<div class="mail-tab" onclick="switchMailTab(this, 'drafts')">📝 임시</div>
<div class="mail-tab" onclick="switchMailTab(this, 'deleted')">🗑️ 휴지통</div>
</div>
<div class="search-bar" style="display:flex; flex-direction:column; gap:8px;">
<input type="text" style="height: 32px; width:100%;" placeholder="제목, 내용, 기관 검색...">
<select style="height: 32px; width:100%; padding:4px; font-size:12px;">
<option>모든 상대기관</option>
<option>라오스 농림부</option>
<option>베트남 전력청</option>
</select>
<div class="flex-center" style="gap:4px; width:100%;">
<input type="date"
style="flex:1; height:32px; padding:4px; font-size:11px; border:1px solid var(--border-color); border-radius:4px;">
<span style="font-size:12px; color:var(--text-sub);">~</span>
<input type="date"
style="flex:1; height:32px; padding:4px; font-size:11px; border:1px solid var(--border-color); border-radius:4px;">
</div>
</div>
<div class="mail-items-container">
<div class="mail-item active">
<div class="flex-between" style="margin-bottom:4px;">
<span style="font-weight:700; color:var(--primary-color);">라오스 농림부</span>
<span style="font-size:11px; color:var(--text-sub);">오후 2:30</span>
</div>
<div style="font-weight:600; font-size:12px; margin-bottom:4px;">ITTC 교육센터 착공식 일정 협의</div>
<div
style="font-size:11px; color:var(--text-sub); display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">
안녕하세요. 착공식 관련하여 첨부 드리는 공문을...</div>
</div>
</div>
<!-- 메일쓰기 및 주소록 버튼 하단 고정 -->
<div class="address-book-footer flex-center" style="gap:8px;">
<button class="btn-confirm"
style="background:var(--primary-color); color:#fff; font-size:12px; padding:8px; flex:1;"
onclick="alert('메일 쓰기 창을 엽니다.')">✍️ 메일쓰기</button>
<button class="btn-confirm"
style="background:#fff; color:var(--primary-color); border:1px solid var(--primary-color); font-size:12px; padding:8px; flex:1;"
onclick="openAddressBook()">📘 주소록</button>
</div>
</section>
<!-- 메일 본문 영역 -->
<section class="mail-content-area">
<div class="mail-content-header">
<h2 style="font-size:18px; color:var(--primary-color); margin-bottom:8px;">ITTC 교육센터 착공식 일정 협의 요청</h2>
<div style="font-size:12px; color:var(--text-sub);"><strong>보낸사람</strong> pany.s@lao.gov.la (라오스 농림부)
</div>
<div style="font-size:12px; color:var(--text-sub);"><strong>날짜</strong> 2026년 2월 26일 14:30</div>
</div>
<div class="mail-body">
안녕하세요. 이태훈 선임연구원님.<br><br>
라오스 ITTC 관개 교육센터 착공식과 관련하여 정부 측 인사의 일정을 반영한 최종 공문을 송부합니다.<br>
</div>
<div class="attachment-area">
<div class="flex-between" style="margin-bottom:12px;">
<div style="font-weight:700; font-size:13px;">첨부파일 리스트</div>
<div class="ai-toggle-wrap">
<span class="ai-label">AI 판단</span>
<label class="switch">
<input type="checkbox" id="aiToggle" onchange="renderFiles()">
<span class="slider"></span>
</label>
</div>
</div>
<div id="attachmentList"></div>
</div>
</section>
<!-- 우측 미리보기 영역 -->
<aside class="mail-preview-area" id="mailPreviewArea">
<!-- 토글 핸들 버튼 -->
<div class="preview-toggle-handle" onclick="togglePreviewAuto()">
<span id="previewToggleIcon"></span>
</div>
<div class="preview-header">
<div class="flex-center" style="gap:8px;">
<h3>미리보기</h3>
<span style="font-size:10px; color:var(--text-sub); font-weight:400;">최대 10페이지까지만 표시됩니다.</span>
</div>
<div class="flex-center" style="gap:12px;">
<button id="fullViewBtn" class="_button-xsmall"
style="background:var(--primary-lv-0); color:#111111; border:none; padding:4px 12px; height:24px; cursor:pointer; display:none;">전체보기</button>
</div>
</div>
<div class="a4-container" id="previewContainer">
<div class="preview-placeholder">파일을 클릭하면<br>미리보기가 표시됩니다.</div>
</div>
</div>
</aside>
</div>
{% include 'modals/address_book.html' %}
<script src="js/common.js"></script>
<script src="js/mail.js"></script>
</body>
</html>

View File

@@ -0,0 +1,54 @@
<!-- 주소록 모달 -->
<div id="addressBookModal" class="modal-overlay">
<div class="modal-content" style="max-width: 850px;">
<div class="modal-header">
<h3>공사 관계자 주소록</h3>
<div class="flex-center" style="gap:10px;">
<button class="_button-small" style="border:none;" onclick="toggleAddContactForm()">+ 추가하기</button>
<span class="modal-close" onclick="closeAddressBook()">&times;</span>
</div>
</div>
<!-- 주소록 추가 폼 (기본 숨김) -->
<div id="addContactForm"
style="display:none; background:var(--bg-muted); padding:15px; border-radius:8px; margin-bottom:15px; border:1px solid var(--border-color);">
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:10px; margin-bottom:10px;">
<input type="text" id="newContactName" placeholder="성명"
style="padding:8px; border:1px solid #ccc; border-radius:4px; font-size:12px;">
<input type="text" id="newContactDept" placeholder="소속/직위"
style="padding:8px; border:1px solid #ccc; border-radius:4px; font-size:12px;">
<input type="text" id="newContactEmail" placeholder="이메일"
style="padding:8px; border:1px solid #ccc; border-radius:4px; font-size:12px;">
<input type="text" id="newContactPhone" placeholder="연락처"
style="padding:8px; border:1px solid #ccc; border-radius:4px; font-size:12px;">
</div>
<div class="flex-center" style="gap:8px;">
<button class="_button-medium" style="flex:1; background:var(--primary-color); color:#fff;"
onclick="addContact()">저장</button>
<button class="_button-medium" style="flex:1; background:#718096; color:#fff;"
onclick="toggleAddContactForm()">취소</button>
</div>
</div>
<div class="search-bar" style="background:#fff; padding:0 0 15px 0;">
<input type="text" placeholder="이름, 부서, 연락처 검색..." style="margin-bottom:8px; height: 32px;">
</div>
<div style="max-height: 400px; overflow-y: auto;">
<table class="data-table">
<thead>
<tr>
<th>성명</th>
<th>소속/직위</th>
<th>이메일</th>
<th>연락처</th>
<th style="text-align:right;">관리</th>
</tr>
</thead>
<tbody id="addressBookBody">
<!-- 동적으로 렌더링됨 -->
</tbody>
</table>
</div>
<button class="btn-confirm" style="margin-top:20px;" onclick="closeAddressBook()">닫기</button>
</div>
</div>

View File

@@ -0,0 +1,22 @@
<!-- 경로 선택 모달 -->
<div id="pathModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h3>파일 보관 경로 선택</h3>
<span class="modal-close" onclick="closeModal()">&times;</span>
</div>
<div class="select-group">
<label>탭 (Tab)</label>
<select id="tabSelect" class="modal-select" onchange="updateCategories()"></select>
</div>
<div class="select-group">
<label>카테고리 (Category)</label>
<select id="categorySelect" class="modal-select" onchange="updateSubs()"></select>
</div>
<div class="select-group">
<label>서브카테고리 (Sub-Category)</label>
<select id="subSelect" class="modal-select"></select>
</div>
<button class="btn-confirm" onclick="applyPathSelection()">경로 확정하기</button>
</div>
</div>