feat: 메일 관리 UI 개편 및 시스템 구조 최적화
- UI/UX: 메일 관리 레이아웃 고도화 및 미리보기 토글 핸들 도입 - 기능: 주소록 CRUD 기능 추가 및 모달 인터페이스 개선 - 구조: CSS 파일 기능별 분리 및 Jinja2 템플릿 엔진 도입 - 백엔드: OCR 비동기 처리 및 CSV 파싱(BOM) 안정화 - 데이터: 2026.03.04 기준 최신 프로젝트 현황 업데이트
This commit is contained in:
@@ -142,7 +142,8 @@ def analyze_file_content(filename: str):
|
||||
if images:
|
||||
ocr_result = pytesseract.image_to_string(images[0], lang='kor+eng')
|
||||
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)
|
||||
elif filename.lower().endswith(('.xlsx', '.xls')):
|
||||
import pandas as pd
|
||||
|
||||
@@ -21,6 +21,8 @@ async def run_crawler_service():
|
||||
results = []
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = None
|
||||
try:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': '브라우저 실행 중...'})}\n\n"
|
||||
browser = await p.chromium.launch(headless=True, args=[
|
||||
"--no-sandbox",
|
||||
@@ -33,7 +35,6 @@ async def run_crawler_service():
|
||||
)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': '사이트 접속 및 로그인 중...'})}\n\n"
|
||||
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"
|
||||
|
||||
except GeneratorExit:
|
||||
# SSE 연결이 클라이언트 측에서 먼저 끊겼을 때 실행
|
||||
if browser: await browser.close()
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'type': 'log', 'message': f'치명적 오류: {str(e)}'})}\n\n"
|
||||
finally:
|
||||
await browser.close()
|
||||
if browser: await browser.close()
|
||||
|
||||
360
dashboard.html
360
dashboard.html
@@ -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>
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
<div class="wrap">
|
||||
<article class="log-filter" style="display: flex;">
|
||||
<article class="log-filter">
|
||||
<div class="head">
|
||||
<span class="title _h3">로그필터</span>
|
||||
<button class="_button-xsmall reset">초기화</button>
|
||||
@@ -10,11 +10,11 @@
|
||||
<span class="subtitle">활동시간</span>
|
||||
<div class="log-date-wrap">
|
||||
<span class="category">시작</span>
|
||||
<input type="date" value="" style="">
|
||||
<input type="date" value="">
|
||||
</div>
|
||||
<div class="log-date-wrap">
|
||||
<span class="category">종료</span>
|
||||
<input type="date" value="" style="">
|
||||
<input type="date" value="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-user">
|
||||
@@ -35,64 +35,64 @@
|
||||
</div>
|
||||
<span class="category">파일 / 폴더관련</span>
|
||||
<label>
|
||||
<input type="checkbox" value="uploadData_file" checked="" style="">
|
||||
<input type="checkbox" value="uploadData_file" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>파일 업로드</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="renameTarget" checked="" style="">
|
||||
<input type="checkbox" value="renameTarget" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>이름 변경</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="removeTarget" checked="" style="">
|
||||
<input type="checkbox" value="removeTarget" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>삭제</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="downloadTarget" checked="" style="">
|
||||
<input type="checkbox" value="downloadTarget" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>다운로드</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="relocateTarget" checked="" style="">
|
||||
<input type="checkbox" value="relocateTarget" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>파일 이동</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="createFolder" checked="" style="">
|
||||
<input type="checkbox" value="createFolder" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>새 폴더 생성</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="setDataPermission_folder" checked="" style="">
|
||||
<input type="checkbox" value="setDataPermission_folder" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>폴더 권한 설정</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="convertPdf" checked="" style="">
|
||||
<input type="checkbox" value="convertPdf" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>PDF 변환</span>
|
||||
</label>
|
||||
<span class="category">유저관련</span>
|
||||
<label>
|
||||
<input type="checkbox" value="editAuthor" checked="" style="">
|
||||
<input type="checkbox" value="editAuthor" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>작성자 변경</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="deletePermission" checked="" style="">
|
||||
<input type="checkbox" value="deletePermission" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>권한 삭제</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" value="addPermission" checked="" style="">
|
||||
<input type="checkbox" value="addPermission" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>권한 추가</span>
|
||||
</label>
|
||||
<span class="category">기타</span>
|
||||
<label>
|
||||
<input type="checkbox" value="summarizeAI" checked="" style="">
|
||||
<input type="checkbox" value="summarizeAI" checked="">
|
||||
<span class="--checkbox"></span>
|
||||
<span>AI 요약</span>
|
||||
</label>
|
||||
@@ -114,46 +114,15 @@
|
||||
<div class="btn set-user-permission-btn permission-min-sub-master" style="display: none;">
|
||||
<div class="text">유저 권한 설정</div>
|
||||
</div>
|
||||
<!-- <div class="btn dev-menu-btn permission-min-dev">
|
||||
<div class="text">개발자 메뉴</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 class="close"></div>
|
||||
</div>
|
||||
<div class="modal-body" style="">
|
||||
<div class="modal-body">
|
||||
<div class="connected-users-wrap" style="display: none;">
|
||||
<div class="user-item-wrap scrollbar"><div class="user-item me" data-user-id="B21364"><img class="profile-image" src="/main/img/archive/empty-profile.svg" style="outline: rgb(24, 114, 89) solid 2px;"><div class="wrap"><div class="top-wrap"><div class="name">이태훈 선임연구원</div><div class="user-permission-sub-master"><h6>부관리자</h6></div><div class="me-badge"><h6>나</h6></div></div><div class="bottom-wrap"><div class="cur-path">현재 위치: /과업개요</div></div></div></div></div>
|
||||
<div class="project-setting-wrap" style="display: flex; flex-direction: column;">
|
||||
<div class="project-name-wrap" style="display: flex; gap:1rem;">
|
||||
<div class="project-setting-wrap">
|
||||
<div class="project-name-wrap">
|
||||
<div>프로젝트명</div>
|
||||
<div class="project-type-wrap" id="project-type-wrap" style="display: none;">
|
||||
<button class="project-type" id="project-type-btn">
|
||||
@@ -192,7 +161,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
<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;">
|
||||
</div>
|
||||
<div class="project-step-wrap">
|
||||
@@ -209,19 +178,16 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div class="peoject-save-wrap">
|
||||
|
||||
|
||||
</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-name">방노성 전무이사</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-lat"><div class="project-location-lat">위도 18.068579</div></div>
|
||||
<div class="project-location-lon"><div class="project-location-lon">경도 102.65966</div></div>
|
||||
|
||||
<div class="project-location-lat">위도 18.068579</div>
|
||||
<div class="project-location-lon">경도 102.65966</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-wrap">
|
||||
@@ -233,7 +199,7 @@
|
||||
</div>
|
||||
<div class="manual-wrap" style="display: none;"></div>
|
||||
<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>
|
||||
<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>
|
||||
<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="input-wrap" style="display: none;"></div>
|
||||
<div class="user-list-wrap" style="display: none;">
|
||||
<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 class="btn-wrap" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
142
index.html
142
index.html
@@ -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
9
js/common.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// 공통 네비게이션 및 유틸리티 로직
|
||||
function navigateTo(path) {
|
||||
location.href = path;
|
||||
}
|
||||
|
||||
// 상단바 클릭 시 홈으로 이동 등 공통 이벤트 설정
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 필요한 경우 공통 초기화 로직 추가
|
||||
});
|
||||
267
js/dashboard.js
Normal file
267
js/dashboard.js
Normal 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
403
js/mail.js
Normal 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);
|
||||
410
mailTest.html
410
mailTest.html
@@ -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()">×</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>
|
||||
68
server.py
68
server.py
@@ -9,13 +9,29 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from analyze import analyze_file_content
|
||||
from crawler_service import run_crawler_service
|
||||
import asyncio
|
||||
from fastapi import Request
|
||||
|
||||
app = FastAPI(title="Project Master Overseas API")
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# --- 유틸리티: 동기 함수를 스레드 풀에서 실행 ---
|
||||
async def run_in_threadpool(func, *args):
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, func, *args)
|
||||
|
||||
# 정적 파일 및 미들웨어 설정
|
||||
app.mount("/style", StaticFiles(directory="style"), name="style")
|
||||
app.mount("/js", StaticFiles(directory="js"), name="js")
|
||||
app.mount("/sample_files", StaticFiles(directory="sample"), name="sample_files")
|
||||
|
||||
@app.get("/sample.png")
|
||||
async def get_sample_img():
|
||||
return FileResponse("sample.png")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@@ -25,18 +41,54 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
# --- 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("/")
|
||||
async def root():
|
||||
return FileResponse("index.html")
|
||||
async def root(request: Request):
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
@app.get("/dashboard")
|
||||
async def get_dashboard():
|
||||
return FileResponse("dashboard.html")
|
||||
async def get_dashboard(request: Request):
|
||||
return templates.TemplateResponse("dashboard.html", {"request": request})
|
||||
|
||||
@app.get("/mailTest")
|
||||
@app.get("/mailTest.html")
|
||||
async def get_mail_test():
|
||||
return FileResponse("mailTest.html")
|
||||
async def get_mail_test(request: Request):
|
||||
return templates.TemplateResponse("mailTest.html", {"request": request})
|
||||
|
||||
# --- 데이터 API ---
|
||||
@app.get("/attachments")
|
||||
@@ -57,9 +109,9 @@ async def get_attachments():
|
||||
@app.get("/analyze-file")
|
||||
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")
|
||||
async def sync_data():
|
||||
|
||||
34
sheet.csv
34
sheet.csv
@@ -1,4 +1,4 @@
|
||||
[PM Overseas 프로젝트 현황],,2026.02.24,,,,,,<<활동로그가 없는 프로젝트 (9),,
|
||||
[PM Overseas 프로젝트 현황],,2026.03.04,,,,,,<<활동로그가 없는 프로젝트 (8),,
|
||||
,,,,,,,,,,
|
||||
No.,프로젝트 명,담당부서,담당자,종료(예정)일,최근 활동로그,과업개요 작성 유무,파일 수,비고,,
|
||||
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,,,
|
||||
4,베트남 푸옥호아 양수 발전 FS,수력부,이철호,2025.11.30,"2026.02.23, 폴더이름변경",O,139,준공도서 3월 작성예정,종료(예정)일 지남,준공
|
||||
5,사우디아라비아 아시르 지잔 고속도로 FS,도로부,공태원,2025.11.21,"2026.02.09, 파일다운로드",O,73,,종료(예정)일 지남,준공
|
||||
6,우즈베키스탄 타슈켄트 철도 FS,철도사업부,김태헌,2026.03.20,"2026.02.05, 파일업로드",O,51,,,
|
||||
7,우즈베키스탄 지방 도로 복원 MP,도로부,장진영,2029.04.28,X,X,0,,,
|
||||
6,우즈베키스탄 지방 도로 복원 MP,도로부,장진영,2029.04.28,X,X,0,,,
|
||||
7,우즈베키스탄 타슈켄트 철도 FS,철도사업부,김태헌,2026.03.20,"2026.02.05, 파일업로드",O,51,,,
|
||||
8,이라크 Habbaniyah Shuaiba AirBase PD,도로부,강동구,2026.12.31,X,X,0,,,
|
||||
9,캄보디아 반테 민체이 관개 홍수저감 MP,수자원1부,이대주,2026.08.28,"2025.12.07, 파일업로드",X,44,,,
|
||||
10,캄보디아 시엠립 하수처리 개선 DD,물환경사업1부,변역근,2028.12.18,"2026.02.06, AI 요약",O,221,,,
|
||||
11,메콩유역 수자원 관리 기후적응 MP,수자원1부,정귀한,2025.12.31,X,X,0,,종료(예정)일 지남,준공
|
||||
9,메콩유역 수자원 관리 기후적응 MP,수자원1부,정귀한,2025.12.31,X,X,0,,종료(예정)일 지남,준공
|
||||
10,캄보디아 반테 민체이 관개 홍수저감 MP,수자원1부,이대주,2026.08.28,"2025.12.07, 파일업로드",X,44,,,
|
||||
11,캄보디아 시엠립 하수처리 개선 DD,물환경사업1부,변역근,2028.12.18,"2026.02.06, AI 요약",O,221,,,
|
||||
12,키르기스스탄 잘랄아바드 상수도 계획 MP,물환경사업1부,변기상,2025.12.31,"2026.02.12, 파일업로드",X,60,,종료(예정)일 지남,준공
|
||||
13,파키스탄 CAREC 도로 감리 DD,도로부,황효섭,2026.10.26,X,X,0,,,
|
||||
14,파키스탄 펀잡 홍수 방재 PMC,수자원1부,방노성,2027.12.31,"2025.12.08, 폴더삭제",O,0,,,
|
||||
15,파키스탄 KP 아보타바드 상수도 PMC,물환경사업2부,변기상,2026.12.31,"2026.02.12, 파일업로드",O,234,,,
|
||||
16,필리핀 홍수 관리 Package5B MP,수자원1부,이희철,2026.05.31,"2025.12.02, 폴더이름변경",O,14,,,
|
||||
17,필리핀 PGN 해상교량 BID2 IDC,구조부,이상희,2026.05.31,"2026.02.11, 파일다운로드",O,631,,,
|
||||
18,필리핀 홍수 복원 InFRA2 DD,수자원1부,이대주,2026.08.07,"2025.12.01, 폴더삭제",O,6,최근로그 >> 폴더자동삭제(파일 개수 미달),,
|
||||
13,파키스탄 펀잡 홍수 방재 PMC,수자원1부,방노성,2027.12.31,"2025.12.08, 폴더삭제",O,0,,,
|
||||
14,파키스탄 KP 아보타바드 상수도 PMC,물환경사업2부,변기상,2026.12.31,"2026.02.26, 파일업로드",O,240,,,
|
||||
15,파키스탄 CAREC 도로 감리 DD,도로부,황효섭,2026.10.26,X,X,0,,,
|
||||
16,필리핀 홍수 복원 InFRA2 DD,수자원1부,이대주,2026.08.07,"2025.12.01, 폴더삭제",O,6,최근로그 >> 폴더자동삭제(파일 개수 미달),,
|
||||
17,필리핀 홍수 관리 Package5B MP,수자원1부,이희철,2026.05.31,"2025.12.02, 폴더이름변경",O,14,,,
|
||||
18,필리핀 PGN 해상교량 BID2 IDC,구조부,이상희,2026.05.31,"2026.02.11, 파일다운로드",O,631,,,
|
||||
19,가나 테치만 상수도 확장 DS,물환경사업2부,-,2029.04.25,X,X,0,책임자 및 담당자 설정X,,
|
||||
20,기니 벼 재배단지 PMC,수자원1부,이대주,2028.12.20,"2025.12.08, 파일업로드",O,43,최근로그 >> 폴더자동삭제(파일 개수 미달),,
|
||||
21,우간다 벼 재배단지 PMC,수자원1부,방노성,2028.12.20,"2025.12.08, 파일업로드",O,52,,,
|
||||
@@ -26,7 +26,7 @@ No.,프로젝트 명,담당부서,담당자,종료(예정)일,최근 활동로
|
||||
23,에티오피아 지하수 관개 환경설계 DD,물환경사업2부,변기상,2026.06.23,X,X,0,,,
|
||||
24,에티오피아 도도타군 관개 PMC,수자원1부,방노성,2026.12.31,"2025.12.01, 폴더이름변경",O,144,탭 1개에 모든파일 업로드 // 최근로그 >> 폴더자동삭제(파일 개수 미달),,
|
||||
25,에티오피아 Adeaa-Becho 지하수 관개 MP,수자원1부,방노성,2026.07.31,"2025.11.21, 파일업로드",O,146,최근로그 >> 폴더자동삭제(파일 개수 미달),,
|
||||
26,탄자니아 Iringa 상하수도 개선 CS,물환경사업1부,백운영,2029.06.08,"2026.02.03, 폴더 생성",X,0,,,
|
||||
26,탄자니아 Iringa 상하수도 개선 CS,물환경사업1부,백운영,2029.06.08,"2026.02.03, 폴더생성",X,0,,,
|
||||
27,탄자니아 Dodoma 하수 설계감리 DD,물환경사업2부,변기상,2027.07.08,"2026.02.04, 폴더삭제",X,32,,,
|
||||
28,탄자니아 잔지바르 쌀 생산 PMC,수자원1부,방노성,2027.12.20,"2025.12.08, 파일 업로드",O,23,,,
|
||||
29,탄자니아 도도마 유수율 상수도개선 PMC,물환경사업1부,박순석,2026.12.31,"2026.02.12, 부관리자권한추가",X,35,,,
|
||||
@@ -35,8 +35,8 @@ No.,프로젝트 명,담당부서,담당자,종료(예정)일,최근 활동로
|
||||
32,볼리비아 에스꼬마 차라짜니 도로 CS,도로부,전홍찬,2029.12.15,"2026.02.06, 파일업로드",X,1,,,
|
||||
33,볼리비아 마모레 교량도로 FS,도로부,황효섭,2025.10.17,"2026.02.06, 파일업로드",X,120,,종료(예정)일 지남,준공
|
||||
34,볼리비아 Bombeo-Colomi 도로설계 DD,도로부,황효섭,2026.07.24,"2025.12.05, 파일삭제",O,48,"더미파일(폴더유지용) 12개, 실 관리부서는 해외사업부",,
|
||||
35,콜롬비아 AI 폐기물 FS,플랜트1부,서재희,2026.02.27,X,X,0,,,
|
||||
36,파라과이 도로 통행료 현대화 MP,교통계획부,오제훈,2025.10.24,X,X,0,,종료(예정)일 지남,준공
|
||||
35,콜롬비아 AI 폐기물 FS,플랜트1부,서재희,2026.02.27,X,X,0,,종료(예정)일 지남,
|
||||
36,파라과이 도로 통행료 현대화 MP,교통계획부,오제훈,2025.10.24,"2025.02.25, 폴더삭제",X,0,,종료(예정)일 지남,준공
|
||||
37,페루 Barranca 상하수도 확장 DD,물환경사업2부,변기상,2026.03.08,"2025.11.14, 파일업로드",O,44,"더미파일(폴더유지용) 27개, 실 관리부서는 해외사업부",,
|
||||
38,엘살바도르 태평양 철도 FS,철도사업부,김태헌,2025.12.31,"2026.02.04, 파일이름변경",X,102,,종료(예정)일 지남,준공
|
||||
39,필리핀 사무소,해외사업부,한형남,,"2026.02.23, 파일업로드",과업개요 페이지 없음,813,,,
|
||||
38,엘살바도르 태평양 철도 FS,철도사업부,김태헌,2025.12.31,"2026.02.24, 폴더자동삭제",X,101,,종료(예정)일 지남,준공
|
||||
39,필리핀 사무소,해외사업부,한형남,,"2026.03.04, 파일다운로드",과업개요 페이지 없음,817,,,
|
||||
|
281
style/common.css
Normal file
281
style/common.css
Normal 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
264
style/dashboard.css
Normal 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
404
style/mail.css
Normal 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;
|
||||
}
|
||||
126
style/style.css
126
style/style.css
@@ -1,123 +1,3 @@
|
||||
: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;
|
||||
--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); }
|
||||
|
||||
@import url('common.css');
|
||||
@import url('dashboard.css');
|
||||
@import url('mail.css');
|
||||
|
||||
61
templates/dashboard.html
Normal file
61
templates/dashboard.html
Normal 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
46
templates/index.html
Normal 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
140
templates/mailTest.html
Normal 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>
|
||||
54
templates/modals/address_book.html
Normal file
54
templates/modals/address_book.html
Normal 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()">×</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>
|
||||
22
templates/modals/path_selector.html
Normal file
22
templates/modals/path_selector.html
Normal 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()">×</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>
|
||||
Reference in New Issue
Block a user