feat: [kakaotalk] 템플릿 관리 목록/상세 대폭 개선
- 테이블 컬럼 확장: 메시지유형, 강조유형, 보안, 버튼수, 검수상태 - 통계 바 추가: 전체/승인/심사중/반려 건수 한눈에 확인 - 상세 모달 개선: 4칸 속성 요약, 타이틀, 이미지, 버튼 상세 표시 - 바로빌 WSDL 전체 필드 활용 (TemplateMessageType, EmphasizeType, SecurityFlag 등) - XSS 방지를 위한 escHtml 헬퍼 추가
This commit is contained in:
@@ -46,7 +46,7 @@
|
||||
<div id="template-detail-modal" class="fixed inset-0 z-50 hidden">
|
||||
<div class="fixed inset-0 bg-black/50" onclick="closeTemplateModal()"></div>
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-xl max-w-lg w-full max-h-[80vh] overflow-y-auto">
|
||||
<div class="bg-white rounded-xl shadow-xl max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between p-6 border-b">
|
||||
<h3 class="font-semibold text-gray-800" id="modal-template-name">템플릿 상세</h3>
|
||||
<button onclick="closeTemplateModal()" class="text-gray-400 hover:text-gray-600">
|
||||
@@ -125,24 +125,83 @@ function loadTemplates() {
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="border-b border-gray-200"><th class="text-left py-3 px-4 font-medium text-gray-600">템플릿명</th><th class="text-left py-3 px-4 font-medium text-gray-600">상태</th><th class="text-center py-3 px-4 font-medium text-gray-600">상세</th></tr></thead><tbody>';
|
||||
// 상태/유형 매핑 헬퍼
|
||||
const statusInfo = getStatusInfo;
|
||||
const msgTypeInfo = getMsgTypeInfo;
|
||||
const emphTypeInfo = getEmphTypeInfo;
|
||||
|
||||
// 요약 통계
|
||||
const stats = { total: items.length, approved: 0, reviewing: 0, rejected: 0, other: 0 };
|
||||
items.forEach(tpl => {
|
||||
const si = statusInfo(tpl.Status);
|
||||
if (si.group === 'approved') stats.approved++;
|
||||
else if (si.group === 'reviewing') stats.reviewing++;
|
||||
else if (si.group === 'rejected') stats.rejected++;
|
||||
else stats.other++;
|
||||
});
|
||||
|
||||
let html = '';
|
||||
|
||||
// 통계 바
|
||||
html += '<div class="flex items-center gap-4 mb-4 text-xs text-gray-500">';
|
||||
html += '<span>전체 <strong class="text-gray-800">' + stats.total + '</strong>건</span>';
|
||||
if (stats.approved) html += '<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-green-400"></span>승인 ' + stats.approved + '</span>';
|
||||
if (stats.reviewing) html += '<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-yellow-400"></span>심사중 ' + stats.reviewing + '</span>';
|
||||
if (stats.rejected) html += '<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-red-400"></span>반려 ' + stats.rejected + '</span>';
|
||||
if (stats.other) html += '<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-gray-400"></span>기타 ' + stats.other + '</span>';
|
||||
html += '</div>';
|
||||
|
||||
// 테이블
|
||||
html += '<div class="overflow-x-auto"><table class="w-full text-sm">';
|
||||
html += '<thead><tr class="border-b border-gray-200 bg-gray-50">';
|
||||
html += '<th class="text-left py-3 px-4 font-medium text-gray-600">템플릿명</th>';
|
||||
html += '<th class="text-center py-3 px-4 font-medium text-gray-600">메시지유형</th>';
|
||||
html += '<th class="text-center py-3 px-4 font-medium text-gray-600">강조유형</th>';
|
||||
html += '<th class="text-center py-3 px-4 font-medium text-gray-600">보안</th>';
|
||||
html += '<th class="text-center py-3 px-4 font-medium text-gray-600">버튼</th>';
|
||||
html += '<th class="text-center py-3 px-4 font-medium text-gray-600">검수상태</th>';
|
||||
html += '<th class="text-center py-3 px-4 font-medium text-gray-600">상세</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
items.forEach((tpl, idx) => {
|
||||
// 바로빌 API Status: int 타입 (WSDL 정의)
|
||||
// 숫자 코드: 1=등록, 2=심사중, 3=승인, 4=반려
|
||||
// 문자 코드(레거시 호환): R=승인, N=반려, P=심사중, S=중단
|
||||
const s = String(tpl.Status);
|
||||
const statusMap = {
|
||||
'1': '등록', '2': '심사중', '3': '승인', '4': '반려',
|
||||
'R': '승인', 'N': '반려', 'P': '심사중', 'S': '중단'
|
||||
};
|
||||
const statusLabel = statusMap[s] || s || '-';
|
||||
const approvedCodes = ['3', 'R'];
|
||||
const rejectedCodes = ['4', 'N'];
|
||||
const statusColor = approvedCodes.includes(s) ? 'green' : (rejectedCodes.includes(s) ? 'red' : 'yellow');
|
||||
const si = statusInfo(tpl.Status);
|
||||
const mi = msgTypeInfo(tpl.TemplateMessageType);
|
||||
const ei = emphTypeInfo(tpl.TemplateEmphasizeType);
|
||||
const btnCount = getBtnCount(tpl);
|
||||
const hasSecurity = tpl.SecurityFlag === true || tpl.SecurityFlag === 'true';
|
||||
|
||||
html += '<tr class="border-b border-gray-50 hover:bg-gray-50">';
|
||||
html += '<td class="py-3 px-4 font-medium">' + (tpl.TemplateName || '-') + '</td>';
|
||||
html += '<td class="py-3 px-4"><span class="px-2 py-0.5 rounded-full text-xs font-medium bg-' + statusColor + '-100 text-' + statusColor + '-700">' + statusLabel + '</span></td>';
|
||||
|
||||
// 템플릿명 + 타이틀/서브타이틀
|
||||
html += '<td class="py-3 px-4">';
|
||||
html += '<div class="font-medium text-gray-800">' + escHtml(tpl.TemplateName || '-') + '</div>';
|
||||
if (tpl.TemplateTitle) html += '<div class="text-xs text-gray-400 mt-0.5">' + escHtml(tpl.TemplateTitle) + (tpl.TemplateSubtitle ? ' / ' + escHtml(tpl.TemplateSubtitle) : '') + '</div>';
|
||||
html += '</td>';
|
||||
|
||||
// 메시지유형
|
||||
html += '<td class="py-3 px-4 text-center"><span class="px-2 py-0.5 rounded text-xs font-medium bg-' + mi.color + '-50 text-' + mi.color + '-600">' + mi.label + '</span></td>';
|
||||
|
||||
// 강조유형
|
||||
html += '<td class="py-3 px-4 text-center"><span class="text-xs text-gray-500">' + ei.label + '</span></td>';
|
||||
|
||||
// 보안
|
||||
html += '<td class="py-3 px-4 text-center">';
|
||||
if (hasSecurity) html += '<span class="px-2 py-0.5 rounded text-xs font-medium bg-purple-50 text-purple-600">보안</span>';
|
||||
else html += '<span class="text-xs text-gray-300">-</span>';
|
||||
html += '</td>';
|
||||
|
||||
// 버튼 수
|
||||
html += '<td class="py-3 px-4 text-center">';
|
||||
if (btnCount > 0) html += '<span class="text-xs text-gray-600">' + btnCount + '개</span>';
|
||||
else html += '<span class="text-xs text-gray-300">-</span>';
|
||||
html += '</td>';
|
||||
|
||||
// 검수상태
|
||||
html += '<td class="py-3 px-4 text-center"><span class="px-2 py-0.5 rounded-full text-xs font-medium bg-' + si.color + '-100 text-' + si.color + '-700">' + si.label + '</span></td>';
|
||||
|
||||
// 상세
|
||||
html += '<td class="py-3 px-4 text-center"><button onclick=\'showTemplateDetail(' + JSON.stringify(tpl).replace(/'/g, "\\'") + ')\' class="text-blue-600 hover:text-blue-800 text-xs">상세보기</button></td>';
|
||||
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
@@ -153,25 +212,128 @@ function loadTemplates() {
|
||||
});
|
||||
}
|
||||
|
||||
// === 헬퍼 함수 ===
|
||||
|
||||
function escHtml(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// 검수 상태 (Status: int)
|
||||
function getStatusInfo(status) {
|
||||
const s = String(status);
|
||||
const map = {
|
||||
'1': { label: '등록', color: 'gray', group: 'other' },
|
||||
'2': { label: '심사중', color: 'yellow', group: 'reviewing' },
|
||||
'3': { label: '승인', color: 'green', group: 'approved' },
|
||||
'4': { label: '반려', color: 'red', group: 'rejected' },
|
||||
'R': { label: '승인', color: 'green', group: 'approved' },
|
||||
'N': { label: '반려', color: 'red', group: 'rejected' },
|
||||
'P': { label: '심사중', color: 'yellow', group: 'reviewing' },
|
||||
'S': { label: '중단', color: 'gray', group: 'other' },
|
||||
};
|
||||
return map[s] || { label: s || '-', color: 'gray', group: 'other' };
|
||||
}
|
||||
|
||||
// 메시지 유형 (TemplateMessageType: int)
|
||||
function getMsgTypeInfo(type) {
|
||||
const s = String(type);
|
||||
const map = {
|
||||
'0': { label: '기본형', color: 'blue' },
|
||||
'1': { label: '부가정보', color: 'indigo' },
|
||||
'2': { label: '채널추가', color: 'teal' },
|
||||
'3': { label: '복합형', color: 'violet' },
|
||||
};
|
||||
return map[s] || { label: s || '-', color: 'gray' };
|
||||
}
|
||||
|
||||
// 강조 유형 (TemplateEmphasizeType: int)
|
||||
function getEmphTypeInfo(type) {
|
||||
const s = String(type);
|
||||
const map = {
|
||||
'0': { label: '없음' },
|
||||
'1': { label: '강조표기' },
|
||||
'2': { label: '이미지' },
|
||||
'3': { label: '아이템리스트' },
|
||||
};
|
||||
return map[s] || { label: s || '-' };
|
||||
}
|
||||
|
||||
// 버튼 개수
|
||||
function getBtnCount(tpl) {
|
||||
if (!tpl.Buttons) return 0;
|
||||
const b = tpl.Buttons.KakaotalkButton;
|
||||
if (Array.isArray(b)) return b.length;
|
||||
return b ? 1 : 0;
|
||||
}
|
||||
|
||||
// 버튼 타입 라벨
|
||||
function btnTypeLabel(type) {
|
||||
const map = { 'WL': '웹링크', 'AL': '앱링크', 'BK': '봇키워드', 'MD': '메시지전달', 'AC': '채널추가' };
|
||||
return map[type] || type || '-';
|
||||
}
|
||||
|
||||
function showTemplateDetail(tpl) {
|
||||
document.getElementById('modal-template-name').textContent = tpl.TemplateName || '템플릿 상세';
|
||||
const content = document.getElementById('modal-template-content');
|
||||
const si = getStatusInfo(tpl.Status);
|
||||
const mi = getMsgTypeInfo(tpl.TemplateMessageType);
|
||||
const ei = getEmphTypeInfo(tpl.TemplateEmphasizeType);
|
||||
const hasSecurity = tpl.SecurityFlag === true || tpl.SecurityFlag === 'true';
|
||||
|
||||
let html = '';
|
||||
html += '<div><label class="text-xs text-gray-500 block mb-1">템플릿 내용</label><div class="bg-gray-50 rounded-lg p-4 text-sm whitespace-pre-wrap">' + (tpl.TemplateContent || '-') + '</div></div>';
|
||||
if (tpl.TemplateExtra) {
|
||||
html += '<div><label class="text-xs text-gray-500 block mb-1">부가 정보</label><div class="bg-gray-50 rounded-lg p-4 text-sm whitespace-pre-wrap">' + tpl.TemplateExtra + '</div></div>';
|
||||
|
||||
// 속성 요약 테이블
|
||||
html += '<div class="grid grid-cols-2 gap-3">';
|
||||
html += '<div class="bg-gray-50 rounded-lg p-3"><div class="text-xs text-gray-400 mb-1">검수상태</div><span class="px-2 py-0.5 rounded-full text-xs font-medium bg-' + si.color + '-100 text-' + si.color + '-700">' + si.label + '</span></div>';
|
||||
html += '<div class="bg-gray-50 rounded-lg p-3"><div class="text-xs text-gray-400 mb-1">메시지유형</div><span class="text-sm font-medium text-gray-700">' + mi.label + '</span></div>';
|
||||
html += '<div class="bg-gray-50 rounded-lg p-3"><div class="text-xs text-gray-400 mb-1">강조유형</div><span class="text-sm text-gray-700">' + ei.label + '</span></div>';
|
||||
html += '<div class="bg-gray-50 rounded-lg p-3"><div class="text-xs text-gray-400 mb-1">보안 템플릿</div><span class="text-sm text-gray-700">' + (hasSecurity ? '예' : '아니오') + '</span></div>';
|
||||
html += '</div>';
|
||||
|
||||
// 타이틀/서브타이틀
|
||||
if (tpl.TemplateTitle) {
|
||||
html += '<div><label class="text-xs text-gray-500 block mb-1">타이틀</label><div class="bg-gray-50 rounded-lg p-3 text-sm">' + escHtml(tpl.TemplateTitle) + (tpl.TemplateSubtitle ? '<span class="text-gray-400 ml-2">' + escHtml(tpl.TemplateSubtitle) + '</span>' : '') + '</div></div>';
|
||||
}
|
||||
|
||||
// 템플릿 내용
|
||||
html += '<div><label class="text-xs text-gray-500 block mb-1">템플릿 내용</label><div class="bg-gray-50 rounded-lg p-4 text-sm whitespace-pre-wrap">' + escHtml(tpl.TemplateContent || '-') + '</div></div>';
|
||||
|
||||
// 부가 정보
|
||||
if (tpl.TemplateExtra) {
|
||||
html += '<div><label class="text-xs text-gray-500 block mb-1">부가 정보</label><div class="bg-gray-50 rounded-lg p-4 text-sm whitespace-pre-wrap">' + escHtml(tpl.TemplateExtra) + '</div></div>';
|
||||
}
|
||||
|
||||
// 이미지
|
||||
if (tpl.Image && (tpl.Image.ImageUrl || tpl.Image.ImageLink)) {
|
||||
html += '<div><label class="text-xs text-gray-500 block mb-1">이미지</label><div class="bg-gray-50 rounded-lg p-3">';
|
||||
if (tpl.Image.ImageUrl) html += '<img src="' + escHtml(tpl.Image.ImageUrl) + '" class="max-w-full rounded border border-gray-200 mb-2" style="max-height:200px;" />';
|
||||
if (tpl.Image.ImageLink) html += '<div class="text-xs text-blue-600 break-all">' + escHtml(tpl.Image.ImageLink) + '</div>';
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// 버튼
|
||||
if (tpl.Buttons) {
|
||||
html += '<div><label class="text-xs text-gray-500 block mb-1">버튼</label><div class="space-y-2">';
|
||||
const btns = Array.isArray(tpl.Buttons.KakaotalkButton) ? tpl.Buttons.KakaotalkButton : (tpl.Buttons.KakaotalkButton ? [tpl.Buttons.KakaotalkButton] : []);
|
||||
btns.forEach(btn => {
|
||||
html += '<div class="bg-gray-50 rounded-lg p-3 text-sm"><span class="font-medium">' + (btn.Name || '-') + '</span> <span class="text-xs text-gray-500">(' + (btn.ButtonType || '-') + ')</span>';
|
||||
if (btn.Url1) html += '<br><span class="text-xs text-blue-600">' + btn.Url1 + '</span>';
|
||||
html += '</div>';
|
||||
html += '<div class="bg-gray-50 rounded-lg p-3 text-sm flex items-start gap-3">';
|
||||
html += '<span class="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-xs font-mono shrink-0">' + (btn.ButtonType || '-') + '</span>';
|
||||
html += '<div class="min-w-0">';
|
||||
html += '<div class="font-medium">' + escHtml(btn.Name || '-') + ' <span class="text-xs text-gray-400 font-normal">' + btnTypeLabel(btn.ButtonType) + '</span></div>';
|
||||
if (btn.Url1) html += '<div class="text-xs text-blue-600 break-all mt-0.5">모바일: ' + escHtml(btn.Url1) + '</div>';
|
||||
if (btn.Url2 && btn.Url2 !== btn.Url1) html += '<div class="text-xs text-blue-600 break-all">PC: ' + escHtml(btn.Url2) + '</div>';
|
||||
html += '</div></div>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// 채널 ID
|
||||
if (tpl.ChannelId) {
|
||||
html += '<div class="text-xs text-gray-400 pt-2 border-t border-gray-100">채널: ' + escHtml(tpl.ChannelId) + '</div>';
|
||||
}
|
||||
|
||||
content.innerHTML = html;
|
||||
document.getElementById('template-detail-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user