Q&A 문의 공개범위 기능 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- 공개범위 설정 기능 개발(ALL/ORG/PRIVATE) - 게시물/댓글 정교화
- 요청 및 DB 처리 확장 - UI, DTO, Service 계층 업데이트
- 감사 로그 및 조회수 처리 로직 추가
This commit is contained in:
Rinjae
2026-07-13 11:32:34 +09:00
parent 9c01db9d00
commit a97378b84d
21 changed files with 721 additions and 63 deletions
+112
View File
@@ -6934,6 +6934,37 @@ button.djb-comment-submit:disabled {
font-size: 13px;
}
.djb-comment-private-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
margin-right: auto;
color: #6B7280;
font-size: 13px;
cursor: pointer;
}
.djb-comment-private-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 8px;
border-radius: 10px;
background-color: #FEF3C7;
color: #92400E;
font-size: 11px;
vertical-align: middle;
}
.djb-comment-item--private {
background-color: #FFFBEB;
}
.djb-comment-item--private-hidden .djb-comment-body,
.djb-comment-item--private-hidden .djb-comment-writer {
color: #9CA3AF;
font-style: italic;
}
.hero-carousel-section {
position: relative;
width: 100%;
@@ -15565,6 +15596,87 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.list-table-row--private {
opacity: 0.55;
cursor: default;
}
.list-table-row--private .inquiry-private-label {
color: #94A3B8;
font-style: italic;
}
.notice-title-link--disabled {
pointer-events: none;
cursor: default;
}
.inquiry-detail-views {
color: #94A3B8;
font-size: 13px;
}
.inquiry-detail-attach {
margin-top: 16px;
}
.inquiry-detail-attach .inquiry-attach-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 1px solid #CBD5E1;
border-radius: 6px;
color: #1E40AF;
font-size: 13px;
text-decoration: none;
}
.inquiry-detail-attach .inquiry-attach-link:hover {
background-color: #F1F5F9;
}
.row-cell--writer {
flex-direction: column;
justify-content: center;
line-height: 1.3;
}
.row-cell--writer .inquiry-writer-org {
color: #94A3B8;
font-size: 12px;
}
.inquiry-visibility-notice {
margin-left: 12px;
font-size: 13px;
font-weight: normal;
color: #64748B;
}
.list-table-body .inquiry-status-badge {
height: 24px;
padding: 0 10px;
font-size: 12px;
}
.inquiry-lock-icon {
vertical-align: -2px;
margin-right: 3px;
color: #64748B;
}
.inquiry-detail-visibility {
display: inline-flex;
align-items: center;
gap: 2px;
color: #64748B;
font-size: 13px;
}
.inquiry-detail-visibility--private {
color: #c0392b;
font-weight: 600;
}
.inquiry-detail-visibility--private .inquiry-lock-icon {
color: #c0392b;
}
.org-register-page {
min-height: 100vh;
padding: 0;
File diff suppressed because one or more lines are too long
@@ -12,6 +12,7 @@
const formEl = document.getElementById('djbCommentForm');
const inputEl = document.getElementById('djbCommentInput');
const counterEl = document.getElementById('djbCommentCharCounter');
const privateEl = document.getElementById('djbCommentPrivate');
function getCsrfToken() {
// 세션 기반 CSRF: 쿠키 대신 <meta name="_csrf">에서 토큰을 읽는다.
@@ -74,14 +75,25 @@
}
comments.forEach(function (c) {
const li = document.createElement('li');
li.className = 'djb-comment-item' + (c.adminYn === 'Y' ? ' djb-comment-item--admin' : '');
var cls = 'djb-comment-item';
if (c.adminYn === 'Y') cls += ' djb-comment-item--admin';
if (c.privatePlaceholder) cls += ' djb-comment-item--private-hidden';
else if (c.privateComment) cls += ' djb-comment-item--private';
li.className = cls;
li.setAttribute('data-comment-id', c.id);
var writerHtml = (c.adminYn === 'Y')
? '<span class="djb-comment-admin-badge">관리자</span>'
: '<span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>';
// 비공개 댓글(열람 권한 있는 경우)에는 "비공개" 태그 표시
var privateTag = (c.privateComment && !c.privatePlaceholder)
? '<span class="djb-comment-private-tag">비공개</span>' : '';
li.innerHTML = ''
+ '<div class="djb-comment-meta">'
+ ' <div class="djb-comment-meta-left">'
+ (c.adminYn === 'Y'
? ' <span class="djb-comment-admin-badge">관리자</span>'
: ' <span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>')
+ writerHtml
+ privateTag
+ ' </div>'
+ ' <div class="djb-comment-meta-right">'
+ ' <span class="djb-comment-date">' + escapeHtml(formatDate(c.createdDate)) + '</span>'
@@ -112,9 +124,10 @@
inputEl.focus();
return;
}
var visibility = (privateEl && privateEl.checked) ? 'PRIVATE' : 'ALL';
fetchJson('/djb/inquiry/' + encodeURIComponent(inquiryId) + '/comments', {
method: 'POST',
body: JSON.stringify({ content: content })
body: JSON.stringify({ content: content, visibility: visibility })
})
.then(function (res) {
if (res.status === 409) throw new Error('종료된 문의에는 댓글을 작성할 수 없습니다.');
@@ -123,6 +136,7 @@
})
.then(function () {
inputEl.value = '';
if (privateEl) privateEl.checked = false;
updateCounter();
load();
})
@@ -254,3 +254,41 @@ button.djb-comment-submit {
color: #6B7280;
font-size: 13px;
}
// ── 260710 댓글 공개범위 ──────────────────────────────
// 작성 폼 비공개 토글
.djb-comment-private-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
margin-right: auto;
color: #6B7280;
font-size: 13px;
cursor: pointer;
}
// 비공개 댓글 태그 (열람 권한 있는 사용자에게 표시)
.djb-comment-private-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 8px;
border-radius: 10px;
background-color: #FEF3C7;
color: #92400E;
font-size: 11px;
vertical-align: middle;
}
// 비공개 댓글 (열람 권한 있음) 배경 강조
.djb-comment-item--private {
background-color: #FFFBEB;
}
// 열람 권한 없는 비공개 댓글 (placeholder)
.djb-comment-item--private-hidden {
.djb-comment-body,
.djb-comment-writer {
color: #9CA3AF;
font-style: italic;
}
}
@@ -692,3 +692,101 @@
// Uses the same action button styles from .inquiry-actions above
// Supports: action-btn-primary, action-btn-secondary
}
// ── 260710 공개범위 / 조회수 / 첨부 ──────────────────────────────
// 비공개 게시물(목록) — 본인·소속 법인 관리자 외에는 흐리게 + 링크 비활성
.list-table-row--private {
opacity: 0.55;
cursor: default;
.inquiry-private-label {
color: #94A3B8;
font-style: italic;
}
}
// 접근 권한 없는 비공개 글 제목 — 클릭 불가
.notice-title-link--disabled {
pointer-events: none;
cursor: default;
}
// 상세 조회수
.inquiry-detail-views {
color: #94A3B8;
font-size: 13px;
}
// 상세 첨부 다운로드 링크 (이미지 인라인 노출 없이 강제 다운로드)
.inquiry-detail-attach {
margin-top: $spacing-md;
.inquiry-attach-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 1px solid #CBD5E1;
border-radius: 6px;
color: #1E40AF;
font-size: 13px;
text-decoration: none;
&:hover {
background-color: #F1F5F9;
}
}
}
// 목록 작성자 셀: 이름 아래 (법인명) 표기
// .row-cell 은 display:flex(가로) 이므로 세로 정렬 위해 column 지정
.row-cell--writer {
flex-direction: column;
justify-content: center;
line-height: 1.3;
.inquiry-writer-org {
color: #94A3B8;
font-size: 12px;
}
}
// 목록 상단 PTL_PROPERTY 공개범위 상한 표기
.inquiry-visibility-notice {
margin-left: 12px;
font-size: 13px;
font-weight: normal;
color: #64748B;
}
// 목록 처리상태 배지 — 글자/크기 축소
.list-table-body .inquiry-status-badge {
height: 24px;
padding: 0 10px;
font-size: 12px;
}
// 비공개 글 자물쇠 아이콘 (목록 제목 앞)
.inquiry-lock-icon {
vertical-align: -2px;
margin-right: 3px;
color: #64748B;
}
// 상세 헤더 공개범위 표기
.inquiry-detail-visibility {
display: inline-flex;
align-items: center;
gap: 2px;
color: #64748B;
font-size: 13px;
&--private {
color: #c0392b;
font-weight: 600;
.inquiry-lock-icon {
color: #c0392b;
}
}
}
@@ -20,6 +20,9 @@
placeholder="내용"
required></textarea>
<div class="djb-comment-form-actions">
<label class="djb-comment-private-toggle">
<input type="checkbox" id="djbCommentPrivate"> 비공개
</label>
<span class="djb-comment-char-counter" id="djbCommentCharCounter">0 / 2000</span>
<button type="submit" class="djb-comment-submit">댓글달기</button>
</div>
@@ -11,7 +11,7 @@
</section>
<section layout:fragment="contentFragment">
<div class="inquiry-detail-container">
<div class="inquiry-detail-container" th:attr="data-inquiry-id=${inquiry.id}">
<!-- Inquiry Header: Status Badge and Title -->
<div class="inquiry-detail-header">
@@ -22,10 +22,20 @@
답변대기
</span>
<div class="inquiry-header-right">
<span class="inquiry-detail-writer"
th:text="${inquiry.inquirerName != null ? inquiry.inquirerName : (inquiry.inquirer != null ? inquiry.inquirer.userName : '')}">작성자</span>
<span class="inquiry-detail-writer" th:text="${inquiry.inquirerDisplay}">작성자 (법인명)</span>
<span class="inquiry-detail-divider">|</span>
<span class="inquiry-detail-date" th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.11.01</span>
<span class="inquiry-detail-divider">|</span>
<span class="inquiry-detail-views" th:text="|조회 ${inquiry.viewCount}|">조회 0</span>
<span class="inquiry-detail-divider">|</span>
<span class="inquiry-detail-visibility"
th:classappend="${inquiry.visibility == 'PRIVATE'} ? ' inquiry-detail-visibility--private'">
<svg th:if="${inquiry.visibility == 'PRIVATE'}" class="inquiry-lock-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="비공개">
<rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor"/>
<path d="M7 10V7a5 5 0 0 1 10 0v3" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
<span th:text="${inquiry.visibilityLabel}">법인공개</span>
</span>
</div>
</div>
<h2 class="inquiry-detail-title" th:text="${inquiry.inquirySubject}">질문 제목</h2>
@@ -36,6 +46,12 @@
<div id="inquiryDetail" class="inquiry-content-body" th:utext="${inquiry.inquiryDetail}">
문의 내용이 여기에 표시됩니다.
</div>
<!-- Attachment: 강제 다운로드 링크만 제공 (이미지 인라인 노출 안 함) -->
<div class="inquiry-detail-attach" th:if="${inquiry.attachFile != null and !inquiry.attachFile.isEmpty()}">
<a th:href="@{/file/download(fileId=${inquiry.attachFile}, fileSn=1)}" class="inquiry-attach-link" download>
첨부 이미지 다운로드
</a>
</div>
</div>
<!-- Admin Response Section -->
@@ -107,6 +123,7 @@
});
</script>
<script th:src="@{/js/djb/inquiry-comments.js}"></script>
<script th:src="@{/js/djb/inquiry-view.js}"></script>
</th:block>
</html>
@@ -22,7 +22,7 @@
<!-- Form Content -->
<div class="register-form-container">
<form name="inquiryForm" id="inquiryForm" th:action="${isNew}? @{/inquiry} : @{/inquiry/edit}"
th:object="${inquiry}" method="post" class="register-form">
th:object="${inquiry}" method="post" enctype="multipart/form-data" class="register-form">
<input type="hidden" th:field="*{id}" th:if="${!isNew}">
<!-- Subject Field -->
@@ -62,6 +62,35 @@
</div>
</div>
<!-- Visibility Field -->
<div class="form-row">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">공개범위</span>
</div>
<div class="form-field-wrapper">
<select id="inquiryVisibility" th:field="*{visibility}" class="form-input">
<option th:if="${allowAll}" value="ALL">전체공개</option>
<option th:if="${allowOrg}" value="ORG">법인공개</option>
<option value="PRIVATE">비공개</option>
</select>
<small class="form-help-text">비공개 글은 본인과 소속 법인 관리자만 열람할 수 있습니다.</small>
</div>
</div>
<!-- Image Attach Field -->
<div class="form-row">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">이미지 첨부</span>
</div>
<div class="form-field-wrapper">
<input type="file" id="inquiryImage" name="image" class="form-input"
accept="image/png,image/jpeg,image/gif">
<small class="form-help-text">jpg, jpeg, png, gif 이미지 1개만 첨부할 수 있습니다.</small>
<small th:if="${!isNew and inquiry.attachFile != null and !inquiry.attachFile.isEmpty()}"
class="form-help-text">현재 첨부된 이미지가 있습니다. 새 파일을 선택하면 교체됩니다.</small>
</div>
</div>
</form>
</div>
@@ -128,6 +157,15 @@
return false;
}
const imageInput = document.getElementById('inquiryImage');
if (imageInput && imageInput.files && imageInput.files.length > 0) {
const ext = imageInput.files[0].name.toLowerCase().split('.').pop();
if (['jpg', 'jpeg', 'png', 'gif'].indexOf(ext) === -1) {
customPopups.showAlert('이미지 파일(jpg, jpeg, png, gif)만 첨부할 수 있습니다.');
return;
}
}
form.submit();
});
@@ -21,6 +21,9 @@
<div class="table-controls">
<h2 class="total-count">
<strong th:text="${page.totalElements}">0</strong>
<span class="inquiry-visibility-notice" th:if="${visibilityCeiling != null}">
공개범위 설정: <span th:text="${visibilityCeiling.label()}">법인공개</span>
</span>
</h2>
<div class="search-field">
@@ -47,6 +50,7 @@
<div class="header-cell" style="flex: 1; min-width: 200px;">제목</div>
<div class="header-cell" style="width: 100px;">작성자</div>
<div class="header-cell" style="width: 120px;">처리상태</div>
<div class="header-cell" style="width: 80px;">조회수</div>
<div class="header-cell" style="width: 120px;">등록일</div>
</div>
@@ -54,20 +58,33 @@
<div class="list-table-body">
<div class="list-table-row"
th:each="inquiry, status : ${inquiries}"
th:onclick="'location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\''"
style="cursor: pointer;">
th:classappend="${inquiry.privatePlaceholder} ? ' list-table-row--private'"
th:onclick="${inquiry.privatePlaceholder} ? null : ('location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\'')"
th:style="${inquiry.privatePlaceholder} ? 'cursor: default;' : 'cursor: pointer;'">
<div class="row-cell row-cell--number" style="width: 80px;" data-label="NO"
th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div>
<div class="row-cell row-cell--title" style="flex: 1; min-width: 200px;" data-label="제목">
<a th:href="@{/inquiry/detail(id=${inquiry.id})}" class="notice-title-link">
<a th:href="${inquiry.privatePlaceholder} ? null : @{/inquiry/detail(id=${inquiry.id})}"
class="notice-title-link"
th:classappend="${inquiry.privatePlaceholder} ? ' notice-title-link--disabled'">
<span class="notice-number" th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
<span th:text="${inquiry.inquirySubject}">질문 제목</span>
<svg th:if="${inquiry.visibility == 'PRIVATE'}" class="inquiry-lock-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="비공개">
<rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor"/>
<path d="M7 10V7a5 5 0 0 1 10 0v3" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
<span th:if="${inquiry.privatePlaceholder}" class="inquiry-private-label">비공개 게시물</span>
<span th:unless="${inquiry.privatePlaceholder}" th:text="${inquiry.inquirySubject}">질문 제목</span>
<span class="inquiry-comment-count"
th:if="${commentCounts != null and commentCounts.get(inquiry.id) != null and commentCounts.get(inquiry.id) > 0}"
th:text="|[${commentCounts.get(inquiry.id)}]|">[3]</span>
</a>
</div>
<div class="row-cell" style="width: 100px;" data-label="작성자" th:text="${inquiry.maskedInquirerName}">홍**</div>
<div class="row-cell row-cell--writer" style="width: 100px;" data-label="작성자">
<span class="inquiry-writer-name" th:text="${inquiry.maskedInquirerName}">홍**</span>
<span class="inquiry-writer-org"
th:if="${inquiry.inquirerOrgName != null and !inquiry.inquirerOrgName.isEmpty()}"
th:text="|(${inquiry.inquirerOrgName})|">(법인명)</span>
</div>
<div class="row-cell" style="width: 120px;" data-label="처리상태">
<span class="inquiry-status-badge"
th:classappend="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).badgeClass(inquiry.inquiryStatus)}"
@@ -75,6 +92,7 @@
답변대기
</span>
</div>
<div class="row-cell" style="width: 80px;" data-label="조회수" th:text="${inquiry.viewCount}">0</div>
<div class="row-cell" style="width: 120px;" data-label="등록일"
th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
</div>