From 840e7dab34a279629556a495c35d0ac8e3a3a4c5 Mon Sep 17 00:00:00 2001 From: Taehoon Date: Tue, 17 Mar 2026 17:49:17 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EC=A0=84=EB=B0=98=20=EC=BD=94=EB=93=9C=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81=20=EB=B0=8F=20=EB=AC=B8=EC=9D=98=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- js/common.js | 77 +++++- js/dashboard.js | 63 ++--- js/inquiries.js | 74 ++--- js/mail.js | 405 ++++++---------------------- style/common.css | 54 ++-- templates/dashboard.html | 8 +- templates/inquiries.html | 8 +- templates/modals/address_book.html | 6 +- templates/modals/path_selector.html | 4 +- 9 files changed, 221 insertions(+), 478 deletions(-) diff --git a/js/common.js b/js/common.js index accf091..6be7296 100644 --- a/js/common.js +++ b/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."); }); diff --git a/js/dashboard.js b/js/dashboard.js index ce399a1..726802a 100644 --- a/js/dashboard.js +++ b/js/dashboard.js @@ -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) {

참여 인원 상세

- +
이름소속권한
${admin}${dept}관리자

최근 활동

- +
유형내용일시
로그동기화 완료${logTime}
@@ -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 `${p.name}${o ? o[1] : "-"}${o ? o[2] : "-"}`; }).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 = ` 크롤링 중단`; logC.style.display = 'block'; logB.innerHTML = '
>>> 엔진 초기화 중...
'; 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; diff --git a/js/inquiries.js b/js/inquiries.js index f1e299e..e615c37 100644 --- a/js/inquiries.js +++ b/js/inquiries.js @@ -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) { ${item.category} ${item.project_nm} ${item.content} - ${item.reply || '-'} ${item.author} ${item.reg_date} - ${item.status} + ${item.reply || '-'} + ${item.status} @@ -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(); -}); diff --git a/js/mail.js b/js/mail.js index 55fae3f..81cdc49 100644 --- a/js/mail.js +++ b/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() {
-
-
-
+
`; 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 = ``; } else { - previewContainer.innerHTML = ` -
- -
${file.name}
-
`; + previewContainer.innerHTML = `
${file.name}
`; } - // 아이템 활성화 스타일 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) {
- - ${mail.person} - -
- ${mail.time} - ${tabType !== 'deleted' ? `` : ''} -
+ ${mail.person} +
${mail.time}
${mail.title}
-
- ${mail.summary} -
+
${mail.summary}
`).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 = `${currentMailTab === 'outbound' ? '받는사람' : '보낸사람'} ${mail.email} (${mail.person})`; - if (dateInfo) dateInfo.innerHTML = `날짜 ${mail.time}`; - if (bodyInfo) bodyInfo.innerHTML = mail.summary.replace(/\n/g, '
') + "

본 내용은 샘플 데이터입니다."; + const title = document.querySelector('.mail-content-header h2'); + if (title) title.innerText = mail.title; + document.querySelector('.mail-body').innerHTML = mail.summary.replace(/\n/g, '
') + "

본 내용은 샘플 데이터입니다."; } 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 => ``).join(''); - updateCategories(); - modal.style.display = 'flex'; + if (tabSelect) { + tabSelect.innerHTML = Object.keys(HIERARCHY).map(tab => ``).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 => ``).join(''); + document.getElementById('categorySelect').innerHTML = Object.keys(HIERARCHY[tab]).map(cat => ``).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 => ``).join(''); + document.getElementById('subSelect').innerHTML = HIERARCHY[tab][cat].map(sub => ``).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 = '
>>> AI 분석 엔진 가동 중...
'; - 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 = ` -
1. 파일 포맷 분석: ${file.name.split('.').pop().toUpperCase()} 감지
-
2. 페이지 스캔: 총 ${analysis.total_pages}페이지 분석 완료
-
3. 문맥 추론: ${result.reason}
- `; - currentFiles[index].analysis = { suggested_path: result.suggested_path, isManual: false }; - renderFiles(); - } catch (e) { - logContent.innerHTML += `
ERR: ${e.message}
`; - 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) => ` - ${c.name} - ${c.dept} - ${c.email} - ${c.phone} + ${c.name}${c.dept}${c.email}${c.phone} @@ -481,19 +222,41 @@ function renderAddressBook() { `).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'); } // 초기화 diff --git a/style/common.css b/style/common.css index 79ae735..9bbf6a0 100644 --- a/style/common.css +++ b/style/common.css @@ -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); } diff --git a/templates/dashboard.html b/templates/dashboard.html index ac3ed67..34300be 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -81,17 +81,17 @@ -