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.content}
+
+
+
+
+ |
+
+ `).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 @@