From 45d78227f3667ddc13b50724ad908b796067a51c Mon Sep 17 00:00:00 2001 From: "bottlenote-app[bot]" <289617182+bottlenote-app[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:58:50 +0900 Subject: [PATCH 1/4] feat(admin): manage campaign contents and metrics --- src/config/menu.config.ts | 25 ++ src/hooks/useCampaignContents.ts | 116 +++++++ .../CampaignContentDetail.tsx | 321 ++++++++++++++++++ .../campaign-contents/CampaignContentList.tsx | 186 ++++++++++ .../__tests__/CampaignContentDetail.test.tsx | 160 +++++++++ .../__tests__/CampaignContentList.test.tsx | 76 +++++ .../campaign-content.schema.ts | 24 ++ src/routes/index.tsx | 27 ++ .../campaign-content.service.test.ts | 55 +++ src/services/campaign-content.service.ts | 73 ++++ src/types/api/campaign-content.api.ts | 72 ++++ src/types/api/index.ts | 1 + 12 files changed, 1136 insertions(+) create mode 100644 src/hooks/useCampaignContents.ts create mode 100644 src/pages/campaign-contents/CampaignContentDetail.tsx create mode 100644 src/pages/campaign-contents/CampaignContentList.tsx create mode 100644 src/pages/campaign-contents/__tests__/CampaignContentDetail.test.tsx create mode 100644 src/pages/campaign-contents/__tests__/CampaignContentList.test.tsx create mode 100644 src/pages/campaign-contents/campaign-content.schema.ts create mode 100644 src/services/__tests__/campaign-content.service.test.ts create mode 100644 src/services/campaign-content.service.ts create mode 100644 src/types/api/campaign-content.api.ts diff --git a/src/config/menu.config.ts b/src/config/menu.config.ts index deb2107..b4c6b3c 100644 --- a/src/config/menu.config.ts +++ b/src/config/menu.config.ts @@ -206,6 +206,31 @@ export const menuConfig: MenuGroup[] = [ }, ], }, + { + id: 'campaign-content', + items: [ + { + id: 'campaign-content-management', + label: '캠페인 콘텐츠 관리', + icon: Layers, + roles: ['ROOT_ADMIN'], + children: [ + { + id: 'campaign-content-list', + label: '캠페인 콘텐츠 목록', + icon: List, + path: '/campaign-contents', + }, + { + id: 'campaign-content-create', + label: '캠페인 콘텐츠 추가', + icon: Plus, + path: '/campaign-contents/new', + }, + ], + }, + ], + }, { id: 'inquiry', items: [ diff --git a/src/hooks/useCampaignContents.ts b/src/hooks/useCampaignContents.ts new file mode 100644 index 0000000..e650a93 --- /dev/null +++ b/src/hooks/useCampaignContents.ts @@ -0,0 +1,116 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useApiQuery } from './useApiQuery'; +import { useApiMutation, type UseApiMutationOptions } from './useApiMutation'; +import { + campaignContentService, + campaignContentKeys, + campaignMetricsKey, + type CampaignContentListResponse, +} from '@/services/campaign-content.service'; +import type { + CampaignContentDetail, + CampaignContentSearchParams, + CampaignContentCreateRequest, + CampaignContentUpdateRequest, + CampaignContentStatusRequest, + CampaignContentMutationResponse, + CampaignContentMetrics, + CampaignContentMetricsRange, +} from '@/types/api'; + +type MutationOptions = Omit< + UseApiMutationOptions, + 'successMessage' +>; +export type CampaignContentChange = { id: number; data: CampaignContentUpdateRequest }; +export type CampaignContentStatusChange = { id: number; data: CampaignContentStatusRequest }; + +function notifySuccess( + options: MutationOptions | undefined, + result: CampaignContentMutationResponse, + vars: V, + context: unknown +) { + ( + options?.onSuccess as + | ((data: CampaignContentMutationResponse, variables: V, context: unknown) => void) + | undefined + )?.(result, vars, context); +} + +export function useCampaignContentList(params: CampaignContentSearchParams) { + return useApiQuery(campaignContentKeys.list({ ...params }), () => + campaignContentService.search(params) + ); +} +export function useCampaignContentDetail(id: number | undefined) { + return useApiQuery( + campaignContentKeys.detail(id ?? 0), + () => campaignContentService.getDetail(id!), + { enabled: Number.isInteger(id) && (id ?? 0) > 0 } + ); +} +export function useCampaignContentMetrics( + id: number | undefined, + range: CampaignContentMetricsRange +) { + return useApiQuery( + campaignMetricsKey(id ?? 0, range), + () => campaignContentService.getMetrics(id!, range), + { enabled: Number.isInteger(id) && (id ?? 0) > 0 } + ); +} +export function useCampaignContentCreate(options?: MutationOptions) { + const client = useQueryClient(); + return useApiMutation(campaignContentService.create, { + successMessage: '캠페인 콘텐츠가 등록되었습니다.', + ...options, + onSuccess: (result, vars, context) => { + client.invalidateQueries({ queryKey: campaignContentKeys.lists() }); + notifySuccess(options, result, vars, context); + }, + }); +} +export function useCampaignContentUpdate(options?: MutationOptions) { + const client = useQueryClient(); + return useApiMutation( + ({ id, data }) => campaignContentService.update(id, data), + { + successMessage: '캠페인 콘텐츠가 수정되었습니다.', + ...options, + onSuccess: (result, vars, context) => { + client.invalidateQueries({ queryKey: campaignContentKeys.lists() }); + client.invalidateQueries({ queryKey: campaignContentKeys.detail(vars.id) }); + notifySuccess(options, result, vars, context); + }, + } + ); +} +export function useCampaignContentStatus(options?: MutationOptions) { + const client = useQueryClient(); + return useApiMutation( + ({ id, data }) => campaignContentService.updateStatus(id, data), + { + successMessage: '상태가 변경되었습니다.', + ...options, + onSuccess: (result, vars, context) => { + client.invalidateQueries({ queryKey: campaignContentKeys.lists() }); + client.invalidateQueries({ queryKey: campaignContentKeys.detail(vars.id) }); + notifySuccess(options, result, vars, context); + }, + } + ); +} +export function useCampaignContentDelete(options?: MutationOptions) { + const client = useQueryClient(); + return useApiMutation(campaignContentService.delete, { + successMessage: '캠페인 콘텐츠가 삭제되었습니다.', + ...options, + onSuccess: (result, id, context) => { + client.invalidateQueries({ queryKey: campaignContentKeys.lists() }); + client.removeQueries({ queryKey: campaignContentKeys.detail(id) }); + client.removeQueries({ queryKey: [...campaignContentKeys.all, 'metrics', id] }); + notifySuccess(options, result, id, context); + }, + }); +} diff --git a/src/pages/campaign-contents/CampaignContentDetail.tsx b/src/pages/campaign-contents/CampaignContentDetail.tsx new file mode 100644 index 0000000..703bdf6 --- /dev/null +++ b/src/pages/campaign-contents/CampaignContentDetail.tsx @@ -0,0 +1,321 @@ +import { useEffect, useState } from 'react'; +import { useLocation, useNavigate, useParams } from 'react-router'; +import { useForm, useWatch } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Switch } from '@/components/ui/switch'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { DetailPageHeader } from '@/components/common/DetailPageHeader'; +import { DeleteConfirmDialog } from '@/components/common/DeleteConfirmDialog'; +import { FormField } from '@/components/common/FormField'; +import { + useCampaignContentDetail, + useCampaignContentMetrics, + useCampaignContentCreate, + useCampaignContentUpdate, + useCampaignContentDelete, +} from '@/hooks/useCampaignContents'; +import { ApiError, getErrorMessage } from '@/lib/api-error'; +import type { CampaignContentMetricsRange } from '@/types/api'; +import { + campaignContentSchema, + campaignContentDefaults, + type CampaignContentFormValues, +} from './campaign-content.schema'; + +function seoulToday() { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Seoul', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date()); + const value = (type: string) => parts.find((part) => part.type === type)?.value ?? ''; + return `${value('year')}-${value('month')}-${value('day')}`; +} + +function CampaignContentMetricsCard({ id }: { id: number }) { + const [range, setRange] = useState({}); + const [from, setFrom] = useState(''); + const [to, setTo] = useState(''); + const [error, setError] = useState(''); + const { data, isLoading, isError, error: apiError } = useCampaignContentMetrics(id, range); + const today = seoulToday(); + const lowerBound = new Date(`${today}T00:00:00Z`); + lowerBound.setUTCDate(lowerBound.getUTCDate() - 89); + const minimum = lowerBound.toISOString().slice(0, 10); + const applyRange = () => { + const start = from || data?.from; + const end = to || data?.to; + if ( + !start || + !end || + start < minimum || + end > today || + start > end || + start > today || + end < minimum + ) { + setError('최근 90일 이내의 올바른 기간을 선택해주세요.'); + return; + } + setError(''); + setRange({ from: start, to: end }); + }; + return ( + + + 참여 지표 + 서울 날짜 기준 선택 기간의 서버 집계입니다. + + +
+ + + +
+ {error && ( +

+ {error} +

+ )} + {isError && ( +

+ {apiError instanceof ApiError && + apiError.hasCode('CAMPAIGN_CONTENT_INVALID_METRICS_RANGE') + ? '조회 기간을 다시 확인해주세요.' + : '지표를 불러오지 못했습니다.'} +

+ )} + {isLoading ? ( +

지표를 불러오는 중...

+ ) : ( + data && ( + <> +

+ 집계 기간: {data.from} ~ {data.to} +

+
+ {( + [ + ['조회 방문자', data.viewVisitors], + ['시작 방문자', data.startVisitors], + ['완료 방문자', data.finishVisitors], + ['결과 조회 회원', data.resultMembers], + ['활성 회원', data.activeMembers], + ] as const + ).map(([label, value]) => ( +
+

{label}

+

{value.toLocaleString()}

+
+ ))} +
+
+ {( + [ + ['완료율', data.completionRate], + ['로그인 전환율', data.loginConversionRate], + ['참여율', data.participationRate], + ] as const + ).map(([label, value]) => ( +
+

{label}

+

{value.toFixed(1)}%

+
+ ))} +
+ + ) + )} +
+
+ ); +} + +export function CampaignContentDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const location = useLocation(); + const from = (location.state as { from?: string } | null)?.from; + const listPath = from?.startsWith('/campaign-contents') ? from : '/campaign-contents'; + const isNew = !id || id === 'new'; + const numericId = + !isNew && id && /^[1-9]\d*$/.test(id) && Number.isSafeInteger(Number(id)) + ? Number(id) + : undefined; + const { data: detail, isLoading, isError } = useCampaignContentDetail(numericId); + const [deleteOpen, setDeleteOpen] = useState(false); + const [actionError, setActionError] = useState(''); + const form = useForm({ + resolver: zodResolver(campaignContentSchema), + defaultValues: campaignContentDefaults, + }); + const isActive = useWatch({ control: form.control, name: 'isActive' }); + useEffect(() => { + if (detail) + form.reset({ + code: detail.code, + name: detail.name, + description: detail.description ?? '', + isActive: detail.isActive, + }); + }, [detail, form]); + + function onError(error: ApiError) { + if ( + error.hasCode('CAMPAIGN_CONTENT_DUPLICATE_CODE') || + error.hasCode('CAMPAIGN_CONTENT_INVALID_CODE') + ) { + form.setError('code', { + message: error.hasCode('CAMPAIGN_CONTENT_DUPLICATE_CODE') + ? '이미 사용 중인 코드입니다.' + : '코드 형식을 확인해주세요.', + }); + } else setActionError(getErrorMessage(error)); + } + const create = useCampaignContentCreate({ + showErrorToast: false, + onSuccess: (result) => navigate(`/campaign-contents/${result.targetId}`), + onError, + }); + const update = useCampaignContentUpdate({ showErrorToast: false, onError }); + const remove = useCampaignContentDelete({ + showErrorToast: false, + onSuccess: () => navigate(listPath), + onError: (error) => { + setActionError( + error.hasCode('CAMPAIGN_CONTENT_HAS_EVENTS') + ? '참여 기록이 있어 삭제할 수 없습니다. 비활성화하세요.' + : getErrorMessage(error) + ); + }, + }); + const pending = create.isPending || update.isPending || remove.isPending; + const submit = form.handleSubmit((values) => { + if (pending) return; + setActionError(''); + const data = { name: values.name, description: values.description, isActive: values.isActive }; + if (isNew) create.mutate({ code: values.code, ...data }); + else if (numericId) update.mutate({ id: numericId, data }); + }); + + if (!isNew && !numericId) + return ( +

+ 올바르지 않은 콘텐츠 ID입니다.{' '} + +

+ ); + return ( +
+ navigate(listPath)} + actions={ + <> + {!isNew && detail && ( + + )} + + + } + /> + {actionError && ( +

+ {actionError} +

+ )} + {!isNew && isLoading ? ( +

로딩 중...

+ ) : !isNew && (isError || !detail) ? ( +

상세 정보를 불러오지 못했습니다.

+ ) : ( + <> + + + 기본 정보 + 코드는 등록 후 수정할 수 없습니다. + + + + + + + + + +