diff --git a/js/dashboard.js b/js/dashboard.js index a0b29a7..7d43ba6 100644 --- a/js/dashboard.js +++ b/js/dashboard.js @@ -70,7 +70,7 @@ async function loadActivityAnalysis(date = "") {
주의 (14일 이내)
${summary.warning}
-
방치 (14일 초과)
${summary.stale}
+
방치 (14일 초과 / 폴더자동삭제)
${summary.stale}
데이터 없음 (파일 0개)
${summary.unknown}
@@ -114,12 +114,20 @@ function createProjectHtml(p) { const recentLog = (!logRaw || logRaw === "X" || logRaw === "데이터 없음") ? "기록 없음" : logRaw; const logTime = recentLog !== "기록 없음" ? recentLog.split(',')[0] : "기록 없음"; - // 파일이 0개 또는 NULL인 경우에만 에러(붉은색) 표시 - const statusClass = (files === 0 || files === null) ? "status-error" : ""; + // '폴더 자동 삭제' 여부 확인 + const isStaleLog = recentLog.replace(/\s/g, "").includes("폴더자동삭제"); + // 파일이 0개 또는 NULL인 경우에만 행 전체 에러(붉은색) 표시 + const isNoFiles = (files === 0 || files === null); + const statusClass = isNoFiles ? "status-error" : ""; + + // 로그 텍스트 스타일 결정 (기록 없음 이거나 폴더자동삭제인 경우) + const logStyleClass = (recentLog === '기록 없음' || isStaleLog) ? 'warning-text' : ''; + const logBoldStyle = isStaleLog ? 'font-weight: 800;' : ''; + return `
-
${name}
${dept}
${admin}
${files || 0}
${recentLog}
+
${name}
${dept}
${admin}
${files || 0}
${recentLog}
diff --git a/js/inquiries.js b/js/inquiries.js new file mode 100644 index 0000000..228ea56 --- /dev/null +++ b/js/inquiries.js @@ -0,0 +1,230 @@ + +async function loadInquiries() { + // Adjust sticky thead position based on header height + const header = document.getElementById('stickyHeader'); + const thead = document.querySelector('.inquiry-table thead'); + if (header && thead) { + const headerHeight = header.offsetHeight; + const totalOffset = 36 + headerHeight; // topbar(36) + sticky header height + document.querySelectorAll('.inquiry-table thead th').forEach(th => { + th.style.top = totalOffset + 'px'; + }); + } + + const pmType = document.getElementById('filterPmType').value; + const category = document.getElementById('filterCategory').value; + const status = document.getElementById('filterStatus').value; + const keyword = document.getElementById('searchKeyword').value; + + const params = new URLSearchParams({ + pm_type: pmType, + category: category, + status: status, + keyword: keyword + }); + const response = await fetch(`/api/inquiries?${params}`); + const data = await response.json(); + + const tbody = document.getElementById('inquiryList'); + tbody.innerHTML = data.map(item => ` + + ${item.no} + ${item.pm_type} + ${item.browser || 'Chrome'} + ${item.category} + ${item.project_nm} + ${item.content} + ${item.reply || '-'} + ${item.author} + ${item.reg_date} + ${item.status} + + + +
+ +
+
+
작성자: ${item.author}
+
등록일: ${item.reg_date}
+
시스템: ${item.pm_type}
+
환경: ${item.browser || 'Chrome'} / ${item.device || 'PC'}
+
+
+

[질문 내용]

+
${item.content}
+
+
+

[조치 및 답변]

+
+ +
+
+
+ + +
+
+ + +
+
+
+ + + + +
+
+ ${item.handled_date ? `
최종 수정일: ${item.handled_date}
` : ''} +
+
+
+
+ + + `).join(''); +} + +function enableEdit(id) { + const form = document.getElementById(`reply-form-${id}`); + form.classList.remove('readonly'); + form.classList.add('editable'); + + document.getElementById(`reply-text-${id}`).disabled = false; + document.getElementById(`reply-status-${id}`).disabled = false; + document.getElementById(`reply-handler-${id}`).disabled = false; + document.getElementById(`reply-text-${id}`).focus(); +} + +async function cancelEdit(id) { + try { + // 서버에서 해당 항목의 원래 데이터를 다시 가져옴 + 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 || ''; + + // UI 상태 원복 (비활성화 및 클래스 변경) + txt.disabled = true; + status.disabled = true; + handler.disabled = true; + + form.classList.remove('editable'); + form.classList.add('readonly'); + } catch (e) { + console.error("취소 중 오류 발생:", e); + 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()) return alert("답변 내용을 입력해 주세요."); + if (!handler.trim()) return alert("처리자 이름을 입력해 주세요."); + + try { + 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(); + } else { + alert("저장에 실패했습니다: " + result.error); + } + } catch (e) { + 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(); + } else { + alert("삭제에 실패했습니다: " + result.error); + } + } catch (e) { + alert("오류가 발생했습니다."); + } +} + +function toggleAccordion(id) { + const detailRow = document.getElementById(`detail-${id}`); + if (!detailRow) return; + const inquiryRow = detailRow.previousElementSibling; + const isActive = detailRow.classList.contains('active'); + + // Close all other active details and remove their row highlights + document.querySelectorAll('.detail-row.active').forEach(row => { + if (row.id !== `detail-${id}`) { + row.classList.remove('active'); + if (row.previousElementSibling) row.previousElementSibling.classList.remove('active-row'); + } + }); + + // Toggle current + if (isActive) { + detailRow.classList.remove('active'); + inquiryRow.classList.remove('active-row'); + } else { + detailRow.classList.add('active'); + inquiryRow.classList.add('active-row'); + + // [추가] 펼쳐진 항목이 보기 편하도록 스크롤 위치 조정 + setTimeout(() => { + const stickyHeader = document.getElementById('stickyHeader'); + const thead = document.querySelector('.inquiry-table thead'); + const topbarHeight = 36; + const stickyHeaderHeight = stickyHeader ? stickyHeader.offsetHeight : 0; + const theadHeight = thead ? thead.offsetHeight : 0; + + // 모든 고정 영역의 합 (Topbar + 필터영역 + 표 헤더) + const totalOffset = topbarHeight + stickyHeaderHeight + theadHeight; + + const rowPosition = inquiryRow.getBoundingClientRect().top + window.pageYOffset; + const offsetPosition = rowPosition - totalOffset; + + window.scrollTo({ + top: offsetPosition, + behavior: 'smooth' + }); + }, 100); + } +} + +function getStatusClass(status) { + if (status === '완료') return 'status-complete'; + if (status === '작업 중') return 'status-working'; + if (status === '확인 중') return 'status-checking'; + return 'status-pending'; +} + +document.addEventListener('DOMContentLoaded', loadInquiries); +window.addEventListener('resize', loadInquiries); diff --git a/server.py b/server.py index bc1c258..ea709f2 100644 --- a/server.py +++ b/server.py @@ -71,6 +71,86 @@ async def get_dashboard(request: Request): async def get_mail_test(request: Request): return templates.TemplateResponse("mailTest.html", {"request": request}) +@app.get("/inquiries") +async def get_inquiries_page(request: Request): + return templates.TemplateResponse("inquiries.html", {"request": request}) + +class InquiryReplyRequest(BaseModel): + reply: str + status: str + handler: str + +# --- 문의사항 API --- +@app.get("/api/inquiries") +async def get_inquiries(pm_type: str = None, category: str = None, status: str = None, keyword: str = None): + # ... (existing code) + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + sql = "SELECT * FROM inquiries WHERE 1=1" + params = [] + if pm_type: + sql += " AND pm_type = %s" + params.append(pm_type) + if category: + sql += " AND category = %s" + params.append(category) + if status: + sql += " AND status = %s" + params.append(status) + if keyword: + sql += " AND (content LIKE %s OR author LIKE %s OR project_nm LIKE %s)" + params.extend([f"%{keyword}%", f"%{keyword}%", f"%{keyword}%"]) + + sql += " ORDER BY no DESC" + cursor.execute(sql, params) + return cursor.fetchall() + except Exception as e: + return {"error": str(e)} + +@app.get("/api/inquiries/{id}") +async def get_inquiry_detail(id: int): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + cursor.execute("SELECT * FROM inquiries WHERE id = %s", (id,)) + return cursor.fetchone() + except Exception as e: + return {"error": str(e)} + +@app.post("/api/inquiries/{id}/reply") +async def update_inquiry_reply(id: int, req: InquiryReplyRequest): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + handled_date = datetime.now().strftime("%Y. %m. %d") + sql = """ + UPDATE inquiries + SET reply = %s, status = %s, handler = %s, handled_date = %s + WHERE id = %s + """ + cursor.execute(sql, (req.reply, req.status, req.handler, handled_date, id)) + conn.commit() + return {"success": True} + except Exception as e: + return {"error": str(e)} + +@app.delete("/api/inquiries/{id}/reply") +async def delete_inquiry_reply(id: int): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + sql = """ + UPDATE inquiries + SET reply = '', status = '미확인', handled_date = '' + WHERE id = %s + """ + cursor.execute(sql, (id,)) + conn.commit() + return {"success": True} + except Exception as e: + return {"error": str(e)} + # --- 분석 및 수집 API --- @app.get("/available-dates") async def get_available_dates(): @@ -153,14 +233,19 @@ async def get_project_activity(date: str = None): # [핵심] 파일이 0개면 무조건 "데이터 없음" status = "unknown" elif has_log: - # 로그 날짜가 있는 경우 정밀 분석 - match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log) - if match: - diff = (target_date_dt - datetime.strptime(match.group(0), "%Y.%m.%d")).days - status = "active" if diff <= 7 else "warning" if diff <= 14 else "stale" - days = diff - else: + if "폴더자동삭제" in log.replace(" ", ""): + # [추가] 폴더 자동 삭제인 경우 날짜 상관없이 무조건 "방치" status = "stale" + days = 999 + else: + # 로그 날짜가 있는 경우 정밀 분석 + match = re.search(r'(\d{4})\.(\d{2})\.(\d{2})', log) + if match: + diff = (target_date_dt - datetime.strptime(match.group(0), "%Y.%m.%d")).days + status = "active" if diff <= 7 else "warning" if diff <= 14 else "stale" + days = diff + else: + status = "stale" else: # 파일은 있지만 로그가 없는 경우 status = "stale" diff --git a/style/inquiries.css b/style/inquiries.css new file mode 100644 index 0000000..5b33ce3 --- /dev/null +++ b/style/inquiries.css @@ -0,0 +1,293 @@ + +.inquiry-board { + padding: 0 20px 32px 20px; + max-width: 98%; /* Expanded from 1400px to use most of the screen */ + margin: 0 auto; + margin-top: 36px; /* topbar height */ +} + +/* Sticky Header Wrapper */ +.board-sticky-header { + position: sticky; + top: 36px; + background: #fff; + z-index: 1000; + padding-top: 15px; + padding-bottom: 10px; + border-bottom: 1px solid #eee; +} + +.board-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.notice-container { + background: #fdfdfd; + padding: 20px; + border-radius: 8px; + border: 1px solid #e0e0e0; + margin-bottom: 24px; + box-shadow: 0 2px 5px rgba(0,0,0,0.02); +} + +.filter-section { + display: flex; + gap: 12px; + background: #f8f9fa; + padding: 12px 16px; + border-radius: 8px; + margin-top: 15px; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.filter-group label { + font-size: 12px; + font-weight: 600; + color: #666; +} + +.filter-group select, +.filter-group input { + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +.inquiry-table { + width: 100%; + background: #fff; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + border-collapse: separate; + border-spacing: 0; + margin-top: 10px; +} + +.inquiry-table thead th { + position: sticky; + top: 310px; /* Adjust this value based on header height or use dynamic JS */ + background: #f8f9fa; + padding: 14px 16px; + text-align: left; + font-size: 13px; + font-weight: 700; + color: #333; + border-bottom: 2px solid #eee; + z-index: 900; +} + +.inquiry-table td { + padding: 14px 16px; + font-size: 13px; + border-bottom: 1px solid #eee; + vertical-align: middle; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 0; /* Necessary for text-overflow to work in table-cells with percentage or flex-like behavior */ +} + +/* Specific overrides for columns that need more or less space */ +.inquiry-table td:nth-child(1) { max-width: 50px; } /* No */ +.inquiry-table td:nth-child(2) { max-width: 120px; } /* PM Type */ +.inquiry-table td:nth-child(3) { max-width: 100px; } /* Env */ +.inquiry-table td:nth-child(4) { max-width: 150px; } /* Category */ +.inquiry-table td:nth-child(5) { max-width: 200px; } /* Project */ +.inquiry-table td:nth-child(6) { max-width: 400px; } /* Content */ +.inquiry-table td:nth-child(7) { max-width: 400px; } /* Reply */ +.inquiry-table td:nth-child(8) { max-width: 100px; } /* Author */ +.inquiry-table td:nth-child(9) { max-width: 120px; } /* Date */ +.inquiry-table td:nth-child(10) { max-width: 100px; } /* Status */ + +/* Reset max-width for detail row to allow it to span full width */ +.detail-row td { + max-width: none; + white-space: normal; + overflow: visible; +} + +.status-badge { + padding: 4px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 700; + display: inline-block; +} + +.status-complete { + background: #e8f5e9; + color: #2e7d32; +} + +.status-working { + background: #e3f2fd; + color: #1565c0; +} + +.status-checking { + background: #fff3e0; + color: #ef6c00; +} + +.status-pending { + background: #f5f5f5; + color: #616161; +} + +.inquiry-row:hover { + background: #fcfcfc; + cursor: pointer; +} + +/* Expanded Row Highlight */ +.inquiry-row.active-row { + background-color: #f0f7f6 !important; /* Very light mint gray */ +} + +.inquiry-row.active-row td { + font-weight: 600; + color: #1e5149; + border-bottom-color: transparent; /* Seamless connection with detail */ +} + +.content-preview { + max-width: 500px; /* Increased from 300px */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Accordion Detail Row Style */ +.detail-row { + display: none; + background: #fdfdfd; +} + +.detail-row.active { + display: table-row; +} + +.detail-container { + padding: 24px; + border-left: 6px solid #1e5149; + background: #f9fafb; /* Slightly different background for grouping */ + box-shadow: inset 0 4px 15px rgba(0,0,0,0.08); + position: relative; /* For positioning the close button */ + border-bottom: 2px solid #eee; +} + +.detail-content-wrapper { + display: flex; + flex-direction: column; + gap: 20px; + border: 1px solid #e5e7eb; + background: #fff; + padding: 25px; + border-radius: 12px; + box-shadow: 0 4px 10px rgba(0,0,0,0.03); +} + +.btn-close-accordion { + position: absolute; + top: 35px; + right: 45px; + background: #eee; + border: none; + padding: 6px 14px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + color: #666; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + transition: all 0.2s; + z-index: 10; +} + +.btn-close-accordion:hover { + background: #e0e0e0; + color: #333; +} + +.btn-close-accordion::after { + content: "▲"; + font-size: 10px; +} + +.detail-q-section { + background: #f8f9fa; + padding: 20px; + border-radius: 8px; +} + +.detail-a-section { + background: #f1f8f7; + padding: 20px; + border-radius: 8px; + border-left: 5px solid #1e5149; +} + +.detail-meta-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 15px; + margin-bottom: 15px; + font-size: 13px; + color: #666; +} + +.detail-label { + font-weight: 700; + color: #888; + margin-right: 8px; +} + +/* Reply Form Enhancement */ +.reply-edit-form textarea { + width: 100%; + height: 120px; /* Fixed height */ + padding: 12px; + border: 1px solid #ddd; + border-radius: 6px; + font-family: inherit; + font-size: 14px; + margin-bottom: 15px; + resize: none; /* Disable resizing */ + background: #fff; + transition: all 0.2s; +} + +.reply-edit-form textarea:disabled, +.reply-edit-form select:disabled, +.reply-edit-form input:disabled { + background: #fcfcfc; + color: #666; + border-color: #eee; + cursor: default; +} + +.reply-edit-form.readonly .btn-save, +.reply-edit-form.readonly .btn-delete, +.reply-edit-form.readonly .btn-cancel { + display: none; +} + +.reply-edit-form.editable .btn-edit { + display: none; +} + +.reply-edit-form.editable textarea { + border-color: #1e5149; + box-shadow: 0 0 0 2px rgba(30, 81, 73, 0.1); +} diff --git a/templates/dashboard.html b/templates/dashboard.html index 6c0c0b2..155a087 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -21,7 +21,7 @@