diff --git a/src/main/resources/templates/notifications/compose.html b/src/main/resources/templates/notifications/compose.html index 0a87858..f35cad8 100644 --- a/src/main/resources/templates/notifications/compose.html +++ b/src/main/resources/templates/notifications/compose.html @@ -5,7 +5,7 @@

대상 공지 발송

-

닉네임으로 사용자를 선택해 관리자 메시지를 푸시 및 인앱 알림으로 발송합니다.

+

ID 또는 닉네임으로 사용자를 선택해 관리자 메시지를 푸시 및 인앱 알림으로 발송합니다.

@@ -32,11 +32,11 @@

대상 공지 발송

- +
대상 공지 발송
- 닉네임 검색 결과에서 사용자를 선택하세요. 중복 선택은 자동으로 제외됩니다. + 닉네임으로 검색해 결과를 클릭하거나, ID(숫자)를 직접 입력해 추가할 수 있습니다. + 콤마/공백으로 구분해 여러 ID를 한 번에 추가하거나 Enter로 첫 번째 항목을 선택하세요.
@@ -159,12 +160,42 @@

미리보기

if (!user) { return ''; } - if (user.nickname && user.nickname.charAt(0) === '#') { + if (!user.nickname) { + return '#' + user.id; + } + if (user.nickname.charAt(0) === '#') { return user.nickname; } return user.nickname + ' (#' + user.id + ')'; } + function parseNumericTokens(raw) { + if (!raw) { + return null; + } + var tokens = raw.trim().split(/[,\s]+/).filter(Boolean); + if (tokens.length === 0) { + return null; + } + var seen = {}; + var ids = []; + for (var i = 0; i < tokens.length; i++) { + if (!/^\d+$/.test(tokens[i])) { + return null; + } + var n = parseInt(tokens[i], 10); + if (!(n > 0)) { + return null; + } + var idStr = String(n); + if (!seen[idStr]) { + seen[idStr] = true; + ids.push(idStr); + } + } + return ids; + } + function syncSelectedRecipients() { if (userIdsEl) { userIdsEl.value = selectedUsers.map(function (user) { @@ -243,7 +274,71 @@

미리보기

syncPreview(); } - function renderSuggestions(users) { + function addRecipientsByIds(ids) { + ids.forEach(function (rawId) { + var id = String(rawId); + var exists = selectedUsers.some(function (selected) { + return selected.id === id; + }); + if (!exists) { + selectedUsers.push({ id: id, nickname: '#' + id }); + } + }); + if (searchEl) { + searchEl.value = ''; + searchEl.focus(); + } + hideSuggestions(); + syncSelectedRecipients(); + syncPreview(); + } + + function buildSuggestionButton(leftText, rightText, onClick) { + var button = document.createElement('button'); + button.type = 'button'; + button.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center'; + var leftSpan = document.createElement('span'); + leftSpan.textContent = leftText; + var rightSpan = document.createElement('span'); + rightSpan.className = 'text-muted small'; + rightSpan.textContent = rightText; + button.appendChild(leftSpan); + button.appendChild(rightSpan); + button.addEventListener('click', onClick); + return button; + } + + function renderBulkIdSuggestions(ids) { + if (!suggestionsEl) { + return; + } + suggestionsEl.innerHTML = ''; + var unselected = ids.filter(function (id) { + return !selectedUsers.some(function (selected) { + return selected.id === id; + }); + }); + if (unselected.length === 0) { + showSuggestionMessage('입력한 ID가 모두 이미 추가되어 있습니다.'); + return; + } + var preview = unselected.slice(0, 5).map(function (id) { + return '#' + id; + }).join(', '); + if (unselected.length > 5) { + preview += ' 외 ' + (unselected.length - 5) + '개'; + } + suggestionsEl.appendChild(buildSuggestionButton( + unselected.length + '개 ID 일괄 추가', + preview, + function () { + addRecipientsByIds(unselected); + } + )); + suggestionsEl.classList.remove('d-none'); + } + + function renderSuggestions(users, directAddId) { if (!suggestionsEl) { return; } @@ -253,22 +348,32 @@

미리보기

return selected.id === String(user.id); }); }); - if (candidates.length === 0) { - showSuggestionMessage('검색 결과가 없습니다.'); - return; + var needsDirectAdd = directAddId + && !selectedUsers.some(function (s) { return s.id === directAddId; }) + && !candidates.some(function (u) { return String(u.id) === directAddId; }); + if (needsDirectAdd) { + suggestionsEl.appendChild(buildSuggestionButton( + 'ID 그대로 추가', + '#' + directAddId, + function () { + addRecipientsByIds([directAddId]); + } + )); } candidates.forEach(function (user) { - var button = document.createElement('button'); - button.type = 'button'; - button.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center'; - button.innerHTML = ''; - button.querySelector('span:first-child').textContent = user.nickname; - button.querySelector('span:last-child').textContent = '#' + user.id; - button.addEventListener('click', function () { - addRecipient(user); - }); - suggestionsEl.appendChild(button); + var label = user.nickname || '(닉네임 없음)'; + suggestionsEl.appendChild(buildSuggestionButton( + label, + '#' + user.id, + function () { + addRecipient(user); + } + )); }); + if (!suggestionsEl.firstChild) { + showSuggestionMessage('검색 결과가 없습니다.'); + return; + } suggestionsEl.classList.remove('d-none'); } @@ -278,6 +383,13 @@

미리보기

hideSuggestions(); return; } + var numericIds = parseNumericTokens(trimmed); + if (numericIds && numericIds.length > 1) { + latestSearchSeq++; + renderBulkIdSuggestions(numericIds); + return; + } + var directAddId = numericIds && numericIds.length === 1 ? numericIds[0] : null; var searchSeq = ++latestSearchSeq; fetch('/notifications/users/search?q=' + encodeURIComponent(trimmed), { headers: { @@ -295,10 +407,15 @@

미리보기

if (searchSeq !== latestSearchSeq) { return; } - renderSuggestions(data && data.users ? data.users : []); + renderSuggestions(data && data.users ? data.users : [], directAddId); }) .catch(function () { - if (searchSeq === latestSearchSeq) { + if (searchSeq !== latestSearchSeq) { + return; + } + if (directAddId) { + renderSuggestions([], directAddId); + } else { showSuggestionMessage('사용자 검색에 실패했습니다.'); } }); @@ -345,6 +462,20 @@

미리보기

searchEl.addEventListener('keydown', function (event) { if (event.key === 'Escape') { hideSuggestions(); + return; + } + if (event.key === 'Enter') { + if (suggestionsEl && !suggestionsEl.classList.contains('d-none')) { + var firstAction = suggestionsEl.querySelector('button.list-group-item-action'); + if (firstAction) { + event.preventDefault(); + firstAction.click(); + return; + } + } + if (searchEl.value.trim()) { + event.preventDefault(); + } } }); } diff --git a/src/main/resources/templates/users/list.html b/src/main/resources/templates/users/list.html index a0ef221..5cd63e2 100644 --- a/src/main/resources/templates/users/list.html +++ b/src/main/resources/templates/users/list.html @@ -5,7 +5,7 @@

사용자 목록

-

대상 공지 발송에 사용할 사용자 ID와 닉네임을 조회합니다.

+

ID 또는 닉네임으로 사용자를 조회합니다.

@@ -18,9 +18,9 @@

사용자 목록

- +
@@ -53,7 +53,12 @@

사용자 목록

#501 - 솔바람 + + 솔바람 + (닉네임 없음) +