refactor: 시스템 전반 코드 리팩토링 및 문의사항 UI 개선
This commit is contained in:
77
js/common.js
77
js/common.js
@@ -1,25 +1,78 @@
|
||||
/**
|
||||
* Project Master Overseas Common JS
|
||||
* 공통 네비게이션, 유틸리티, 전역 이벤트 관리
|
||||
* 공통 네비게이션, 통합 모달 관리, 유틸리티
|
||||
*/
|
||||
|
||||
// --- 공통 상수 ---
|
||||
const API = {
|
||||
INQUIRIES: '/api/inquiries',
|
||||
PROJECT_DATA: '/project-data',
|
||||
PROJECT_ACTIVITY: '/project-activity',
|
||||
AVAILABLE_DATES: '/available-dates',
|
||||
SYNC: '/sync',
|
||||
STOP_SYNC: '/stop-sync',
|
||||
AUTH_CRAWL: '/auth/crawl',
|
||||
ANALYZE_FILE: '/analyze-file',
|
||||
ATTACHMENTS: '/attachments'
|
||||
};
|
||||
|
||||
// --- 네비게이션 ---
|
||||
function navigateTo(path) {
|
||||
location.href = path;
|
||||
}
|
||||
|
||||
// --- 전역 이벤트: 모든 모달창 ESC 키로 닫기 ---
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
// 대시보드 모달
|
||||
if (typeof closeAuthModal === 'function') closeAuthModal();
|
||||
if (typeof closeActivityModal === 'function') closeActivityModal();
|
||||
|
||||
// 메일 시스템 모달
|
||||
if (typeof closeModal === 'function') closeModal();
|
||||
if (typeof closeAddressBook === 'function') closeAddressBook();
|
||||
// --- 통합 모달 관리자 ---
|
||||
const ModalManager = {
|
||||
open(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
// 포커스 자동 이동 (ID 입력란이 있으면)
|
||||
const firstInput = modal.querySelector('input');
|
||||
if (firstInput) firstInput.focus();
|
||||
}
|
||||
},
|
||||
close(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) modal.style.display = 'none';
|
||||
},
|
||||
closeAll() {
|
||||
document.querySelectorAll('.modal-overlay').forEach(m => m.style.display = 'none');
|
||||
}
|
||||
};
|
||||
|
||||
// --- 유틸리티 함수 ---
|
||||
const Utils = {
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr.replace(/-/g, '.');
|
||||
},
|
||||
|
||||
// 상태별 CSS 클래스 매핑
|
||||
getStatusClass(status) {
|
||||
const map = {
|
||||
'완료': 'status-complete',
|
||||
'작업 중': 'status-working',
|
||||
'확인 중': 'status-checking',
|
||||
'정상': 'active',
|
||||
'주의': 'warning',
|
||||
'방치': 'stale',
|
||||
'데이터 없음': 'unknown'
|
||||
};
|
||||
return map[status] || 'status-pending';
|
||||
},
|
||||
|
||||
// 한글 파일명 인코딩 안전 처리
|
||||
getSafeFileUrl(filename) {
|
||||
return `/sample_files/${encodeURIComponent(filename)}`;
|
||||
}
|
||||
};
|
||||
|
||||
// --- 전역 이벤트 ---
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') ModalManager.closeAll();
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 공통 초기화 로직
|
||||
console.log("Common module initialized.");
|
||||
});
|
||||
|
||||
@@ -13,8 +13,7 @@ const CONTINENT_ORDER = { "아시아": 1, "아프리카": 2, "아메리카": 3,
|
||||
// --- 초기화 ---
|
||||
async function init() {
|
||||
console.log("Dashboard Initializing...");
|
||||
const container = document.getElementById('projectAccordion');
|
||||
if (!container) return;
|
||||
if (!document.getElementById('projectAccordion')) return;
|
||||
|
||||
await loadAvailableDates();
|
||||
await loadDataByDate();
|
||||
@@ -23,7 +22,7 @@ async function init() {
|
||||
// --- 데이터 통신 및 로드 ---
|
||||
async function loadAvailableDates() {
|
||||
try {
|
||||
const response = await fetch('/available-dates');
|
||||
const response = await fetch(API.AVAILABLE_DATES);
|
||||
const dates = await response.json();
|
||||
if (dates?.length > 0) {
|
||||
const selectHtml = `
|
||||
@@ -40,7 +39,7 @@ async function loadAvailableDates() {
|
||||
async function loadDataByDate(selectedDate = "") {
|
||||
try {
|
||||
await loadActivityAnalysis(selectedDate);
|
||||
const url = selectedDate ? `/project-data?date=${selectedDate}` : `/project-data?t=${Date.now()}`;
|
||||
const url = selectedDate ? `${API.PROJECT_DATA}?date=${selectedDate}` : `${API.PROJECT_DATA}?t=${Date.now()}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
@@ -56,7 +55,7 @@ async function loadActivityAnalysis(date = "") {
|
||||
const dashboard = document.getElementById('activityDashboard');
|
||||
if (!dashboard) return;
|
||||
try {
|
||||
const url = date ? `/project-activity?date=${date}` : `/project-activity`;
|
||||
const url = date ? `${API.PROJECT_ACTIVITY}?date=${date}` : API.PROJECT_ACTIVITY;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
if (data.error) return;
|
||||
@@ -114,20 +113,13 @@ function createProjectHtml(p) {
|
||||
const recentLog = (!logRaw || logRaw === "X" || logRaw === "데이터 없음") ? "기록 없음" : logRaw;
|
||||
const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음";
|
||||
|
||||
// '폴더 자동 삭제' 여부 확인
|
||||
const isStaleLog = recentLog.replace(/\s/g, "").includes("폴더자동삭제");
|
||||
// 파일이 0개 또는 NULL인 경우에만 행 전체 에러(붉은색) 표시
|
||||
const isNoFiles = (files === 0 || files === null);
|
||||
const statusClass = isNoFiles ? "status-error" : "";
|
||||
|
||||
// 로그 텍스트 스타일 결정
|
||||
// 폴더자동삭제는 위험(error), 기록 없음은 주의(warning)
|
||||
let logStyleClass = "";
|
||||
if (isStaleLog) {
|
||||
logStyleClass = "error-text";
|
||||
} else if (recentLog === "기록 없음") {
|
||||
logStyleClass = "warning-text";
|
||||
}
|
||||
if (isStaleLog) logStyleClass = "error-text";
|
||||
else if (recentLog === "기록 없음") logStyleClass = "warning-text";
|
||||
|
||||
const logBoldStyle = isStaleLog ? 'font-weight: 800;' : '';
|
||||
|
||||
@@ -140,14 +132,14 @@ function createProjectHtml(p) {
|
||||
<div class="detail-grid">
|
||||
<div class="detail-section">
|
||||
<h4>참여 인원 상세</h4>
|
||||
<table class="data-table" id="personnel-table">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>이름</th><th>소속</th><th>권한</th></tr></thead>
|
||||
<tbody><tr><td>${admin}</td><td>${dept}</td><td>관리자</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h4>최근 활동</h4>
|
||||
<table class="data-table" id="activity-table">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>유형</th><th>내용</th><th>일시</th></tr></thead>
|
||||
<tbody><tr><td><span class="badge">로그</span></td><td>동기화 완료</td><td>${logTime}</td></tr></tbody>
|
||||
</table>
|
||||
@@ -166,29 +158,18 @@ function toggleAccordion(h) {
|
||||
}
|
||||
|
||||
function showActivityDetails(status) {
|
||||
const modal = document.getElementById('activityDetailModal'), tbody = document.getElementById('modalTableBody'), title = document.getElementById('modalTitle');
|
||||
const names = { active: '정상', warning: '주의', stale: '방치', unknown: '데이터 없음' };
|
||||
const filtered = (projectActivityDetails || []).filter(d => d.status === status);
|
||||
title.innerText = `${names[status]} 목록 (${filtered.length}개)`;
|
||||
tbody.innerHTML = filtered.map(p => {
|
||||
document.getElementById('modalTitle').innerText = `${names[status]} 목록 (${filtered.length}개)`;
|
||||
document.getElementById('modalTableBody').innerHTML = filtered.map(p => {
|
||||
const o = rawData.find(r => r[0] === p.name);
|
||||
return `<tr class="modal-row" onclick="scrollToProject('${p.name}')"><td><strong>${p.name}</strong></td><td>${o ? o[1] : "-"}</td><td>${o ? o[2] : "-"}</td></tr>`;
|
||||
}).join('');
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeActivityModal() {
|
||||
const modal = document.getElementById('activityDetailModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
function closeAuthModal() {
|
||||
const modal = document.getElementById('authModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
ModalManager.open('activityDetailModal');
|
||||
}
|
||||
|
||||
function scrollToProject(name) {
|
||||
closeActivityModal();
|
||||
ModalManager.close('activityDetailModal');
|
||||
const target = Array.from(document.querySelectorAll('.repo-title')).find(t => t.innerText.trim() === name.trim())?.closest('.accordion-header');
|
||||
if (target) {
|
||||
let p = target.parentElement;
|
||||
@@ -208,27 +189,23 @@ function scrollToProject(name) {
|
||||
async function syncData() {
|
||||
if (isCrawling) {
|
||||
if (confirm("크롤링을 중단하시겠습니까?")) {
|
||||
const res = await fetch('/stop-sync');
|
||||
const res = await fetch(API.STOP_SYNC);
|
||||
if ((await res.json()).success) document.getElementById('syncBtn').innerText = "중단 요청 중...";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const modal = document.getElementById('authModal');
|
||||
if (modal) {
|
||||
document.getElementById('authId').value = ''; document.getElementById('authPw').value = '';
|
||||
document.getElementById('authErrorMessage').style.display = 'none';
|
||||
modal.style.display = 'flex'; document.getElementById('authId').focus();
|
||||
}
|
||||
document.getElementById('authId').value = '';
|
||||
document.getElementById('authPw').value = '';
|
||||
document.getElementById('authErrorMessage').style.display = 'none';
|
||||
ModalManager.open('authModal');
|
||||
}
|
||||
|
||||
function closeAuthModal() { document.getElementById('authModal').style.display = 'none'; }
|
||||
|
||||
async function submitAuth() {
|
||||
const id = document.getElementById('authId').value, pw = document.getElementById('authPw').value, err = document.getElementById('authErrorMessage');
|
||||
try {
|
||||
const res = await fetch('/auth/crawl', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: id, password: pw }) });
|
||||
const res = await fetch(API.AUTH_CRAWL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: id, password: pw }) });
|
||||
const data = await res.json();
|
||||
if (data.success) { closeAuthModal(); startCrawlProcess(); }
|
||||
if (data.success) { ModalManager.close('authModal'); startCrawlProcess(); }
|
||||
else { err.innerText = "크롤링을 할 수 없습니다."; err.style.display = 'block'; }
|
||||
} catch { err.innerText = "서버 연결 실패"; err.style.display = 'block'; }
|
||||
}
|
||||
@@ -239,7 +216,7 @@ async function startCrawlProcess() {
|
||||
btn.classList.add('loading'); btn.style.backgroundColor = 'var(--error-color)'; btn.innerHTML = `<span class="spinner"></span> 크롤링 중단`;
|
||||
logC.style.display = 'block'; logB.innerHTML = '<div style="color:#aaa; margin-bottom:10px;">>>> 엔진 초기화 중...</div>';
|
||||
try {
|
||||
const res = await fetch(`/sync`);
|
||||
const res = await fetch(API.SYNC);
|
||||
const reader = res.body.getReader(), decoder = new TextDecoder();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
/**
|
||||
* Project Master Overseas Inquiries JS
|
||||
* 기능: 문의사항 로드, 필터링, 답변 관리, 아코디언 및 이미지 모달
|
||||
*/
|
||||
|
||||
// --- 초기화 ---
|
||||
async function loadInquiries() {
|
||||
initStickyHeader();
|
||||
|
||||
@@ -14,7 +19,7 @@ async function loadInquiries() {
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/inquiries?${params}`);
|
||||
const response = await fetch(`${API.INQUIRIES}?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
updateStats(data);
|
||||
@@ -51,10 +56,10 @@ function renderInquiryList(data) {
|
||||
<td title="${item.category}">${item.category}</td>
|
||||
<td title="${item.project_nm}">${item.project_nm}</td>
|
||||
<td class="content-preview" title="${item.content}">${item.content}</td>
|
||||
<td class="content-preview" title="${item.reply || ''}" style="color: #1e5149; font-weight: 500;">${item.reply || '-'}</td>
|
||||
<td title="${item.author}">${item.author}</td>
|
||||
<td title="${item.reg_date}">${item.reg_date}</td>
|
||||
<td><span class="status-badge ${getStatusClass(item.status)}">${item.status}</span></td>
|
||||
<td class="content-preview" title="${item.reply || ''}" style="color: #1e5149; font-weight: 500;">${item.reply || '-'}</td>
|
||||
<td><span class="status-badge ${Utils.getStatusClass(item.status)}">${item.status}</span></td>
|
||||
</tr>
|
||||
<tr id="detail-${item.id}" class="detail-row">
|
||||
<td colspan="11">
|
||||
@@ -129,7 +134,6 @@ function renderInquiryList(data) {
|
||||
function enableEdit(id) {
|
||||
const form = document.getElementById(`reply-form-${id}`);
|
||||
form.classList.replace('readonly', 'editable');
|
||||
|
||||
const elements = [`reply-text-${id}`, `reply-status-${id}`, `reply-handler-${id}`];
|
||||
elements.forEach(elId => document.getElementById(elId).disabled = false);
|
||||
document.getElementById(`reply-text-${id}`).focus();
|
||||
@@ -137,61 +141,40 @@ function enableEdit(id) {
|
||||
|
||||
async function cancelEdit(id) {
|
||||
try {
|
||||
const response = await fetch(`/api/inquiries/${id}`);
|
||||
const response = await fetch(`${API.INQUIRIES}/${id}`);
|
||||
const item = await response.json();
|
||||
|
||||
const form = document.getElementById(`reply-form-${id}`);
|
||||
const txt = document.getElementById(`reply-text-${id}`);
|
||||
const status = document.getElementById(`reply-status-${id}`);
|
||||
const handler = document.getElementById(`reply-handler-${id}`);
|
||||
|
||||
txt.value = item.reply || '';
|
||||
status.value = item.status;
|
||||
handler.value = item.handler || '';
|
||||
|
||||
[txt, status, handler].forEach(el => el.disabled = true);
|
||||
form.classList.replace('editable', 'readonly');
|
||||
} catch (e) {
|
||||
loadInquiries();
|
||||
}
|
||||
document.getElementById(`reply-form-${id}`).classList.replace('editable', 'readonly');
|
||||
} catch { loadInquiries(); }
|
||||
}
|
||||
|
||||
async function saveReply(id) {
|
||||
const reply = document.getElementById(`reply-text-${id}`).value;
|
||||
const status = document.getElementById(`reply-status-${id}`).value;
|
||||
const handler = document.getElementById(`reply-handler-${id}`).value;
|
||||
|
||||
if (!reply.trim() || !handler.trim()) return alert("내용과 처리자를 모두 입력해 주세요.");
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/inquiries/${id}/reply`, {
|
||||
const response = await fetch(`${API.INQUIRIES}/${id}/reply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reply, status, handler })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert("저장되었습니다.");
|
||||
loadInquiries();
|
||||
}
|
||||
} catch (e) {
|
||||
alert("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
if ((await response.json()).success) { alert("저장되었습니다."); loadInquiries(); }
|
||||
} catch { alert("저장 중 오류가 발생했습니다."); }
|
||||
}
|
||||
|
||||
async function deleteReply(id) {
|
||||
if (!confirm("답변을 삭제하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/inquiries/${id}/reply`, { method: 'DELETE' });
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert("삭제되었습니다.");
|
||||
loadInquiries();
|
||||
}
|
||||
} catch (e) {
|
||||
alert("삭제 중 오류가 발생했습니다.");
|
||||
}
|
||||
const response = await fetch(`${API.INQUIRIES}/${id}/reply`, { method: 'DELETE' });
|
||||
if ((await response.json()).success) { alert("삭제되었습니다."); loadInquiries(); }
|
||||
} catch { alert("삭제 중 오류가 발생했습니다."); }
|
||||
}
|
||||
|
||||
function toggleAccordion(id) {
|
||||
@@ -226,11 +209,6 @@ function scrollToRow(row) {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function getStatusClass(status) {
|
||||
const map = { '완료': 'status-complete', '작업 중': 'status-working', '확인 중': 'status-checking' };
|
||||
return map[status] || 'status-pending';
|
||||
}
|
||||
|
||||
function updateStats(data) {
|
||||
const counts = {
|
||||
Total: data.length,
|
||||
@@ -247,16 +225,8 @@ function updateStats(data) {
|
||||
}
|
||||
|
||||
function openImageModal(src) {
|
||||
const modal = document.getElementById('imageModal');
|
||||
if (modal) {
|
||||
document.getElementById('modalImage').src = src;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function closeImageModal() {
|
||||
const modal = document.getElementById('imageModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
document.getElementById('modalImage').src = src;
|
||||
ModalManager.open('imageModal');
|
||||
}
|
||||
|
||||
function toggleImageSection(id) {
|
||||
@@ -268,11 +238,5 @@ function toggleImageSection(id) {
|
||||
icon.textContent = isCollapsed ? '▼' : '▲';
|
||||
}
|
||||
|
||||
// Global Initialization
|
||||
document.addEventListener('DOMContentLoaded', loadInquiries);
|
||||
window.addEventListener('resize', initStickyHeader);
|
||||
|
||||
// Global Key Events (ESC)
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeImageModal();
|
||||
});
|
||||
|
||||
405
js/mail.js
405
js/mail.js
@@ -1,82 +1,27 @@
|
||||
/**
|
||||
* Project Master Overseas Mail Management JS
|
||||
* 기능: 첨부파일 로드, AI 분석, 메일 목록 렌더링, 미리보기, 주소록 관리
|
||||
*/
|
||||
|
||||
let currentFiles = [];
|
||||
let editingIndex = -1;
|
||||
|
||||
const HIERARCHY = {
|
||||
"행정": {
|
||||
"계약": ["계약관리", "기성관리", "업무지시서", "인원관리"],
|
||||
"업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"]
|
||||
},
|
||||
"설계성과품": {
|
||||
"시방서": ["공사시방서", "장비 반입허가 검토서"],
|
||||
"설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"],
|
||||
"수량산출서": ["토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"],
|
||||
"내역서": ["단가산출서"],
|
||||
"보고서": ["실시설계보고서", "지반조사보고서", "구조계산서", "수리 및 전기계산서", "기타보고서", "기술자문 및 심의"],
|
||||
"측량계산부": ["측량계산부"],
|
||||
"설계단계 수행협의": ["회의·협의"]
|
||||
},
|
||||
"시공성과품": {
|
||||
"설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공", "교통안전시설공", "부대공", "용지공 & 기타공"]
|
||||
},
|
||||
"시공검측": {
|
||||
"토공": ["검측 (깨기)", "검측 (연약지반)", "검측 (발파)", "검측 (노체)", "검측 (노상)", "검측 (토취장)"],
|
||||
"배수공": ["검측 (V형측구)", "검측 (산마루측구)", "검측 (U형측구)", "검측 (U형측구)(안)", "검측 (L형측구, J형측구)", "검측 (도수로)", "검측 (도수로)(안)", "검측 (횡배수관)", "검측 (종배수관)", "검측 (맹암거)", "검측 (통로암거)", "검측 (수로암거)", "검측 (호안공)", "검측 (옹벽공)", "검측 (용수개거)"],
|
||||
"구조물공": ["검측 (평목교-거더, 부대공)", "검측 (평목교)(안)", "검측 (개착터널, 생태통로)"],
|
||||
"포장공": ["검측 (기층, 보조기층)"],
|
||||
"부대공": ["검측 (환경)", "검측 (지장가옥,건물 철거)", "검측 (방음벽 등)"],
|
||||
"비탈면안전공": ["검측 (식생보호공)", "검측 (구조물보호공)"],
|
||||
"교통안전시설공": ["검측 (낙석방지책)"],
|
||||
"검측 양식서류": ["검측 양식서류"]
|
||||
},
|
||||
"설계변경": {
|
||||
"실정보고(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물공", "포장공", "교통안전공", "부대공", "전기공사", "미확정공", "안전관리", "환경관리", "품질관리", "자재관리", "지장물", "기타"],
|
||||
"실정보고(대술~정안)": ["토공", "배수공", "비탈면안전공", "포장공", "부대공", "안전관리", "환경관리", "자재관리", "기타"],
|
||||
"기술지원 검토": ["토공", "배수공", "교량공(평목교)", "구조물&부대공", "기타"],
|
||||
"시공계획(어천~공주)": ["토공", "배수공", "교량공(평목교)", "구조물&부대&포장&교통안전공", "환경 및 품질관리"]
|
||||
},
|
||||
"공사관리": {
|
||||
"공정·일정": ["공정표", "월간 공정보고", "작업일보"],
|
||||
"품질 관리": ["품질시험계획서", "품질시험 실적보고", "콘크리트 타설현황[어천~공주(4차)]", "품질관리비 사용내역", "균열관리", "품질관리 양식서류"],
|
||||
"안전 관리": ["안전관리계획서", "안전관리 실적보고", "위험성 평가", "사전작업허가서", "안전관리비 사용내역", "안전관리수준평가", "안전관리 양식서류"],
|
||||
"환경 관리": ["환경영향평가", "사전재해영향성검토", "유지관리 및 보수점검", "환경보전비 사용내역", "건설폐기물 관리"],
|
||||
"자재 관리 (관급)": ["자재구매요청 (레미콘, 철근)", "자재구매요청 (그 외)", "납품기한", "계약 변경", "자재 반입·수불 관리", "자재관리 양식서류"],
|
||||
"자재 관리 (사급)": ["자재공급원 승인", "자재 반입·수불 관리", "자재 검수·확인"],
|
||||
"점검 (정리중)": ["내부점검", "외부점검"],
|
||||
"공문": ["접수(수신)", "발송(발신)", "하도급", "인력", "방침"]
|
||||
},
|
||||
"민원관리": {
|
||||
"민원(어천~공주)": ["처리대장", "보상", "공사일반", "환경분쟁"],
|
||||
"실정보고(어천~공주)": ["민원"],
|
||||
"실정보고(대술~정안)": ["민원"]
|
||||
}
|
||||
"행정": { "계약": ["계약관리", "기성관리", "업무지시서", "인원관리"], "업무관리": ["업무일지(2025)", "업무일지(2025년 이전)", "발주처 정기보고", "본사업무보고", "공사감독일지", "양식서류"] },
|
||||
"설계성과품": { "시방서": ["공사시방서"], "설계도면": ["공통", "토공", "비탈면안전공", "배수공", "교량공", "포장공"], "수량산출서": ["토공", "배수공"], "내역서": ["단가산출서"], "보고서": ["실시설계보고서", "지반조사보고서"], "측량계산부": ["측량계산부"], "설계단계 수행협의": ["회의·협의"] },
|
||||
"시공검측": { "토공": ["검측 (깨기)", "검측 (노체)"], "배수공": ["검측 (V형측구)", "검측 (종배수관)"], "구조물공": ["검측 (평목교)"], "포장공": ["검측 (기층)"] },
|
||||
"설계변경": { "실정보고": ["토공", "배수공", "안전관리"], "기술지원 검토": ["토공", "구조물&부대공"] }
|
||||
};
|
||||
|
||||
const MAIL_SAMPLES = {
|
||||
inbound: [
|
||||
{ person: "라오스 농림부", email: "pany.s@lao.gov.la", time: "2026-03-05", title: "ITTC 교육센터 착공식 일정 협의", summary: "안녕하세요. 착공식 관련하여 정부 측 인사의 일정을 반영한 최종 공문을 송부합니다. 원활한 진행을 위해 협조 부탁드립니다.", active: true },
|
||||
{ person: "베트남 전력청 (EVN)", email: "contact@evn.com.vn", time: "2026-03-05", title: "송전망 확충사업 기술자문 검토 요청", summary: "제8차 전력개발계획에 따른 북부 지역 계통 안정화 방안에 대한 귀사의 의견을 요청드립니다. 전문가의 식견이 필요합니다.", active: false },
|
||||
{ person: "현대건설 (김철수 소장)", email: "cs.kim@hdec.co.kr", time: "2026-03-04", title: "[긴급] 어천-공주(4차) 하도급 변경계약 통보", summary: "철거공사 물량 변동에 따른 계약 금액 조정 건입니다. 검토 후 승인 부탁드립니다. 기한이 촉박합니다.", active: false },
|
||||
{ person: "한국도로공사", email: "safety@ex.co.kr", time: "2026-02-24", title: "설 연휴 대비 현장 안전점검 실시 안내", summary: "전 현장을 대상으로 동절기 및 연휴 기간 화재 예방 및 비상연락망 점검을 실시할 예정입니다. 준비에 만전을 기해 주십시오.", active: false },
|
||||
{ person: "정안-대술 현장사무소", email: "office@jeongan.site", time: "2026-02-23", title: "지반조사보고서 및 구조계산서 송부", summary: "STA. 15+200 구간 암반 노출에 따른 설계 변경 기초 자료를 보내드립니다. 구조적 안전성 검토가 완료되었습니다.", active: false }
|
||||
{ person: "라오스 농림부", email: "pany.s@lao.gov.la", time: "2026-03-05", title: "ITTC 교육센터 착공식 일정 협의", summary: "착공식 관련하여 정부 측 인사의 일정을 반영한 최종 공문을 송부합니다.", active: true },
|
||||
{ person: "현대건설 (김철수 소장)", email: "cs.kim@hdec.co.kr", time: "2026-03-04", title: "[긴급] 어천-공주(4차) 하도급 변경계약 통보", summary: "철거공사 물량 변동에 따른 계약 금액 조정 건입니다. 검토 후 승인 부탁드립니다.", active: false }
|
||||
],
|
||||
outbound: [
|
||||
{ person: "Pany S. (라오스 농림부)", email: "pany.s@lao.gov.la", time: "2026-03-05", title: "Re: ITTC 교육센터 착공식 일정 확인", summary: "네, 보내주신 공문 확인하였습니다. 내부 검토 후 회신 드리겠습니다. 긍정적인 방향으로 검토 중입니다.", active: false },
|
||||
{ person: "김철수 소장 (현대건설)", email: "cs.kim@hdec.co.kr", time: "2026-03-05", title: "하도급 변경계약 검토 의견 회신", summary: "요청하신 물량 변동 내역 중 일부 항목에 대해 보완이 필요합니다. 상세 내역을 다시 정리해서 보내주세요.", active: false },
|
||||
{ person: "베트남 전력청 담당자", email: "admin@evn.com.vn", time: "2026-03-05", title: "[자료제공] 송전망 기술 규격서", summary: "요청하신 한국 내 송전망 설계 가이드라인 및 규격서를 송부합니다. 업무에 참고하시기 바랍니다.", active: false },
|
||||
{ person: "공사관리부 (본사)", email: "hq_pm@projectmaster.com", time: "2026-03-04", title: "어천-공주 2월 월간 공정보고서 제출", summary: "2월 한 달간의 주요 공정 및 예산 집행 현황 보고서입니다. 목표 대비 진척률은 95%입니다.", active: false },
|
||||
{ person: "안전관리팀", email: "safety_team@projectmaster.com", time: "2026-03-04", title: "현장 안전 점검 조치 결과 보고", summary: "지난 점검 시 지적된 5개 항목에 대한 시정 조치를 완료하였습니다. 증빙 사진을 첨부합니다.", active: false },
|
||||
{ person: "라오스 관개국", email: "irrigation@lao.gov.la", time: "2026-03-03", title: "교육생 명단 및 비자 발급 서류 요청", summary: "국내 초청 연수를 위한 교육생 여권 사본 및 비자 서류를 요청드립니다. 빠른 회신 부탁드립니다.", active: false },
|
||||
{ person: "동아지질 현장팀", email: "donga@site.com", time: "2026-03-02", title: "지반 개량 공법 검토안 발송", summary: "연약지반 구간에 대한 DCM 공법 적용 검토 자료입니다. 시공 효율성 향상이 기대됩니다.", active: false },
|
||||
{ person: "품질관리실", email: "quality@projectmaster.com", time: "2026-02-28", title: "레미콘 공급원 승인 서류 제출", summary: "어천 현장용 레미콘 공급업체(동양) 승인 요청 건입니다. 시험 성적서를 첨부하였습니다.", active: false },
|
||||
{ person: "민원인 (정안면)", email: "citizen@jeongan.net", time: "2026-02-27", title: "소음 분진 대책 수립 답변", summary: "민원 제기하신 구간에 대해 방진벽 추가 설치 및 살수차 증차를 결정하였습니다. 불편을 드려 죄송합니다.", active: false },
|
||||
{ person: "한국엔지니어링", email: "design@k-eng.com", time: "2026-02-26", title: "설계 도면 수정 사항 반영 요청", summary: "STA. 250~300 구간 옹벽 높이 변경 사항 반영 부탁드립니다. 배수 계획과 연동되어야 합니다.", active: false }
|
||||
{ person: "공사관리부 (본사)", email: "hq_pm@projectmaster.com", time: "2026-03-04", title: "어천-공주 2월 월간 공정보고서 제출", summary: "2월 한 달간의 주요 공정 및 예산 집행 현황 보고서입니다.", active: false }
|
||||
],
|
||||
drafts: [
|
||||
{ person: "(임시저장)", email: "unknown@draft.com", time: "2026-03-05", title: "기술 지원 검토 의견서 작성 중", summary: "검토 의견: 1. 교량 상부 공법 변경에 따른 구조 검토가 선행되어야 함. 2. 공기 단축 가능성 분석 중...", active: false }
|
||||
],
|
||||
deleted: [
|
||||
{ person: "스팸메일", email: "spam@ads.com", time: "2026-02-20", title: "[광고] 건설 장비 렌탈 특가", summary: "본 메일은 관련 법규에 의거하여 발송되었습니다. 무선 굴착기 및 지게차 렌탈 할인 혜택을 확인하세요.", active: false }
|
||||
]
|
||||
drafts: [], deleted: []
|
||||
};
|
||||
|
||||
let currentMailTab = 'inbound';
|
||||
@@ -85,12 +30,10 @@ let filteredMails = [];
|
||||
// --- 첨부파일 데이터 로드 및 렌더링 ---
|
||||
async function loadAttachments() {
|
||||
try {
|
||||
const res = await fetch('/attachments');
|
||||
const res = await fetch(API.ATTACHMENTS);
|
||||
currentFiles = await res.json();
|
||||
renderFiles();
|
||||
} catch (e) {
|
||||
console.error("Failed to load attachments:", e);
|
||||
}
|
||||
} catch (e) { console.error("Failed to load attachments:", e); }
|
||||
}
|
||||
|
||||
function renderFiles() {
|
||||
@@ -129,67 +72,54 @@ function renderFiles() {
|
||||
<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>
|
||||
<div id="log-area-${index}" class="file-log-area"><div id="log-content-${index}"></div></div>
|
||||
`;
|
||||
container.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// --- 미리보기 제어 ---
|
||||
function togglePreviewAuto() {
|
||||
const area = document.getElementById('mailPreviewArea');
|
||||
const icon = document.getElementById('previewToggleIcon');
|
||||
if (!area) return;
|
||||
|
||||
const isActive = area.classList.toggle('active');
|
||||
if (icon) icon.innerText = isActive ? '▶' : '◀';
|
||||
}
|
||||
|
||||
function showPreview(index, event) {
|
||||
// 버튼 클릭 시 미리보기 방지
|
||||
if (event && (event.target.closest('.btn-group') || event.target.closest('.path-display'))) return;
|
||||
|
||||
const file = currentFiles[index];
|
||||
if (!file) return;
|
||||
|
||||
const previewContainer = document.getElementById('previewContainer');
|
||||
const fullViewBtn = document.getElementById('fullViewBtn');
|
||||
const previewArea = document.getElementById('mailPreviewArea');
|
||||
const toggleIcon = document.getElementById('previewToggleIcon');
|
||||
const fullViewBtn = document.getElementById('fullViewBtn');
|
||||
const previewContainer = document.getElementById('previewContainer');
|
||||
|
||||
// UI 활성화
|
||||
if (previewArea) {
|
||||
previewArea.classList.add('active');
|
||||
if (toggleIcon) toggleIcon.innerText = '▶';
|
||||
}
|
||||
|
||||
// 파일 경로 및 유형 처리
|
||||
const isPdf = file.name.toLowerCase().endsWith('.pdf');
|
||||
const fileUrl = `/sample_files/${encodeURIComponent(file.name)}`;
|
||||
|
||||
const fileUrl = Utils.getSafeFileUrl(file.name);
|
||||
if (fullViewBtn) {
|
||||
fullViewBtn.style.display = 'block';
|
||||
fullViewBtn.onclick = () => window.open(fileUrl, 'PMFullView', 'width=1000,height=800');
|
||||
}
|
||||
|
||||
if (isPdf) {
|
||||
if (file.name.toLowerCase().endsWith('.pdf')) {
|
||||
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" style="max-width:80%; max-height:60%; margin-bottom:20px;">
|
||||
<div style="font-weight:700; color:var(--primary-color);">${file.name}</div>
|
||||
</div>`;
|
||||
previewContainer.innerHTML = `<div class="flex-column flex-center" style="height:100%; padding:20px; text-align:center;"><img src="/sample.png" style="max-width:80%; max-height:60%; margin-bottom:20px;"><div style="font-weight:700; color:var(--primary-color);">${file.name}</div></div>`;
|
||||
}
|
||||
|
||||
// 아이템 활성화 스타일
|
||||
document.querySelectorAll('.attachment-item').forEach(item => item.classList.remove('active'));
|
||||
if (event && event.currentTarget) event.currentTarget.classList.add('active');
|
||||
if (event?.currentTarget) event.currentTarget.classList.add('active');
|
||||
}
|
||||
|
||||
// --- 메일 리스트 관리 ---
|
||||
function togglePreviewAuto() {
|
||||
const area = document.getElementById('mailPreviewArea');
|
||||
const icon = document.getElementById('previewToggleIcon');
|
||||
if (area) {
|
||||
const isActive = area.classList.toggle('active');
|
||||
if (icon) icon.innerText = isActive ? '▶' : '◀';
|
||||
}
|
||||
}
|
||||
|
||||
// --- 메일 리스트 제어 ---
|
||||
function renderMailList(tabType, mailsToShow = null) {
|
||||
currentMailTab = tabType;
|
||||
const container = document.querySelector('.mail-items-container');
|
||||
@@ -204,18 +134,11 @@ function renderMailList(tabType, mailsToShow = null) {
|
||||
<input type="checkbox" class="mail-item-checkbox" onclick="event.stopPropagation()" onchange="updateBulkActionBar()">
|
||||
<div class="mail-item-content">
|
||||
<div class="flex-between" style="margin-bottom:6px;">
|
||||
<span style="font-weight:700; font-size:14px; color:${mail.active ? 'var(--primary-color)' : 'var(--text-main)'};">
|
||||
${mail.person}
|
||||
</span>
|
||||
<div class="mail-item-info">
|
||||
<span class="mail-date">${mail.time}</span>
|
||||
${tabType !== 'deleted' ? `<button class="btn-mail-delete" onclick="deleteSingleMail(event, '${tabType}', ${idx})">삭제</button>` : ''}
|
||||
</div>
|
||||
<span style="font-weight:700; font-size:14px; color:${mail.active ? 'var(--primary-color)' : 'var(--text-main)'};">${mail.person}</span>
|
||||
<div class="mail-item-info"><span class="mail-date">${mail.time}</span></div>
|
||||
</div>
|
||||
<div style="font-weight:600; font-size:12px; margin-bottom:4px; color:#2d3748;">${mail.title}</div>
|
||||
<div style="font-size:11px; color:var(--text-sub); display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; line-height:1.4;">
|
||||
${mail.summary}
|
||||
</div>
|
||||
<div class="text-truncate" style="-webkit-line-clamp:2; display:-webkit-box; -webkit-box-orient:vertical; white-space:normal; font-size:11px; color:var(--text-sub);">${mail.summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
@@ -225,255 +148,73 @@ function renderMailList(tabType, mailsToShow = null) {
|
||||
}
|
||||
|
||||
function selectMailItem(el, index) {
|
||||
document.querySelectorAll('.mail-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
const nameSpan = item.querySelector('.mail-item-content span:first-child');
|
||||
if (nameSpan) nameSpan.style.color = 'var(--text-main)';
|
||||
});
|
||||
document.querySelectorAll('.mail-item').forEach(item => item.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
const currentNameSpan = el.querySelector('.mail-item-content span:first-child');
|
||||
if (currentNameSpan) currentNameSpan.style.color = 'var(--primary-color)';
|
||||
|
||||
const mail = filteredMails[index];
|
||||
if (mail) updateMailContent(mail);
|
||||
}
|
||||
|
||||
function updateMailContent(mail) {
|
||||
const headerTitle = document.querySelector('.mail-content-header h2');
|
||||
const senderInfo = document.querySelectorAll('.mail-content-header div')[0];
|
||||
const dateInfo = document.querySelectorAll('.mail-content-header div')[1];
|
||||
const bodyInfo = document.querySelector('.mail-body');
|
||||
|
||||
if (headerTitle) headerTitle.innerText = mail.title;
|
||||
if (senderInfo) senderInfo.innerHTML = `<strong>${currentMailTab === 'outbound' ? '받는사람' : '보낸사람'}</strong> ${mail.email} (${mail.person})`;
|
||||
if (dateInfo) dateInfo.innerHTML = `<strong>날짜</strong> ${mail.time}`;
|
||||
if (bodyInfo) bodyInfo.innerHTML = mail.summary.replace(/\n/g, '<br>') + "<br><br>본 내용은 샘플 데이터입니다.";
|
||||
const title = document.querySelector('.mail-content-header h2');
|
||||
if (title) title.innerText = mail.title;
|
||||
document.querySelector('.mail-body').innerHTML = mail.summary.replace(/\n/g, '<br>') + "<br><br>본 내용은 샘플 데이터입니다.";
|
||||
}
|
||||
|
||||
function switchMailTab(el, tabType) {
|
||||
document.querySelectorAll('.mail-tab').forEach(tab => tab.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
MAIL_SAMPLES[tabType].forEach((m, idx) => m.active = (idx === 0));
|
||||
renderMailList(tabType);
|
||||
}
|
||||
|
||||
// --- 검색 및 필터 ---
|
||||
function searchMails() {
|
||||
const query = document.querySelector('.search-bar input[type="text"]').value.toLowerCase();
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
|
||||
const results = (MAIL_SAMPLES[currentMailTab] || []).filter(mail => {
|
||||
const matchQuery = mail.title.toLowerCase().includes(query) ||
|
||||
mail.person.toLowerCase().includes(query) ||
|
||||
mail.summary.toLowerCase().includes(query);
|
||||
let matchDate = true;
|
||||
if (startDate) matchDate = matchDate && (mail.time >= startDate);
|
||||
if (endDate) matchDate = matchDate && (mail.time <= endDate);
|
||||
return matchQuery && matchDate;
|
||||
});
|
||||
renderMailList(currentMailTab, results);
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
document.querySelector('.search-bar input[type="text"]').value = '';
|
||||
document.getElementById('startDate').value = '';
|
||||
document.getElementById('endDate').value = '';
|
||||
renderMailList(currentMailTab);
|
||||
}
|
||||
|
||||
// --- 액션바 관리 ---
|
||||
function updateBulkActionBar() {
|
||||
const checkboxes = document.querySelectorAll('.mail-item-checkbox:checked');
|
||||
const actionBar = document.getElementById('mailBulkActions');
|
||||
const selectedCountSpan = document.getElementById('selectedCount');
|
||||
if (!actionBar || !selectedCountSpan) return;
|
||||
|
||||
if (checkboxes.length > 0) {
|
||||
actionBar.classList.add('active');
|
||||
selectedCountSpan.innerText = `${checkboxes.length}개 선택됨`;
|
||||
} else {
|
||||
actionBar.classList.remove('active');
|
||||
const selectAll = document.getElementById('selectAllMails');
|
||||
if (selectAll) selectAll.checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll(el) {
|
||||
document.querySelectorAll('.mail-item-checkbox').forEach(cb => cb.checked = el.checked);
|
||||
updateBulkActionBar();
|
||||
}
|
||||
|
||||
function deleteSingleMail(event, tabType, index) {
|
||||
if (event) event.stopPropagation();
|
||||
if (!confirm("이 메일을 삭제하시겠습니까?")) return;
|
||||
const mail = MAIL_SAMPLES[tabType].splice(index, 1)[0];
|
||||
MAIL_SAMPLES.deleted.unshift(mail);
|
||||
renderMailList(tabType);
|
||||
}
|
||||
|
||||
function deleteSelectedMails() {
|
||||
const checkboxes = document.querySelectorAll('.mail-item-checkbox');
|
||||
const selectedIndices = [];
|
||||
checkboxes.forEach((cb, idx) => { if (cb.checked) selectedIndices.push(idx); });
|
||||
if (selectedIndices.length === 0) return;
|
||||
if (!confirm(`${selectedIndices.length}개의 메일을 삭제하시겠습니까?`)) return;
|
||||
const tabMails = MAIL_SAMPLES[currentMailTab];
|
||||
selectedIndices.sort((a, b) => b - a).forEach(idx => {
|
||||
const mail = tabMails.splice(idx, 1)[0];
|
||||
MAIL_SAMPLES.deleted.unshift(mail);
|
||||
});
|
||||
renderMailList(currentMailTab);
|
||||
}
|
||||
|
||||
// --- 경로 선택 모달 ---
|
||||
function openPathModal(index, event) {
|
||||
if (event) event.stopPropagation();
|
||||
editingIndex = index;
|
||||
const modal = document.getElementById('pathModal');
|
||||
const tabSelect = document.getElementById('tabSelect');
|
||||
if (!modal || !tabSelect) return;
|
||||
|
||||
tabSelect.innerHTML = Object.keys(HIERARCHY).map(tab => `<option value="${tab}">${tab}</option>`).join('');
|
||||
updateCategories();
|
||||
modal.style.display = 'flex';
|
||||
if (tabSelect) {
|
||||
tabSelect.innerHTML = Object.keys(HIERARCHY).map(tab => `<option value="${tab}">${tab}</option>`).join('');
|
||||
updateCategories();
|
||||
ModalManager.open('pathModal');
|
||||
}
|
||||
}
|
||||
|
||||
function updateCategories() {
|
||||
const tab = document.getElementById('tabSelect').value;
|
||||
const catSelect = document.getElementById('categorySelect');
|
||||
if (!catSelect) return;
|
||||
catSelect.innerHTML = Object.keys(HIERARCHY[tab]).map(cat => `<option value="${cat}">${cat}</option>`).join('');
|
||||
document.getElementById('categorySelect').innerHTML = Object.keys(HIERARCHY[tab]).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');
|
||||
if (!subSelect) return;
|
||||
subSelect.innerHTML = HIERARCHY[tab][cat].map(sub => `<option value="${sub}">${sub}</option>`).join('');
|
||||
document.getElementById('subSelect').innerHTML = HIERARCHY[tab][cat].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}`;
|
||||
|
||||
const path = `${document.getElementById('tabSelect').value} > ${document.getElementById('categorySelect').value} > ${document.getElementById('subSelect').value}`;
|
||||
if (!currentFiles[editingIndex].analysis) currentFiles[editingIndex].analysis = {};
|
||||
currentFiles[editingIndex].analysis.suggested_path = fullPath;
|
||||
currentFiles[editingIndex].analysis.suggested_path = path;
|
||||
currentFiles[editingIndex].analysis.isManual = true;
|
||||
renderFiles();
|
||||
closeModal();
|
||||
ModalManager.close('pathModal');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const modal = document.getElementById('pathModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
// --- AI 분석 및 업로드 ---
|
||||
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">>>> AI 분석 엔진 가동 중...</div>';
|
||||
recLabel.innerText = '분석 중...';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/analyze-file?filename=${encodeURIComponent(file.name)}`);
|
||||
const analysis = await res.json();
|
||||
const result = analysis.final_result;
|
||||
|
||||
logContent.innerHTML = `
|
||||
<div class="log-line">1. 파일 포맷 분석: ${file.name.split('.').pop().toUpperCase()} 감지</div>
|
||||
<div class="log-line">2. 페이지 스캔: 총 ${analysis.total_pages}페이지 분석 완료</div>
|
||||
<div class="log-line">3. 문맥 추론: ${result.reason}</div>
|
||||
`;
|
||||
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; }
|
||||
if (confirm(`업로드하시겠습니까?\n위치: ${file.analysis.suggested_path}`)) 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" }
|
||||
];
|
||||
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'; }
|
||||
}
|
||||
|
||||
function toggleAddContactForm() {
|
||||
const form = document.getElementById('addContactForm');
|
||||
if (!form) return;
|
||||
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];
|
||||
if (!contact) return;
|
||||
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 (!addressBookData[index]) return;
|
||||
if (confirm(`'${addressBookData[index].name}'님을 주소록에서 삭제하시겠습니까?`)) {
|
||||
addressBookData.splice(index, 1);
|
||||
renderAddressBook();
|
||||
}
|
||||
}
|
||||
function openAddressBook() { renderAddressBook(); ModalManager.open('addressBookModal'); }
|
||||
function closeAddressBook() { ModalManager.close('addressBookModal'); }
|
||||
|
||||
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><strong>${c.name}</strong></td><td>${c.dept}</td><td>${c.email}</td><td>${c.phone}</td>
|
||||
<td style="text-align:right;">
|
||||
<button class="_button-xsmall" onclick="editContact(${idx})">수정</button>
|
||||
<button class="_button-xsmall" style="color:var(--error-color); border-color:#feb2b2; background:#fff5f5;" onclick="deleteContact(${idx})">삭제</button>
|
||||
@@ -481,19 +222,41 @@ function renderAddressBook() {
|
||||
</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; }
|
||||
}
|
||||
|
||||
function editContact(index) {
|
||||
const c = addressBookData[index];
|
||||
contactEditingIndex = index;
|
||||
document.getElementById('newContactName').value = c.name;
|
||||
document.getElementById('newContactDept').value = c.dept;
|
||||
document.getElementById('newContactEmail').value = c.email;
|
||||
document.getElementById('newContactPhone').value = c.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) return alert("이름을 입력해주세요.");
|
||||
const data = { name, dept: document.getElementById('newContactDept').value, email: document.getElementById('newContactEmail').value, phone: document.getElementById('newContactPhone').value };
|
||||
if (contactEditingIndex > -1) addressBookData[contactEditingIndex] = data;
|
||||
else addressBookData.push(data);
|
||||
renderAddressBook(); toggleAddContactForm();
|
||||
}
|
||||
|
||||
if (contactEditingIndex > -1) addressBookData[contactEditingIndex] = { name, dept, email, phone };
|
||||
else addressBookData.push({ name, dept, email, phone });
|
||||
|
||||
renderAddressBook();
|
||||
document.getElementById('addContactForm').style.display = 'none';
|
||||
contactEditingIndex = -1;
|
||||
// --- 공통 액션 ---
|
||||
function updateBulkActionBar() {
|
||||
const count = document.querySelectorAll('.mail-item-checkbox:checked').length;
|
||||
const bar = document.getElementById('mailBulkActions');
|
||||
if (count > 0) { bar.classList.add('active'); document.getElementById('selectedCount').innerText = `${count}개 선택됨`; }
|
||||
else bar.classList.remove('active');
|
||||
}
|
||||
|
||||
// 초기화
|
||||
|
||||
@@ -61,10 +61,13 @@ input, select, textarea, button { font-family: inherit; }
|
||||
a { text-decoration: none; color: inherit; }
|
||||
button { cursor: pointer; border: none; transition: all 0.2s ease; }
|
||||
|
||||
/* Utilities */
|
||||
/* Utilities: Layout & Text */
|
||||
.flex-center { display: flex; align-items: center; justify-content: center; }
|
||||
.flex-between { display: flex; align-items: center; justify-content: space-between; }
|
||||
.flex-column { display: flex; flex-direction: column; }
|
||||
.text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.w-full { width: 100%; }
|
||||
.pointer { cursor: pointer; }
|
||||
|
||||
/* Components: Topbar */
|
||||
.topbar {
|
||||
@@ -103,19 +106,20 @@ button { cursor: pointer; border: none; transition: all 0.2s ease; }
|
||||
margin-bottom: 20px; border-bottom: 1px solid var(--border-color); padding-bottom: 12px;
|
||||
}
|
||||
.modal-header h3 { margin: 0; font-size: 16px; color: var(--primary-color); font-weight: 700; }
|
||||
.modal-close { cursor: pointer; font-size: 24px; color: var(--text-sub); line-height: 1; }
|
||||
.modal-close { cursor: pointer; font-size: 24px; color: var(--text-sub); line-height: 1; transition: 0.2s; }
|
||||
.modal-close:hover { color: var(--text-main); }
|
||||
|
||||
/* Components: Data Tables */
|
||||
.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 { width: 100%; border-collapse: collapse; font-size: 12px; background: #fff; }
|
||||
.data-table th, .data-table td { padding: 12px 10px; border-bottom: 1px solid var(--border-color); text-align: left; }
|
||||
.data-table th { color: var(--text-sub); font-weight: 700; background: var(--bg-muted); font-size: 11px; text-transform: uppercase; }
|
||||
.data-table tr:hover { background: var(--hover-bg); }
|
||||
|
||||
/* Components: Buttons (Unified) */
|
||||
/* Components: Standard Buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
padding: 8px 16px; border-radius: var(--radius-lg); font-weight: 600; font-size: 13px;
|
||||
border: none; cursor: pointer; transition: all 0.2s ease;
|
||||
}
|
||||
.btn-primary { background: var(--primary-color); color: #fff; }
|
||||
.btn-primary:hover { background: var(--primary-hover); transform: translateY(-1px); }
|
||||
@@ -124,7 +128,7 @@ button { cursor: pointer; border: none; transition: all 0.2s ease; }
|
||||
.btn-danger { background: #fee2e2; color: #dc2626; }
|
||||
.btn-danger:hover { background: #fecaca; }
|
||||
|
||||
/* Existing Utils - Compatibility */
|
||||
/* Compatibility Utils */
|
||||
._button-xsmall {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 4px 10px; font-size: 11px; font-weight: 600; border-radius: 4px; border: 1px solid var(--border-color);
|
||||
@@ -142,17 +146,17 @@ button { cursor: pointer; border: none; transition: all 0.2s ease; }
|
||||
}
|
||||
.sync-btn { background: var(--primary-color); color: #fff; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; }
|
||||
|
||||
/* Badges */
|
||||
.badge { padding: 2px 8px; border-radius: 20px; font-size: 11px; font-weight: 700; display: inline-block; background: #eee; }
|
||||
|
||||
/* Status Colors (Common) */
|
||||
.bg-success { background: #e8f5e9; color: #2e7d32; }
|
||||
.bg-warning { background: #fff8e1; color: #FFBF00; }
|
||||
.bg-danger { background: #ffebee; color: #dc2626; }
|
||||
.bg-info { background: #e3f2fd; color: #1565c0; }
|
||||
/* Badges & Status Colors */
|
||||
.badge {
|
||||
padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 700;
|
||||
display: inline-block; background: var(--primary-lv-1); color: var(--primary-color);
|
||||
}
|
||||
|
||||
.status-complete { background: #e8f5e9; color: #2e7d32; }
|
||||
.status-working { background: #fff8e1; color: #FFBF00; }
|
||||
.status-checking { background: #e3f2fd; color: #1565c0; }
|
||||
.status-pending { background: #f5f5f5; color: #757575; }
|
||||
.status-error { background: #fee9e7; }
|
||||
.status-warning { background: #fff9e6; }
|
||||
|
||||
.warning-text { color: #FFBF00; font-weight: 600; }
|
||||
.error-text { color: #F21D0D !important; font-weight: 700; }
|
||||
@@ -163,21 +167,3 @@ button { cursor: pointer; border: none; transition: all 0.2s ease; }
|
||||
border-radius: 50%; border-top-color: #fff; animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Modals (Refined) */
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px);
|
||||
z-index: 3000; justify-content: center; align-items: center;
|
||||
}
|
||||
.modal-content {
|
||||
background: white; padding: 24px; border-radius: var(--radius-xl);
|
||||
width: 90%; max-width: 500px; box-shadow: var(--box-shadow-modal);
|
||||
}
|
||||
.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; color: var(--primary-color); font-weight: 700; }
|
||||
.modal-close { cursor: pointer; font-size: 24px; color: var(--text-sub); line-height: 1; transition: 0.2s; }
|
||||
.modal-close:hover { color: var(--text-main); }
|
||||
|
||||
@@ -81,17 +81,17 @@
|
||||
<div id="authErrorMessage" class="error-text" style="display:none; color: var(--error-color); font-size: 12px; font-weight: 600; text-align: center; margin-top: -10px;">크롤링을 할 수 없습니다.</div>
|
||||
</div>
|
||||
<div class="auth-footer" style="display: grid; grid-template-columns: 1fr 1.5fr; gap: 12px;">
|
||||
<button class="btn btn-secondary" style="height: 48px;" onclick="closeAuthModal()">취소</button>
|
||||
<button class="btn btn-secondary" style="height: 48px;" onclick="ModalManager.close('authModal')">취소</button>
|
||||
<button class="btn btn-primary" style="height: 48px;" onclick="submitAuth()">인증 및 실행</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="activityDetailModal" class="modal-overlay" onclick="closeActivityModal()">
|
||||
<div id="activityDetailModal" class="modal-overlay" onclick="ModalManager.close('activityDetailModal')">
|
||||
<div class="modal-content" style="max-width: 600px; padding: 0; overflow: hidden;" onclick="event.stopPropagation()">
|
||||
<div class="modal-header" style="padding: 20px; margin-bottom: 0;">
|
||||
<h3 id="modalTitle">상세 목록</h3>
|
||||
<span class="modal-close" onclick="closeActivityModal()">×</span>
|
||||
<span class="modal-close" onclick="ModalManager.close('activityDetailModal')">×</span>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 20px; max-height: 70vh; overflow-y: auto;">
|
||||
<table class="data-table">
|
||||
@@ -108,7 +108,7 @@
|
||||
</table>
|
||||
</div>
|
||||
<div style="padding: 16px 20px; border-top: 1px solid var(--border-color); text-align: right; background: #fdfdfd;">
|
||||
<button class="btn btn-secondary" onclick="closeActivityModal()">닫기</button>
|
||||
<button class="btn btn-secondary" onclick="ModalManager.close('activityDetailModal')">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -143,9 +143,9 @@
|
||||
<th width="150">구분</th>
|
||||
<th>프로젝트</th>
|
||||
<th width="400">문의내용</th>
|
||||
<th width="400">답변내용</th>
|
||||
<th width="100">작성자</th>
|
||||
<th width="120">날짜</th>
|
||||
<th width="400">답변내용</th>
|
||||
<th width="100">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -156,11 +156,11 @@
|
||||
</main>
|
||||
|
||||
<!-- 이미지 크게 보기 모달 (디자인 가이드 - 화이트 계열 및 우측 하단 닫기 적용) -->
|
||||
<div id="imageModal" class="modal-overlay" onclick="closeImageModal()">
|
||||
<div id="imageModal" class="modal-overlay" onclick="ModalManager.close('imageModal')">
|
||||
<div class="modal-content" style="max-width: 960px; width: 92%; padding: 0; overflow: hidden; border-radius: 12px; border: 1px solid #e2e8f0; background: #fff;" onclick="event.stopPropagation()">
|
||||
<div class="modal-header" style="padding: 16px 24px; margin-bottom: 0; border-bottom: 1px solid #f1f5f9; background: #fff;">
|
||||
<h3 style="color: #1e5149; font-weight: 700; font-size: 16px;">첨부 이미지 확대 보기</h3>
|
||||
<span class="modal-close" onclick="closeImageModal()" style="font-size: 24px; color: #94a3b8;">×</span>
|
||||
<span class="modal-close" onclick="ModalManager.close('imageModal')" style="font-size: 24px; color: #94a3b8;">×</span>
|
||||
</div>
|
||||
<div style="padding: 32px; background: #fff; display: flex; justify-content: center; align-items: center; min-height: 300px; max-height: 75vh; overflow: auto;">
|
||||
<img id="modalImage" style="max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 10px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1);">
|
||||
@@ -168,7 +168,7 @@
|
||||
<div style="padding: 16px 24px; border-top: 1px solid #f1f5f9; text-align: right; background: #fff;">
|
||||
<button class="_button-medium" style="background: #1e5149; color: #fff; border: none; padding: 10px 28px; border-radius: 8px; cursor: pointer; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#163b36'" onmouseout="this.style.background='#1e5149'"
|
||||
onclick="closeImageModal()">닫기</button>
|
||||
onclick="ModalManager.close('imageModal')">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<!-- 주소록 모달 (공통 규격 적용) -->
|
||||
<div id="addressBookModal" class="modal-overlay" onclick="closeAddressBook()">
|
||||
<div id="addressBookModal" class="modal-overlay" onclick="ModalManager.close('addressBookModal')">
|
||||
<div class="modal-content" style="max-width: 850px;" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h3>공사 관계자 주소록</h3>
|
||||
<div class="flex-center" style="gap:10px;">
|
||||
<button class="btn btn-primary" onclick="toggleAddContactForm()">+ 추가하기</button>
|
||||
<span class="modal-close" onclick="closeAddressBook()">×</span>
|
||||
<span class="modal-close" onclick="ModalManager.close('addressBookModal')">×</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top:20px; text-align:right;">
|
||||
<button class="btn btn-secondary" style="padding: 8px 32px;" onclick="closeAddressBook()">닫기</button>
|
||||
<button class="btn btn-secondary" style="padding: 8px 32px;" onclick="ModalManager.close('addressBookModal')">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- 경로 선택 모달 (공통 규격 적용) -->
|
||||
<div id="pathModal" class="modal-overlay" onclick="closeModal()">
|
||||
<div id="pathModal" class="modal-overlay" onclick="ModalManager.close('pathModal')">
|
||||
<div class="modal-content" style="max-width: 500px;" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h3>파일 보관 경로 선택</h3>
|
||||
<span class="modal-close" onclick="closeModal()">×</span>
|
||||
<span class="modal-close" onclick="ModalManager.close('pathModal')">×</span>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 16px; margin-bottom: 24px;">
|
||||
<div class="select-group" style="margin-bottom: 0;">
|
||||
|
||||
Reference in New Issue
Block a user