diff --git a/app/admin/sms/hooks/useSmsJobs.ts b/app/admin/sms/hooks/useSmsJobs.ts new file mode 100644 index 00000000..a2d8d148 --- /dev/null +++ b/app/admin/sms/hooks/useSmsJobs.ts @@ -0,0 +1,19 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { smsService, type SmsJobListParams } from '@/app/services/sms'; + +export function useSmsJobs(params: SmsJobListParams) { + return useQuery({ + queryKey: ['sms-jobs', params], + queryFn: () => smsService.getJobs(params), + }); +} + +export function useSmsJobFailures(jobId: string | null, page: number, limit = 20) { + return useQuery({ + queryKey: ['sms-job-failures', jobId, page, limit], + queryFn: () => smsService.getJobFailures(jobId!, { page, limit }), + enabled: !!jobId, + }); +} diff --git a/app/admin/sms/jobs/page.tsx b/app/admin/sms/jobs/page.tsx new file mode 100644 index 00000000..cf1e4980 --- /dev/null +++ b/app/admin/sms/jobs/page.tsx @@ -0,0 +1,5 @@ +import SmsJobsScreen from './sms-jobs'; + +export default function SmsJobsPage() { + return ; +} diff --git a/app/admin/sms/jobs/sms-jobs.tsx b/app/admin/sms/jobs/sms-jobs.tsx new file mode 100644 index 00000000..4f58421c --- /dev/null +++ b/app/admin/sms/jobs/sms-jobs.tsx @@ -0,0 +1,435 @@ +// TITLE: - SMS 대량 발송 이력 조회 (읽기 전용) +'use client'; + +import { useState } from 'react'; +import { ChevronLeft, ChevronRight, X } from 'lucide-react'; +import type { + RecipientFilterSummary, + SmsJobListItem, + SmsJobStatus, +} from '@/app/services/sms'; +import { getAdminErrorMessage } from '@/shared/lib/http/admin-fetch'; +import { useSmsJobFailures, useSmsJobs } from '../hooks/useSmsJobs'; + +const PAGE_LIMIT = 20; +const FAILURES_PAGE_LIMIT = 20; + +const STATUS_OPTIONS: Array<{ value: '' | SmsJobStatus; label: string }> = [ + { value: '', label: '전체 상태' }, + { value: 'QUEUED', label: '대기중' }, + { value: 'RUNNING', label: '발송중' }, + { value: 'COMPLETED', label: '완료' }, + { value: 'FAILED', label: '실패' }, +]; + +const STATUS_LABEL: Record = { + QUEUED: '대기중', + RUNNING: '발송중', + COMPLETED: '완료', + FAILED: '실패', +}; + +const STATUS_STYLE: Record = { + QUEUED: 'text-[#1F2937] bg-[#F3F4F6] border border-[#D1D5DB]', + RUNNING: 'text-white bg-[#3B82F6]', + COMPLETED: 'text-white bg-[#885AEB]', + FAILED: 'text-white bg-red-500', +}; + +// UTC ISO 문자열 → KST 'YYYY-MM-DD HH:mm' +function formatKstDateTime(iso: string | null): string { + if (!iso) return '-'; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' }).slice(0, 16); +} + +function summarizeFilter(summary: RecipientFilterSummary): string { + if (summary.userIdCount) return `지정 유저 ${summary.userIdCount}명`; + const parts: string[] = []; + if (summary.regionCodeCount) parts.push(`지역 ${summary.regionCodeCount}개`); + if (summary.universityIdCount) parts.push(`학교 ${summary.universityIdCount}개`); + if (summary.gender) parts.push(summary.gender === 'MALE' ? '남성' : '여성'); + if (summary.excludeUserIdCount) parts.push(`제외 ${summary.excludeUserIdCount}명`); + return parts.length ? parts.join(' · ') : '전체 대상'; +} + +function StatusBadge({ status }: { status: SmsJobStatus }) { + return ( + + {STATUS_LABEL[status] ?? status} + + ); +} + +// MARK: - 실패 사유 모달 +function JobDetailModal({ job, onClose }: { job: SmsJobListItem; onClose: () => void }) { + const [page, setPage] = useState(1); + const { data, isLoading, error } = useSmsJobFailures(job.id, page, FAILURES_PAGE_LIMIT); + + const failures = data?.data ?? []; + const meta = data?.meta; + + return ( +
+
+ {/* 헤더 */} +
+
+

발송 잡 상세

+

{job.id}

+
+ +
+ +
+ {/* 잡 요약 */} +
+
+
발송 일시
+
{formatKstDateTime(job.createdAt)}
+
+
+
상태
+ +
+
+
대상 / 성공 / 실패
+
+ {job.totalCount} / {job.sentCount} /{' '} + 0 ? 'text-red-600 font-medium' : ''}> + {job.failedCount} + +
+
+
+
대상 조건
+
{summarizeFilter(job.filterSummary)}
+
+
+
유형
+
{job.type}
+
+
+
요청자
+
{job.createdBy}
+
+
+
시작
+
{formatKstDateTime(job.startedAt)}
+
+
+
종료
+
{formatKstDateTime(job.completedAt)}
+
+
+ + {/* 메시지 전문 */} +
+
메시지 (미리보기 100자)
+
+							{job.messagePreview}
+						
+
+ + {/* 실패 목록 */} +
+
+

+ 실패 사유 목록{meta ? ` (총 ${meta.total}건)` : ''} +

+ {meta && meta.totalPages > 1 && ( +
+ + + {page} / {meta.totalPages} + + +
+ )} +
+ + {isLoading ? ( +
+
+
+ ) : error ? ( +

+ {getAdminErrorMessage(error, '실패 목록을 불러오지 못했습니다.')} +

+ ) : failures.length === 0 ? ( +

실패 건이 없습니다.

+ ) : ( +
+ + + + + + + + + + + {failures.map((failure) => ( + + + + + + + ))} + +
시각전화번호유저 ID실패 사유
+ {formatKstDateTime(failure.createdAt)} + + {failure.phoneNumber ?? '-'} + + {failure.userId ?? '-'} + + {failure.errorMessage ?? '-'} +
+
+ )} +
+
+
+
+ ); +} + +// MARK: - 대량 발송 이력 화면 +function SmsJobsContent() { + const [page, setPage] = useState(1); + const [status, setStatus] = useState<'' | SmsJobStatus>(''); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [selectedJob, setSelectedJob] = useState(null); + + const { data, isLoading, error } = useSmsJobs({ + page, + limit: PAGE_LIMIT, + status: status || undefined, + startDate: startDate || undefined, + endDate: endDate || undefined, + }); + + const jobs = data?.data ?? []; + const meta = data?.meta; + + const resetToFirstPage = () => setPage(1); + + return ( +
+
+

대량 발송 이력

+

+ 필터 기반 대량 SMS 발송 잡 이력입니다. 잡을 클릭하면 실패 사유를 확인할 수 + 있습니다. (조회 전용) +

+
+ + {/* MARK: - 필터 */} +
+
+ + { + setStartDate(e.target.value); + resetToFirstPage(); + }} + className='border border-[#D1D5DB] rounded-md px-3 py-2 text-sm' + /> +
+
+ + { + setEndDate(e.target.value); + resetToFirstPage(); + }} + className='border border-[#D1D5DB] rounded-md px-3 py-2 text-sm' + /> +
+
+ + +
+ {(startDate || endDate || status) && ( + + )} +
+ + {/* MARK: - 잡 목록 */} +
+ {isLoading ? ( +
+
+
+ ) : error ? ( +

+ {getAdminErrorMessage(error, '이력을 불러오지 못했습니다.')} +

+ ) : jobs.length === 0 ? ( +

발송 이력이 없습니다.

+ ) : ( +
+ + + + + + + + + + + + + + {jobs.map((job) => ( + setSelectedJob(job)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSelectedJob(job); + } + }} + tabIndex={0} + role='button' + aria-label={`${formatKstDateTime(job.createdAt)} 발송 잡 상세 보기`} + className='border-b border-[#F3F4F6] cursor-pointer hover:bg-[#F9FAFB] focus:outline-none focus-visible:bg-[#F3F0FC]' + > + + + + + + + + + ))} + +
발송 일시메시지유형대상성공실패상태
+ {formatKstDateTime(job.createdAt)} + +
{job.messagePreview}
+
{summarizeFilter(job.filterSummary)}
+
{job.type}{job.totalCount}{job.sentCount} 0 ? 'text-red-600 font-medium' : 'text-[#374151]'}`} + > + {job.failedCount} + + +
+
+ )} + + {/* MARK: - 페이지네이션 */} + {meta && meta.total > 0 && ( +
+ 총 {meta.total}건 +
+ + + {meta.page} / {Math.max(1, meta.totalPages)} + + +
+
+ )} +
+ + {selectedJob && setSelectedJob(null)} />} +
+ ); +} + +export default function SmsJobsScreen() { + return ; +} diff --git a/app/services/sms.ts b/app/services/sms.ts index d3bfc833..b32c6f71 100644 --- a/app/services/sms.ts +++ b/app/services/sms.ts @@ -23,6 +23,8 @@ const SMS_ENDPOINTS = { RECIPIENTS_PREVIEW: '/admin/v2/sms/recipients/preview', SEND_BY_FILTER: '/admin/v2/sms/send-by-filter', JOB_STATUS: (jobId: string) => `/admin/v2/sms/jobs/${jobId}`, + JOBS: '/admin/v2/sms/jobs', + JOB_FAILURES: (jobId: string) => `/admin/v2/sms/jobs/${jobId}/failures`, } as const; // MARK: - 필터 기반 발송 타입 @@ -112,6 +114,55 @@ export interface JobStatus { completedAt?: string; } +// MARK: - 대량 발송 이력 (읽기 전용) +export interface PaginationMeta { + page: number; + limit: number; + total: number; + totalPages: number; +} + +export interface SmsJobListParams { + page?: number; + limit?: number; + status?: SmsJobStatus; + startDate?: string; // YYYY-MM-DD (KST) + endDate?: string; // YYYY-MM-DD (KST) +} + +/** 목록 응답용 필터 요약 — API가 원본 ID 배열 대신 건수만 내려준다 (PII·응답 크기 절감) */ +export interface RecipientFilterSummary { + gender: string | null; + userIdCount: number; + excludeUserIdCount: number; + regionCodeCount: number; + universityIdCount: number; +} + +export interface SmsJobListItem { + id: string; + status: SmsJobStatus; + type: SmsJobType; + /** 본문 앞 100자 미리보기 — 전문은 목록 API에 내려오지 않는다 */ + messagePreview: string; + filterSummary: RecipientFilterSummary; + totalCount: number; + sentCount: number; + failedCount: number; + createdBy: string; + createdAt: string; + startedAt: string | null; + completedAt: string | null; +} + +export interface SmsJobFailureItem { + id: string; + userId: string | null; + phoneNumber: string | null; + errorMessage: string | null; + createdAt: string; +} + // MARK: - 로컬스토리지 헬퍼 함수 class LocalStorageHelper { static getItem(key: string, defaultValue?: T): T | null { @@ -298,6 +349,44 @@ export const smsService = { throw new SmsApiError(`작업 상태 조회 실패 (${jobId})`, error); } }, + + async getJobs( + params: SmsJobListParams = {}, + ): Promise<{ data: SmsJobListItem[]; meta: PaginationMeta }> { + try { + const stringParams: Record = {}; + if (params.page != null) stringParams.page = String(params.page); + if (params.limit != null) stringParams.limit = String(params.limit); + if (params.status) stringParams.status = params.status; + if (params.startDate) stringParams.startDate = params.startDate; + if (params.endDate) stringParams.endDate = params.endDate; + + return await adminGet<{ data: SmsJobListItem[]; meta: PaginationMeta }>( + SMS_ENDPOINTS.JOBS, + stringParams, + ); + } catch (error) { + throw new SmsApiError('대량 발송 이력 조회 실패', error); + } + }, + + async getJobFailures( + jobId: string, + params: { page?: number; limit?: number } = {}, + ): Promise<{ data: SmsJobFailureItem[]; meta: PaginationMeta }> { + try { + const stringParams: Record = {}; + if (params.page != null) stringParams.page = String(params.page); + if (params.limit != null) stringParams.limit = String(params.limit); + + return await adminGet<{ data: SmsJobFailureItem[]; meta: PaginationMeta }>( + SMS_ENDPOINTS.JOB_FAILURES(jobId), + stringParams, + ); + } catch (error) { + throw new SmsApiError(`발송 실패 목록 조회 실패 (${jobId})`, error); + } + }, }; diff --git a/shared/ui/admin/sidebar.tsx b/shared/ui/admin/sidebar.tsx index f82f51d5..55cde494 100644 --- a/shared/ui/admin/sidebar.tsx +++ b/shared/ui/admin/sidebar.tsx @@ -122,7 +122,14 @@ export const NAV_CATEGORIES: NavCategory[] = [ icon: '📢', label: '마케팅', items: [ - { href: '/admin/sms', label: 'SMS 관리' }, + { + id: 'sms', + label: 'SMS 관리', + children: [ + { href: '/admin/sms', label: 'SMS 발송' }, + { href: '/admin/sms/jobs', label: '대량 발송 이력' }, + ], + }, { href: '/admin/push-notifications', label: '푸시 알림 관리' }, { href: '/admin/push-groups', label: '푸시 타겟 그룹' }, { href: '/admin/broadcast-push', label: '예약 푸시 발송' },