From 13454b49ce50b5add0fde9fa60c93b22edd6c4dc Mon Sep 17 00:00:00 2001 From: smartnewbie Date: Wed, 8 Jul 2026 17:23:40 +0900 Subject: [PATCH] feat(admin): add policy revision registration screen (Phase 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin screen "정책 개정 등록" for the policy-document revision notice system. - app/admin/policy-documents: list (status filter, publish action, consent-progress dialog) + create form - PolicyDocumentForm: picks documentType, shows only the relevant decision checklist (5축 / 불리·중대 / 매체·광고·야간 / 범위확대), handles 200-response gate outcomes (saved / blockers / warnings+ack) - content.ts policyDocuments API + use-content hooks + zod schema + types/admin + sidebar link Frontend only. Calls sometimes-api admin/v2/content/policy-documents. Merge AFTER the sometimes-api Phase 1b API is deployed (else 404). --- app/admin/hooks/forms/index.ts | 1 + .../forms/schemas/policy-document.schema.ts | 83 +++ app/admin/hooks/use-content.ts | 59 +- .../components/PolicyDocumentForm.tsx | 658 ++++++++++++++++++ app/admin/policy-documents/create/page.tsx | 7 + app/admin/policy-documents/page.tsx | 293 ++++++++ app/services/admin/content.ts | 74 ++ app/services/admin/index.ts | 4 + shared/ui/admin/sidebar.tsx | 1 + types/admin.ts | 123 ++++ 10 files changed, 1302 insertions(+), 1 deletion(-) create mode 100644 app/admin/hooks/forms/schemas/policy-document.schema.ts create mode 100644 app/admin/policy-documents/components/PolicyDocumentForm.tsx create mode 100644 app/admin/policy-documents/create/page.tsx create mode 100644 app/admin/policy-documents/page.tsx diff --git a/app/admin/hooks/forms/index.ts b/app/admin/hooks/forms/index.ts index 9caad85b..bec60ec3 100644 --- a/app/admin/hooks/forms/index.ts +++ b/app/admin/hooks/forms/index.ts @@ -14,3 +14,4 @@ export * from './schemas/community.schema'; export * from './schemas/ios-refund.schema'; export * from './schemas/sometime-article.schema'; export * from './schemas/longform.schema'; +export * from './schemas/policy-document.schema'; diff --git a/app/admin/hooks/forms/schemas/policy-document.schema.ts b/app/admin/hooks/forms/schemas/policy-document.schema.ts new file mode 100644 index 00000000..026e417d --- /dev/null +++ b/app/admin/hooks/forms/schemas/policy-document.schema.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; + +export const policyDocumentTypeSchema = z.enum([ + 'TERMS_OF_SERVICE', + 'PRIVACY_POLICY', + 'DATA_COLLECTION_CONSENT', + 'SENSITIVE_INFO_CONSENT', + 'THIRD_PARTY_PROVISION', + 'MARKETING_CONSENT', + 'REFUND_POLICY', + 'LBS_TERMS', + 'LOCATION_INFO_CONSENT', + 'CHILD_SAFETY_POLICY', +]); + +export const policyDocumentSchema = z + .object({ + documentType: policyDocumentTypeSchema, + version: z.string().min(1, '버전을 입력해주세요.'), + diffSummary: z.string().min(1, '신구대조 내용을 입력해주세요.'), + changeReason: z.string().min(1, '변경사유를 입력해주세요.'), + contentUrl: z + .string() + .optional() + .refine((v) => !v || /^https?:\/\/.+/.test(v), { + message: '올바른 URL 형식을 입력해주세요.', + }), + noticeStartedAt: z.string().min(1, '공지 시작일을 입력해주세요.'), + effectiveAt: z.string().min(1, '시행일을 입력해주세요.'), + noticeVisibleUntil: z.string().optional(), + isMandatory: z.boolean(), + + // 5축 (개인정보처리방침 / 수집이용동의 / 민감정보동의 / 제3자제공동의) + axisCollectionItems: z.boolean().optional(), + axisPurpose: z.boolean().optional(), + axisRetentionPeriod: z.boolean().optional(), + axisThirdParty: z.boolean().optional(), + axisSensitiveInfo: z.boolean().optional(), + + // 불리·중대 변경 (이용약관 / 환불정책) + adverseOrMaterial: z.boolean().optional(), + + // 마케팅 수신동의 확대 항목 + marketingMediaExpanded: z.boolean().optional(), + marketingAdTypeExpanded: z.boolean().optional(), + marketingNightExpanded: z.boolean().optional(), + + // 위치기반서비스 범위 확대 + locationScopeExpanded: z.boolean().optional(), + + // 재동의 예외 처리 + reconsentOverride: z.boolean(), + reconsentOverrideReason: z.string().optional(), + + // 민감정보 동의 - 개인정보처리방침 §23③ 반영 확인 + privacyPolicyDisclosureConfirmed: z.boolean().optional(), + + // 필수 항목 - 최소수집 원칙 확인 + minimalCollectionConfirmed: z.boolean().optional(), + }) + .superRefine((data, ctx) => { + if (data.reconsentOverride && !data.reconsentOverrideReason?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['reconsentOverrideReason'], + message: '재동의 예외 처리 시 사유를 입력해주세요.', + }); + } + + if (data.noticeStartedAt && data.effectiveAt) { + const start = new Date(data.noticeStartedAt); + const effective = new Date(data.effectiveAt); + if (!Number.isNaN(start.getTime()) && !Number.isNaN(effective.getTime()) && effective < start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['effectiveAt'], + message: '시행일은 공지 시작일 이후여야 합니다.', + }); + } + } + }); + +export type PolicyDocumentFormValues = z.infer; diff --git a/app/admin/hooks/use-content.ts b/app/admin/hooks/use-content.ts index b5cb46d6..f2f75515 100644 --- a/app/admin/hooks/use-content.ts +++ b/app/admin/hooks/use-content.ts @@ -9,9 +9,12 @@ import type { CreateNoticeRequest, CreatePresetRequest, CreateSometimeArticleRequest, + PolicyDocumentStatus, + PolicyDocumentType, PublishCardNewsRequest, PublishNoticeRequest, PushResendNoticeRequest, + RegisterPolicyDocumentRequest, UpdateBannerOrderRequest, UpdateBannerRequest, UpdateCardNewsRequest, @@ -22,7 +25,7 @@ import type { UpdateVideoRequest, VideoStatus, } from '@/types/admin'; -import type { AppReviewsParams } from '@/app/services/admin/content'; +import type { AppReviewsParams, PolicyDocumentListParams } from '@/app/services/admin/content'; // Query keys export const contentKeys = { @@ -55,6 +58,12 @@ export const contentKeys = { videos: () => [...contentKeys.all, 'videos'] as const, videoList: (params?: object) => [...contentKeys.videos(), 'list', params] as const, videoDetail: (id: string) => [...contentKeys.videos(), 'detail', id] as const, + policyDocuments: () => [...contentKeys.all, 'policy-documents'] as const, + policyDocumentList: (params?: PolicyDocumentListParams) => + [...contentKeys.policyDocuments(), 'list', params] as const, + policyDocumentDetail: (id: string) => [...contentKeys.policyDocuments(), 'detail', id] as const, + policyDocumentConsentProgress: (id: string) => + [...contentKeys.policyDocuments(), 'consent-progress', id] as const, }; // ==================== Background Presets ==================== @@ -569,3 +578,51 @@ export function useBulkCreateVideos() { }, }); } + +// ==================== 정책 개정 등록 ==================== + +export function usePolicyDocumentList(params: PolicyDocumentListParams = {}) { + return useQuery({ + queryKey: contentKeys.policyDocumentList(params), + queryFn: () => AdminService.policyDocuments.getList(params), + }); +} + +export function usePolicyDocument(id: string) { + return useQuery({ + queryKey: contentKeys.policyDocumentDetail(id), + queryFn: () => AdminService.policyDocuments.get(id), + enabled: !!id, + }); +} + +export function useRegisterPolicyDocument() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: RegisterPolicyDocumentRequest) => AdminService.policyDocuments.register(data), + onSuccess: (result) => { + if (result.saved) { + queryClient.invalidateQueries({ queryKey: contentKeys.policyDocuments() }); + } + }, + }); +} + +export function usePublishPolicyDocument() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => AdminService.policyDocuments.publish(id), + onSuccess: (_result, id) => { + queryClient.invalidateQueries({ queryKey: contentKeys.policyDocuments() }); + queryClient.invalidateQueries({ queryKey: contentKeys.policyDocumentDetail(id) }); + }, + }); +} + +export function usePolicyConsentProgress(id: string) { + return useQuery({ + queryKey: contentKeys.policyDocumentConsentProgress(id), + queryFn: () => AdminService.policyDocuments.consentProgress(id), + enabled: !!id, + }); +} diff --git a/app/admin/policy-documents/components/PolicyDocumentForm.tsx b/app/admin/policy-documents/components/PolicyDocumentForm.tsx new file mode 100644 index 00000000..2a309ac6 --- /dev/null +++ b/app/admin/policy-documents/components/PolicyDocumentForm.tsx @@ -0,0 +1,658 @@ +'use client'; + +import { useState } from 'react'; +import { Controller } from 'react-hook-form'; +import { + Box, + Typography, + TextField, + Button, + Paper, + Alert, + AlertTitle, + CircularProgress, + FormControl, + InputLabel, + Select, + MenuItem, + FormControlLabel, + Checkbox, + Switch, + Divider, +} from '@mui/material'; +import SaveIcon from '@mui/icons-material/Save'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { useRouter } from 'next/navigation'; +import { useAdminForm } from '@/app/admin/hooks/forms'; +import { + policyDocumentSchema, + type PolicyDocumentFormValues, +} from '@/app/admin/hooks/forms/schemas/policy-document.schema'; +import { useRegisterPolicyDocument } from '@/app/admin/hooks'; +import { useUnsavedGuard } from '@/app/admin/hooks/use-unsaved-guard'; +import { useToast } from '@/shared/ui/admin/toast/toast-context'; +import { useConfirm } from '@/shared/ui/admin/confirm-dialog/confirm-dialog-context'; +import { getAdminErrorMessage } from '@/shared/lib/http/admin-fetch'; +import type { + PolicyDecision, + PolicyDocumentType, + RegisterPolicyDocumentRequest, +} from '@/types/admin'; + +const DOCUMENT_TYPE_LABELS: Record = { + TERMS_OF_SERVICE: '이용약관', + PRIVACY_POLICY: '개인정보처리방침', + DATA_COLLECTION_CONSENT: '개인정보 수집·이용 동의', + SENSITIVE_INFO_CONSENT: '민감정보 처리 동의', + THIRD_PARTY_PROVISION: '제3자 제공 동의', + MARKETING_CONSENT: '마케팅 수신 동의', + REFUND_POLICY: '환불정책', + LBS_TERMS: '위치기반서비스 이용약관', + LOCATION_INFO_CONSENT: '위치정보 수집 동의', + CHILD_SAFETY_POLICY: '아동 안전 정책', +}; + +const FIVE_AXIS_TYPES: PolicyDocumentType[] = [ + 'PRIVACY_POLICY', + 'DATA_COLLECTION_CONSENT', + 'SENSITIVE_INFO_CONSENT', + 'THIRD_PARTY_PROVISION', +]; +const ADVERSE_TYPES: PolicyDocumentType[] = ['TERMS_OF_SERVICE', 'REFUND_POLICY']; +const LOCATION_TYPES: PolicyDocumentType[] = ['LBS_TERMS', 'LOCATION_INFO_CONSENT']; + +const FIVE_AXIS_FIELDS = [ + ['axisCollectionItems', '수집 항목'], + ['axisPurpose', '이용 목적'], + ['axisRetentionPeriod', '보유·이용 기간'], + ['axisThirdParty', '제3자 제공'], + ['axisSensitiveInfo', '민감정보 처리'], +] as const; + +const MARKETING_FIELDS = [ + ['marketingMediaExpanded', '수신 매체 확대 (문자/이메일/앱푸시 등)'], + ['marketingAdTypeExpanded', '광고 유형 확대'], + ['marketingNightExpanded', '야간 광고 수신 확대'], +] as const; + +const DEFAULT_VALUES: PolicyDocumentFormValues = { + documentType: 'TERMS_OF_SERVICE', + version: '', + diffSummary: '', + changeReason: '', + contentUrl: '', + noticeStartedAt: '', + effectiveAt: '', + noticeVisibleUntil: '', + isMandatory: false, + axisCollectionItems: false, + axisPurpose: false, + axisRetentionPeriod: false, + axisThirdParty: false, + axisSensitiveInfo: false, + adverseOrMaterial: false, + marketingMediaExpanded: false, + marketingAdTypeExpanded: false, + marketingNightExpanded: false, + locationScopeExpanded: false, + reconsentOverride: false, + reconsentOverrideReason: '', + privacyPolicyDisclosureConfirmed: false, + minimalCollectionConfirmed: false, +}; + +function toIsoString(localDateTime: string): string { + return new Date(localDateTime).toISOString(); +} + +function buildPayload( + data: PolicyDocumentFormValues, + acknowledgeWarnings: boolean, +): RegisterPolicyDocumentRequest { + const payload: RegisterPolicyDocumentRequest = { + documentType: data.documentType, + version: data.version.trim(), + diffSummary: data.diffSummary.trim(), + changeReason: data.changeReason.trim(), + contentUrl: data.contentUrl?.trim() || undefined, + noticeStartedAt: toIsoString(data.noticeStartedAt), + effectiveAt: toIsoString(data.effectiveAt), + noticeVisibleUntil: data.noticeVisibleUntil ? toIsoString(data.noticeVisibleUntil) : undefined, + isMandatory: data.isMandatory, + }; + + if (acknowledgeWarnings) { + payload.acknowledgeWarnings = true; + } + + if (FIVE_AXIS_TYPES.includes(data.documentType)) { + payload.axisCollectionItems = !!data.axisCollectionItems; + payload.axisPurpose = !!data.axisPurpose; + payload.axisRetentionPeriod = !!data.axisRetentionPeriod; + payload.axisThirdParty = !!data.axisThirdParty; + payload.axisSensitiveInfo = !!data.axisSensitiveInfo; + } + + if (ADVERSE_TYPES.includes(data.documentType)) { + payload.adverseOrMaterial = !!data.adverseOrMaterial; + payload.reconsentOverride = !!data.reconsentOverride; + if (data.reconsentOverride) { + payload.reconsentOverrideReason = data.reconsentOverrideReason?.trim(); + } + } + + if (data.documentType === 'MARKETING_CONSENT') { + payload.marketingMediaExpanded = !!data.marketingMediaExpanded; + payload.marketingAdTypeExpanded = !!data.marketingAdTypeExpanded; + payload.marketingNightExpanded = !!data.marketingNightExpanded; + } + + if (LOCATION_TYPES.includes(data.documentType)) { + payload.locationScopeExpanded = !!data.locationScopeExpanded; + } + + if (data.documentType === 'SENSITIVE_INFO_CONSENT') { + payload.privacyPolicyDisclosureConfirmed = !!data.privacyPolicyDisclosureConfirmed; + } + + if (data.isMandatory) { + payload.minimalCollectionConfirmed = !!data.minimalCollectionConfirmed; + } + + return payload; +} + +function IssueList({ items }: { items: string[] }) { + return ( + + {items.map((item, idx) => ( + + {item} + + ))} + + ); +} + +export function PolicyDocumentForm() { + const router = useRouter(); + const toast = useToast(); + const confirmAction = useConfirm(); + const registerMutation = useRegisterPolicyDocument(); + + const [blockers, setBlockers] = useState([]); + const [warnings, setWarnings] = useState([]); + const [decision, setDecision] = useState(null); + const [saved, setSaved] = useState(false); + + const { + control, + watch, + getValues, + handleFormSubmit, + formState: { isSubmitting, isDirty }, + } = useAdminForm({ + schema: policyDocumentSchema, + defaultValues: DEFAULT_VALUES, + }); + + useUnsavedGuard(isDirty && !saved, isSubmitting); + + const documentType = watch('documentType'); + const isMandatory = watch('isMandatory'); + const reconsentOverride = watch('reconsentOverride'); + + const showFiveAxis = FIVE_AXIS_TYPES.includes(documentType); + const showAdverse = ADVERSE_TYPES.includes(documentType); + const showMarketing = documentType === 'MARKETING_CONSENT'; + const showLocation = LOCATION_TYPES.includes(documentType); + const showPrivacyDisclosure = documentType === 'SENSITIVE_INFO_CONSENT'; + + const submit = async (data: PolicyDocumentFormValues, acknowledgeWarnings: boolean) => { + setBlockers([]); + try { + const payload = buildPayload(data, acknowledgeWarnings); + const result = await registerMutation.mutateAsync(payload); + setDecision(result.decision); + + if (result.saved) { + setWarnings([]); + setSaved(true); + toast.success( + `정책 문서가 등록되었습니다. (공지 트랙: ${result.decision.noticeTrack}, 재동의 필요: ${ + result.decision.requiresReconsent ? '예' : '아니오' + })`, + ); + router.push('/admin/policy-documents'); + return; + } + + // saved === false: 200 응답이지만 blockers 또는 warnings로 인해 저장되지 않음 + if (result.blockers.length > 0) { + setBlockers(result.blockers); + setWarnings(result.warnings ?? []); + toast.error(result.message || '등록할 수 없습니다. 아래 항목을 확인해주세요.'); + return; + } + + setWarnings(result.warnings ?? []); + toast.warning(result.message || '경고 사항을 확인한 후 다시 등록해주세요.'); + } catch (err) { + toast.error(getAdminErrorMessage(err, '등록에 실패했습니다.')); + } + }; + + const onSubmit = handleFormSubmit((data) => submit(data, false)); + + const handleAcknowledgeSubmit = async () => { + await submit(getValues(), true); + }; + + const handleCancel = async () => { + if (isDirty && !saved) { + const ok = await confirmAction({ + title: '작성 취소', + message: '작성 중인 내용이 저장되지 않습니다. 취소하시겠습니까?', + }); + if (!ok) return; + } + router.push('/admin/policy-documents'); + }; + + return ( + + + + + 정책 개정 등록 + + + + {blockers.length > 0 && ( + + 등록할 수 없습니다 + + {warnings.length > 0 && ( + <> + + + 추가 경고 사항 + + + + )} + + )} + + {blockers.length === 0 && warnings.length > 0 && ( + + 확인이 필요한 경고 + + + + )} + + {decision && blockers.length === 0 && ( + + 공지 트랙: {decision.noticeTrack} · 재동의 필요: {decision.requiresReconsent ? '예' : '아니오'} + {decision.reconsentAxes.length > 0 && ` · 재동의 축: ${decision.reconsentAxes.join(', ')}`} + {decision.needsLegalReview && ' · 법무 검토 필요'} + + )} + + + + 기본 정보 + + + ( + + 문서 종류 + + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + + + + 공지·시행 일정 + + + + ( + + )} + /> + ( + + )} + /> + + + ( + + )} + /> + + + + + 필수 여부 + + ( + field.onChange(e.target.checked)} />} + label="필수 동의 항목" + /> + )} + /> + + {isMandatory && ( + <> + + ( + field.onChange(e.target.checked)} /> + } + label="최소수집 원칙을 확인했습니다." + /> + )} + /> + + )} + + + {showFiveAxis && ( + + + 5축 변경 체크리스트 + + + 이번 개정에서 변경된 항목을 모두 선택해주세요. + + {FIVE_AXIS_FIELDS.map(([name, label]) => ( + ( + field.onChange(e.target.checked)} /> + } + label={label} + sx={{ display: 'block' }} + /> + )} + /> + ))} + + {showPrivacyDisclosure && ( + <> + + ( + field.onChange(e.target.checked)} /> + } + label="개인정보처리방침에 §23③ 반영 여부를 확인했습니다." + /> + )} + /> + + )} + + )} + + {showAdverse && ( + + + 불리·중대 변경 여부 + + ( + field.onChange(e.target.checked)} /> + } + label="이용자에게 불리하거나 중대한 변경입니다." + /> + )} + /> + + + + ( + field.onChange(e.target.checked)} /> + } + label="재동의 예외 처리 (재동의를 받지 않고 진행)" + /> + )} + /> + + {reconsentOverride && ( + ( + + )} + /> + )} + + )} + + {showMarketing && ( + + + 마케팅 수신동의 확대 체크리스트 + + {MARKETING_FIELDS.map(([name, label]) => ( + ( + field.onChange(e.target.checked)} /> + } + label={label} + sx={{ display: 'block' }} + /> + )} + /> + ))} + + )} + + {showLocation && ( + + + 위치 정보 범위 체크리스트 + + ( + field.onChange(e.target.checked)} /> + } + label="위치 정보 수집·이용 범위가 확대되었습니다." + /> + )} + /> + + )} + + + + + + + ); +} diff --git a/app/admin/policy-documents/create/page.tsx b/app/admin/policy-documents/create/page.tsx new file mode 100644 index 00000000..634f5c91 --- /dev/null +++ b/app/admin/policy-documents/create/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { PolicyDocumentForm } from '../components/PolicyDocumentForm'; + +export default function CreatePolicyDocumentPage() { + return ; +} diff --git a/app/admin/policy-documents/page.tsx b/app/admin/policy-documents/page.tsx new file mode 100644 index 00000000..74294c68 --- /dev/null +++ b/app/admin/policy-documents/page.tsx @@ -0,0 +1,293 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { + Box, + Typography, + Button, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Chip, + CircularProgress, + FormControl, + InputLabel, + Select, + MenuItem, + IconButton, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + LinearProgress, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import FactCheckIcon from '@mui/icons-material/FactCheck'; +import SendIcon from '@mui/icons-material/Send'; +import { + usePolicyDocumentList, + usePolicyConsentProgress, + usePublishPolicyDocument, +} from '@/app/admin/hooks'; +import { useToast } from '@/shared/ui/admin/toast/toast-context'; +import { useConfirm } from '@/shared/ui/admin/confirm-dialog/confirm-dialog-context'; +import { getAdminErrorMessage } from '@/shared/lib/http/admin-fetch'; +import type { PolicyDocumentStatus, PolicyDocumentType } from '@/types/admin'; + +const DOCUMENT_TYPE_LABELS: Record = { + TERMS_OF_SERVICE: '이용약관', + PRIVACY_POLICY: '개인정보처리방침', + DATA_COLLECTION_CONSENT: '개인정보 수집·이용 동의', + SENSITIVE_INFO_CONSENT: '민감정보 처리 동의', + THIRD_PARTY_PROVISION: '제3자 제공 동의', + MARKETING_CONSENT: '마케팅 수신 동의', + REFUND_POLICY: '환불정책', + LBS_TERMS: '위치기반서비스 이용약관', + LOCATION_INFO_CONSENT: '위치정보 수집 동의', + CHILD_SAFETY_POLICY: '아동 안전 정책', +}; + +const STATUS_LABELS: Record = { + DRAFT: '초안', + SCHEDULED: '공지 예정', + NOTICE_ACTIVE: '공지 중', + EFFECTIVE: '시행 중', + SUPERSEDED: '대체됨', +}; + +const STATUS_COLORS: Record = { + DRAFT: 'default', + SCHEDULED: 'info', + NOTICE_ACTIVE: 'warning', + EFFECTIVE: 'success', + SUPERSEDED: 'default', +}; + +function formatDate(value?: string | null) { + if (!value) return '-'; + return new Date(value).toLocaleString('ko-KR'); +} + +function ConsentProgressDialog({ id, onClose }: { id: string; onClose: () => void }) { + const { data, isLoading } = usePolicyConsentProgress(id); + + return ( + + 재동의 진행 현황 + + {isLoading || !data ? ( + + + + ) : ( + + + {DOCUMENT_TYPE_LABELS[data.documentType]} · v{data.version} + + + + 완료율 + + {(data.completionRate * 100).toFixed(1)}% + + + + + + 대상 유저 + {data.eligibleUsers.toLocaleString()}명 + + + 동의 + {data.consented.toLocaleString()}명 + + + 거부 + {data.declined.toLocaleString()}명 + + + 대기 + {data.pending.toLocaleString()}명 + + + )} + + + + + + ); +} + +export default function PolicyDocumentsPage() { + const toast = useToast(); + const confirmAction = useConfirm(); + + const [statusFilter, setStatusFilter] = useState(''); + const [typeFilter, setTypeFilter] = useState(''); + const [progressId, setProgressId] = useState(null); + + const { data: documents = [], isLoading } = usePolicyDocumentList({ + status: statusFilter || undefined, + documentType: typeFilter || undefined, + }); + + const publishMutation = usePublishPolicyDocument(); + + const handlePublish = async (id: string, version: string) => { + const ok = await confirmAction({ + title: '공지 개시', + message: `버전 ${version} 문서의 공지를 지금 개시하시겠습니까?`, + }); + if (!ok) return; + try { + await publishMutation.mutateAsync(id); + toast.success('공지가 개시되었습니다.'); + } catch (err: unknown) { + toast.error(getAdminErrorMessage(err, '공지 개시에 실패했습니다.')); + } + }; + + return ( + + + + 정책 개정 등록 + + + + + + + 문서 종류 + + + + + 상태 + + + + + {isLoading ? ( + + + + ) : ( + + + + + 문서 종류 + 버전 + 상태 + 공지 트랙 + 재동의 필요 + 시행일 + 등록일 + 작업 + + + + {documents.length === 0 ? ( + + + + 등록된 정책 문서가 없습니다. + + + + ) : ( + documents.map((doc) => ( + + {DOCUMENT_TYPE_LABELS[doc.documentType] ?? doc.documentType} + {doc.version} + + + + {doc.noticeTrack} + + {doc.requiresReconsent ? ( + + ) : ( + '-' + )} + + {formatDate(doc.effectiveAt)} + {formatDate(doc.createdAt)} + + + {doc.requiresReconsent && ( + setProgressId(doc.id)} + title="재동의 진행 현황" + > + + + )} + {(doc.status === 'DRAFT' || doc.status === 'SCHEDULED') && ( + handlePublish(doc.id, doc.version)} + title="공지 개시" + disabled={publishMutation.isPending} + > + + + )} + + + + )) + )} + +
+
+ )} + + {progressId && setProgressId(null)} />} +
+ ); +} diff --git a/app/services/admin/content.ts b/app/services/admin/content.ts index 4aa0f2da..7d3672f0 100644 --- a/app/services/admin/content.ts +++ b/app/services/admin/content.ts @@ -9,12 +9,19 @@ import type { Banner, BannerPosition, CardNewsTrack, + ConsentProgress, CreateBannerRequest, CreateCardNewsRequest, CreatePresetRequest, CreateSometimeArticleRequest, + PolicyDocument, + PolicyDocumentStatus, + PolicyDocumentType, PublishCardNewsRequest, PublishCardNewsResponse, + PublishPolicyDocumentResponse, + RegisterPolicyDocumentRequest, + RegisterPolicyDocumentResponse, UpdateBannerOrderRequest, UpdateBannerRequest, UpdateCardNewsRequest, @@ -551,3 +558,70 @@ export const publicReviews = { } }, }; + +// ==================== 정책 개정 등록 ==================== +export interface PolicyDocumentListParams { + documentType?: PolicyDocumentType; + status?: PolicyDocumentStatus; +} + +export const policyDocuments = { + register: async (data: RegisterPolicyDocumentRequest): Promise => { + try { + const res = await adminPost<{ data: RegisterPolicyDocumentResponse }>( + '/admin/v2/content/policy-documents', + data, + ); + return res.data; + } catch (error: any) { + throw error; + } + }, + + getList: async (params: PolicyDocumentListParams = {}): Promise => { + try { + const query: Record = {}; + if (params.documentType) query.documentType = params.documentType; + if (params.status) query.status = params.status; + const res = await adminGet<{ data: PolicyDocument[] }>( + '/admin/v2/content/policy-documents', + Object.keys(query).length > 0 ? query : undefined, + ); + return res.data; + } catch (error: any) { + throw error; + } + }, + + get: async (id: string): Promise => { + try { + const res = await adminGet<{ data: PolicyDocument }>(`/admin/v2/content/policy-documents/${id}`); + return res.data; + } catch (error: any) { + throw error; + } + }, + + publish: async (id: string): Promise => { + try { + const res = await adminPost<{ data: PublishPolicyDocumentResponse }>( + `/admin/v2/content/policy-documents/${id}/publish`, + {}, + ); + return res.data; + } catch (error: any) { + throw error; + } + }, + + consentProgress: async (id: string): Promise => { + try { + const res = await adminGet<{ data: ConsentProgress }>( + `/admin/v2/content/policy-documents/${id}/consent-progress`, + ); + return res.data; + } catch (error: any) { + throw error; + } + }, +}; diff --git a/app/services/admin/index.ts b/app/services/admin/index.ts index 638fc1e0..17b6a89c 100644 --- a/app/services/admin/index.ts +++ b/app/services/admin/index.ts @@ -29,7 +29,9 @@ export { appReviews, communityReviewArticles, publicReviews, + policyDocuments, } from './content'; +export type { PolicyDocumentListParams } from './content'; export { notices } from './notices'; export type { NoticeListParams } from './notices'; export { etaMission } from './eta-mission'; @@ -275,6 +277,7 @@ import { appReviews, communityReviewArticles, publicReviews, + policyDocuments, } from './content'; import { notices } from './notices'; import { etaMission } from './eta-mission'; @@ -348,6 +351,7 @@ const AdminService = { appReviews, communityReviewArticles, publicReviews, + policyDocuments, fcmTokens, getProfileReports: reports.getProfileReports, featureFlags, diff --git a/shared/ui/admin/sidebar.tsx b/shared/ui/admin/sidebar.tsx index 95d3bed6..6ddcf888 100644 --- a/shared/ui/admin/sidebar.tsx +++ b/shared/ui/admin/sidebar.tsx @@ -125,6 +125,7 @@ export const NAV_CATEGORIES: NavCategory[] = [ { href: '/admin/content', label: '운영 콘텐츠 관리' }, { href: '/admin/seo', label: 'SEO 상태' }, { href: '/admin/banners', label: '배너 관리' }, + { href: '/admin/policy-documents', label: '정책 개정 등록' }, { id: 'utm-management', label: 'UTM 추적 관리', diff --git a/types/admin.ts b/types/admin.ts index 44e80d65..eacb5a9e 100644 --- a/types/admin.ts +++ b/types/admin.ts @@ -1134,3 +1134,126 @@ export interface PublishNoticeResponse { success: boolean; sentCount?: number; } + +// ==================== Policy Documents (정책 개정 등록) ==================== + +export type PolicyDocumentType = + | 'TERMS_OF_SERVICE' + | 'PRIVACY_POLICY' + | 'DATA_COLLECTION_CONSENT' + | 'SENSITIVE_INFO_CONSENT' + | 'THIRD_PARTY_PROVISION' + | 'MARKETING_CONSENT' + | 'REFUND_POLICY' + | 'LBS_TERMS' + | 'LOCATION_INFO_CONSENT' + | 'CHILD_SAFETY_POLICY'; + +export type PolicyDocumentStatus = + | 'DRAFT' + | 'SCHEDULED' + | 'NOTICE_ACTIVE' + | 'EFFECTIVE' + | 'SUPERSEDED'; + +export interface PolicyDecision { + noticeTrack: string; + requiresReconsent: boolean; + reconsentAxes: string[]; + needsLegalReview: boolean; +} + +export interface PolicyDocument { + id: string; + documentType: PolicyDocumentType; + version: string; + country?: string; + noticeTrack: string; + requiresReconsent: boolean; + reconsentAxes: string[]; + isMandatory: boolean; + diffSummary: string; + changeReason: string; + contentUrl?: string | null; + noticeStartedAt: string; + effectiveAt: string; + noticeVisibleUntil?: string | null; + status: PolicyDocumentStatus; + createdBy?: string | null; + createdAt: string; +} + +export interface RegisterPolicyDocumentRequest { + documentType: PolicyDocumentType; + version: string; + diffSummary: string; + changeReason: string; + contentUrl?: string; + noticeStartedAt: string; + effectiveAt: string; + noticeVisibleUntil?: string; + isMandatory: boolean; + // 5축 (PRIVACY_POLICY / DATA_COLLECTION_CONSENT / SENSITIVE_INFO_CONSENT / THIRD_PARTY_PROVISION) + axisCollectionItems?: boolean; + axisPurpose?: boolean; + axisRetentionPeriod?: boolean; + axisThirdParty?: boolean; + axisSensitiveInfo?: boolean; + // 불리·중대 변경 (TERMS_OF_SERVICE / REFUND_POLICY) + adverseOrMaterial?: boolean; + // 마케팅 수신동의 (MARKETING_CONSENT) + marketingMediaExpanded?: boolean; + marketingAdTypeExpanded?: boolean; + marketingNightExpanded?: boolean; + // 위치 정보 (LBS_TERMS / LOCATION_INFO_CONSENT) + locationScopeExpanded?: boolean; + // 재동의 예외 처리 + reconsentOverride?: boolean; + reconsentOverrideReason?: string; + // 민감정보 동의 - 개인정보처리방침 §23③ 반영 확인 + privacyPolicyDisclosureConfirmed?: boolean; + // 필수 항목 - 최소수집 원칙 확인 + minimalCollectionConfirmed?: boolean; + // 경고를 확인하고 재제출 + acknowledgeWarnings?: boolean; +} + +export interface RegisterPolicyDocumentSavedResponse { + preview: false; + saved: true; + document: PolicyDocument; + decision: PolicyDecision; + warnings: string[]; + needsLegalReview: boolean; +} + +export interface RegisterPolicyDocumentNotSavedResponse { + preview: true; + saved: false; + blockers: string[]; + warnings: string[]; + decision: PolicyDecision; + message: string; +} + +export type RegisterPolicyDocumentResponse = + | RegisterPolicyDocumentSavedResponse + | RegisterPolicyDocumentNotSavedResponse; + +export interface PublishPolicyDocumentResponse { + id: string; + status: 'NOTICE_ACTIVE'; + dispatch?: unknown; +} + +export interface ConsentProgress { + policyDocumentId: string; + documentType: PolicyDocumentType; + version: string; + requiresReconsent: boolean; + eligibleUsers: number; + consented: number; + declined: number; + pending: number; + completionRate: number; +}