From e0f04c65e1993a9224fb5eaabb399a2417726066 Mon Sep 17 00:00:00 2001 From: smartnewbie Date: Sat, 18 Jul 2026 19:04:40 +0900 Subject: [PATCH 1/2] feat(admin): add SMS bulk send job history screen (read-only) --- app/admin/sms/hooks/useSmsJobs.ts | 19 ++ app/admin/sms/jobs/page.tsx | 5 + app/admin/sms/jobs/sms-jobs.tsx | 435 ++++++++++++++++++++++++++++++ app/services/sms.ts | 79 ++++++ shared/ui/admin/sidebar.tsx | 9 +- 5 files changed, 546 insertions(+), 1 deletion(-) create mode 100644 app/admin/sms/hooks/useSmsJobs.ts create mode 100644 app/admin/sms/jobs/page.tsx create mode 100644 app/admin/sms/jobs/sms-jobs.tsx 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..7eccb4fe --- /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 { + RecipientFilter, + 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(filter: RecipientFilter): string { + if (filter.userIds?.length) return `지정 유저 ${filter.userIds.length}명`; + const parts: string[] = []; + if (filter.regionCodes?.length) parts.push(`지역 ${filter.regionCodes.length}개`); + if (filter.universityIds?.length) parts.push(`학교 ${filter.universityIds.length}개`); + if (filter.gender) parts.push(filter.gender === 'MALE' ? '남성' : '여성'); + if (filter.excludeUserIds?.length) parts.push(`제외 ${filter.excludeUserIds.length}명`); + 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.filter)}
+
+
+
유형
+
{job.type}
+
+
+
요청자
+
{job.createdBy}
+
+
+
시작
+
{formatKstDateTime(job.startedAt)}
+
+
+
종료
+
{formatKstDateTime(job.completedAt)}
+
+
+ + {/* 메시지 전문 */} +
+
메시지
+
+							{job.message}
+						
+
+ + {/* 실패 목록 */} +
+
+

+ 실패 사유 목록{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.message}
+
{summarizeFilter(job.filter)}
+
{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..7c6d5c83 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,45 @@ 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) +} + +export interface SmsJobListItem { + id: string; + status: SmsJobStatus; + type: SmsJobType; + message: string; + filter: RecipientFilter; + 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 +339,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: '예약 푸시 발송' }, From 686b8cc5eb6bbeae371dddfab36579d88644479c Mon Sep 17 00:00:00 2001 From: smartnewbie Date: Sun, 19 Jul 2026 14:14:15 +0900 Subject: [PATCH 2/2] fix(admin): align sms jobs screen with slimmed list API contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - filter → filterSummary(건수), message → messagePreview(100자) — api #305 응답 경량화 동기화 - 모달 라벨을 '메시지 (미리보기 100자)'로 정직하게 표기 --- app/admin/sms/jobs/sms-jobs.tsx | 24 ++++++++++++------------ app/services/sms.ts | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/app/admin/sms/jobs/sms-jobs.tsx b/app/admin/sms/jobs/sms-jobs.tsx index 7eccb4fe..4f58421c 100644 --- a/app/admin/sms/jobs/sms-jobs.tsx +++ b/app/admin/sms/jobs/sms-jobs.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { ChevronLeft, ChevronRight, X } from 'lucide-react'; import type { - RecipientFilter, + RecipientFilterSummary, SmsJobListItem, SmsJobStatus, } from '@/app/services/sms'; @@ -44,13 +44,13 @@ function formatKstDateTime(iso: string | null): string { return date.toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' }).slice(0, 16); } -function summarizeFilter(filter: RecipientFilter): string { - if (filter.userIds?.length) return `지정 유저 ${filter.userIds.length}명`; +function summarizeFilter(summary: RecipientFilterSummary): string { + if (summary.userIdCount) return `지정 유저 ${summary.userIdCount}명`; const parts: string[] = []; - if (filter.regionCodes?.length) parts.push(`지역 ${filter.regionCodes.length}개`); - if (filter.universityIds?.length) parts.push(`학교 ${filter.universityIds.length}개`); - if (filter.gender) parts.push(filter.gender === 'MALE' ? '남성' : '여성'); - if (filter.excludeUserIds?.length) parts.push(`제외 ${filter.excludeUserIds.length}명`); + 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(' · ') : '전체 대상'; } @@ -118,7 +118,7 @@ function JobDetailModal({ job, onClose }: { job: SmsJobListItem; onClose: () =>
대상 조건
-
{summarizeFilter(job.filter)}
+
{summarizeFilter(job.filterSummary)}
유형
@@ -140,9 +140,9 @@ function JobDetailModal({ job, onClose }: { job: SmsJobListItem; onClose: () => {/* 메시지 전문 */}
-
메시지
+
메시지 (미리보기 100자)
-							{job.message}
+							{job.messagePreview}
 						
@@ -373,8 +373,8 @@ function SmsJobsContent() { {formatKstDateTime(job.createdAt)} -
{job.message}
-
{summarizeFilter(job.filter)}
+
{job.messagePreview}
+
{summarizeFilter(job.filterSummary)}
{job.type} {job.totalCount} diff --git a/app/services/sms.ts b/app/services/sms.ts index 7c6d5c83..b32c6f71 100644 --- a/app/services/sms.ts +++ b/app/services/sms.ts @@ -130,12 +130,22 @@ export interface SmsJobListParams { 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; - message: string; - filter: RecipientFilter; + /** 본문 앞 100자 미리보기 — 전문은 목록 API에 내려오지 않는다 */ + messagePreview: string; + filterSummary: RecipientFilterSummary; totalCount: number; sentCount: number; failedCount: number;