diff --git a/app/admin/sms/hooks/useSmsRegistry.ts b/app/admin/sms/hooks/useSmsRegistry.ts new file mode 100644 index 00000000..c430c6d7 --- /dev/null +++ b/app/admin/sms/hooks/useSmsRegistry.ts @@ -0,0 +1,12 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { smsService } from '@/app/services/sms'; + +export function useSmsRegistry() { + return useQuery({ + queryKey: ['sms-registry'], + queryFn: () => smsService.getRegistry(), + staleTime: 5 * 60 * 1000, + }); +} diff --git a/app/admin/sms/registry/page.tsx b/app/admin/sms/registry/page.tsx new file mode 100644 index 00000000..56fe0ad9 --- /dev/null +++ b/app/admin/sms/registry/page.tsx @@ -0,0 +1,5 @@ +import SmsRegistryScreen from './sms-registry'; + +export default function SmsRegistryPage() { + return ; +} diff --git a/app/admin/sms/registry/sms-registry.tsx b/app/admin/sms/registry/sms-registry.tsx new file mode 100644 index 00000000..691b2b3b --- /dev/null +++ b/app/admin/sms/registry/sms-registry.tsx @@ -0,0 +1,347 @@ +// TITLE: - SMS 문자 명부 (레지스트리 조회, 읽기 전용) +'use client'; + +import { useMemo, useState } from 'react'; +import { X } from 'lucide-react'; +import type { + SmsLegalClass, + SmsRegistryCategory, + SmsRegistryEntry, +} from '@/app/services/sms'; +import { useSmsRegistry } from '../hooks/useSmsRegistry'; + +const CATEGORY_OPTIONS: Array<{ value: '' | SmsRegistryCategory; label: string }> = [ + { value: '', label: '전체 카테고리' }, + { value: 'auth', label: 'auth' }, + { value: 'transactional', label: 'transactional' }, + { value: 'retention', label: 'retention' }, + { value: 'marketing', label: 'marketing' }, + { value: 'admin', label: 'admin' }, +]; + +const LEGAL_STYLE: Record = { + informational: 'bg-sky-50 text-sky-800 border-sky-200', + advertising: 'bg-amber-50 text-amber-900 border-amber-200', +}; + +const LEGAL_LABEL: Record = { + informational: '정보성', + advertising: '광고성', +}; + +const QUIET_LABEL: Record = { + allow: '허용', + block: '차단', + defer: '연기', +}; + +function LegalBadge({ legalClass }: { legalClass: SmsLegalClass }) { + return ( + + {LEGAL_LABEL[legalClass]} + + ); +} + +function PreviewModal({ + entry, + onClose, +}: { + entry: SmsRegistryEntry; + onClose: () => void; +}) { + const ko = entry.template.ko; + const ja = entry.template.ja; + + return ( +
+
+
+
+

{entry.smsType}

+

+ {entry.category} · {LEGAL_LABEL[entry.legalClass]} · {entry.msgType} +

+
+ +
+ +
+
+
+
수신 대상
+
{entry.recipient}
+
+
+
성별
+
{entry.gender}
+
+
+
copyType
+
{entry.copyType}
+
+
+
야간 정책
+
+ {QUIET_LABEL[entry.policy.quietHours] ?? entry.policy.quietHours} +
+
+
+
동의 필요
+
+ {entry.policy.consentRequired ? '예' : '아니오'} +
+
+
+
캠페인 링크
+
+ {entry.link?.campaign ?? '-'} +
+
+
+ + {entry.evidence.length > 0 && ( +
+
evidence
+
+ {entry.evidence.map((field) => ( + + {field} + + ))} +
+
+ )} + + {ko && ( +
+
+
KO 미리보기
+
+ {ko.byteLength}B · {ko.effectiveMsgType} + {ko.exceedsSmsLimit ? ' · SMS 한도 초과' : ''} +
+
+
+								{ko.preview}
+							
+
+ )} + + {ja && ( +
+
+
JA 미리보기
+
+ {ja.byteLength}B · {ja.effectiveMsgType} +
+
+
+								{ja.preview}
+							
+
+ )} + +

+ 조회 전용입니다. 이 화면에서 발송·수정할 수 없습니다. +

+
+
+
+ ); +} + +function SmsRegistryContent() { + const { data, isLoading, error } = useSmsRegistry(); + const [category, setCategory] = useState<'' | SmsRegistryCategory>(''); + const [legalClass, setLegalClass] = useState<'' | SmsLegalClass>(''); + const [selected, setSelected] = useState(null); + + const rows = useMemo(() => { + const defs = data?.definitions ?? {}; + return Object.values(defs) + .filter((entry) => (category ? entry.category === category : true)) + .filter((entry) => (legalClass ? entry.legalClass === legalClass : true)) + .sort((a, b) => a.smsType.localeCompare(b.smsType)); + }, [data, category, legalClass]); + + return ( +
+
+

문자 명부

+

+ 코드 레지스트리에 등록된 SMS 종류·정책·문안 미리보기입니다. (조회 전용) +

+ {data && ( +

+ version {data.version} · 총 {data.stats.total}종 + {data.stats.byLegalClass.informational != null && + ` · 정보성 ${data.stats.byLegalClass.informational}`} + {data.stats.byLegalClass.advertising != null && + ` · 광고성 ${data.stats.byLegalClass.advertising}`} +

+ )} +
+ +
+
+ + +
+
+ + +
+
+ + {isLoading && ( +
+ 불러오는 중… +
+ )} + + {error && ( +
+ 문자 명부를 불러오지 못했습니다.{' '} + {error instanceof Error ? error.message : String(error)} +
+ )} + + {!isLoading && !error && ( +
+ {rows.length === 0 ? ( +
+ 조건에 맞는 문자 종류가 없습니다. +
+ ) : ( +
+ + + + + + + + + + + + + + + {rows.map((entry) => { + const ko = entry.template.ko; + const rowTint = + entry.legalClass === 'advertising' + ? 'bg-amber-50/40' + : ''; + return ( + + + + + + + + + + + ); + })} + +
smsType법적 분류카테고리야간동의타입바이트미리보기
+ {entry.smsType} + + + + {entry.category} + + {QUIET_LABEL[entry.policy.quietHours] ?? + entry.policy.quietHours} + + {entry.policy.consentRequired ? '필요' : '-'} + + {entry.msgType} + + {ko ? ( + + {ko.byteLength}B + + ) : ( + '-' + )} + + +
+
+ )} +
+ )} + + {selected && setSelected(null)} />} +
+ ); +} + +export default function SmsRegistryScreen() { + return ; +} diff --git a/app/services/sms.ts b/app/services/sms.ts index b32c6f71..7a1e996a 100644 --- a/app/services/sms.ts +++ b/app/services/sms.ts @@ -25,6 +25,7 @@ const SMS_ENDPOINTS = { JOB_STATUS: (jobId: string) => `/admin/v2/sms/jobs/${jobId}`, JOBS: '/admin/v2/sms/jobs', JOB_FAILURES: (jobId: string) => `/admin/v2/sms/jobs/${jobId}/failures`, + REGISTRY: '/admin/v2/sms/registry', } as const; // MARK: - 필터 기반 발송 타입 @@ -163,6 +164,53 @@ export interface SmsJobFailureItem { createdAt: string; } + +// MARK: - SMS 문자 명부 (레지스트리, 읽기 전용) +export type SmsLegalClass = 'informational' | 'advertising'; +export type SmsRegistryCategory = 'auth' | 'transactional' | 'retention' | 'marketing' | 'admin'; + +export interface SmsRegistryTemplatePreview { + raw: string; + preview: string; + byteLength: number; + effectiveMsgType: 'SMS' | 'LMS'; + exceedsSmsLimit: boolean; +} + +export interface SmsRegistryEntry { + smsType: string; + recipient: 'user' | 'phone'; + category: SmsRegistryCategory; + legalClass: SmsLegalClass; + copyType: string; + gender: string; + trigger: { type: 'event' } | { type: 'cron'; schedule: string }; + audience: { type: 'single' } | { type: 'query'; resolver: string }; + template: Partial>; + link: { campaign: string } | null; + policy: { + quietHours: 'allow' | 'block' | 'defer'; + consentRequired: boolean; + allowSuspended: boolean; + throttle: { key: string; ttlSeconds: number } | null; + weeklyCap: number | null; + campaignCap: { touches: number; windowDays: number } | null; + }; + evidence: string[]; + msgType: 'SMS' | 'LMS'; +} + +export interface SmsAdminRegistryResponse { + version: string; + definitions: Record; + stats: { + total: number; + byCategory: Record; + byLegalClass: Record; + byMsgType: Record; + }; +} + // MARK: - 로컬스토리지 헬퍼 함수 class LocalStorageHelper { static getItem(key: string, defaultValue?: T): T | null { @@ -387,6 +435,18 @@ export const smsService = { throw new SmsApiError(`발송 실패 목록 조회 실패 (${jobId})`, error); } }, + + async getRegistry(): Promise { + try { + const res = await adminGet<{ data: SmsAdminRegistryResponse } | SmsAdminRegistryResponse>( + SMS_ENDPOINTS.REGISTRY, + ); + const body = (res as { data?: SmsAdminRegistryResponse }).data ?? (res as SmsAdminRegistryResponse); + return body; + } catch (error) { + throw new SmsApiError('SMS 문자 명부 조회 실패', error); + } + }, }; diff --git a/shared/ui/admin/sidebar.tsx b/shared/ui/admin/sidebar.tsx index 55cde494..6cfef87d 100644 --- a/shared/ui/admin/sidebar.tsx +++ b/shared/ui/admin/sidebar.tsx @@ -127,6 +127,7 @@ export const NAV_CATEGORIES: NavCategory[] = [ label: 'SMS 관리', children: [ { href: '/admin/sms', label: 'SMS 발송' }, + { href: '/admin/sms/registry', label: '문자 명부' }, { href: '/admin/sms/jobs', label: '대량 발송 이력' }, ], },