Compare commits

...

2 Commits

Author SHA1 Message Date
9bb2ecd703 README.md - 디자인 가이드 추가 2026-02-26 18:03:12 +09:00
6d71f94ca8 style - 디자인 가이드 적용
crawler_api.py - 클릭방식으로 변환
README.md - 디자인 가이드 추가
analyze.md - 텍스트 비교 방식으로 분석
2026-02-26 17:47:16 +09:00
31 changed files with 2241 additions and 480 deletions

2
.env Normal file
View File

@@ -0,0 +1,2 @@
PM_USER_ID=b21364
PM_PASSWORD=b21364!.

14
.gemini/settings.json Normal file
View File

@@ -0,0 +1,14 @@
{
"mcpServers": {
"gitea": {
"command": "npx",
"args": [
"@andrebuzeli/git-mcp@latest"
],
"env": {
"GITEA_HOST": "https://gitea.hmac.kr",
"GITEA_ACCESS_TOKEN": "34a35034b9335b5129c8bfcd27e841d83f0aeaed"
}
}
}
}

View File

@@ -3,3 +3,38 @@
1. **언어 설정**: 영어로 생각하되, 모든 답변은 한국어로 작성한다. (일본어, 중국어는 절대 사용하지 않는다.)
2. **수정 권한 제한**: 사용자가 명시적으로 지시한 사항 외에는 **절대 절대 절대** 코드를 임의로 수정하지 않는다.
3. **로그 기록 철저**: 모달 오픈 여부, 수집 성공/실패 여부 등 진행 상황을 실시간 로그에 상세히 표시한다.
---
## 🎨 디자인 가이드 (Design System)
이 프로젝트는 `tokens.json`에 정의된 디자인 시스템을 준수합니다.
### 1. 컬러 시스템 (Colors)
- **Primary**: `#1E5149` (primary-lv-6) - 브랜드 핵심 컬러
- **Background**: `#FFFFFF` (Light Default) / `#F9FAFB` (Light Muted)
- **Point Colors**:
- Blue: `#0D8DF2` (Info)
- Green: `#4DB251` (Success)
- Red: `#F21D0D` (Error)
- Yellow: `#FFBF00` (Warning)
- **Special**: `ai_color` (Purple-Blue Gradient) - AI 관련 요소 전용
### 2. 타이포그래피 (Typography)
- **Font Family**: `Pretendard`, `sans-serif`
- **Scale**:
- **H1**: 20px / ExtraBold (pretendard-0)
- **H2**: 16px / SemiBold (pretendard-1)
- **H3/H4**: 14px / SemiBold or Regular
- **Body/P**: 12px / Regular (pretendard-2)
### 3. 레이아웃 및 간격 (Dimensions)
- **Spacing Unit**: Base 4px (xs: 4px, sm: 8px, md: 16px, lg: 32px, xl: 64px)
- **Border Radius**: sm: 4px, lg: 8px, xl: 16px
- **Shadow**: `0 8px 24px rgba(0,0,0,0.16)` (box__drop-shadow)
### 4. 컴포넌트 규칙
- **Buttons**: `borderRadius.lg (8px)` 적용, Primary 배경색 사용
- **Cards**: `borderRadius.lg (8px)` 적용, Subtle Shadow 활용
- **Topbar**: Height 36px, `headercolor` 그라데이션 적용 가능

Binary file not shown.

Binary file not shown.

92
analyze.py Normal file
View File

@@ -0,0 +1,92 @@
import os
import re
import unicodedata
from pypdf import PdfReader
try:
import pytesseract
from pdf2image import convert_from_path
from PIL import Image
TESSERACT_PATH = r'C:\Users\User\AppData\Local\Programs\Tesseract-OCR\tesseract.exe'
POPPLER_PATH = r'D:\이태훈\00크롬다운로드\poppler-25.12.0\Library\bin'
pytesseract.pytesseract.tesseract_cmd = TESSERACT_PATH
OCR_AVAILABLE = True
except ImportError:
OCR_AVAILABLE = False
def analyze_file_content(filename: str):
file_path = os.path.join("sample", filename)
if not os.path.exists(file_path):
return {"error": "File not found"}
log_steps = []
# Layer 1: 제목 분석 (Quick)
log_steps.append("1. 레이어: 파일 제목(Title) 스캔 중...")
title_text = filename.lower().replace(" ", "")
# Layer 2: 텍스트 추출 (Fast)
log_steps.append("2. 레이어: PDF 텍스트 엔진(Extraction) 가동...")
text_content = ""
try:
if filename.lower().endswith(".pdf"):
reader = PdfReader(file_path)
for page in reader.pages[:5]: # 전체가 아닌 핵심 페이지 위주
page_txt = page.extract_text()
if page_txt: text_content += page_txt + "\n"
text_content = unicodedata.normalize('NFC', text_content)
log_steps.append(f" - 텍스트 데이터 확보 완료 ({len(text_content)}자)")
except:
log_steps.append(" - 텍스트 추출 실패")
# Layer 3: OCR 정밀 분석 (Deep)
log_steps.append("3. 레이어: OCR 이미지 스캔(Vision) 강제 실행...")
ocr_content = ""
if OCR_AVAILABLE and os.path.exists(TESSERACT_PATH):
try:
# 상징적인 첫 페이지 위주 OCR (성능과 정확도 타협)
images = convert_from_path(file_path, first_page=1, last_page=2, poppler_path=POPPLER_PATH)
for i, img in enumerate(images):
page_ocr = pytesseract.image_to_string(img, lang='kor+eng')
ocr_content += unicodedata.normalize('NFC', page_ocr) + "\n"
log_steps.append(f" - OCR 스캔 완료 ({len(ocr_content)}자)")
except Exception as e:
log_steps.append(f" - OCR 오류: {str(e)[:20]}")
# 3중 레이어 데이터 통합
full_pool = (title_text + " | " + text_content + " | " + ocr_content).lower().replace(" ", "").replace("\n", "")
# 분석 초기화
result = {
"suggested_path": "분석실패",
"confidence": "Low",
"log_steps": log_steps,
"raw_text": f"--- TITLE ---\n{filename}\n\n--- TEXT ---\n{text_content[:1000]}\n\n--- OCR ---\n{ocr_content[:1000]}",
"reason": "학습된 키워드 일치 항목 없음"
}
# 최종 추천 로직 (합의 알고리즘)
is_eocheon = any(k in full_pool for k in ["어천", "공주"])
if "실정보고" in full_pool or "실정" in full_pool:
if is_eocheon:
if "품질" in full_pool:
result["suggested_path"] = "설계변경 > 실정보고(어천~공주) > 품질관리"
result["reason"] = "3중 레이어 분석: 실정보고+어천공주+품질관리 키워드 통합 검출"
elif any(k in full_pool for k in ["토지", "임대"]):
result["suggested_path"] = "설계변경 > 실정보고(어천~공주) > 기타"
result["reason"] = "3중 레이어 분석: 토지임대 관련 실정보고(어천-공주) 확인"
else:
result["suggested_path"] = "설계변경 > 실정보고(어천~공주) > 기타"
result["reason"] = "3중 레이어 분석: 실정보고(어천-공주) 문서 판정"
result["confidence"] = "100%"
else:
result["suggested_path"] = "설계변경 > 실정보고(어천~공주) > 기타" # 폴백
result["confidence"] = "80%"
result["reason"] = "실정보고 키워드는 발견되었으나 프로젝트명 교차 검증 실패 (기본값 제안)"
elif "품질" in full_pool:
result["suggested_path"] = "공사관리 > 품질 관리 > 품질시험계획서"
result["confidence"] = "90%"
result["reason"] = "텍스트/OCR 레이어에서 품질 관리 지표 다수 식별"
return result

View File

@@ -9,6 +9,7 @@ from fastapi.responses import StreamingResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from playwright.async_api import async_playwright
from dotenv import load_dotenv
from analyze import analyze_file_content
load_dotenv()
@@ -25,10 +26,37 @@ app.add_middleware(
allow_headers=["*"],
)
@app.get("/")
@app.get("/dashboard")
async def get_dashboard():
return FileResponse("dashboard.html")
@app.get("/mailTest")
async def get_mail_test():
return FileResponse("mailTest.html")
@app.get("/attachments")
async def get_attachments():
sample_path = "sample"
if not os.path.exists(sample_path):
os.makedirs(sample_path)
files = []
for f in os.listdir(sample_path):
f_path = os.path.join(sample_path, f)
if os.path.isfile(f_path):
files.append({
"name": f,
"size": f"{os.path.getsize(f_path) / 1024:.1f} KB"
})
return files
@app.get("/analyze-file")
async def analyze_file(filename: str):
return analyze_file_content(filename)
@app.get("/")
async def root():
return FileResponse("index.html")
@app.get("/sync")
async def sync_data():
async def event_generator():
@@ -97,18 +125,18 @@ async def sync_data():
modal_sel = "article.archive-modal"
if await page.locator(modal_sel).is_visible():
yield f"data: {json.dumps({'type': 'log', 'message': ' - [로그] 모달 발견. 데이터 추출 중...'})}\n\n"
# 사용자 제공 정밀 셀렉터 기반 추출
date_sel = "body > article.archive-modal > div > div > div.modal-body > div.log-wrap > div.log-item-wrap.log-body.scrollbar.scroll-container > div.date > div.text"
user_sel = "body > article.archive-modal > div > div > div.modal-body > div.log-wrap > div.log-item-wrap.log-body.scrollbar.scroll-container > div.user > div.text"
act_sel = "body > article.archive-modal > div > div > div.modal-body > div.log-wrap > div.log-item-wrap.log-body.scrollbar.scroll-container > div.activity > div.text"
yield f"data: {json.dumps({'type': 'log', 'message': ' - [로그] 모달 발견. 데이터 로딩 대기...'})}\n\n"
# .log-body 내부의 데이터만 타겟팅하도록 수정
date_sel = "article.archive-modal .log-body .date .text"
user_sel = "article.archive-modal .log-body .user .text"
act_sel = "article.archive-modal .log-body .activity .text"
# 데이터가 나타날 때까지 반복 대기
# 데이터가 나타날 때까지 최대 15초 대기
success_log = False
for _ in range(10):
for _ in range(15):
if await page.locator(date_sel).count() > 0:
raw_date = (await page.locator(date_sel).first.inner_text()).strip()
if raw_date and "활동시간" not in raw_date:
if raw_date:
success_log = True
break
await asyncio.sleep(1)
@@ -148,32 +176,33 @@ async def sync_data():
await asyncio.sleep(0.5)
if popup_page:
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 창 발견. 데이터 로딩 대기 (최대 80초)...'})}\n\n"
target_selector = "#composition-list h6"
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 창 발견. 데이터 로딩 대기 (최대 30초)...'})}\n\n"
# 사용자 제공 정밀 선택자 적용 (nth-child(3)가 실제 데이터)
target_selector = "#composition-list h6:nth-child(3)"
success_comp = False
# 최대 80초간 까지 대기
for _ in range(80):
# 최대 30초간 데이터가 나타날 때까지 대기
for _ in range(30):
h6_count = await popup_page.locator(target_selector).count()
if h6_count > 5: # 일정 개수 이상의 목록이 나타나면 로딩 시작으로 간주
if h6_count > 0:
success_comp = True
break
await asyncio.sleep(1)
if success_comp:
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 데이터 감지됨. 15초간 최종 렌더링 대기...'})}\n\n"
await asyncio.sleep(15) # 완전한 로딩을 위한 강제 대기
yield f"data: {json.dumps({'type': 'log', 'message': ' - [구성] 데이터 감지됨. 최종 렌더링 대기...'})}\n\n"
await asyncio.sleep(10) # 렌더링 안정화를 위한 대기
# 유연한 데이터 수집
# 모든 h6:nth-child(3) 요소를 순회하며 숫자 합산
locators_h6 = popup_page.locator(target_selector)
h6_count = await locators_h6.count()
current_total = 0
for j in range(h6_count):
text = (await locators_h6.nth(j).inner_text()).strip()
# 텍스트 내에서 숫자만 추출 (여러 줄일 경우 마지막 줄 기준)
nums = re.findall(r'\d+', text.split('\n')[-1])
if nums:
val = int(nums[0])
if val < 5000: current_total += val
current_total += int(nums[0])
file_count = current_total
yield f"data: {json.dumps({'type': 'log', 'message': f' - [구성] 성공 ({file_count}개)'})}\n\n"

View File

@@ -1,32 +1,37 @@
<!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" 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">
<h2>Project Master Overseas</h2>
<a href="/">
<h2>Project Master Test</h2>
</a>
</div>
<ul class="nav-list">
<li class="nav-item active">대시보드</li>
<li class="nav-item">문의사항 <span class="badge" style="background:#FFFFFF;color:var(--primary-color); border-radius:10px; font-weight: bold; padding: 2px 5px;">12</span></li>
<li class="nav-item">로그관리</li>
<li class="nav-item">파일관리</li>
<li class="nav-item">인원관리</li>
<li class="nav-item">공지사항</li>
<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>
<h1>프로젝트 현황</h1>
</div>
<div style="display:flex; align-items:center;">
<button id="syncBtn" class="sync-btn" onclick="syncData()">
@@ -38,8 +43,11 @@
</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="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>
@@ -92,8 +100,8 @@
];
const continentMap = {
"라오스": "아시아", "미얀마": "아시아", "베트남": "아시아", "사우디아라비아": "아시아",
"우즈베키스탄": "아시아", "이라크": "아시아", "캄보디아": "아시아",
"라오스": "아시아", "미얀마": "아시아", "베트남": "아시아", "사우디아라비아": "아시아",
"우즈베키스탄": "아시아", "이라크": "아시아", "캄보디아": "아시아",
"키르기스스탄": "아시아", "파키스탄": "아시아", "필리핀": "아시아",
"아르헨티나": "아메리카", "온두라스": "아메리카", "볼리비아": "아메리카", "콜롬비아": "아메리카",
"파라과이": "아메리카", "페루": "아메리카", "엘살바도르": "아메리카",
@@ -116,7 +124,7 @@
const projectName = item[0];
let continent = "";
let country = "";
if (projectName.endsWith("사무소")) {
continent = "지사";
country = projectName.split(" ")[0];
@@ -127,10 +135,10 @@
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 });
});
@@ -141,7 +149,7 @@
sortedContinents.forEach(continent => {
const continentGroup = document.createElement('div');
continentGroup.className = 'continent-group';
let continentHtml = `
<div class="continent-header" onclick="toggleGroup(this)">
<span>${continent}</span>
@@ -161,48 +169,40 @@
</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}) => {
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 personnelCount = Math.floor(Math.random()*15)+3; // 인원은 시트에 없으므로 임의 할당 유지
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">
<div class="accordion-item ${statusClass}">
<div class="accordion-header" onclick="toggleAccordion(this)">
<div>
<span class="header-label">프로젝트 명</span>
<span class="header-value" title="${projectName}">${projectName}</span>
</div>
<div>
<span class="header-label">담당부서</span>
<span class="header-value">${dept}</span>
</div>
<div>
<span class="header-label">관리자</span>
<span class="header-value">${admin}</span>
</div>
<div>
<span class="header-label">파일 수</span>
<span class="header-value">${fileCount}</span>
</div>
<div>
<span class="header-label">인원</span>
<span class="header-value">${personnelCount}명</span>
</div>
<div>
<span class="header-label">최근 로그</span>
<span class="header-value" style="color:var(--text-sub); font-size:11px;" title="${recentLog}">${recentLog}</span>
</div>
<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">
@@ -245,44 +245,36 @@
continentHtml += `
</div>
`;
continentGroup.innerHTML = continentHtml;
container.appendChild(continentGroup);
});
const allContinents = container.querySelectorAll('.continent-group');
allContinents.forEach(continent => {
continent.classList.add('active');
continent.querySelector('.continent-header .toggle-icon').textContent = '▲';
});
const allCountries = container.querySelectorAll('.country-group');
allCountries.forEach(country => {
country.classList.add('active');
country.querySelector('.country-header .toggle-icon').textContent = '▲';
});
}
function toggleGroup(header) {
const group = header.parentElement;
const icon = header.querySelector('.toggle-icon');
group.classList.toggle('active');
if (group.classList.contains('active')) {
icon.textContent = '▲';
} else {
icon.textContent = '▼';
}
}
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');
if (el !== item) el.classList.remove('active');
});
item.classList.toggle('active');
}
@@ -290,11 +282,11 @@
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 = ''; // 이전 로그 삭제
@@ -307,7 +299,7 @@
try {
const response = await fetch(`/sync`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
@@ -317,21 +309,21 @@
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,''))
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 !== "기존데이터유지") {
@@ -340,7 +332,7 @@
target[4] = scrapedItem.fileCount;
}
});
document.getElementById('projectAccordion').innerHTML = '';
init();
addLog(">>> 모든 동기화 작업이 완료되었습니다!");
@@ -364,4 +356,5 @@
init();
</script>
</body>
</html>

266
debug_modal.html Normal file
View File

@@ -0,0 +1,266 @@
<div class="wrap">
<article class="log-filter" style="display: flex;">
<div class="head">
<span class="title _h3">로그필터</span>
<button class="_button-xsmall reset">초기화</button>
</div>
<div class="body">
<div class="log-date">
<span class="subtitle">활동시간</span>
<div class="log-date-wrap">
<span class="category">시작</span>
<input type="date" value="" style="">
</div>
<div class="log-date-wrap">
<span class="category">종료</span>
<input type="date" value="" style="">
</div>
</div>
<div class="log-user">
<span class="subtitle">사용자</span>
<div class="custom-select-wrap">
<div class="custom-select-display">모든 사용자</div>
<ul class="custom-select-list" style="display: none;"><li data-value="allUser">모든 사용자</li><li data-value="213057">213057 (박진규)</li><li data-value="225044">225044 (박종호)</li><li data-value="B21364">B21364 (이태훈)</li><li data-value="B22027">B22027 (김혜인)</li><li data-value="dev5">dev5 (시스템관리E)</li><li data-value="dev6">dev6 (시스템관리F)</li><li data-value="dev7">dev7 (시스템관리G)</li><li data-value="M07318">M07318 (김원기)</li></ul>
<select id="log-user-select" name="log-user-select" hidden=""><option value="allUser">모든 사용자</option><option value="213057">213057 (박진규)</option><option value="225044">225044 (박종호)</option><option value="B21364">B21364 (이태훈)</option><option value="B22027">B22027 (김혜인)</option><option value="dev5">dev5 (시스템관리E)</option><option value="dev6">dev6 (시스템관리F)</option><option value="dev7">dev7 (시스템관리G)</option><option value="M07318">M07318 (김원기)</option></select>
</div>
</div>
<div class="log-activity">
<div class="head-group">
<span class="subtitle">활동유형</span>
<div class="button-wrap">
<button class="_button-xsmall select-all">전체선택</button>
<button class="_button-xsmall clear-all">전체해제</button>
</div>
</div>
<span class="category">파일 / 폴더관련</span>
<label>
<input type="checkbox" value="uploadData_file" checked="" style="">
<span class="--checkbox"></span>
<span>파일 업로드</span>
</label>
<label>
<input type="checkbox" value="renameTarget" checked="" style="">
<span class="--checkbox"></span>
<span>이름 변경</span>
</label>
<label>
<input type="checkbox" value="removeTarget" checked="" style="">
<span class="--checkbox"></span>
<span>삭제</span>
</label>
<label>
<input type="checkbox" value="downloadTarget" checked="" style="">
<span class="--checkbox"></span>
<span>다운로드</span>
</label>
<label>
<input type="checkbox" value="relocateTarget" checked="" style="">
<span class="--checkbox"></span>
<span>파일 이동</span>
</label>
<label>
<input type="checkbox" value="createFolder" checked="" style="">
<span class="--checkbox"></span>
<span>새 폴더 생성</span>
</label>
<label>
<input type="checkbox" value="setDataPermission_folder" checked="" style="">
<span class="--checkbox"></span>
<span>폴더 권한 설정</span>
</label>
<label>
<input type="checkbox" value="convertPdf" checked="" style="">
<span class="--checkbox"></span>
<span>PDF 변환</span>
</label>
<span class="category">유저관련</span>
<label>
<input type="checkbox" value="editAuthor" checked="" style="">
<span class="--checkbox"></span>
<span>작성자 변경</span>
</label>
<label>
<input type="checkbox" value="deletePermission" checked="" style="">
<span class="--checkbox"></span>
<span>권한 삭제</span>
</label>
<label>
<input type="checkbox" value="addPermission" checked="" style="">
<span class="--checkbox"></span>
<span>권한 추가</span>
</label>
<span class="category">기타</span>
<label>
<input type="checkbox" value="summarizeAI" checked="" style="">
<span class="--checkbox"></span>
<span>AI 요약</span>
</label>
</div>
</div>
<div class="foot">
<button class="_button-medium">적용</button>
</div>
</article>
<div class="modal-wrap">
<div class="modal-header narrow-area">
<div class="title">
<div class="left-wrap">
<div class="title-wrap">
<div class="text">활동로그</div>
<div class="users-count" style="display: none;">1 명</div>
</div>
<div class="btn set-user-permission-btn permission-min-sub-master" style="display: none;">
<div class="text">유저 권한 설정</div>
</div>
<!-- <div 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="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>프로젝트명</div>
<div class="project-type-wrap" id="project-type-wrap" style="display: none;">
<button class="project-type" id="project-type-btn">
<h5 class="project-type__label --type__support">지원</h5>
<i class="project-type__icon"></i>
</button>
<h5 class="--type-capsule" id="project-type-capsule">시공</h5>
<ul class="project-type__list">
<li class="project-type__list_item --type__construction">시공</li>
<li class="project-type__list_item --type__design">설계</li>
<li class="project-type__list_item --type__surgest">제안</li>
<li class="project-type__list_item --type__research">연구</li>
<li class="project-type__list_item --type__support">지원</li>
<li class="project-type__list_item --type__center">센터</li>
<li class="project-type__list_item --type__survey">측량</li>
</ul>
</div>
<div class="project-type-wrap" id="project-type-wrap-overseas" style="display: flex;">
<button class="project-type" id="project-type-btn-overseas" style="min-width: 107.523px; text-align: center; justify-content: center; display: none; align-items: center;">
<h5 class="project-type__label --type__MP">MP (기본계획)</h5>
<i class="project-type__icon"></i>
</button>
<h5 class="--type-capsule" id="project-type-capsule-overseas" style="min-width: 107.523px; text-align: center; justify-content: center; display: flex; align-items: center;">PMC (실시설계)</h5>
<ul class="project-type__list" style="min-width: 107.523px;">
<li class="project-type__list_item --type__MP" style="padding-left: 5px; padding-right: 5px;">MP (기본계획)</li>
<li class="project-type__list_item --type__DD" style="padding-left: 5px; padding-right: 5px;">DD (실시설계)</li>
<li class="project-type__list_item --type__FS" style="padding-left: 5px; padding-right: 5px;">FS (타당성조사)</li>
<li class="project-type__list_item --type__PD" style="padding-left: 5px; padding-right: 5px;">PD (기본설계)</li>
<li class="project-type__list_item --type__DS" style="padding-left: 5px; padding-right: 5px;">DS (설계감리)</li>
<li class="project-type__list_item --type__CS" style="padding-left: 5px; padding-right: 5px;">CS (시공감리)</li>
<li class="project-type__list_item --type__PMC" style="padding-left: 5px; padding-right: 5px;">PMC (실시설계)</li>
<li class="project-type__list_item --type__IDC" style="padding-left: 5px; padding-right: 5px;">IDC (타당성조사)</li>
<li class="project-type__list_item --type__DR" style="padding-left: 5px; padding-right: 5px;">DR (설계검토)</li>
<li class="project-type__list_item --type__ETC" style="padding-left: 5px; padding-right: 5px;">ETC (기타)</li>
</ul>
</div>
<div class="project-input-wrap" style="display: flex; gap:1rem;">
<div class="project-setting-name" id="project-name-view" style="display: flex;"> ITTC 관개 교육센터</div>
<input type="text" class="project-setting-name" id="project-name-input" style="display: none; border: 1px solid black;">
</div>
<div class="project-step-wrap">
<button class="project-step" id="project-step-btn" style="display: none;">
<h5 class="project-step__label --step__active">진행</h5>
<i class="project-step__icon"></i>
</button>
<h5 class="project-step-capsule --step-capsule__active" id="project-step-capsule" style="display: flex;">진행</h5>
<ul class="project-step__list">
<li class="project-step__list_item --step__active">진행</li>
<li class="project-step__list_item --step__stop">중지</li>
<li class="project-step__list_item --step__done">완료</li>
<li class="project-step__list_item --step__wait">대기</li>
</ul>
</div>
<div class="peoject-save-wrap">
</div>
</div>
<div class="project-manager-wrap" style="display: flex; gap:1rem;">
<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-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>
</div>
<div class="btn-wrap">
<div class="logout-btn">
<div class="image"></div>
<div class="text">로그아웃</div>
</div>
</div>
</div>
<div class="manual-wrap" style="display: none;"></div>
<div class="size-wrap" style="display: none;">
<div class="chart" _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="text">저장공간 관련 문의: GSIM 개발팀 이호성 수석연구원</div>
</div>
<div class="log-wrap" style="opacity: 1; display: flex;">
<div class="log-item-wrap log-header">
<div class="log-item">
<div class="date"><div class="text">활동시간</div></div>
<div class="user"><div class="text">사용자</div></div>
<div class="activity"><div class="text">활동유형</div></div>
<div class="log"><div class="text">활동내용</div></div>
</div>
</div>
<div class="log-item-wrap log-body scrollbar scroll-container"></div>
</div>
<div class="text-wrap" style="display: none;">undefined</div>
<!-- <div class="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>
</div>
</div>

BIN
debug_modal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

142
index.html Normal file
View File

@@ -0,0 +1,142 @@
<!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>

199
mailTest.html Normal file
View File

@@ -0,0 +1,199 @@
<!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">
</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" 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" checked onchange="renderFiles()">
<span class="slider"></span>
</label>
</div>
</div>
<div id="attachmentList"></div>
</div>
</section>
</div>
<script>
let currentFiles = [];
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";
const btnAiClass = isAiActive ? 'btn-ai' : 'btn-normal';
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" style="display:none;">추천 위치 탐색 중...</span>
</div>
<div class="btn-group">
<button class="btn-upload ${btnAiClass}" 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);
});
}
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.style.display = 'inline-block';
recLabel.innerText = '분석 중...';
try {
const res = await fetch(`/analyze-file?filename=${encodeURIComponent(file.name)}`);
const analysis = await res.json();
analysis.log_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 = `[결과] ${analysis.suggested_path}<br>└ ${analysis.reason}`;
logContent.appendChild(resultLine);
// 원본 보기 추가
const details = document.createElement('details');
details.style.marginTop = "5px";
details.innerHTML = `
<summary style="color:#da8cf1; cursor:pointer; font-size:10px;">[추출 원본 데이터 확인]</summary>
<div style="color:#a0aec0; padding:8px; background:#2d3748; margin-top:5px; white-space:pre-wrap; max-height:150px; overflow-y:auto; border-radius:4px;">${analysis.raw_text}</div>
`;
logContent.appendChild(details);
recLabel.innerText = `추천: ${analysis.suggested_path}`;
if(analysis.suggested_path === "분석실패") {
recLabel.style.color = "#f21d0d";
recLabel.style.background = "#fee9e7";
}
currentFiles[index].analysis = analysis; // 결과 저장
} catch (e) {
logContent.innerHTML += '<div class="log-line" style="color:red;">ERR: 분석 오류</div>';
}
}
function confirmUpload(index) {
const file = currentFiles[index];
const path = file.analysis ? file.analysis.suggested_path : "선택한 탭";
let message = `[${file.name}] 파일을 업로드하시겠습니까?`;
if(file.analysis && file.analysis.suggested_path !== "분석실패") {
message = `AI가 추천한 위치로 업로드하시겠습니까?\n\n위치: ${path}`;
}
if (confirm(message)) {
alert("업로드가 완료되었습니다.");
}
}
loadAttachments();
</script>
</body>
</html>

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "AICodeTest",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -1,4 +1,5 @@
fastapi==0.110.0
uvicorn==0.29.0
playwright==1.42.0
python-dotenv==1.0.1
python-dotenv==1.0.1
pypdf==4.1.0

BIN
server.log Normal file

Binary file not shown.

42
sheet.csv Normal file
View File

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

View File

@@ -1,412 +1,123 @@
:root {
--primary-color: #1E5149;
--bg-color: #FFFFFF;
--text-main: #222222;
--text-sub: #666666;
--border-color: #E5E7EB;
/* 매우 연한 회색 라인 */
--hover-bg: #F9FAFB;
/* 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;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* 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; }
body {
font-family: 'Pretendard', sans-serif;
font-size: 13px;
color: var(--text-main);
background-color: var(--bg-color);
display: flex;
min-height: 100vh;
}
/* 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; }
/* Topbar */
.topbar {
width: 100%;
background-color: #1E5149;
/* sample.png 탑바 다크 슬레이트 배경색 */
color: #FFFFFF;
padding: 0 1.5rem;
position: fixed;
top: 0;
left: 0;
height: 36px;
display: flex;
align-items: center;
z-index: 100;
}
/* 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); }
.topbar-header {
margin-right: 2.5rem;
}
/* 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; }
.topbar-header h2 {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.3px;
}
/* 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; }
.nav-list {
list-style: none;
display: flex;
align-items: center;
height: 100%;
}
/* 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; }
.nav-item {
padding: 0 1rem;
height: 28px;
border-radius: 4px;
margin: 0 2px;
cursor: pointer;
transition: all 0.2s;
color: rgba(255, 255, 255, 0.7);
display: flex;
justify-content: center;
align-items: center;
gap: 6px;
font-size: 13px;
}
.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); }
.nav-item:hover,
.nav-item.active {
background-color: #E9EEED;
color: #1E5149;
font-weight: 500;
}
.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; }
/* Main Content */
.main-content {
margin-top: 36px;
flex: 1;
padding: 2rem 2.5rem;
width: 100%;
max-width: 1400px;
margin-left: auto;
margin-right: auto;
}
.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; }
header {
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: flex-end;
padding-bottom: 0.8rem;
border-bottom: 1px solid var(--border-color);
/* 선 굵기와 색상 얇게 */
}
header h1 {
font-size: 18px;
font-weight: 700;
color: var(--primary-color);
}
.admin-info {
color: var(--text-sub);
font-size: 12px;
}
/* Multi-level Accordion (Minimalist/No Box Design) */
.continent-group {
margin-bottom: 3rem;
}
.continent-header {
color: var(--text-main);
padding: 0.5rem 0;
font-size: 16px;
font-weight: 700;
cursor: pointer;
display: flex;
justify-content: flex-start;
align-items: center;
border-bottom: 2px solid var(--text-main);
margin-bottom: 1rem;
}
.continent-body {
display: none;
}
.continent-group.active > .continent-body {
display: block;
}
.country-group {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px dashed var(--border-color); /* 국가 사이 구분선 */
}
.country-group:last-child {
margin-bottom: 1rem;
padding-bottom: 0;
border-bottom: none;
}
.country-header {
color: var(--primary-color);
padding: 0.5rem 0;
font-size: 14px;
font-weight: 700;
cursor: pointer;
display: flex;
justify-content: flex-start;
align-items: center;
margin-bottom: 0.5rem;
}
.country-body {
display: none;
padding-left: 0.5rem; /* Slight indent instead of borders */
}
.country-group.active > .country-body {
display: block;
}
.toggle-icon {
font-size: 10px;
margin-left: 8px;
color: #999;
}
/* Accordion Styles (Projects - Row Based) */
.accordion-container {
display: flex;
flex-direction: column;
width: 100%;
}
.accordion-item {
border-bottom: 1px solid var(--border-color);
}
.accordion-item:last-child {
border-bottom: none;
}
.accordion-header {
display: grid;
grid-template-columns: 2.5fr 1fr 1fr 1fr 1fr 2fr;
gap: 1rem;
padding: 1rem 0;
cursor: pointer;
align-items: center;
background-color: transparent;
transition: opacity 0.2s;
}
.accordion-item:hover .accordion-header {
opacity: 0.7;
}
.accordion-item.active .accordion-header {
/* No border-bottom or background change for active */
}
.accordion-header > div {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-label {
.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;
color: var(--text-sub);
margin-bottom: 3px;
display: block;
font-weight: 400;
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; }
.header-value {
font-weight: 500;
font-size: 13px;
color: var(--text-main);
}
.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); }
.accordion-body {
display: none;
padding: 1.5rem 0;
background-color: transparent;
}
/* 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; }
.accordion-item.active .accordion-body {
display: block;
}
/* 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); }
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3rem;
}
.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; }
.detail-section h4 {
margin-bottom: 1rem;
color: var(--text-main);
font-size: 13px;
font-weight: 600;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
}
/* 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); }
/* Table Styles - Super Minimal Line Style */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 8px 4px;
border-bottom: 1px solid var(--border-color);
text-align: left;
}
.data-table th {
color: var(--text-sub);
font-weight: 400;
font-size: 12px;
}
.data-table td {
font-size: 12px;
color: var(--text-main);
}
.data-table tr:last-child td {
border-bottom: none;
}
/* General Utilities */
.badge {
background: #EEEEEE;
color: #555555;
padding: 2px 6px;
border-radius: 2px;
/* 라운드 거의 없앰 */
font-size: 11px;
font-weight: 500;
}
.status-up {
color: #D32F2F;
font-weight: 500;
}
.status-down {
color: #1976D2;
font-weight: 500;
}
/* Sync Button */
.sync-btn {
background-color: var(--primary-color);
color: #FFFFFF;
border: 1px solid var(--primary-color);
padding: 6px 14px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s, opacity 0.2s;
margin-right: 1rem;
display: flex;
align-items: center;
gap: 6px;
}
.sync-btn:hover {
background-color: #153A34;
}
.sync-btn:disabled {
background-color: #A0B2AF;
border-color: #A0B2AF;
cursor: not-allowed;
}
/* Spinner */
.spinner {
display: none;
width: 12px;
height: 12px;
border: 2px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.sync-btn.loading .spinner {
display: inline-block;
}
/* --- Responsive Design --- */
@media screen and (max-width: 1024px) {
.accordion-header {
grid-template-columns: 2fr 1fr 1fr 1fr 1fr 1.5fr;
}
}
@media screen and (max-width: 768px) {
.topbar {
overflow-x: auto;
white-space: nowrap;
padding: 0 1rem;
}
/* 스크롤바 숨김 */
.topbar::-webkit-scrollbar {
display: none;
}
.main-content {
padding: 1.5rem 1rem;
}
header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.detail-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
/* 모바일에서 아코디언 헤더를 다단으로 배치 */
.accordion-header {
grid-template-columns: 1fr 1fr;
row-gap: 1rem;
}
/* 프로젝트 명과 최근 로그는 공간을 넓게 쓰도록 설정 */
.accordion-header > div:nth-child(1),
.accordion-header > div:nth-child(6) {
grid-column: span 2;
}
.continent-header {
font-size: 15px;
}
}
@media screen and (max-width: 480px) {
/* 아주 작은 화면에서는 1열로 배치 */
.accordion-header {
grid-template-columns: 1fr;
}
.accordion-header > div {
grid-column: span 1 !important;
}
.topbar-header h2 {
font-size: 13px;
margin-right: 1rem;
}
.nav-item {
padding: 0 0.5rem;
font-size: 12px;
}
}

1229
tokens.json Normal file

File diff suppressed because it is too large Load Diff