From a4dbd2b25cdc2ba4bcd1dcf5d728d007b1e9e953 Mon Sep 17 00:00:00 2001 From: yeeun426 Date: Mon, 16 Feb 2026 18:29:00 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat=20:=20admin=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=B0=B0=EB=84=88=20=EA=B4=80=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/banner.ts | 103 +++++- src/app/admin/AdminSidebar.tsx | 4 + .../admin/banner/integrated-banners/page.tsx | 348 ++++++++++++++++++ src/domain/admin/ui/table/TableLayout.tsx | 9 +- 4 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 src/app/admin/banner/integrated-banners/page.tsx diff --git a/src/api/banner.ts b/src/api/banner.ts index f03a09b7d..99be60a0b 100644 --- a/src/api/banner.ts +++ b/src/api/banner.ts @@ -39,7 +39,59 @@ export const bannerAdminListSchema = z.object({ bannerList: z.array(bannerAdminListItemSchema), }); -export type bannerType = 'MAIN' | 'PROGRAM' | 'LINE' | 'POPUP' | 'MAIN_BOTTOM'; +// 공통 배너 타입 정의 +export type CommonBannerType = + | 'HOME_TOP' + | 'HOME_BOTTOM' + | 'PROGRAM' + | 'MY_PAGE'; + +// 공통 배너 아이템 스키마 (노출 중 - type 필수) +export const commonBannerAdminListItemSchema = z.object({ + type: z.enum(['HOME_TOP', 'HOME_BOTTOM', 'PROGRAM', 'MY_PAGE']), + commonBannerId: z.number(), + title: z.string().nullable().optional(), + landingUrl: z.string().nullable().optional(), + startDate: z.string().nullable().optional(), + endDate: z.string().nullable().optional(), + isVisible: z.boolean().nullable().optional(), +}); + +// 공통 배너 아이템 스키마 (전체 - type optional) +export const commonBannerAdminAllItemSchema = z.object({ + commonBannerId: z.number(), + title: z.string().nullable().optional(), + landingUrl: z.string().nullable().optional(), + startDate: z.string().nullable().optional(), + endDate: z.string().nullable().optional(), + isVisible: z.boolean().nullable().optional(), +}); + +// 공통 배너 리스트 스키마 - 타입별로 그룹화된 객체 (노출 중) +export const commonBannerAdminListSchema = z.object({ + commonBannerList: z.record(z.array(commonBannerAdminListItemSchema)), +}); + +// 공통 배너 리스트 스키마 - 배열 형식 (전체) +export const commonBannerAdminArrayListSchema = z.object({ + commonBannerList: z.array(commonBannerAdminAllItemSchema), +}); + +export type CommonBannerAdminListItemType = z.infer< + typeof commonBannerAdminListItemSchema +>; + +export type CommonBannerAdminAllItemType = z.infer< + typeof commonBannerAdminAllItemSchema +>; + +export type bannerType = + | 'MAIN' + | 'PROGRAM' + | 'LINE' + | 'POPUP' + | 'MAIN_BOTTOM' + | 'COMMON'; export const getBnnerListForAdminQueryKey = (type: bannerType) => [ 'banner', @@ -47,6 +99,19 @@ export const getBnnerListForAdminQueryKey = (type: bannerType) => [ type, ]; +export const getCommonBannerForAdminQueryKey = () => [ + 'banner', + 'admin', + 'common', +]; + +export const getActiveBannersForAdminQueryKey = () => [ + 'banner', + 'admin', + 'common', + 'active', +]; + export const useGetBannerListForAdmin = ({ type }: { type: bannerType }) => { return useQuery({ queryKey: getBnnerListForAdminQueryKey(type), @@ -61,6 +126,34 @@ export const useGetBannerListForAdmin = ({ type }: { type: bannerType }) => { }); }; +// 전체 공통 배너 조회 +export const useGetCommonBannerForAdmin = ({ + enabled, +}: { enabled?: boolean } = {}) => { + return useQuery({ + queryKey: getCommonBannerForAdminQueryKey(), + queryFn: async () => { + const res = await axios('/admin/common-banner', {}); + return commonBannerAdminArrayListSchema.parse(res.data.data); + }, + enabled, + }); +}; + +// 노출 중인 공통 배너만 조회 +export const useGetActiveBannersForAdmin = ({ + enabled, +}: { enabled?: boolean } = {}) => { + return useQuery({ + queryKey: getActiveBannersForAdminQueryKey(), + queryFn: async () => { + const res = await axios('/admin/common-banner/visible', {}); + return commonBannerAdminListSchema.parse(res.data.data); + }, + enabled, + }); +}; + export const bannerAdminDetailSchema = z.object({ bannerAdminDetailVo: bannerAdminListItemSchema, }); @@ -202,6 +295,14 @@ export const useDeleteBannerForAdmin = ({ queryClient.invalidateQueries({ queryKey: getBannerDetailForAdminQueryKey(data.bannerId, data.type), }); + if (data.type === 'COMMON') { + queryClient.invalidateQueries({ + queryKey: getCommonBannerForAdminQueryKey(), + }); + queryClient.invalidateQueries({ + queryKey: getActiveBannersForAdminQueryKey(), + }); + } successCallback?.(); }, onError: (error) => { diff --git a/src/app/admin/AdminSidebar.tsx b/src/app/admin/AdminSidebar.tsx index 4d2f41ad4..c538ffc6b 100644 --- a/src/app/admin/AdminSidebar.tsx +++ b/src/app/admin/AdminSidebar.tsx @@ -78,6 +78,10 @@ const navData = [ name: '상단 띠 배너 관리', url: '/admin/banner/top-bar-banners', }, + { + name: '통합 배너 관리', + url: '/admin/banner/integrated-banners', + }, { name: '프로그램 배너 관리', url: '/admin/banner/program-banners', diff --git a/src/app/admin/banner/integrated-banners/page.tsx b/src/app/admin/banner/integrated-banners/page.tsx new file mode 100644 index 000000000..fc62a192f --- /dev/null +++ b/src/app/admin/banner/integrated-banners/page.tsx @@ -0,0 +1,348 @@ +'use client'; + +import { useMemo, useState } from 'react'; + +import { + CommonBannerAdminAllItemType, + CommonBannerAdminListItemType, + CommonBannerType, + useDeleteBannerForAdmin, + useGetActiveBannersForAdmin, + useGetCommonBannerForAdmin, +} from '@/api/banner'; +import WarningModal from '@/common/alert/WarningModal'; +import EmptyContainer from '@/common/container/EmptyContainer'; +import LoadingContainer from '@/common/loading/LoadingContainer'; +import BannerVisibilityToggle from '@/domain/admin/banner/BannerVisibilityToggle'; +import TableLayout from '@/domain/admin/ui/table/TableLayout'; +import dayjs from '@/lib/dayjs'; +import { DataGrid, GridColDef, GridRowParams } from '@mui/x-data-grid'; +import { Pencil, Trash } from 'lucide-react'; +import Link from 'next/link'; + +type TabType = 'active' | 'all'; + +// 배너 위치 타입별 한글 이름 매핑 +const BANNER_TYPE_LABELS: Record = { + HOME_TOP: '홈 상단', + HOME_BOTTOM: '홈 하단', + PROGRAM: '프로그램', + MY_PAGE: '마이페이지', +}; + +const CommonBanners = () => { + const [activeTab, setActiveTab] = useState('active'); + const [isDeleteModalShown, setIsDeleteModalShown] = useState(false); + const [bannerIdForDeleting, setBannerIdForDeleting] = useState(); + + // 노출 중 탭 API + const { + data: activeBannerData, + isLoading: isActiveLoading, + error: activeError, + } = useGetActiveBannersForAdmin({ enabled: activeTab === 'active' }); + + // 전체 탭 API + const { + data: allBannerData, + isLoading: isAllLoading, + error: allError, + } = useGetCommonBannerForAdmin({ enabled: activeTab === 'all' }); + + const isLoading = activeTab === 'active' ? isActiveLoading : isAllLoading; + const error = activeTab === 'active' ? activeError : allError; + + const { mutate: deleteBanner } = useDeleteBannerForAdmin({ + successCallback: async () => { + setIsDeleteModalShown(false); + }, + }); + + const handleDeleteButtonClicked = (bannerId: number) => { + setBannerIdForDeleting(bannerId); + setIsDeleteModalShown(true); + }; + + // 노출 중 탭: 배너 위치별로 그룹화된 데이터 (기존 UI 유지) + const flattenedBanners = useMemo(() => { + if (activeTab !== 'active' || !activeBannerData?.commonBannerList) + return []; + + const banners: Array< + CommonBannerAdminListItemType & { + bannerPosition: string; + uniqueId: string; + } + > = []; + + const bannerList = activeBannerData.commonBannerList; + + // 객체 형식인 경우 (노출 중 탭) + if (!Array.isArray(bannerList)) { + Object.entries(bannerList).forEach(([key, bannerArray]) => { + bannerArray.forEach((banner) => { + banners.push({ + ...banner, + bannerPosition: BANNER_TYPE_LABELS[banner.type] || banner.type, + uniqueId: `${banner.type}-${banner.commonBannerId}`, + }); + }); + }); + } + + return banners; + }, [activeTab, activeBannerData]); + + // 전체 탭: 평탄화된 배너 리스트 + const allBanners = useMemo(() => { + if (activeTab !== 'all' || !allBannerData?.commonBannerList) return []; + + const bannerList = allBannerData.commonBannerList; + + // 배열 형식인 경우 (전체 탭) + if (Array.isArray(bannerList)) { + return bannerList.map((banner) => ({ + ...banner, + uniqueId: `${banner.commonBannerId}`, + })); + } + + return []; + }, [activeTab, allBannerData]); + + // 노출 중 탭용 컬럼 (기존 유지) + const activeColumns = useMemo< + GridColDef< + CommonBannerAdminListItemType & { + bannerPosition: string; + uniqueId: string; + } + >[] + >( + () => [ + { + field: 'bannerPosition', + headerName: '배너 위치', + width: 150, + valueGetter: (_, row) => row.bannerPosition, + }, + { + field: 'title', + headerName: '제목', + flex: 1, + valueGetter: (_, row) => row.title || '-', + }, + { + field: 'landingUrl', + headerName: '랜딩 URL', + width: 250, + valueGetter: (_, row) => row.landingUrl || '-', + }, + { + field: 'date', + headerName: '노출 기간', + width: 250, + valueGetter: (_, row) => + `${dayjs(row.startDate).format('YYYY-MM-DD')} ~ ${dayjs( + row.endDate, + ).format('YYYY-MM-DD')}`, + }, + { + field: 'management', + type: 'actions', + headerName: '관리', + width: 150, + getActions: ( + params: GridRowParams< + CommonBannerAdminListItemType & { + bannerPosition: string; + uniqueId: string; + } + >, + ) => { + const id = params.row.commonBannerId; + + return [ + + + , + handleDeleteButtonClicked(Number(id))} + />, + ]; + }, + }, + ], + [], + ); + + // 전체 탭용 컬럼 + const allColumns = useMemo< + GridColDef[] + >( + () => [ + { + field: 'title', + headerName: '제목', + flex: 1, + valueGetter: (_, row) => row.title || '-', + }, + { + field: 'landingUrl', + headerName: '링크', + width: 250, + valueGetter: (_, row) => row.landingUrl || '-', + }, + { + field: 'isVisible', + headerName: '노출 여부', + width: 150, + renderCell: ({ row }) => ( + + ), + }, + { + field: 'date', + headerName: '노출 기간', + width: 250, + valueGetter: (_, row) => + `${dayjs(row.startDate).format('YYYY-MM-DD')} ~ ${dayjs( + row.endDate, + ).format('YYYY-MM-DD')}`, + }, + { + field: 'management', + type: 'actions', + headerName: '관리', + width: 100, + getActions: ( + params: GridRowParams< + CommonBannerAdminAllItemType & { uniqueId: string } + >, + ) => { + const id = params.row.commonBannerId; + + return [ + + + , + handleDeleteButtonClicked(Number(id))} + />, + ]; + }, + }, + ], + [], + ); + + const renderContent = () => { + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + // 노출 중 탭 (기존 UI 유지) + if (activeTab === 'active') { + if (flattenedBanners.length === 0) { + return ; + } + + return ( + row.uniqueId} + columns={activeColumns} + hideFooter + /> + ); + } + + // 전체 탭 + if (activeTab === 'all') { + if (allBanners.length === 0) { + return ; + } + + return ( + row.uniqueId} + columns={allColumns} + hideFooter + /> + ); + } + + return ; + }; + + return ( + <> + + + | + + + } + > + {renderContent()} + + + bannerIdForDeleting && + deleteBanner({ + bannerId: bannerIdForDeleting, + type: 'COMMON', + }) + } + onCancel={() => setIsDeleteModalShown(false)} + confirmText="삭제" + /> + + ); +}; + +export default CommonBanners; diff --git a/src/domain/admin/ui/table/TableLayout.tsx b/src/domain/admin/ui/table/TableLayout.tsx index c904fc821..e5cfab32d 100644 --- a/src/domain/admin/ui/table/TableLayout.tsx +++ b/src/domain/admin/ui/table/TableLayout.tsx @@ -8,9 +8,15 @@ interface TableLayoutProps { href: string; }; children: React.ReactNode; + tabs?: React.ReactNode; } -const TableLayout = ({ title, headerButton, children }: TableLayoutProps) => { +const TableLayout = ({ + title, + headerButton, + children, + tabs, +}: TableLayoutProps) => { return (
@@ -24,6 +30,7 @@ const TableLayout = ({ title, headerButton, children }: TableLayoutProps) => { )}
+ {tabs &&
{tabs}
}
{children}
); From d325a3f1e35b74b50792cc3bfdea9591b1ab9250 Mon Sep 17 00:00:00 2001 From: yeeun426 Date: Wed, 18 Feb 2026 23:51:40 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat=20:=20=EB=85=B8=EC=B6=9C=20=EC=A4=91?= =?UTF-8?q?=20ui=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/banner/integrated-banners/page.tsx | 236 ++++++++++-------- 1 file changed, 131 insertions(+), 105 deletions(-) diff --git a/src/app/admin/banner/integrated-banners/page.tsx b/src/app/admin/banner/integrated-banners/page.tsx index fc62a192f..a82c9f35a 100644 --- a/src/app/admin/banner/integrated-banners/page.tsx +++ b/src/app/admin/banner/integrated-banners/page.tsx @@ -30,6 +30,114 @@ const BANNER_TYPE_LABELS: Record = { MY_PAGE: '마이페이지', }; +const BANNER_TYPE_ORDER: CommonBannerType[] = [ + 'HOME_TOP', + 'HOME_BOTTOM', + 'PROGRAM', + 'MY_PAGE', +]; + +interface ActiveBannerTableProps { + groupedData: Record; + onDeleteClick: (bannerId: number) => void; +} + +const ActiveBannerTable = ({ + groupedData, + onDeleteClick, +}: ActiveBannerTableProps) => { + return ( + + + + + + + + + + + + + {BANNER_TYPE_ORDER.map((type) => { + const banners = groupedData[type] ?? []; + const label = BANNER_TYPE_LABELS[type]; + + if (banners.length === 0) { + // 배너 없는 위치: 빈 행으로 표시 + return ( + + + + + ); + } + + return banners.map((banner, index) => ( + + {/* 첫 번째 행에만 위치명 표시 */} + {index === 0 && ( + + )} + + + + + + + )); + })} + +
+ 배너 위치 + + 제목 + + 랜딩 URL + + 노출 시작일 + + 노출 종료일 + + 관리 +
+ {label} + + - +
+ {label} + {banner.title || '-'} + {banner.landingUrl || '-'} + + {dayjs(banner.startDate).format('YYYY-MM-DD\nHH:mm:ss')} + + {dayjs(banner.endDate).format('YYYY-MM-DD\nHH:mm:ss')} + +
+ + + + onDeleteClick(Number(banner.commonBannerId))} + /> +
+
+ ); +}; + const CommonBanners = () => { const [activeTab, setActiveTab] = useState('active'); const [isDeleteModalShown, setIsDeleteModalShown] = useState(false); @@ -63,43 +171,37 @@ const CommonBanners = () => { setIsDeleteModalShown(true); }; - // 노출 중 탭: 배너 위치별로 그룹화된 데이터 (기존 UI 유지) - const flattenedBanners = useMemo(() => { - if (activeTab !== 'active' || !activeBannerData?.commonBannerList) - return []; + // 노출 중 탭: 위치별로 그룹화된 데이터 + const groupedActiveBanners = useMemo(() => { + const result: Record = { + HOME_TOP: [], + HOME_BOTTOM: [], + PROGRAM: [], + MY_PAGE: [], + }; - const banners: Array< - CommonBannerAdminListItemType & { - bannerPosition: string; - uniqueId: string; - } - > = []; + if (activeTab !== 'active' || !activeBannerData?.commonBannerList) + return result; const bannerList = activeBannerData.commonBannerList; - // 객체 형식인 경우 (노출 중 탭) if (!Array.isArray(bannerList)) { Object.entries(bannerList).forEach(([key, bannerArray]) => { - bannerArray.forEach((banner) => { - banners.push({ - ...banner, - bannerPosition: BANNER_TYPE_LABELS[banner.type] || banner.type, - uniqueId: `${banner.type}-${banner.commonBannerId}`, - }); - }); + if (key in result) { + result[key as CommonBannerType] = bannerArray; + } }); } - return banners; + return result; }, [activeTab, activeBannerData]); - // 전체 탭: 평탄화된 배너 리스트 + // 전체 탭: 배너 리스트 const allBanners = useMemo(() => { if (activeTab !== 'all' || !allBannerData?.commonBannerList) return []; const bannerList = allBannerData.commonBannerList; - // 배열 형식인 경우 (전체 탭) if (Array.isArray(bannerList)) { return bannerList.map((banner) => ({ ...banner, @@ -110,78 +212,6 @@ const CommonBanners = () => { return []; }, [activeTab, allBannerData]); - // 노출 중 탭용 컬럼 (기존 유지) - const activeColumns = useMemo< - GridColDef< - CommonBannerAdminListItemType & { - bannerPosition: string; - uniqueId: string; - } - >[] - >( - () => [ - { - field: 'bannerPosition', - headerName: '배너 위치', - width: 150, - valueGetter: (_, row) => row.bannerPosition, - }, - { - field: 'title', - headerName: '제목', - flex: 1, - valueGetter: (_, row) => row.title || '-', - }, - { - field: 'landingUrl', - headerName: '랜딩 URL', - width: 250, - valueGetter: (_, row) => row.landingUrl || '-', - }, - { - field: 'date', - headerName: '노출 기간', - width: 250, - valueGetter: (_, row) => - `${dayjs(row.startDate).format('YYYY-MM-DD')} ~ ${dayjs( - row.endDate, - ).format('YYYY-MM-DD')}`, - }, - { - field: 'management', - type: 'actions', - headerName: '관리', - width: 150, - getActions: ( - params: GridRowParams< - CommonBannerAdminListItemType & { - bannerPosition: string; - uniqueId: string; - } - >, - ) => { - const id = params.row.commonBannerId; - - return [ - - - , - handleDeleteButtonClicked(Number(id))} - />, - ]; - }, - }, - ], - [], - ); - // 전체 탭용 컬럼 const allColumns = useMemo< GridColDef[] @@ -196,7 +226,7 @@ const CommonBanners = () => { { field: 'landingUrl', headerName: '링크', - width: 250, + width: 300, valueGetter: (_, row) => row.landingUrl || '-', }, { @@ -260,19 +290,15 @@ const CommonBanners = () => { return ; } - // 노출 중 탭 (기존 UI 유지) + // 노출 중 탭: 커스텀 그룹핑 테이블 if (activeTab === 'active') { - if (flattenedBanners.length === 0) { - return ; - } - return ( - row.uniqueId} - columns={activeColumns} - hideFooter - /> +
+ +
); } From 72551b2f6983bb106846a8976322170048f9c3c5 Mon Sep 17 00:00:00 2001 From: yeeun426 Date: Sat, 21 Feb 2026 22:54:23 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat=20:=20=ED=86=B5=ED=95=A9=20=EB=B0=B0?= =?UTF-8?q?=EB=84=88=20=EC=83=9D=EC=84=B1=20API=20=EC=8A=A4=ED=8E=99=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=ED=8F=B4=EB=8D=94=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/banner.ts | 127 ++++++++ src/api/file.ts | 21 ++ src/app/admin/AdminSidebar.tsx | 2 +- .../components/CommonBannerInputContent.tsx | 285 ++++++++++++++++++ .../admin/banner/common-banners/new/page.tsx | 59 ++++ .../page.tsx | 8 +- .../program/ui/editor/EditorTemplate.tsx | 4 +- 7 files changed, 499 insertions(+), 7 deletions(-) create mode 100644 src/app/admin/banner/common-banners/new/components/CommonBannerInputContent.tsx create mode 100644 src/app/admin/banner/common-banners/new/page.tsx rename src/app/admin/banner/{integrated-banners => common-banners}/page.tsx (98%) diff --git a/src/api/banner.ts b/src/api/banner.ts index 99be60a0b..2649be7e4 100644 --- a/src/api/banner.ts +++ b/src/api/banner.ts @@ -1,6 +1,7 @@ import axios from '@/utils/axios'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { z } from 'zod'; +import { uploadFileForId } from './file'; export const bannerAdminListItemSchema = z.object({ id: z.number(), @@ -331,6 +332,132 @@ export const bannerUserListSchema = z.object({ bannerList: z.array(bannerUserListItemSchema), }); +const maybeUploadCommonBanner = (file: File | null): Promise => + file ? uploadFileForId({ file, type: 'COMMON_BANNER' }) : Promise.resolve(null); + +export type CommonBannerFormValue = { + title: string; + landingUrl: string; + isVisible: boolean; + startDate: string; + endDate: string; + types: Record; + homePcFile: File | null; + homeMobileFile: File | null; + programPcFile: File | null; + programMobileFile: File | null; +}; + +export const usePostCommonBannerForAdmin = ({ + successCallback, + errorCallback, +}: { + successCallback?: () => void; + errorCallback?: (error: Error) => void; +}) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (form: CommonBannerFormValue) => { + const { types } = form; + const needsHome = types.HOME_TOP || types.HOME_BOTTOM; + const needsProgram = types.PROGRAM; + const needsMyPage = types.MY_PAGE; + + // 필수 이미지 유효성 검사 + if (needsMyPage && !form.homeMobileFile) { + throw new Error( + '마이페이지 선택 시 홈 배너 (모바일) 이미지는 필수입니다.', + ); + } + if (needsMyPage && !form.programMobileFile) { + throw new Error( + '마이페이지 선택 시 프로그램 배너 (모바일) 이미지는 필수입니다.', + ); + } + + // 필요한 파일만 병렬 업로드 + const [ + homePcFileId, + homeMobileFileId, + programPcFileId, + programMobileFileId, + ] = await Promise.all([ + needsHome ? maybeUploadCommonBanner(form.homePcFile) : Promise.resolve(null), + needsHome || needsMyPage + ? maybeUploadCommonBanner(form.homeMobileFile) + : Promise.resolve(null), + needsProgram ? maybeUploadCommonBanner(form.programPcFile) : Promise.resolve(null), + needsProgram || needsMyPage + ? maybeUploadCommonBanner(form.programMobileFile) + : Promise.resolve(null), + ]); + + // 선택된 위치별 PC/MOBILE 항목 생성 + const commonBannerDetailInfoList: { + type: string; + agentType: 'PC' | 'MOBILE'; + fileId: number; + }[] = []; + + if (types.HOME_TOP) { + if (homePcFileId) + commonBannerDetailInfoList.push({ type: 'HOME_TOP', agentType: 'PC', fileId: homePcFileId }); + if (homeMobileFileId) + commonBannerDetailInfoList.push({ type: 'HOME_TOP', agentType: 'MOBILE', fileId: homeMobileFileId }); + } + + if (types.HOME_BOTTOM) { + if (homePcFileId) + commonBannerDetailInfoList.push({ type: 'HOME_BOTTOM', agentType: 'PC', fileId: homePcFileId }); + if (homeMobileFileId) + commonBannerDetailInfoList.push({ type: 'HOME_BOTTOM', agentType: 'MOBILE', fileId: homeMobileFileId }); + } + + if (types.PROGRAM) { + if (programPcFileId) + commonBannerDetailInfoList.push({ type: 'PROGRAM', agentType: 'PC', fileId: programPcFileId }); + if (programMobileFileId) + commonBannerDetailInfoList.push({ type: 'PROGRAM', agentType: 'MOBILE', fileId: programMobileFileId }); + } + + if (types.MY_PAGE) { + if (programMobileFileId) + commonBannerDetailInfoList.push({ type: 'MY_PAGE', agentType: 'PC', fileId: programMobileFileId }); + if (homeMobileFileId) + commonBannerDetailInfoList.push({ type: 'MY_PAGE', agentType: 'MOBILE', fileId: homeMobileFileId }); + } + + // 통합 배너 생성 + const res = await axios.post('/admin/common-banner', { + commonBannerInfo: { + title: form.title, + landingUrl: form.landingUrl, + startDate: form.startDate + ? new Date(form.startDate).toISOString() + : null, + endDate: form.endDate ? new Date(form.endDate).toISOString() : null, + isVisible: form.isVisible, + }, + commonBannerDetailInfoList, + }); + + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getCommonBannerForAdminQueryKey(), + }); + queryClient.invalidateQueries({ + queryKey: getActiveBannersForAdminQueryKey(), + }); + successCallback?.(); + }, + onError: (error: Error) => { + errorCallback?.(error); + }, + }); +}; + export const useGetBannerListForUser = ({ type }: { type: bannerType }) => { return useQuery({ queryKey: ['banner', type], diff --git a/src/api/file.ts b/src/api/file.ts index f64c9b7e2..ebc74afed 100644 --- a/src/api/file.ts +++ b/src/api/file.ts @@ -15,6 +15,7 @@ export const fileType = z.enum([ 'BLOG_BANNER', 'CURATION_ITEM', 'BANNER_MAIN_BOTTOM', + 'COMMON_BANNER', ]); export type FileType = z.infer; @@ -50,3 +51,23 @@ export async function uploadFile({ return fileUrl; } + +export async function uploadFileForId({ + file, + type, +}: { + file: File; + type: FileType; +}) { + const formData = new FormData(); + formData.append('file', file); + + const res = await axios.post('/file', formData, { + params: { type }, + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return res.data.data.fileId as number; +} diff --git a/src/app/admin/AdminSidebar.tsx b/src/app/admin/AdminSidebar.tsx index c538ffc6b..0b1c45444 100644 --- a/src/app/admin/AdminSidebar.tsx +++ b/src/app/admin/AdminSidebar.tsx @@ -80,7 +80,7 @@ const navData = [ }, { name: '통합 배너 관리', - url: '/admin/banner/integrated-banners', + url: '/admin/banner/common-banners', }, { name: '프로그램 배너 관리', diff --git a/src/app/admin/banner/common-banners/new/components/CommonBannerInputContent.tsx b/src/app/admin/banner/common-banners/new/components/CommonBannerInputContent.tsx new file mode 100644 index 000000000..066c9e045 --- /dev/null +++ b/src/app/admin/banner/common-banners/new/components/CommonBannerInputContent.tsx @@ -0,0 +1,285 @@ +'use client'; + +import { CommonBannerType } from '@/api/banner'; +import { useRef } from 'react'; + +export type CommonBannerFormValue = { + title: string; + landingUrl: string; + isVisible: boolean; + startDate: string; + endDate: string; + types: Record; + homePcFile: File | null; + homeMobileFile: File | null; + programPcFile: File | null; + programMobileFile: File | null; +}; + +interface Props { + value: CommonBannerFormValue; + onChange: (value: CommonBannerFormValue) => void; +} + +const BANNER_TYPE_LABELS: Record = { + HOME_TOP: '홈 상단', + HOME_BOTTOM: '홈 하단', + PROGRAM: '프로그램 페이지', + MY_PAGE: '마이페이지', +}; + +const BANNER_TYPES: CommonBannerType[] = [ + 'HOME_TOP', + 'HOME_BOTTOM', + 'PROGRAM', + 'MY_PAGE', +]; + +const ImageUploadBox = ({ + label, + file, + previewUrl, + onChange, +}: { + label: string; + file: File | null; + previewUrl?: string | null; + onChange: (file: File | null) => void; +}) => { + const inputRef = useRef(null); + const preview = file ? URL.createObjectURL(file) : previewUrl; + + return ( +
+ + {label} + +
inputRef.current?.click()} + > + {preview ? ( + <> + {label} + + + ) : ( + + + )} + { + const f = e.target.files?.[0] ?? null; + onChange(f); + // reset so same file can be re-selected + e.target.value = ''; + }} + /> +
+
+ ); +}; + +const FormRow = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( +
+ + {label} + +
{children}
+
+); + +const CommonBannerInputContent = ({ value, onChange }: Props) => { + const set = (partial: Partial) => + onChange({ ...value, ...partial }); + + const setType = (type: CommonBannerType, checked: boolean) => + onChange({ ...value, types: { ...value.types, [type]: checked } }); + + // 홈 위치(HOME_TOP | HOME_BOTTOM) 선택 여부 + const hasHome = value.types.HOME_TOP || value.types.HOME_BOTTOM; + // 프로그램 위치 선택 여부 + const hasProgram = value.types.PROGRAM; + // 마이페이지 선택 여부 + const hasMyPage = value.types.MY_PAGE; + + // 이미지 섹션 표시 여부 + const showHomeImages = hasHome || hasMyPage; + const showProgramImages = hasProgram || hasMyPage; + + return ( +
+ {/* 제목 */} + + set({ title: e.target.value })} + /> + + + {/* 랜딩 URL */} + + set({ landingUrl: e.target.value })} + /> + + + {/* 노출 여부 */} + +
+ {[ + { label: '노출함', val: true }, + { label: '노출안함', val: false }, + ].map(({ label, val }) => ( + + ))} +
+
+ + {/* 노출 기간 */} + +
+
+ set({ startDate: e.target.value })} + /> +
+ - +
+ set({ endDate: e.target.value })} + /> +
+
+
+ + {/* 노출 위치 */} + +
+ {BANNER_TYPES.map((type) => ( + + ))} +
+
+ + {/* 이미지 */} + +
+ {/* 홈 배너: HOME_TOP/HOME_BOTTOM 또는 MY_PAGE 선택 시 표시 */} + {showHomeImages && ( +
+ {hasHome && ( + set({ homePcFile: f })} + /> + )} + set({ homeMobileFile: f })} + /> +
+ )} + + {/* 프로그램 배너: PROGRAM 또는 MY_PAGE 선택 시 표시 */} + {showProgramImages && ( +
+ {hasProgram && ( + set({ programPcFile: f })} + /> + )} + set({ programMobileFile: f })} + /> +
+ )} + + {/* 아무 위치도 선택 안 한 경우 안내 */} + {!showHomeImages && !showProgramImages && ( +

+ 노출 위치를 선택하면 이미지 업로드 항목이 표시됩니다. +

+ )} + + {/* 마이페이지 안내 문구 */} + {hasMyPage && ( +
+

※ 마이페이지 배너는 별도 이미지를 사용하지 않습니다.

+

+ - 마이페이지 PC : 프로그램 배너 (모바일) 이미지가 노출됩니다. +

+

- 마이페이지 모바일 : 홈 배너 (모바일) 이미지가 노출됩니다.

+
+ )} +
+
+
+ ); +}; + +export default CommonBannerInputContent; diff --git a/src/app/admin/banner/common-banners/new/page.tsx b/src/app/admin/banner/common-banners/new/page.tsx new file mode 100644 index 000000000..492dcd83a --- /dev/null +++ b/src/app/admin/banner/common-banners/new/page.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { CommonBannerFormValue, usePostCommonBannerForAdmin } from '@/api/banner'; +import EditorTemplate from '@/domain/admin/program/ui/editor/EditorTemplate'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import CommonBannerInputContent from './components/CommonBannerInputContent'; + +const CommonBannerCreate = () => { + const router = useRouter(); + + const [value, setValue] = useState({ + title: '', + landingUrl: '', + isVisible: true, + startDate: '', + endDate: '', + // 노출 위치 체크박스 + types: { + HOME_TOP: false, + HOME_BOTTOM: false, + PROGRAM: false, + MY_PAGE: false, + }, + // 이미지 파일 + homePcFile: null, + homeMobileFile: null, + programPcFile: null, + programMobileFile: null, + }); + + const { mutate: tryCreateCommonBanner } = usePostCommonBannerForAdmin({ + successCallback: () => { + router.push('/admin/banner/common-banners'); + }, + errorCallback: (error) => { + console.error(error); + alert('통합 배너 등록에 실패했습니다.'); + }, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + tryCreateCommonBanner(value); + }; + + return ( + + + + ); +}; + +export default CommonBannerCreate; diff --git a/src/app/admin/banner/integrated-banners/page.tsx b/src/app/admin/banner/common-banners/page.tsx similarity index 98% rename from src/app/admin/banner/integrated-banners/page.tsx rename to src/app/admin/banner/common-banners/page.tsx index a82c9f35a..2df53a9dc 100644 --- a/src/app/admin/banner/integrated-banners/page.tsx +++ b/src/app/admin/banner/common-banners/page.tsx @@ -114,9 +114,9 @@ const ActiveBannerTable = ({ {dayjs(banner.endDate).format('YYYY-MM-DD\nHH:mm:ss')} -
+
{ return [ @@ -327,7 +327,7 @@ const CommonBanners = () => { title="통합 배너 관리" headerButton={{ label: '등록', - href: '/admin/home/common-banners/new', + href: '/admin/banner/common-banners/new', }} tabs={
diff --git a/src/domain/admin/program/ui/editor/EditorTemplate.tsx b/src/domain/admin/program/ui/editor/EditorTemplate.tsx index f387d4128..19b5bb60a 100644 --- a/src/domain/admin/program/ui/editor/EditorTemplate.tsx +++ b/src/domain/admin/program/ui/editor/EditorTemplate.tsx @@ -22,11 +22,11 @@ const EditorTemplate = ({ children, }: EditorTemplateProps) => { return ( -
+

{title}

-
+
{children}
{submitButton.text} From edd2823d490d2f6ff86a8444bd1dc2ba451d26e0 Mon Sep 17 00:00:00 2001 From: yeeun426 Date: Sat, 21 Feb 2026 23:40:03 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=ED=86=B5=ED=95=A9=20=EB=B0=B0?= =?UTF-8?q?=EB=84=88=20=EC=88=98=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/banner.ts | 153 ++++++++++++++++++ .../banner/common-banners/[id]/edit/page.tsx | 134 +++++++++++++++ .../components/CommonBannerInputContent.tsx | 27 ++-- src/app/admin/banner/common-banners/page.tsx | 4 +- 4 files changed, 298 insertions(+), 20 deletions(-) create mode 100644 src/app/admin/banner/common-banners/[id]/edit/page.tsx diff --git a/src/api/banner.ts b/src/api/banner.ts index 2649be7e4..cd43ff0c7 100644 --- a/src/api/banner.ts +++ b/src/api/banner.ts @@ -335,6 +335,26 @@ export const bannerUserListSchema = z.object({ const maybeUploadCommonBanner = (file: File | null): Promise => file ? uploadFileForId({ file, type: 'COMMON_BANNER' }) : Promise.resolve(null); +export type CommonBannerDetailInfo = { + type: CommonBannerType; + agentType: 'PC' | 'MOBILE'; + fileId: number; + commonBannerDetailId?: number; + fileUrl?: string; +}; + +export type CommonBannerDetailResponse = { + commonBanner: { + commonBannerId: number; + title: string; + landingUrl: string; + startDate: string; + endDate: string; + isVisible: boolean; + }; + commonBannerDetailList: CommonBannerDetailInfo[]; +}; + export type CommonBannerFormValue = { title: string; landingUrl: string; @@ -346,6 +366,16 @@ export type CommonBannerFormValue = { homeMobileFile: File | null; programPcFile: File | null; programMobileFile: File | null; + // 수정 시 기존 fileId 보존용 + homePcFileId?: number | null; + homeMobileFileId?: number | null; + programPcFileId?: number | null; + programMobileFileId?: number | null; + // 수정 시 기존 이미지 미리보기용 + homePcFileUrl?: string | null; + homeMobileFileUrl?: string | null; + programPcFileUrl?: string | null; + programMobileFileUrl?: string | null; }; export const usePostCommonBannerForAdmin = ({ @@ -458,6 +488,129 @@ export const usePostCommonBannerForAdmin = ({ }); }; +// 공통 배너 상세 조회 +export const getCommonBannerDetailForAdminQueryKey = ( + commonBannerId: number, +) => ['banner', 'admin', 'common', 'detail', commonBannerId]; + +export const useGetCommonBannerDetailForAdmin = ({ + commonBannerId, +}: { + commonBannerId: number; +}) => { + return useQuery({ + queryKey: getCommonBannerDetailForAdminQueryKey(commonBannerId), + queryFn: async () => { + const res = await axios(`/admin/common-banner/${commonBannerId}`); + return res.data.data as CommonBannerDetailResponse; + }, + refetchOnWindowFocus: false, + }); +}; + +// 공통 배너 수정 +export const useEditCommonBannerForAdmin = ({ + successCallback, + errorCallback, +}: { + successCallback?: () => void; + errorCallback?: (error: Error) => void; +}) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + commonBannerId, + form, + }: { + commonBannerId: number; + form: CommonBannerFormValue; + }) => { + const { types } = form; + const needsHome = types.HOME_TOP || types.HOME_BOTTOM; + const needsProgram = types.PROGRAM; + const needsMyPage = types.MY_PAGE; + + // 새 파일이 있으면 업로드, 없으면 기존 fileId 유지 + const [ + homePcFileId, + homeMobileFileId, + programPcFileId, + programMobileFileId, + ] = await Promise.all([ + needsHome && form.homePcFile + ? maybeUploadCommonBanner(form.homePcFile) + : Promise.resolve(form.homePcFileId ?? null), + (needsHome || needsMyPage) && form.homeMobileFile + ? maybeUploadCommonBanner(form.homeMobileFile) + : Promise.resolve(form.homeMobileFileId ?? null), + needsProgram && form.programPcFile + ? maybeUploadCommonBanner(form.programPcFile) + : Promise.resolve(form.programPcFileId ?? null), + (needsProgram || needsMyPage) && form.programMobileFile + ? maybeUploadCommonBanner(form.programMobileFile) + : Promise.resolve(form.programMobileFileId ?? null), + ]); + + const commonBannerDetailInfoList: CommonBannerDetailInfo[] = []; + + if (types.HOME_TOP) { + if (homePcFileId) + commonBannerDetailInfoList.push({ type: 'HOME_TOP', agentType: 'PC', fileId: homePcFileId }); + if (homeMobileFileId) + commonBannerDetailInfoList.push({ type: 'HOME_TOP', agentType: 'MOBILE', fileId: homeMobileFileId }); + } + + if (types.HOME_BOTTOM) { + if (homePcFileId) + commonBannerDetailInfoList.push({ type: 'HOME_BOTTOM', agentType: 'PC', fileId: homePcFileId }); + if (homeMobileFileId) + commonBannerDetailInfoList.push({ type: 'HOME_BOTTOM', agentType: 'MOBILE', fileId: homeMobileFileId }); + } + + if (types.PROGRAM) { + if (programPcFileId) + commonBannerDetailInfoList.push({ type: 'PROGRAM', agentType: 'PC', fileId: programPcFileId }); + if (programMobileFileId) + commonBannerDetailInfoList.push({ type: 'PROGRAM', agentType: 'MOBILE', fileId: programMobileFileId }); + } + + if (types.MY_PAGE) { + if (programMobileFileId) + commonBannerDetailInfoList.push({ type: 'MY_PAGE', agentType: 'PC', fileId: programMobileFileId }); + if (homeMobileFileId) + commonBannerDetailInfoList.push({ type: 'MY_PAGE', agentType: 'MOBILE', fileId: homeMobileFileId }); + } + + const res = await axios.patch(`/admin/common-banner/${commonBannerId}`, { + commonBannerInfo: { + title: form.title, + landingUrl: form.landingUrl, + startDate: form.startDate + ? new Date(form.startDate).toISOString() + : null, + endDate: form.endDate ? new Date(form.endDate).toISOString() : null, + isVisible: form.isVisible, + }, + commonBannerDetailInfoList, + }); + + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getCommonBannerForAdminQueryKey(), + }); + queryClient.invalidateQueries({ + queryKey: getActiveBannersForAdminQueryKey(), + }); + successCallback?.(); + }, + onError: (error: Error) => { + errorCallback?.(error); + }, + }); +}; + export const useGetBannerListForUser = ({ type }: { type: bannerType }) => { return useQuery({ queryKey: ['banner', type], diff --git a/src/app/admin/banner/common-banners/[id]/edit/page.tsx b/src/app/admin/banner/common-banners/[id]/edit/page.tsx new file mode 100644 index 000000000..639d8e8bd --- /dev/null +++ b/src/app/admin/banner/common-banners/[id]/edit/page.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { + CommonBannerDetailResponse, + CommonBannerFormValue, + CommonBannerType, + useEditCommonBannerForAdmin, + useGetCommonBannerDetailForAdmin, +} from '@/api/banner'; +import EditorTemplate from '@/domain/admin/program/ui/editor/EditorTemplate'; +import LoadingContainer from '@/common/loading/LoadingContainer'; +import { useParams, useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import CommonBannerInputContent from '../../new/components/CommonBannerInputContent'; + +const toDatetimeLocal = (iso: string) => { + const date = new Date(iso); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +}; + +const mapDetailToFormValue = ( + detail: CommonBannerDetailResponse, +): CommonBannerFormValue => { + const types: Record = { + HOME_TOP: false, + HOME_BOTTOM: false, + PROGRAM: false, + MY_PAGE: false, + }; + + let homePcFileId: number | null = null; + let homeMobileFileId: number | null = null; + let programPcFileId: number | null = null; + let programMobileFileId: number | null = null; + let homePcFileUrl: string | null = null; + let homeMobileFileUrl: string | null = null; + let programPcFileUrl: string | null = null; + let programMobileFileUrl: string | null = null; + + for (const item of detail.commonBannerDetailList ?? []) { + types[item.type] = true; + + if (item.type === 'HOME_TOP' || item.type === 'HOME_BOTTOM') { + if (item.agentType === 'PC') { homePcFileId = item.fileId; homePcFileUrl = item.fileUrl ?? null; } + if (item.agentType === 'MOBILE') { homeMobileFileId = item.fileId; homeMobileFileUrl = item.fileUrl ?? null; } + } else if (item.type === 'PROGRAM') { + if (item.agentType === 'PC') { programPcFileId = item.fileId; programPcFileUrl = item.fileUrl ?? null; } + if (item.agentType === 'MOBILE') { programMobileFileId = item.fileId; programMobileFileUrl = item.fileUrl ?? null; } + } else if (item.type === 'MY_PAGE') { + // MY_PAGE PC = 프로그램 모바일, MY_PAGE MOBILE = 홈 모바일 + if (item.agentType === 'PC') { programMobileFileId = item.fileId; programMobileFileUrl = item.fileUrl ?? null; } + if (item.agentType === 'MOBILE') { homeMobileFileId = item.fileId; homeMobileFileUrl = item.fileUrl ?? null; } + } + } + + const { commonBanner } = detail; + + return { + title: commonBanner.title || '', + landingUrl: commonBanner.landingUrl || '', + isVisible: commonBanner.isVisible, + startDate: commonBanner.startDate + ? toDatetimeLocal(commonBanner.startDate) + : '', + endDate: commonBanner.endDate + ? toDatetimeLocal(commonBanner.endDate) + : '', + types, + homePcFile: null, + homeMobileFile: null, + programPcFile: null, + programMobileFile: null, + homePcFileId, + homeMobileFileId, + programPcFileId, + programMobileFileId, + homePcFileUrl, + homeMobileFileUrl, + programPcFileUrl, + programMobileFileUrl, + }; +}; + +const CommonBannerEdit = () => { + const router = useRouter(); + const params = useParams(); + const commonBannerId = Number(params.id); + + const { data, isLoading } = useGetCommonBannerDetailForAdmin({ + commonBannerId, + }); + + const [value, setValue] = useState(null); + + useEffect(() => { + if (data && !value) { + setValue(mapDetailToFormValue(data)); + } + }, [data, value]); + + const { mutate: tryEditCommonBanner } = useEditCommonBannerForAdmin({ + successCallback: () => { + router.push('/admin/banner/common-banners'); + }, + errorCallback: (error) => { + console.error(error); + alert('통합 배너 수정에 실패했습니다.'); + }, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!value) return; + tryEditCommonBanner({ commonBannerId, form: value }); + }; + + if (isLoading || !value) { + return ; + } + + return ( + + + + ); +}; + +export default CommonBannerEdit; diff --git a/src/app/admin/banner/common-banners/new/components/CommonBannerInputContent.tsx b/src/app/admin/banner/common-banners/new/components/CommonBannerInputContent.tsx index 066c9e045..d2faabc98 100644 --- a/src/app/admin/banner/common-banners/new/components/CommonBannerInputContent.tsx +++ b/src/app/admin/banner/common-banners/new/components/CommonBannerInputContent.tsx @@ -1,21 +1,8 @@ 'use client'; -import { CommonBannerType } from '@/api/banner'; +import { CommonBannerFormValue, CommonBannerType } from '@/api/banner'; import { useRef } from 'react'; -export type CommonBannerFormValue = { - title: string; - landingUrl: string; - isVisible: boolean; - startDate: string; - endDate: string; - types: Record; - homePcFile: File | null; - homeMobileFile: File | null; - programPcFile: File | null; - programMobileFile: File | null; -}; - interface Props { value: CommonBannerFormValue; onChange: (value: CommonBannerFormValue) => void; @@ -230,13 +217,15 @@ const CommonBannerInputContent = ({ value, onChange }: Props) => { set({ homePcFile: f })} + previewUrl={value.homePcFileUrl} + onChange={(f) => set({ homePcFile: f, homePcFileUrl: null })} /> )} set({ homeMobileFile: f })} + previewUrl={value.homeMobileFileUrl} + onChange={(f) => set({ homeMobileFile: f, homeMobileFileUrl: null })} />
)} @@ -248,13 +237,15 @@ const CommonBannerInputContent = ({ value, onChange }: Props) => { set({ programPcFile: f })} + previewUrl={value.programPcFileUrl} + onChange={(f) => set({ programPcFile: f, programPcFileUrl: null })} /> )} set({ programMobileFile: f })} + previewUrl={value.programMobileFileUrl} + onChange={(f) => set({ programMobileFile: f, programMobileFileUrl: null })} />
)} diff --git a/src/app/admin/banner/common-banners/page.tsx b/src/app/admin/banner/common-banners/page.tsx index 2df53a9dc..dfc7dc865 100644 --- a/src/app/admin/banner/common-banners/page.tsx +++ b/src/app/admin/banner/common-banners/page.tsx @@ -91,7 +91,7 @@ const ActiveBannerTable = ({ return banners.map((banner, index) => ( {/* 첫 번째 행에만 위치명 표시 */} @@ -351,7 +351,7 @@ const CommonBanners = () => {
} > - {renderContent()} +
{renderContent()}
Date: Sun, 22 Feb 2026 16:22:04 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat=20:=20=EB=B0=B0=EB=84=88=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/banner.ts | 106 +++++++++++++------ src/app/admin/banner/common-banners/page.tsx | 12 +-- 2 files changed, 75 insertions(+), 43 deletions(-) diff --git a/src/api/banner.ts b/src/api/banner.ts index cd43ff0c7..ac58ba2e8 100644 --- a/src/api/banner.ts +++ b/src/api/banner.ts @@ -422,56 +422,61 @@ export const usePostCommonBannerForAdmin = ({ : Promise.resolve(null), ]); - // 선택된 위치별 PC/MOBILE 항목 생성 - const commonBannerDetailInfoList: { + // 타입별 detail 리스트 생성 + const requestsByType: { type: string; - agentType: 'PC' | 'MOBILE'; - fileId: number; + details: { type: string; agentType: 'PC' | 'MOBILE'; fileId: number }[]; }[] = []; if (types.HOME_TOP) { - if (homePcFileId) - commonBannerDetailInfoList.push({ type: 'HOME_TOP', agentType: 'PC', fileId: homePcFileId }); - if (homeMobileFileId) - commonBannerDetailInfoList.push({ type: 'HOME_TOP', agentType: 'MOBILE', fileId: homeMobileFileId }); + const details: { type: string; agentType: 'PC' | 'MOBILE'; fileId: number }[] = []; + if (homePcFileId) details.push({ type: 'HOME_TOP', agentType: 'PC', fileId: homePcFileId }); + if (homeMobileFileId) details.push({ type: 'HOME_TOP', agentType: 'MOBILE', fileId: homeMobileFileId }); + if (details.length > 0) requestsByType.push({ type: 'HOME_TOP', details }); } if (types.HOME_BOTTOM) { - if (homePcFileId) - commonBannerDetailInfoList.push({ type: 'HOME_BOTTOM', agentType: 'PC', fileId: homePcFileId }); - if (homeMobileFileId) - commonBannerDetailInfoList.push({ type: 'HOME_BOTTOM', agentType: 'MOBILE', fileId: homeMobileFileId }); + const details: { type: string; agentType: 'PC' | 'MOBILE'; fileId: number }[] = []; + if (homePcFileId) details.push({ type: 'HOME_BOTTOM', agentType: 'PC', fileId: homePcFileId }); + if (homeMobileFileId) details.push({ type: 'HOME_BOTTOM', agentType: 'MOBILE', fileId: homeMobileFileId }); + if (details.length > 0) requestsByType.push({ type: 'HOME_BOTTOM', details }); } if (types.PROGRAM) { - if (programPcFileId) - commonBannerDetailInfoList.push({ type: 'PROGRAM', agentType: 'PC', fileId: programPcFileId }); - if (programMobileFileId) - commonBannerDetailInfoList.push({ type: 'PROGRAM', agentType: 'MOBILE', fileId: programMobileFileId }); + const details: { type: string; agentType: 'PC' | 'MOBILE'; fileId: number }[] = []; + if (programPcFileId) details.push({ type: 'PROGRAM', agentType: 'PC', fileId: programPcFileId }); + if (programMobileFileId) details.push({ type: 'PROGRAM', agentType: 'MOBILE', fileId: programMobileFileId }); + if (details.length > 0) requestsByType.push({ type: 'PROGRAM', details }); } if (types.MY_PAGE) { - if (programMobileFileId) - commonBannerDetailInfoList.push({ type: 'MY_PAGE', agentType: 'PC', fileId: programMobileFileId }); - if (homeMobileFileId) - commonBannerDetailInfoList.push({ type: 'MY_PAGE', agentType: 'MOBILE', fileId: homeMobileFileId }); + const details: { type: string; agentType: 'PC' | 'MOBILE'; fileId: number }[] = []; + if (programMobileFileId) details.push({ type: 'MY_PAGE', agentType: 'PC', fileId: programMobileFileId }); + if (homeMobileFileId) details.push({ type: 'MY_PAGE', agentType: 'MOBILE', fileId: homeMobileFileId }); + if (details.length > 0) requestsByType.push({ type: 'MY_PAGE', details }); } - // 통합 배너 생성 - const res = await axios.post('/admin/common-banner', { - commonBannerInfo: { - title: form.title, - landingUrl: form.landingUrl, - startDate: form.startDate - ? new Date(form.startDate).toISOString() - : null, - endDate: form.endDate ? new Date(form.endDate).toISOString() : null, - isVisible: form.isVisible, - }, - commonBannerDetailInfoList, - }); - - return res.data; + const commonBannerInfo = { + title: form.title, + landingUrl: form.landingUrl, + startDate: form.startDate + ? new Date(form.startDate).toISOString() + : null, + endDate: form.endDate ? new Date(form.endDate).toISOString() : null, + isVisible: form.isVisible, + }; + + // 타입별로 개별 POST 요청 + const results = await Promise.all( + requestsByType.map((req) => + axios.post('/admin/common-banner', { + commonBannerInfo, + commonBannerDetailInfoList: req.details, + }), + ), + ); + + return results.map((res) => res.data); }, onSuccess: () => { queryClient.invalidateQueries({ @@ -611,6 +616,37 @@ export const useEditCommonBannerForAdmin = ({ }); }; +// 통합 배너 삭제 +export const useDeleteCommonBannerForAdmin = ({ + successCallback, + errorCallback, +}: { + successCallback?: () => void; + errorCallback?: (error: Error) => void; +}) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (commonBannerId: number) => { + const res = await axios.delete( + `/admin/common-banner/${commonBannerId}`, + ); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getCommonBannerForAdminQueryKey(), + }); + queryClient.invalidateQueries({ + queryKey: getActiveBannersForAdminQueryKey(), + }); + successCallback?.(); + }, + onError: (error: Error) => { + errorCallback?.(error); + }, + }); +}; + export const useGetBannerListForUser = ({ type }: { type: bannerType }) => { return useQuery({ queryKey: ['banner', type], diff --git a/src/app/admin/banner/common-banners/page.tsx b/src/app/admin/banner/common-banners/page.tsx index dfc7dc865..1a018779f 100644 --- a/src/app/admin/banner/common-banners/page.tsx +++ b/src/app/admin/banner/common-banners/page.tsx @@ -6,7 +6,7 @@ import { CommonBannerAdminAllItemType, CommonBannerAdminListItemType, CommonBannerType, - useDeleteBannerForAdmin, + useDeleteCommonBannerForAdmin, useGetActiveBannersForAdmin, useGetCommonBannerForAdmin, } from '@/api/banner'; @@ -160,8 +160,8 @@ const CommonBanners = () => { const isLoading = activeTab === 'active' ? isActiveLoading : isAllLoading; const error = activeTab === 'active' ? activeError : allError; - const { mutate: deleteBanner } = useDeleteBannerForAdmin({ - successCallback: async () => { + const { mutate: deleteCommonBanner } = useDeleteCommonBannerForAdmin({ + successCallback: () => { setIsDeleteModalShown(false); }, }); @@ -358,11 +358,7 @@ const CommonBanners = () => { title="배너 삭제" content="정말로 배너를 삭제하시겠습니까?" onConfirm={() => - bannerIdForDeleting && - deleteBanner({ - bannerId: bannerIdForDeleting, - type: 'COMMON', - }) + bannerIdForDeleting && deleteCommonBanner(bannerIdForDeleting) } onCancel={() => setIsDeleteModalShown(false)} confirmText="삭제" From 10c3b2065aa656efde6652ac96d348620b79ff1b Mon Sep 17 00:00:00 2001 From: yeeun426 Date: Sun, 22 Feb 2026 23:48:11 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20=ED=86=B5=ED=95=A9=20=EB=B0=B0?= =?UTF-8?q?=EB=84=88=20=EB=85=B8=EC=B6=9C=20=EC=97=AC=EB=B6=80=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20api=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/banner.ts | 47 ++++++++++++++++++++ src/app/admin/banner/common-banners/page.tsx | 30 +++++++++++-- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/api/banner.ts b/src/api/banner.ts index ac58ba2e8..2382bdf25 100644 --- a/src/api/banner.ts +++ b/src/api/banner.ts @@ -616,6 +616,53 @@ export const useEditCommonBannerForAdmin = ({ }); }; +// 통합 배너 노출 여부 토글 +export const useToggleCommonBannerVisibility = ({ + successCallback, + errorCallback, +}: { + successCallback?: () => void; + errorCallback?: (error: Error) => void; +} = {}) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + commonBannerId, + isVisible, + }: { + commonBannerId: number; + isVisible: boolean; + }) => { + const detailRes = await axios(`/admin/common-banner/${commonBannerId}`); + const detail = detailRes.data.data as CommonBannerDetailResponse; + + const res = await axios.patch(`/admin/common-banner/${commonBannerId}`, { + commonBannerInfo: { + title: detail.commonBanner.title, + landingUrl: detail.commonBanner.landingUrl, + startDate: detail.commonBanner.startDate, + endDate: detail.commonBanner.endDate, + isVisible, + }, + commonBannerDetailInfoList: detail.commonBannerDetailList, + }); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getCommonBannerForAdminQueryKey(), + }); + queryClient.invalidateQueries({ + queryKey: getActiveBannersForAdminQueryKey(), + }); + successCallback?.(); + }, + onError: (error: Error) => { + errorCallback?.(error); + }, + }); +}; + // 통합 배너 삭제 export const useDeleteCommonBannerForAdmin = ({ successCallback, diff --git a/src/app/admin/banner/common-banners/page.tsx b/src/app/admin/banner/common-banners/page.tsx index 1a018779f..7a6864842 100644 --- a/src/app/admin/banner/common-banners/page.tsx +++ b/src/app/admin/banner/common-banners/page.tsx @@ -9,13 +9,14 @@ import { useDeleteCommonBannerForAdmin, useGetActiveBannersForAdmin, useGetCommonBannerForAdmin, + useToggleCommonBannerVisibility, } from '@/api/banner'; import WarningModal from '@/common/alert/WarningModal'; import EmptyContainer from '@/common/container/EmptyContainer'; import LoadingContainer from '@/common/loading/LoadingContainer'; -import BannerVisibilityToggle from '@/domain/admin/banner/BannerVisibilityToggle'; import TableLayout from '@/domain/admin/ui/table/TableLayout'; import dayjs from '@/lib/dayjs'; +import { Switch } from '@mui/material'; import { DataGrid, GridColDef, GridRowParams } from '@mui/x-data-grid'; import { Pencil, Trash } from 'lucide-react'; import Link from 'next/link'; @@ -138,6 +139,27 @@ const ActiveBannerTable = ({ ); }; +const CommonBannerVisibilityToggle = ({ + commonBannerId, + isVisible, +}: { + commonBannerId: number; + isVisible: boolean; +}) => { + const { mutate } = useToggleCommonBannerVisibility({ + errorCallback: (error) => { + alert(error); + }, + }); + + return ( + mutate({ commonBannerId, isVisible: !isVisible })} + /> + ); +}; + const CommonBanners = () => { const [activeTab, setActiveTab] = useState('active'); const [isDeleteModalShown, setIsDeleteModalShown] = useState(false); @@ -234,9 +256,9 @@ const CommonBanners = () => { headerName: '노출 여부', width: 150, renderCell: ({ row }) => ( - ), }, From e514250da0a46f280f48af531e061a6c82e3c678 Mon Sep 17 00:00:00 2001 From: yeeun426 Date: Mon, 23 Feb 2026 00:01:45 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat=20:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/AdminSidebar.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/app/admin/AdminSidebar.tsx b/src/app/admin/AdminSidebar.tsx index 0b1c45444..0b40d2da5 100644 --- a/src/app/admin/AdminSidebar.tsx +++ b/src/app/admin/AdminSidebar.tsx @@ -61,14 +61,6 @@ const navData = [ name: '홈 큐레이션 관리', url: '/admin/home/curation', }, - { - name: '홈 상단 배너 관리', - url: '/admin/home/main-banners', - }, - { - name: '홈 하단 배너 관리', - url: '/admin/home/bottom-banners', - }, ], }, { @@ -82,10 +74,6 @@ const navData = [ name: '통합 배너 관리', url: '/admin/banner/common-banners', }, - { - name: '프로그램 배너 관리', - url: '/admin/banner/program-banners', - }, { name: '팝업 관리', url: '/admin/banner/pop-up', From 84a7901c7e95dac03de0a7f8bf179653beb57138 Mon Sep 17 00:00:00 2001 From: yeeun426 Date: Mon, 23 Feb 2026 00:27:13 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat=20:=20USER=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=B0=B0=EB=84=88=20api=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/banner.ts | 58 +++++++++++++++++++ .../home/banner/BottomBannerSection.tsx | 26 ++++----- src/domain/home/banner/MainBannerSection.tsx | 26 ++++----- src/domain/mypage/MyPageBanner.tsx | 35 +++++------ src/domain/program/Banner.tsx | 28 ++++----- 5 files changed, 116 insertions(+), 57 deletions(-) diff --git a/src/api/banner.ts b/src/api/banner.ts index 2382bdf25..73d37ab80 100644 --- a/src/api/banner.ts +++ b/src/api/banner.ts @@ -707,3 +707,61 @@ export const useGetBannerListForUser = ({ type }: { type: bannerType }) => { }, }); }; + +// 유저단 공통 배너 API +const commonBannerUserItemRawSchema = z.object({ + type: z.string(), + agentType: z.enum(['PC', 'MOBILE']), + title: z.string().nullable().optional(), + landingUrl: z.string().nullable().optional(), + fileUrl: z.string().nullable().optional(), +}); + +const commonBannerUserListRawSchema = z.object({ + commonBannerList: z.array(commonBannerUserItemRawSchema), +}); + +export type CommonBannerUserItem = { + title: string | null | undefined; + landingUrl: string | null | undefined; + imgUrl: string | null | undefined; + mobileImgUrl: string | null | undefined; +}; + +export const useGetCommonBannerListForUser = ({ + type, +}: { + type: CommonBannerType; +}) => { + return useQuery({ + queryKey: ['common-banner', type], + queryFn: async () => { + const res = await axios('/common-banner', { + params: { type }, + }); + const parsed = commonBannerUserListRawSchema.parse(res.data.data); + + // PC/MOBILE 아이템을 같은 title+landingUrl 기준으로 그룹핑 + const groupMap = new Map(); + for (const item of parsed.commonBannerList) { + const key = `${item.title ?? ''}::${item.landingUrl ?? ''}`; + if (!groupMap.has(key)) { + groupMap.set(key, { + title: item.title, + landingUrl: item.landingUrl, + imgUrl: null, + mobileImgUrl: null, + }); + } + const group = groupMap.get(key)!; + if (item.agentType === 'PC') { + group.imgUrl = item.fileUrl; + } else { + group.mobileImgUrl = item.fileUrl; + } + } + + return Array.from(groupMap.values()); + }, + }); +}; diff --git a/src/domain/home/banner/BottomBannerSection.tsx b/src/domain/home/banner/BottomBannerSection.tsx index 0f2c1dd79..f0c78d549 100644 --- a/src/domain/home/banner/BottomBannerSection.tsx +++ b/src/domain/home/banner/BottomBannerSection.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useGetBannerListForUser } from '@/api/banner'; +import { useGetCommonBannerListForUser } from '@/api/banner'; import LoadingContainer from '@/common/loading/LoadingContainer'; import { MOBILE_MEDIA_QUERY } from '@/utils/constants'; import { useMediaQuery } from '@mui/material'; @@ -11,7 +11,7 @@ import { Swiper, SwiperSlide } from 'swiper/react'; const BottomBannerSection = () => { const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY); - const { data, isLoading } = useGetBannerListForUser({ type: 'MAIN_BOTTOM' }); + const { data: bannerList, isLoading } = useGetCommonBannerListForUser({ type: 'HOME_BOTTOM' }); const handleClickBanner = (e: MouseEvent) => { const target = e.target as HTMLElement; @@ -29,13 +29,13 @@ const BottomBannerSection = () => {
- ) : !data || !data.bannerList || data.bannerList.length === 0 ? null : ( + ) : !bannerList || bannerList.length === 0 ? null : (
1 ? { delay: 2500 } : false} + autoplay={bannerList.length > 1 ? { delay: 2500 } : false} modules={[Pagination, Autoplay, Navigation]} - loop={data.bannerList.length > 1} - navigation={data.bannerList.length > 1} + loop={bannerList.length > 1} + navigation={bannerList.length > 1} pagination={{ type: 'fraction', renderFraction: (currentClass, totalClass) => @@ -48,18 +48,18 @@ const BottomBannerSection = () => { slidesPerView={1} className="aspect-[3.2/1] rounded-sm md:aspect-[6.4/1]" > - {data.bannerList.map((banner) => ( - + {bannerList.map((banner, index) => ( + @@ -67,7 +67,7 @@ const BottomBannerSection = () => { src={ isMobile ? banner.mobileImgUrl || '' : banner.imgUrl || '' } - alt={'main-banner' + banner.id} + alt={'main-banner-' + index} className="h-full w-full rounded-sm object-cover" /> diff --git a/src/domain/home/banner/MainBannerSection.tsx b/src/domain/home/banner/MainBannerSection.tsx index aca3919a9..d737ffdff 100644 --- a/src/domain/home/banner/MainBannerSection.tsx +++ b/src/domain/home/banner/MainBannerSection.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useGetBannerListForUser } from '@/api/banner'; +import { useGetCommonBannerListForUser } from '@/api/banner'; import LoadingContainer from '@/common/loading/LoadingContainer'; import { MOBILE_MEDIA_QUERY } from '@/utils/constants'; import { useMediaQuery } from '@mui/material'; @@ -11,7 +11,7 @@ import { Swiper, SwiperSlide } from 'swiper/react'; const MainBannerSection = () => { const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY); - const { data, isLoading } = useGetBannerListForUser({ type: 'MAIN' }); + const { data: bannerList, isLoading } = useGetCommonBannerListForUser({ type: 'HOME_TOP' }); const handleClickBanner = (e: MouseEvent) => { const target = e.target as HTMLElement; @@ -29,13 +29,13 @@ const MainBannerSection = () => {
- ) : !data || !data.bannerList || data.bannerList.length === 0 ? null : ( + ) : !bannerList || bannerList.length === 0 ? null : (
1 ? { delay: 2500 } : false} + autoplay={bannerList.length > 1 ? { delay: 2500 } : false} modules={[Pagination, Autoplay, Navigation]} - loop={data.bannerList.length > 1} - navigation={data.bannerList.length > 1} + loop={bannerList.length > 1} + navigation={bannerList.length > 1} pagination={{ type: 'fraction', renderFraction: (currentClass, totalClass) => @@ -48,15 +48,15 @@ const MainBannerSection = () => { slidesPerView={1} className="aspect-[3.2/1] rounded-sm md:aspect-[6.4/1]" > - {data.bannerList.map((banner) => ( - + {bannerList.map((banner, index) => ( + { src={ isMobile ? banner.mobileImgUrl || '' : banner.imgUrl || '' } - alt={'main-banner' + banner.id} + alt={'main-banner-' + index} className="h-full w-full rounded-sm object-cover" /> diff --git a/src/domain/mypage/MyPageBanner.tsx b/src/domain/mypage/MyPageBanner.tsx index 2dee8e3cd..375d1e5d9 100644 --- a/src/domain/mypage/MyPageBanner.tsx +++ b/src/domain/mypage/MyPageBanner.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import type { Swiper as SwiperType } from 'swiper'; import { Autoplay, Navigation, Pagination } from 'swiper/modules'; import { Swiper, SwiperSlide } from 'swiper/react'; @@ -9,8 +9,10 @@ import 'swiper/css'; import 'swiper/css/navigation'; import 'swiper/css/pagination'; -import { useGetBannerListForUser } from '@/api/banner'; +import { useGetCommonBannerListForUser } from '@/api/banner'; import HybridLink from '@/common/HybridLink'; +import { MOBILE_MEDIA_QUERY } from '@/utils/constants'; +import { useMediaQuery } from '@mui/material'; interface MyPageBannerProps { className?: string; @@ -19,17 +21,14 @@ interface MyPageBannerProps { const BANNER_AUTOPLAY_DELAY_MS = 2500; const MyPageBanner = ({ className }: MyPageBannerProps) => { - const { data, isLoading } = useGetBannerListForUser({ type: 'MAIN' }); + const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY); + const { data: bannerList = [], isLoading } = useGetCommonBannerListForUser({ + type: 'MY_PAGE', + }); const [isPlay, setIsPlay] = useState(true); const [currentIndex, setCurrentIndex] = useState(1); const swiperRef = useRef(null); - // mobileImgUrl이 있는 배너만 필터링 - const bannerList = useMemo(() => { - if (!data?.bannerList) return []; - return data.bannerList.filter((banner) => banner.mobileImgUrl); - }, [data]); - const handleTogglePlay = () => { if (!swiperRef.current) return; @@ -63,23 +62,25 @@ const MyPageBanner = ({ className }: MyPageBannerProps) => { slidesPerView={1} className="mypage-banner-swiper aspect-[3.2/1] h-full w-full" > - {bannerList.map((banner) => ( - + {bannerList.map((banner, index) => ( + {'mypage-banner' diff --git a/src/domain/program/Banner.tsx b/src/domain/program/Banner.tsx index 0415df809..f633737b2 100644 --- a/src/domain/program/Banner.tsx +++ b/src/domain/program/Banner.tsx @@ -3,7 +3,7 @@ import HybridLink from '@/common/HybridLink'; import { useEffect, useRef, useState } from 'react'; -import { useGetUserProgramBannerListQuery } from '@/api/program'; +import { useGetCommonBannerListForUser } from '@/api/banner'; const Banner = () => { const imgRef = useRef(null); @@ -27,16 +27,16 @@ const Banner = () => { }; }, []); - const { data, isLoading } = useGetUserProgramBannerListQuery(); + const { data: bannerList, isLoading } = useGetCommonBannerListForUser({ type: 'PROGRAM' }); useEffect(() => { if (!isPlay) return; // 배너 슬라이드 애니메이션 const timer = setInterval(() => { - if (!data) return; + if (!bannerList) return; const distance = imgRef.current?.offsetWidth; - const nextIndex = (bannerIndex + 1) % data.bannerList.length; + const nextIndex = (bannerIndex + 1) % bannerList.length; setBannerIndex(nextIndex); innerRef.current?.style.setProperty( @@ -48,9 +48,9 @@ const Banner = () => { return () => { clearInterval(timer); }; - }, [bannerIndex, data, isPlay]); + }, [bannerIndex, bannerList, isPlay]); - if (isLoading || !data || data.bannerList.length === 0) return null; + if (isLoading || !bannerList || bannerList.length === 0) return null; return (
@@ -58,19 +58,19 @@ const Banner = () => { ref={innerRef} className="flex flex-nowrap items-center transition-transform duration-300 ease-in-out" > - {data.bannerList.map((banner) => ( + {bannerList.map((banner, index) => ( 배너 이미지 @@ -85,9 +85,9 @@ const Banner = () => { /> {bannerIndex + 1 < 10 ? `0${bannerIndex + 1}` : bannerIndex + 1} /{' '} - {data.bannerList.length < 10 - ? `0${data.bannerList.length}` - : data.bannerList.length} + {bannerList.length < 10 + ? `0${bannerList.length}` + : bannerList.length}
From 91a42530b14bd84071a2e304c71a210b278154f6 Mon Sep 17 00:00:00 2001 From: yeeun426 Date: Mon, 23 Feb 2026 00:35:28 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat=20:=20=EB=93=B1=EB=A1=9D/=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/banner.ts | 12 ------ .../banner/common-banners/[id]/edit/page.tsx | 40 +++++++++++++++++++ .../admin/banner/common-banners/new/page.tsx | 40 +++++++++++++++++++ 3 files changed, 80 insertions(+), 12 deletions(-) diff --git a/src/api/banner.ts b/src/api/banner.ts index 73d37ab80..571cbbc1b 100644 --- a/src/api/banner.ts +++ b/src/api/banner.ts @@ -393,18 +393,6 @@ export const usePostCommonBannerForAdmin = ({ const needsProgram = types.PROGRAM; const needsMyPage = types.MY_PAGE; - // 필수 이미지 유효성 검사 - if (needsMyPage && !form.homeMobileFile) { - throw new Error( - '마이페이지 선택 시 홈 배너 (모바일) 이미지는 필수입니다.', - ); - } - if (needsMyPage && !form.programMobileFile) { - throw new Error( - '마이페이지 선택 시 프로그램 배너 (모바일) 이미지는 필수입니다.', - ); - } - // 필요한 파일만 병렬 업로드 const [ homePcFileId, diff --git a/src/app/admin/banner/common-banners/[id]/edit/page.tsx b/src/app/admin/banner/common-banners/[id]/edit/page.tsx index 639d8e8bd..3aaa1fd78 100644 --- a/src/app/admin/banner/common-banners/[id]/edit/page.tsx +++ b/src/app/admin/banner/common-banners/[id]/edit/page.tsx @@ -112,6 +112,46 @@ const CommonBannerEdit = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!value) return; + + // 노출 기간 검증 + if (!value.startDate || !value.endDate) { + alert('노출 시작일과 종료일을 모두 입력해주세요.'); + return; + } + if (new Date(value.startDate) >= new Date(value.endDate)) { + alert('노출 종료일은 시작일보다 이후여야 합니다.'); + return; + } + + // 노출 위치 선택 여부 + const { types } = value; + if (!types.HOME_TOP && !types.HOME_BOTTOM && !types.PROGRAM && !types.MY_PAGE) { + alert('노출 위치를 하나 이상 선택해주세요.'); + return; + } + + // 필수 이미지 검증 (새 파일 또는 기존 fileId가 있어야 함) + const needsHome = types.HOME_TOP || types.HOME_BOTTOM; + const needsProgram = types.PROGRAM; + const needsMyPage = types.MY_PAGE; + + if (needsHome && !value.homePcFile && !value.homePcFileId) { + alert('홈 배너 (PC) 이미지를 업로드해주세요.'); + return; + } + if ((needsHome || needsMyPage) && !value.homeMobileFile && !value.homeMobileFileId) { + alert('홈 배너 (모바일) 이미지를 업로드해주세요.'); + return; + } + if (needsProgram && !value.programPcFile && !value.programPcFileId) { + alert('프로그램 배너 (PC) 이미지를 업로드해주세요.'); + return; + } + if ((needsProgram || needsMyPage) && !value.programMobileFile && !value.programMobileFileId) { + alert('프로그램 배너 (모바일) 이미지를 업로드해주세요.'); + return; + } + tryEditCommonBanner({ commonBannerId, form: value }); }; diff --git a/src/app/admin/banner/common-banners/new/page.tsx b/src/app/admin/banner/common-banners/new/page.tsx index 492dcd83a..00701e3f1 100644 --- a/src/app/admin/banner/common-banners/new/page.tsx +++ b/src/app/admin/banner/common-banners/new/page.tsx @@ -41,6 +41,46 @@ const CommonBannerCreate = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + + // 노출 기간 검증 + if (!value.startDate || !value.endDate) { + alert('노출 시작일과 종료일을 모두 입력해주세요.'); + return; + } + if (new Date(value.startDate) >= new Date(value.endDate)) { + alert('노출 종료일은 시작일보다 이후여야 합니다.'); + return; + } + + // 노출 위치 선택 여부 + const { types } = value; + if (!types.HOME_TOP && !types.HOME_BOTTOM && !types.PROGRAM && !types.MY_PAGE) { + alert('노출 위치를 하나 이상 선택해주세요.'); + return; + } + + // 필수 이미지 검증 + const needsHome = types.HOME_TOP || types.HOME_BOTTOM; + const needsProgram = types.PROGRAM; + const needsMyPage = types.MY_PAGE; + + if (needsHome && !value.homePcFile) { + alert('홈 배너 (PC) 이미지를 업로드해주세요.'); + return; + } + if ((needsHome || needsMyPage) && !value.homeMobileFile) { + alert('홈 배너 (모바일) 이미지를 업로드해주세요.'); + return; + } + if (needsProgram && !value.programPcFile) { + alert('프로그램 배너 (PC) 이미지를 업로드해주세요.'); + return; + } + if ((needsProgram || needsMyPage) && !value.programMobileFile) { + alert('프로그램 배너 (모바일) 이미지를 업로드해주세요.'); + return; + } + tryCreateCommonBanner(value); }; From bcd0574eb307b6fd6fe32163f61f3a07bef0185c Mon Sep 17 00:00:00 2001 From: yeeun426 Date: Sat, 28 Feb 2026 00:35:22 +0900 Subject: [PATCH 10/14] =?UTF-8?q?feat=20:=20=EB=A7=8C=EB=A3=8C=EB=90=9C=20?= =?UTF-8?q?=EB=B0=B0=EB=84=88=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/banner.ts | 29 ++++++++++++ src/app/admin/banner/common-banners/page.tsx | 50 ++++++++++++++------ 2 files changed, 64 insertions(+), 15 deletions(-) diff --git a/src/api/banner.ts b/src/api/banner.ts index 571cbbc1b..aae91b180 100644 --- a/src/api/banner.ts +++ b/src/api/banner.ts @@ -651,6 +651,35 @@ export const useToggleCommonBannerVisibility = ({ }); }; +// 만료된 통합 배너 일괄 업데이트 +export const useUpdateExpiredCommonBanners = ({ + successCallback, + errorCallback, +}: { + successCallback?: () => void; + errorCallback?: (error: Error) => void; +} = {}) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => { + const res = await axios.patch('/admin/common-banner/expired'); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getCommonBannerForAdminQueryKey(), + }); + queryClient.invalidateQueries({ + queryKey: getActiveBannersForAdminQueryKey(), + }); + successCallback?.(); + }, + onError: (error: Error) => { + errorCallback?.(error); + }, + }); +}; + // 통합 배너 삭제 export const useDeleteCommonBannerForAdmin = ({ successCallback, diff --git a/src/app/admin/banner/common-banners/page.tsx b/src/app/admin/banner/common-banners/page.tsx index 7a6864842..efffb2ee3 100644 --- a/src/app/admin/banner/common-banners/page.tsx +++ b/src/app/admin/banner/common-banners/page.tsx @@ -10,6 +10,7 @@ import { useGetActiveBannersForAdmin, useGetCommonBannerForAdmin, useToggleCommonBannerVisibility, + useUpdateExpiredCommonBanners, } from '@/api/banner'; import WarningModal from '@/common/alert/WarningModal'; import EmptyContainer from '@/common/container/EmptyContainer'; @@ -188,6 +189,16 @@ const CommonBanners = () => { }, }); + const { mutate: updateExpiredBanners, isPending: isUpdatingExpired } = + useUpdateExpiredCommonBanners({ + successCallback: () => { + alert('만료된 배너가 업데이트되었습니다.'); + }, + errorCallback: (error) => { + alert('업데이트에 실패했습니다: ' + error.message); + }, + }); + const handleDeleteButtonClicked = (bannerId: number) => { setBannerIdForDeleting(bannerId); setIsDeleteModalShown(true); @@ -352,23 +363,32 @@ const CommonBanners = () => { href: '/admin/banner/common-banners/new', }} tabs={ -
- - | +
+
+ + | + +
} From a23a005fc7b8fb0c0b575c39455bb822be57bfa1 Mon Sep 17 00:00:00 2001 From: yeeun426 Date: Sat, 28 Feb 2026 00:37:56 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat=20:=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/banner/common-banners/page.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/admin/banner/common-banners/page.tsx b/src/app/admin/banner/common-banners/page.tsx index efffb2ee3..3976744be 100644 --- a/src/app/admin/banner/common-banners/page.tsx +++ b/src/app/admin/banner/common-banners/page.tsx @@ -19,7 +19,7 @@ import TableLayout from '@/domain/admin/ui/table/TableLayout'; import dayjs from '@/lib/dayjs'; import { Switch } from '@mui/material'; import { DataGrid, GridColDef, GridRowParams } from '@mui/x-data-grid'; -import { Pencil, Trash } from 'lucide-react'; +import { Pencil, RefreshCw, Trash } from 'lucide-react'; import Link from 'next/link'; type TabType = 'active' | 'all'; @@ -384,10 +384,14 @@ const CommonBanners = () => {
From dc3e1fea6c04798e0016a8cf6b44d47d85521261 Mon Sep 17 00:00:00 2001 From: yeeun426 Date: Sat, 28 Feb 2026 18:47:47 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20=EB=B0=B0=EB=84=88=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20API=20=ED=98=B8=EC=B6=9C=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/banner.ts | 71 ++++++++++++++++++++--------------------------- 1 file changed, 30 insertions(+), 41 deletions(-) diff --git a/src/api/banner.ts b/src/api/banner.ts index aae91b180..b437086ee 100644 --- a/src/api/banner.ts +++ b/src/api/banner.ts @@ -410,61 +410,50 @@ export const usePostCommonBannerForAdmin = ({ : Promise.resolve(null), ]); - // 타입별 detail 리스트 생성 - const requestsByType: { - type: string; - details: { type: string; agentType: 'PC' | 'MOBILE'; fileId: number }[]; - }[] = []; + const commonBannerDetailInfoList: CommonBannerDetailInfo[] = []; if (types.HOME_TOP) { - const details: { type: string; agentType: 'PC' | 'MOBILE'; fileId: number }[] = []; - if (homePcFileId) details.push({ type: 'HOME_TOP', agentType: 'PC', fileId: homePcFileId }); - if (homeMobileFileId) details.push({ type: 'HOME_TOP', agentType: 'MOBILE', fileId: homeMobileFileId }); - if (details.length > 0) requestsByType.push({ type: 'HOME_TOP', details }); + if (homePcFileId) + commonBannerDetailInfoList.push({ type: 'HOME_TOP', agentType: 'PC', fileId: homePcFileId }); + if (homeMobileFileId) + commonBannerDetailInfoList.push({ type: 'HOME_TOP', agentType: 'MOBILE', fileId: homeMobileFileId }); } if (types.HOME_BOTTOM) { - const details: { type: string; agentType: 'PC' | 'MOBILE'; fileId: number }[] = []; - if (homePcFileId) details.push({ type: 'HOME_BOTTOM', agentType: 'PC', fileId: homePcFileId }); - if (homeMobileFileId) details.push({ type: 'HOME_BOTTOM', agentType: 'MOBILE', fileId: homeMobileFileId }); - if (details.length > 0) requestsByType.push({ type: 'HOME_BOTTOM', details }); + if (homePcFileId) + commonBannerDetailInfoList.push({ type: 'HOME_BOTTOM', agentType: 'PC', fileId: homePcFileId }); + if (homeMobileFileId) + commonBannerDetailInfoList.push({ type: 'HOME_BOTTOM', agentType: 'MOBILE', fileId: homeMobileFileId }); } if (types.PROGRAM) { - const details: { type: string; agentType: 'PC' | 'MOBILE'; fileId: number }[] = []; - if (programPcFileId) details.push({ type: 'PROGRAM', agentType: 'PC', fileId: programPcFileId }); - if (programMobileFileId) details.push({ type: 'PROGRAM', agentType: 'MOBILE', fileId: programMobileFileId }); - if (details.length > 0) requestsByType.push({ type: 'PROGRAM', details }); + if (programPcFileId) + commonBannerDetailInfoList.push({ type: 'PROGRAM', agentType: 'PC', fileId: programPcFileId }); + if (programMobileFileId) + commonBannerDetailInfoList.push({ type: 'PROGRAM', agentType: 'MOBILE', fileId: programMobileFileId }); } if (types.MY_PAGE) { - const details: { type: string; agentType: 'PC' | 'MOBILE'; fileId: number }[] = []; - if (programMobileFileId) details.push({ type: 'MY_PAGE', agentType: 'PC', fileId: programMobileFileId }); - if (homeMobileFileId) details.push({ type: 'MY_PAGE', agentType: 'MOBILE', fileId: homeMobileFileId }); - if (details.length > 0) requestsByType.push({ type: 'MY_PAGE', details }); + if (programMobileFileId) + commonBannerDetailInfoList.push({ type: 'MY_PAGE', agentType: 'PC', fileId: programMobileFileId }); + if (homeMobileFileId) + commonBannerDetailInfoList.push({ type: 'MY_PAGE', agentType: 'MOBILE', fileId: homeMobileFileId }); } - const commonBannerInfo = { - title: form.title, - landingUrl: form.landingUrl, - startDate: form.startDate - ? new Date(form.startDate).toISOString() - : null, - endDate: form.endDate ? new Date(form.endDate).toISOString() : null, - isVisible: form.isVisible, - }; - - // 타입별로 개별 POST 요청 - const results = await Promise.all( - requestsByType.map((req) => - axios.post('/admin/common-banner', { - commonBannerInfo, - commonBannerDetailInfoList: req.details, - }), - ), - ); + const res = await axios.post('/admin/common-banner', { + commonBannerInfo: { + title: form.title, + landingUrl: form.landingUrl, + startDate: form.startDate + ? new Date(form.startDate).toISOString() + : null, + endDate: form.endDate ? new Date(form.endDate).toISOString() : null, + isVisible: form.isVisible, + }, + commonBannerDetailInfoList, + }); - return results.map((res) => res.data); + return res.data; }, onSuccess: () => { queryClient.invalidateQueries({ From 21347411466a1c6fbafbe0310f5599ff10f4fef8 Mon Sep 17 00:00:00 2001 From: yeeun426 Date: Sun, 1 Mar 2026 01:53:05 +0900 Subject: [PATCH 13/14] =?UTF-8?q?feat=20:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=AA=A8=EB=B0=94=EC=9D=BC=EC=9D=98=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0,=20=ED=99=88=20=EB=B0=B0=EB=84=88=EB=A1=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/banner.ts | 12 ++++++------ .../banner/common-banners/[id]/edit/page.tsx | 6 +++--- .../new/components/CommonBannerInputContent.tsx | 17 +++++++++++------ .../admin/banner/common-banners/new/page.tsx | 2 +- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/api/banner.ts b/src/api/banner.ts index b437086ee..08e5e338e 100644 --- a/src/api/banner.ts +++ b/src/api/banner.ts @@ -405,7 +405,7 @@ export const usePostCommonBannerForAdmin = ({ ? maybeUploadCommonBanner(form.homeMobileFile) : Promise.resolve(null), needsProgram ? maybeUploadCommonBanner(form.programPcFile) : Promise.resolve(null), - needsProgram || needsMyPage + needsProgram ? maybeUploadCommonBanner(form.programMobileFile) : Promise.resolve(null), ]); @@ -434,8 +434,8 @@ export const usePostCommonBannerForAdmin = ({ } if (types.MY_PAGE) { - if (programMobileFileId) - commonBannerDetailInfoList.push({ type: 'MY_PAGE', agentType: 'PC', fileId: programMobileFileId }); + if (homeMobileFileId) + commonBannerDetailInfoList.push({ type: 'MY_PAGE', agentType: 'PC', fileId: homeMobileFileId }); if (homeMobileFileId) commonBannerDetailInfoList.push({ type: 'MY_PAGE', agentType: 'MOBILE', fileId: homeMobileFileId }); } @@ -528,7 +528,7 @@ export const useEditCommonBannerForAdmin = ({ needsProgram && form.programPcFile ? maybeUploadCommonBanner(form.programPcFile) : Promise.resolve(form.programPcFileId ?? null), - (needsProgram || needsMyPage) && form.programMobileFile + needsProgram && form.programMobileFile ? maybeUploadCommonBanner(form.programMobileFile) : Promise.resolve(form.programMobileFileId ?? null), ]); @@ -557,8 +557,8 @@ export const useEditCommonBannerForAdmin = ({ } if (types.MY_PAGE) { - if (programMobileFileId) - commonBannerDetailInfoList.push({ type: 'MY_PAGE', agentType: 'PC', fileId: programMobileFileId }); + if (homeMobileFileId) + commonBannerDetailInfoList.push({ type: 'MY_PAGE', agentType: 'PC', fileId: homeMobileFileId }); if (homeMobileFileId) commonBannerDetailInfoList.push({ type: 'MY_PAGE', agentType: 'MOBILE', fileId: homeMobileFileId }); } diff --git a/src/app/admin/banner/common-banners/[id]/edit/page.tsx b/src/app/admin/banner/common-banners/[id]/edit/page.tsx index 3aaa1fd78..99e3ef3fc 100644 --- a/src/app/admin/banner/common-banners/[id]/edit/page.tsx +++ b/src/app/admin/banner/common-banners/[id]/edit/page.tsx @@ -48,9 +48,9 @@ const mapDetailToFormValue = ( if (item.agentType === 'PC') { programPcFileId = item.fileId; programPcFileUrl = item.fileUrl ?? null; } if (item.agentType === 'MOBILE') { programMobileFileId = item.fileId; programMobileFileUrl = item.fileUrl ?? null; } } else if (item.type === 'MY_PAGE') { - // MY_PAGE PC = 프로그램 모바일, MY_PAGE MOBILE = 홈 모바일 - if (item.agentType === 'PC') { programMobileFileId = item.fileId; programMobileFileUrl = item.fileUrl ?? null; } + // MY_PAGE PC, MOBILE 모두 홈 배너 (모바일) 이미지 사용 if (item.agentType === 'MOBILE') { homeMobileFileId = item.fileId; homeMobileFileUrl = item.fileUrl ?? null; } + if (item.agentType === 'PC' && !homeMobileFileId) { homeMobileFileId = item.fileId; homeMobileFileUrl = item.fileUrl ?? null; } } } @@ -147,7 +147,7 @@ const CommonBannerEdit = () => { alert('프로그램 배너 (PC) 이미지를 업로드해주세요.'); return; } - if ((needsProgram || needsMyPage) && !value.programMobileFile && !value.programMobileFileId) { + if (needsProgram && !value.programMobileFile && !value.programMobileFileId) { alert('프로그램 배너 (모바일) 이미지를 업로드해주세요.'); return; } diff --git a/src/app/admin/banner/common-banners/new/components/CommonBannerInputContent.tsx b/src/app/admin/banner/common-banners/new/components/CommonBannerInputContent.tsx index d2faabc98..6fd8ebfde 100644 --- a/src/app/admin/banner/common-banners/new/components/CommonBannerInputContent.tsx +++ b/src/app/admin/banner/common-banners/new/components/CommonBannerInputContent.tsx @@ -114,7 +114,7 @@ const CommonBannerInputContent = ({ value, onChange }: Props) => { // 이미지 섹션 표시 여부 const showHomeImages = hasHome || hasMyPage; - const showProgramImages = hasProgram || hasMyPage; + const showProgramImages = hasProgram; return (
@@ -225,7 +225,9 @@ const CommonBannerInputContent = ({ value, onChange }: Props) => { label="홈 배너 (모바일)" file={value.homeMobileFile} previewUrl={value.homeMobileFileUrl} - onChange={(f) => set({ homeMobileFile: f, homeMobileFileUrl: null })} + onChange={(f) => + set({ homeMobileFile: f, homeMobileFileUrl: null }) + } />
)} @@ -238,14 +240,18 @@ const CommonBannerInputContent = ({ value, onChange }: Props) => { label="프로그램 배너 (PC)" file={value.programPcFile} previewUrl={value.programPcFileUrl} - onChange={(f) => set({ programPcFile: f, programPcFileUrl: null })} + onChange={(f) => + set({ programPcFile: f, programPcFileUrl: null }) + } /> )} set({ programMobileFile: f, programMobileFileUrl: null })} + onChange={(f) => + set({ programMobileFile: f, programMobileFileUrl: null }) + } /> )} @@ -262,9 +268,8 @@ const CommonBannerInputContent = ({ value, onChange }: Props) => {

※ 마이페이지 배너는 별도 이미지를 사용하지 않습니다.

- - 마이페이지 PC : 프로그램 배너 (모바일) 이미지가 노출됩니다. + - 마이페이지 PC, 모바일 : 홈 배너 (모바일) 이미지가 노출됩니다.

-

- 마이페이지 모바일 : 홈 배너 (모바일) 이미지가 노출됩니다.

)} diff --git a/src/app/admin/banner/common-banners/new/page.tsx b/src/app/admin/banner/common-banners/new/page.tsx index 00701e3f1..ff461734b 100644 --- a/src/app/admin/banner/common-banners/new/page.tsx +++ b/src/app/admin/banner/common-banners/new/page.tsx @@ -76,7 +76,7 @@ const CommonBannerCreate = () => { alert('프로그램 배너 (PC) 이미지를 업로드해주세요.'); return; } - if ((needsProgram || needsMyPage) && !value.programMobileFile) { + if (needsProgram && !value.programMobileFile) { alert('프로그램 배너 (모바일) 이미지를 업로드해주세요.'); return; } From 72a7ae9e00953d488cdfe90ad8f5b9cafadcff38 Mon Sep 17 00:00:00 2001 From: yeeun426 Date: Sun, 1 Mar 2026 02:21:44 +0900 Subject: [PATCH 14/14] =?UTF-8?q?feat=20:=20=ED=8F=B4=EB=8D=94=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B8=B0=EB=B0=98=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/banner.ts | 124 ++++++--- .../banner/common-banners/[id]/edit/page.tsx | 159 +---------- .../admin/banner/common-banners/new/page.tsx | 87 +----- src/app/admin/banner/common-banners/page.tsx | 255 ++---------------- .../common-banner/ActiveBannerTable.tsx | 132 +++++++++ .../CommonBannerInputContent.tsx | 0 .../CommonBannerVisibilityToggle.tsx | 25 ++ .../common-banner/useCommonBannerCreate.ts | 95 +++++++ .../common-banner/useCommonBannerEdit.ts | 188 +++++++++++++ .../banner/common-banner/useCommonBanners.ts | 115 ++++++++ 10 files changed, 678 insertions(+), 502 deletions(-) create mode 100644 src/domain/admin/banner/common-banner/ActiveBannerTable.tsx rename src/{app/admin/banner/common-banners/new/components => domain/admin/banner/common-banner}/CommonBannerInputContent.tsx (100%) create mode 100644 src/domain/admin/banner/common-banner/CommonBannerVisibilityToggle.tsx create mode 100644 src/domain/admin/pages/banner/common-banner/useCommonBannerCreate.ts create mode 100644 src/domain/admin/pages/banner/common-banner/useCommonBannerEdit.ts create mode 100644 src/domain/admin/pages/banner/common-banner/useCommonBanners.ts diff --git a/src/api/banner.ts b/src/api/banner.ts index 08e5e338e..4ecdb5bb6 100644 --- a/src/api/banner.ts +++ b/src/api/banner.ts @@ -333,7 +333,9 @@ export const bannerUserListSchema = z.object({ }); const maybeUploadCommonBanner = (file: File | null): Promise => - file ? uploadFileForId({ file, type: 'COMMON_BANNER' }) : Promise.resolve(null); + file + ? uploadFileForId({ file, type: 'COMMON_BANNER' }) + : Promise.resolve(null); export type CommonBannerDetailInfo = { type: CommonBannerType; @@ -400,11 +402,15 @@ export const usePostCommonBannerForAdmin = ({ programPcFileId, programMobileFileId, ] = await Promise.all([ - needsHome ? maybeUploadCommonBanner(form.homePcFile) : Promise.resolve(null), + needsHome + ? maybeUploadCommonBanner(form.homePcFile) + : Promise.resolve(null), needsHome || needsMyPage ? maybeUploadCommonBanner(form.homeMobileFile) : Promise.resolve(null), - needsProgram ? maybeUploadCommonBanner(form.programPcFile) : Promise.resolve(null), + needsProgram + ? maybeUploadCommonBanner(form.programPcFile) + : Promise.resolve(null), needsProgram ? maybeUploadCommonBanner(form.programMobileFile) : Promise.resolve(null), @@ -414,30 +420,62 @@ export const usePostCommonBannerForAdmin = ({ if (types.HOME_TOP) { if (homePcFileId) - commonBannerDetailInfoList.push({ type: 'HOME_TOP', agentType: 'PC', fileId: homePcFileId }); + commonBannerDetailInfoList.push({ + type: 'HOME_TOP', + agentType: 'PC', + fileId: homePcFileId, + }); if (homeMobileFileId) - commonBannerDetailInfoList.push({ type: 'HOME_TOP', agentType: 'MOBILE', fileId: homeMobileFileId }); + commonBannerDetailInfoList.push({ + type: 'HOME_TOP', + agentType: 'MOBILE', + fileId: homeMobileFileId, + }); } if (types.HOME_BOTTOM) { if (homePcFileId) - commonBannerDetailInfoList.push({ type: 'HOME_BOTTOM', agentType: 'PC', fileId: homePcFileId }); + commonBannerDetailInfoList.push({ + type: 'HOME_BOTTOM', + agentType: 'PC', + fileId: homePcFileId, + }); if (homeMobileFileId) - commonBannerDetailInfoList.push({ type: 'HOME_BOTTOM', agentType: 'MOBILE', fileId: homeMobileFileId }); + commonBannerDetailInfoList.push({ + type: 'HOME_BOTTOM', + agentType: 'MOBILE', + fileId: homeMobileFileId, + }); } if (types.PROGRAM) { if (programPcFileId) - commonBannerDetailInfoList.push({ type: 'PROGRAM', agentType: 'PC', fileId: programPcFileId }); + commonBannerDetailInfoList.push({ + type: 'PROGRAM', + agentType: 'PC', + fileId: programPcFileId, + }); if (programMobileFileId) - commonBannerDetailInfoList.push({ type: 'PROGRAM', agentType: 'MOBILE', fileId: programMobileFileId }); + commonBannerDetailInfoList.push({ + type: 'PROGRAM', + agentType: 'MOBILE', + fileId: programMobileFileId, + }); } if (types.MY_PAGE) { if (homeMobileFileId) - commonBannerDetailInfoList.push({ type: 'MY_PAGE', agentType: 'PC', fileId: homeMobileFileId }); + commonBannerDetailInfoList.push({ + type: 'MY_PAGE', + agentType: 'PC', + fileId: homeMobileFileId, + }); if (homeMobileFileId) - commonBannerDetailInfoList.push({ type: 'MY_PAGE', agentType: 'MOBILE', fileId: homeMobileFileId }); + commonBannerDetailInfoList.push({ + type: 'MY_PAGE', + agentType: 'MOBILE', + fileId: homeMobileFileId, + }); } const res = await axios.post('/admin/common-banner', { @@ -537,30 +575,62 @@ export const useEditCommonBannerForAdmin = ({ if (types.HOME_TOP) { if (homePcFileId) - commonBannerDetailInfoList.push({ type: 'HOME_TOP', agentType: 'PC', fileId: homePcFileId }); + commonBannerDetailInfoList.push({ + type: 'HOME_TOP', + agentType: 'PC', + fileId: homePcFileId, + }); if (homeMobileFileId) - commonBannerDetailInfoList.push({ type: 'HOME_TOP', agentType: 'MOBILE', fileId: homeMobileFileId }); + commonBannerDetailInfoList.push({ + type: 'HOME_TOP', + agentType: 'MOBILE', + fileId: homeMobileFileId, + }); } if (types.HOME_BOTTOM) { if (homePcFileId) - commonBannerDetailInfoList.push({ type: 'HOME_BOTTOM', agentType: 'PC', fileId: homePcFileId }); + commonBannerDetailInfoList.push({ + type: 'HOME_BOTTOM', + agentType: 'PC', + fileId: homePcFileId, + }); if (homeMobileFileId) - commonBannerDetailInfoList.push({ type: 'HOME_BOTTOM', agentType: 'MOBILE', fileId: homeMobileFileId }); + commonBannerDetailInfoList.push({ + type: 'HOME_BOTTOM', + agentType: 'MOBILE', + fileId: homeMobileFileId, + }); } if (types.PROGRAM) { if (programPcFileId) - commonBannerDetailInfoList.push({ type: 'PROGRAM', agentType: 'PC', fileId: programPcFileId }); + commonBannerDetailInfoList.push({ + type: 'PROGRAM', + agentType: 'PC', + fileId: programPcFileId, + }); if (programMobileFileId) - commonBannerDetailInfoList.push({ type: 'PROGRAM', agentType: 'MOBILE', fileId: programMobileFileId }); + commonBannerDetailInfoList.push({ + type: 'PROGRAM', + agentType: 'MOBILE', + fileId: programMobileFileId, + }); } if (types.MY_PAGE) { if (homeMobileFileId) - commonBannerDetailInfoList.push({ type: 'MY_PAGE', agentType: 'PC', fileId: homeMobileFileId }); + commonBannerDetailInfoList.push({ + type: 'MY_PAGE', + agentType: 'PC', + fileId: homeMobileFileId, + }); if (homeMobileFileId) - commonBannerDetailInfoList.push({ type: 'MY_PAGE', agentType: 'MOBILE', fileId: homeMobileFileId }); + commonBannerDetailInfoList.push({ + type: 'MY_PAGE', + agentType: 'MOBILE', + fileId: homeMobileFileId, + }); } const res = await axios.patch(`/admin/common-banner/${commonBannerId}`, { @@ -610,18 +680,8 @@ export const useToggleCommonBannerVisibility = ({ commonBannerId: number; isVisible: boolean; }) => { - const detailRes = await axios(`/admin/common-banner/${commonBannerId}`); - const detail = detailRes.data.data as CommonBannerDetailResponse; - const res = await axios.patch(`/admin/common-banner/${commonBannerId}`, { - commonBannerInfo: { - title: detail.commonBanner.title, - landingUrl: detail.commonBanner.landingUrl, - startDate: detail.commonBanner.startDate, - endDate: detail.commonBanner.endDate, - isVisible, - }, - commonBannerDetailInfoList: detail.commonBannerDetailList, + commonBannerInfo: { isVisible }, }); return res.data; }, @@ -680,9 +740,7 @@ export const useDeleteCommonBannerForAdmin = ({ const queryClient = useQueryClient(); return useMutation({ mutationFn: async (commonBannerId: number) => { - const res = await axios.delete( - `/admin/common-banner/${commonBannerId}`, - ); + const res = await axios.delete(`/admin/common-banner/${commonBannerId}`); return res.data; }, onSuccess: () => { diff --git a/src/app/admin/banner/common-banners/[id]/edit/page.tsx b/src/app/admin/banner/common-banners/[id]/edit/page.tsx index 99e3ef3fc..5604ac85a 100644 --- a/src/app/admin/banner/common-banners/[id]/edit/page.tsx +++ b/src/app/admin/banner/common-banners/[id]/edit/page.tsx @@ -1,159 +1,12 @@ 'use client'; -import { - CommonBannerDetailResponse, - CommonBannerFormValue, - CommonBannerType, - useEditCommonBannerForAdmin, - useGetCommonBannerDetailForAdmin, -} from '@/api/banner'; -import EditorTemplate from '@/domain/admin/program/ui/editor/EditorTemplate'; import LoadingContainer from '@/common/loading/LoadingContainer'; -import { useParams, useRouter } from 'next/navigation'; -import { useEffect, useState } from 'react'; -import CommonBannerInputContent from '../../new/components/CommonBannerInputContent'; - -const toDatetimeLocal = (iso: string) => { - const date = new Date(iso); - const pad = (n: number) => String(n).padStart(2, '0'); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; -}; - -const mapDetailToFormValue = ( - detail: CommonBannerDetailResponse, -): CommonBannerFormValue => { - const types: Record = { - HOME_TOP: false, - HOME_BOTTOM: false, - PROGRAM: false, - MY_PAGE: false, - }; - - let homePcFileId: number | null = null; - let homeMobileFileId: number | null = null; - let programPcFileId: number | null = null; - let programMobileFileId: number | null = null; - let homePcFileUrl: string | null = null; - let homeMobileFileUrl: string | null = null; - let programPcFileUrl: string | null = null; - let programMobileFileUrl: string | null = null; - - for (const item of detail.commonBannerDetailList ?? []) { - types[item.type] = true; - - if (item.type === 'HOME_TOP' || item.type === 'HOME_BOTTOM') { - if (item.agentType === 'PC') { homePcFileId = item.fileId; homePcFileUrl = item.fileUrl ?? null; } - if (item.agentType === 'MOBILE') { homeMobileFileId = item.fileId; homeMobileFileUrl = item.fileUrl ?? null; } - } else if (item.type === 'PROGRAM') { - if (item.agentType === 'PC') { programPcFileId = item.fileId; programPcFileUrl = item.fileUrl ?? null; } - if (item.agentType === 'MOBILE') { programMobileFileId = item.fileId; programMobileFileUrl = item.fileUrl ?? null; } - } else if (item.type === 'MY_PAGE') { - // MY_PAGE PC, MOBILE 모두 홈 배너 (모바일) 이미지 사용 - if (item.agentType === 'MOBILE') { homeMobileFileId = item.fileId; homeMobileFileUrl = item.fileUrl ?? null; } - if (item.agentType === 'PC' && !homeMobileFileId) { homeMobileFileId = item.fileId; homeMobileFileUrl = item.fileUrl ?? null; } - } - } - - const { commonBanner } = detail; - - return { - title: commonBanner.title || '', - landingUrl: commonBanner.landingUrl || '', - isVisible: commonBanner.isVisible, - startDate: commonBanner.startDate - ? toDatetimeLocal(commonBanner.startDate) - : '', - endDate: commonBanner.endDate - ? toDatetimeLocal(commonBanner.endDate) - : '', - types, - homePcFile: null, - homeMobileFile: null, - programPcFile: null, - programMobileFile: null, - homePcFileId, - homeMobileFileId, - programPcFileId, - programMobileFileId, - homePcFileUrl, - homeMobileFileUrl, - programPcFileUrl, - programMobileFileUrl, - }; -}; - -const CommonBannerEdit = () => { - const router = useRouter(); - const params = useParams(); - const commonBannerId = Number(params.id); - - const { data, isLoading } = useGetCommonBannerDetailForAdmin({ - commonBannerId, - }); - - const [value, setValue] = useState(null); - - useEffect(() => { - if (data && !value) { - setValue(mapDetailToFormValue(data)); - } - }, [data, value]); - - const { mutate: tryEditCommonBanner } = useEditCommonBannerForAdmin({ - successCallback: () => { - router.push('/admin/banner/common-banners'); - }, - errorCallback: (error) => { - console.error(error); - alert('통합 배너 수정에 실패했습니다.'); - }, - }); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!value) return; - - // 노출 기간 검증 - if (!value.startDate || !value.endDate) { - alert('노출 시작일과 종료일을 모두 입력해주세요.'); - return; - } - if (new Date(value.startDate) >= new Date(value.endDate)) { - alert('노출 종료일은 시작일보다 이후여야 합니다.'); - return; - } - - // 노출 위치 선택 여부 - const { types } = value; - if (!types.HOME_TOP && !types.HOME_BOTTOM && !types.PROGRAM && !types.MY_PAGE) { - alert('노출 위치를 하나 이상 선택해주세요.'); - return; - } - - // 필수 이미지 검증 (새 파일 또는 기존 fileId가 있어야 함) - const needsHome = types.HOME_TOP || types.HOME_BOTTOM; - const needsProgram = types.PROGRAM; - const needsMyPage = types.MY_PAGE; - - if (needsHome && !value.homePcFile && !value.homePcFileId) { - alert('홈 배너 (PC) 이미지를 업로드해주세요.'); - return; - } - if ((needsHome || needsMyPage) && !value.homeMobileFile && !value.homeMobileFileId) { - alert('홈 배너 (모바일) 이미지를 업로드해주세요.'); - return; - } - if (needsProgram && !value.programPcFile && !value.programPcFileId) { - alert('프로그램 배너 (PC) 이미지를 업로드해주세요.'); - return; - } - if (needsProgram && !value.programMobileFile && !value.programMobileFileId) { - alert('프로그램 배너 (모바일) 이미지를 업로드해주세요.'); - return; - } +import CommonBannerInputContent from '@/domain/admin/banner/common-banner/CommonBannerInputContent'; +import useCommonBannerEdit from '@/domain/admin/pages/banner/common-banner/useCommonBannerEdit'; +import EditorTemplate from '@/domain/admin/program/ui/editor/EditorTemplate'; - tryEditCommonBanner({ commonBannerId, form: value }); - }; +const CommonBannerEditPage = () => { + const { value, setValue, isLoading, handleSubmit } = useCommonBannerEdit(); if (isLoading || !value) { return ; @@ -171,4 +24,4 @@ const CommonBannerEdit = () => { ); }; -export default CommonBannerEdit; +export default CommonBannerEditPage; diff --git a/src/app/admin/banner/common-banners/new/page.tsx b/src/app/admin/banner/common-banners/new/page.tsx index ff461734b..b6063d72e 100644 --- a/src/app/admin/banner/common-banners/new/page.tsx +++ b/src/app/admin/banner/common-banners/new/page.tsx @@ -1,88 +1,11 @@ 'use client'; -import { CommonBannerFormValue, usePostCommonBannerForAdmin } from '@/api/banner'; +import CommonBannerInputContent from '@/domain/admin/banner/common-banner/CommonBannerInputContent'; +import useCommonBannerCreate from '@/domain/admin/pages/banner/common-banner/useCommonBannerCreate'; import EditorTemplate from '@/domain/admin/program/ui/editor/EditorTemplate'; -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; -import CommonBannerInputContent from './components/CommonBannerInputContent'; -const CommonBannerCreate = () => { - const router = useRouter(); - - const [value, setValue] = useState({ - title: '', - landingUrl: '', - isVisible: true, - startDate: '', - endDate: '', - // 노출 위치 체크박스 - types: { - HOME_TOP: false, - HOME_BOTTOM: false, - PROGRAM: false, - MY_PAGE: false, - }, - // 이미지 파일 - homePcFile: null, - homeMobileFile: null, - programPcFile: null, - programMobileFile: null, - }); - - const { mutate: tryCreateCommonBanner } = usePostCommonBannerForAdmin({ - successCallback: () => { - router.push('/admin/banner/common-banners'); - }, - errorCallback: (error) => { - console.error(error); - alert('통합 배너 등록에 실패했습니다.'); - }, - }); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - // 노출 기간 검증 - if (!value.startDate || !value.endDate) { - alert('노출 시작일과 종료일을 모두 입력해주세요.'); - return; - } - if (new Date(value.startDate) >= new Date(value.endDate)) { - alert('노출 종료일은 시작일보다 이후여야 합니다.'); - return; - } - - // 노출 위치 선택 여부 - const { types } = value; - if (!types.HOME_TOP && !types.HOME_BOTTOM && !types.PROGRAM && !types.MY_PAGE) { - alert('노출 위치를 하나 이상 선택해주세요.'); - return; - } - - // 필수 이미지 검증 - const needsHome = types.HOME_TOP || types.HOME_BOTTOM; - const needsProgram = types.PROGRAM; - const needsMyPage = types.MY_PAGE; - - if (needsHome && !value.homePcFile) { - alert('홈 배너 (PC) 이미지를 업로드해주세요.'); - return; - } - if ((needsHome || needsMyPage) && !value.homeMobileFile) { - alert('홈 배너 (모바일) 이미지를 업로드해주세요.'); - return; - } - if (needsProgram && !value.programPcFile) { - alert('프로그램 배너 (PC) 이미지를 업로드해주세요.'); - return; - } - if (needsProgram && !value.programMobileFile) { - alert('프로그램 배너 (모바일) 이미지를 업로드해주세요.'); - return; - } - - tryCreateCommonBanner(value); - }; +const CommonBannerCreatePage = () => { + const { value, setValue, handleSubmit } = useCommonBannerCreate(); return ( { ); }; -export default CommonBannerCreate; +export default CommonBannerCreatePage; diff --git a/src/app/admin/banner/common-banners/page.tsx b/src/app/admin/banner/common-banners/page.tsx index 3976744be..449866b8e 100644 --- a/src/app/admin/banner/common-banners/page.tsx +++ b/src/app/admin/banner/common-banners/page.tsx @@ -1,249 +1,36 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; -import { - CommonBannerAdminAllItemType, - CommonBannerAdminListItemType, - CommonBannerType, - useDeleteCommonBannerForAdmin, - useGetActiveBannersForAdmin, - useGetCommonBannerForAdmin, - useToggleCommonBannerVisibility, - useUpdateExpiredCommonBanners, -} from '@/api/banner'; +import { CommonBannerAdminAllItemType } from '@/api/banner'; import WarningModal from '@/common/alert/WarningModal'; import EmptyContainer from '@/common/container/EmptyContainer'; import LoadingContainer from '@/common/loading/LoadingContainer'; +import ActiveBannerTable from '@/domain/admin/banner/common-banner/ActiveBannerTable'; +import CommonBannerVisibilityToggle from '@/domain/admin/banner/common-banner/CommonBannerVisibilityToggle'; +import useCommonBanners from '@/domain/admin/pages/banner/common-banner/useCommonBanners'; import TableLayout from '@/domain/admin/ui/table/TableLayout'; import dayjs from '@/lib/dayjs'; -import { Switch } from '@mui/material'; import { DataGrid, GridColDef, GridRowParams } from '@mui/x-data-grid'; import { Pencil, RefreshCw, Trash } from 'lucide-react'; import Link from 'next/link'; -type TabType = 'active' | 'all'; - -// 배너 위치 타입별 한글 이름 매핑 -const BANNER_TYPE_LABELS: Record = { - HOME_TOP: '홈 상단', - HOME_BOTTOM: '홈 하단', - PROGRAM: '프로그램', - MY_PAGE: '마이페이지', -}; - -const BANNER_TYPE_ORDER: CommonBannerType[] = [ - 'HOME_TOP', - 'HOME_BOTTOM', - 'PROGRAM', - 'MY_PAGE', -]; - -interface ActiveBannerTableProps { - groupedData: Record; - onDeleteClick: (bannerId: number) => void; -} - -const ActiveBannerTable = ({ - groupedData, - onDeleteClick, -}: ActiveBannerTableProps) => { - return ( - - - - - - - - - - - - - {BANNER_TYPE_ORDER.map((type) => { - const banners = groupedData[type] ?? []; - const label = BANNER_TYPE_LABELS[type]; - - if (banners.length === 0) { - // 배너 없는 위치: 빈 행으로 표시 - return ( - - - - - ); - } - - return banners.map((banner, index) => ( - - {/* 첫 번째 행에만 위치명 표시 */} - {index === 0 && ( - - )} - - - - - - - )); - })} - -
- 배너 위치 - - 제목 - - 랜딩 URL - - 노출 시작일 - - 노출 종료일 - - 관리 -
- {label} - - - -
- {label} - {banner.title || '-'} - {banner.landingUrl || '-'} - - {dayjs(banner.startDate).format('YYYY-MM-DD\nHH:mm:ss')} - - {dayjs(banner.endDate).format('YYYY-MM-DD\nHH:mm:ss')} - -
- - - - onDeleteClick(Number(banner.commonBannerId))} - /> -
-
- ); -}; - -const CommonBannerVisibilityToggle = ({ - commonBannerId, - isVisible, -}: { - commonBannerId: number; - isVisible: boolean; -}) => { - const { mutate } = useToggleCommonBannerVisibility({ - errorCallback: (error) => { - alert(error); - }, - }); - - return ( - mutate({ commonBannerId, isVisible: !isVisible })} - /> - ); -}; - -const CommonBanners = () => { - const [activeTab, setActiveTab] = useState('active'); - const [isDeleteModalShown, setIsDeleteModalShown] = useState(false); - const [bannerIdForDeleting, setBannerIdForDeleting] = useState(); - - // 노출 중 탭 API - const { - data: activeBannerData, - isLoading: isActiveLoading, - error: activeError, - } = useGetActiveBannersForAdmin({ enabled: activeTab === 'active' }); - - // 전체 탭 API +const CommonBannersPage = () => { const { - data: allBannerData, - isLoading: isAllLoading, - error: allError, - } = useGetCommonBannerForAdmin({ enabled: activeTab === 'all' }); - - const isLoading = activeTab === 'active' ? isActiveLoading : isAllLoading; - const error = activeTab === 'active' ? activeError : allError; - - const { mutate: deleteCommonBanner } = useDeleteCommonBannerForAdmin({ - successCallback: () => { - setIsDeleteModalShown(false); - }, - }); - - const { mutate: updateExpiredBanners, isPending: isUpdatingExpired } = - useUpdateExpiredCommonBanners({ - successCallback: () => { - alert('만료된 배너가 업데이트되었습니다.'); - }, - errorCallback: (error) => { - alert('업데이트에 실패했습니다: ' + error.message); - }, - }); - - const handleDeleteButtonClicked = (bannerId: number) => { - setBannerIdForDeleting(bannerId); - setIsDeleteModalShown(true); - }; - - // 노출 중 탭: 위치별로 그룹화된 데이터 - const groupedActiveBanners = useMemo(() => { - const result: Record = { - HOME_TOP: [], - HOME_BOTTOM: [], - PROGRAM: [], - MY_PAGE: [], - }; - - if (activeTab !== 'active' || !activeBannerData?.commonBannerList) - return result; - - const bannerList = activeBannerData.commonBannerList; - - if (!Array.isArray(bannerList)) { - Object.entries(bannerList).forEach(([key, bannerArray]) => { - if (key in result) { - result[key as CommonBannerType] = bannerArray; - } - }); - } - - return result; - }, [activeTab, activeBannerData]); - - // 전체 탭: 배너 리스트 - const allBanners = useMemo(() => { - if (activeTab !== 'all' || !allBannerData?.commonBannerList) return []; - - const bannerList = allBannerData.commonBannerList; - - if (Array.isArray(bannerList)) { - return bannerList.map((banner) => ({ - ...banner, - uniqueId: `${banner.commonBannerId}`, - })); - } - - return []; - }, [activeTab, allBannerData]); + activeTab, + setActiveTab, + isLoading, + error, + groupedActiveBanners, + allBanners, + isDeleteModalShown, + setIsDeleteModalShown, + bannerIdForDeleting, + handleDeleteButtonClicked, + deleteCommonBanner, + isUpdatingExpired, + updateExpiredBanners, + } = useCommonBanners(); // 전체 탭용 컬럼 const allColumns = useMemo< @@ -413,4 +200,4 @@ const CommonBanners = () => { ); }; -export default CommonBanners; +export default CommonBannersPage; diff --git a/src/domain/admin/banner/common-banner/ActiveBannerTable.tsx b/src/domain/admin/banner/common-banner/ActiveBannerTable.tsx new file mode 100644 index 000000000..a86723c5a --- /dev/null +++ b/src/domain/admin/banner/common-banner/ActiveBannerTable.tsx @@ -0,0 +1,132 @@ +import { + CommonBannerAdminListItemType, + CommonBannerType, +} from '@/api/banner'; +import dayjs from '@/lib/dayjs'; +import { Pencil, Trash } from 'lucide-react'; +import Link from 'next/link'; + +// 배너 위치 타입별 한글 이름 매핑 +const BANNER_TYPE_LABELS: Record = { + HOME_TOP: '홈 상단', + HOME_BOTTOM: '홈 하단', + PROGRAM: '프로그램', + MY_PAGE: '마이페이지', +}; + +const BANNER_TYPE_ORDER: CommonBannerType[] = [ + 'HOME_TOP', + 'HOME_BOTTOM', + 'PROGRAM', + 'MY_PAGE', +]; + +interface ActiveBannerTableProps { + groupedData: Record; + onDeleteClick: (bannerId: number) => void; +} + +const ActiveBannerTable = ({ + groupedData, + onDeleteClick, +}: ActiveBannerTableProps) => { + return ( + + + + + + + + + + + + + {BANNER_TYPE_ORDER.map((type) => { + const banners = groupedData[type] ?? []; + const label = BANNER_TYPE_LABELS[type]; + + if (banners.length === 0) { + // 배너 없는 위치: 빈 행으로 표시 + return ( + + + + + ); + } + + return banners.map((banner, index) => ( + + {/* 첫 번째 행에만 위치명 표시 */} + {index === 0 && ( + + )} + + + + + + + )); + })} + +
+ 배너 위치 + + 제목 + + 랜딩 URL + + 노출 시작일 + + 노출 종료일 + + 관리 +
+ {label} + + - +
+ {label} + + {banner.title || '-'} + + {banner.landingUrl || '-'} + + {dayjs(banner.startDate).format('YYYY-MM-DD\nHH:mm:ss')} + + {dayjs(banner.endDate).format('YYYY-MM-DD\nHH:mm:ss')} + +
+ + + + + onDeleteClick(Number(banner.commonBannerId)) + } + /> +
+
+ ); +}; + +export default ActiveBannerTable; diff --git a/src/app/admin/banner/common-banners/new/components/CommonBannerInputContent.tsx b/src/domain/admin/banner/common-banner/CommonBannerInputContent.tsx similarity index 100% rename from src/app/admin/banner/common-banners/new/components/CommonBannerInputContent.tsx rename to src/domain/admin/banner/common-banner/CommonBannerInputContent.tsx diff --git a/src/domain/admin/banner/common-banner/CommonBannerVisibilityToggle.tsx b/src/domain/admin/banner/common-banner/CommonBannerVisibilityToggle.tsx new file mode 100644 index 000000000..b12d04cda --- /dev/null +++ b/src/domain/admin/banner/common-banner/CommonBannerVisibilityToggle.tsx @@ -0,0 +1,25 @@ +import { useToggleCommonBannerVisibility } from '@/api/banner'; +import { Switch } from '@mui/material'; + +const CommonBannerVisibilityToggle = ({ + commonBannerId, + isVisible, +}: { + commonBannerId: number; + isVisible: boolean; +}) => { + const { mutate } = useToggleCommonBannerVisibility({ + errorCallback: (error) => { + alert(error); + }, + }); + + return ( + mutate({ commonBannerId, isVisible: !isVisible })} + /> + ); +}; + +export default CommonBannerVisibilityToggle; diff --git a/src/domain/admin/pages/banner/common-banner/useCommonBannerCreate.ts b/src/domain/admin/pages/banner/common-banner/useCommonBannerCreate.ts new file mode 100644 index 000000000..5dfd4cc65 --- /dev/null +++ b/src/domain/admin/pages/banner/common-banner/useCommonBannerCreate.ts @@ -0,0 +1,95 @@ +import { useState } from 'react'; + +import { + CommonBannerFormValue, + usePostCommonBannerForAdmin, +} from '@/api/banner'; +import { useRouter } from 'next/navigation'; + +const useCommonBannerCreate = () => { + const router = useRouter(); + + const [value, setValue] = useState({ + title: '', + landingUrl: '', + isVisible: true, + startDate: '', + endDate: '', + // 노출 위치 체크박스 + types: { + HOME_TOP: false, + HOME_BOTTOM: false, + PROGRAM: false, + MY_PAGE: false, + }, + // 이미지 파일 + homePcFile: null, + homeMobileFile: null, + programPcFile: null, + programMobileFile: null, + }); + + const { mutate: tryCreateCommonBanner } = usePostCommonBannerForAdmin({ + successCallback: () => { + router.push('/admin/banner/common-banners'); + }, + errorCallback: (error) => { + console.error(error); + alert('통합 배너 등록에 실패했습니다.'); + }, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // 노출 기간 검증 + if (!value.startDate || !value.endDate) { + alert('노출 시작일과 종료일을 모두 입력해주세요.'); + return; + } + if (new Date(value.startDate) >= new Date(value.endDate)) { + alert('노출 종료일은 시작일보다 이후여야 합니다.'); + return; + } + + // 노출 위치 선택 여부 + const { types } = value; + if ( + !types.HOME_TOP && + !types.HOME_BOTTOM && + !types.PROGRAM && + !types.MY_PAGE + ) { + alert('노출 위치를 하나 이상 선택해주세요.'); + return; + } + + // 필수 이미지 검증 + const needsHome = types.HOME_TOP || types.HOME_BOTTOM; + const needsProgram = types.PROGRAM; + const needsMyPage = types.MY_PAGE; + + if (needsHome && !value.homePcFile) { + alert('홈 배너 (PC) 이미지를 업로드해주세요.'); + return; + } + if ((needsHome || needsMyPage) && !value.homeMobileFile) { + alert('홈 배너 (모바일) 이미지를 업로드해주세요.'); + return; + } + if (needsProgram && !value.programPcFile) { + alert('프로그램 배너 (PC) 이미지를 업로드해주세요.'); + return; + } + if (needsProgram && !value.programMobileFile) { + alert('프로그램 배너 (모바일) 이미지를 업로드해주세요.'); + return; + } + + tryCreateCommonBanner(value); + }; + + return { value, setValue, handleSubmit }; +}; + +export default useCommonBannerCreate; diff --git a/src/domain/admin/pages/banner/common-banner/useCommonBannerEdit.ts b/src/domain/admin/pages/banner/common-banner/useCommonBannerEdit.ts new file mode 100644 index 000000000..fb12c6d2f --- /dev/null +++ b/src/domain/admin/pages/banner/common-banner/useCommonBannerEdit.ts @@ -0,0 +1,188 @@ +import { useEffect, useState } from 'react'; + +import { + CommonBannerDetailResponse, + CommonBannerFormValue, + CommonBannerType, + useEditCommonBannerForAdmin, + useGetCommonBannerDetailForAdmin, +} from '@/api/banner'; +import { useParams, useRouter } from 'next/navigation'; + +const toDatetimeLocal = (iso: string) => { + const date = new Date(iso); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +}; + +const mapDetailToFormValue = ( + detail: CommonBannerDetailResponse, +): CommonBannerFormValue => { + const types: Record = { + HOME_TOP: false, + HOME_BOTTOM: false, + PROGRAM: false, + MY_PAGE: false, + }; + + let homePcFileId: number | null = null; + let homeMobileFileId: number | null = null; + let programPcFileId: number | null = null; + let programMobileFileId: number | null = null; + let homePcFileUrl: string | null = null; + let homeMobileFileUrl: string | null = null; + let programPcFileUrl: string | null = null; + let programMobileFileUrl: string | null = null; + + for (const item of detail.commonBannerDetailList ?? []) { + types[item.type] = true; + + if (item.type === 'HOME_TOP' || item.type === 'HOME_BOTTOM') { + if (item.agentType === 'PC') { + homePcFileId = item.fileId; + homePcFileUrl = item.fileUrl ?? null; + } + if (item.agentType === 'MOBILE') { + homeMobileFileId = item.fileId; + homeMobileFileUrl = item.fileUrl ?? null; + } + } else if (item.type === 'PROGRAM') { + if (item.agentType === 'PC') { + programPcFileId = item.fileId; + programPcFileUrl = item.fileUrl ?? null; + } + if (item.agentType === 'MOBILE') { + programMobileFileId = item.fileId; + programMobileFileUrl = item.fileUrl ?? null; + } + } else if (item.type === 'MY_PAGE') { + // MY_PAGE PC, MOBILE 모두 홈 배너 (모바일) 이미지 사용 + if (item.agentType === 'MOBILE') { + homeMobileFileId = item.fileId; + homeMobileFileUrl = item.fileUrl ?? null; + } + if (item.agentType === 'PC' && !homeMobileFileId) { + homeMobileFileId = item.fileId; + homeMobileFileUrl = item.fileUrl ?? null; + } + } + } + + const { commonBanner } = detail; + + return { + title: commonBanner.title || '', + landingUrl: commonBanner.landingUrl || '', + isVisible: commonBanner.isVisible, + startDate: commonBanner.startDate + ? toDatetimeLocal(commonBanner.startDate) + : '', + endDate: commonBanner.endDate + ? toDatetimeLocal(commonBanner.endDate) + : '', + types, + homePcFile: null, + homeMobileFile: null, + programPcFile: null, + programMobileFile: null, + homePcFileId, + homeMobileFileId, + programPcFileId, + programMobileFileId, + homePcFileUrl, + homeMobileFileUrl, + programPcFileUrl, + programMobileFileUrl, + }; +}; + +const useCommonBannerEdit = () => { + const router = useRouter(); + const params = useParams(); + const commonBannerId = Number(params.id); + + const { data, isLoading } = useGetCommonBannerDetailForAdmin({ + commonBannerId, + }); + + const [value, setValue] = useState(null); + + useEffect(() => { + if (data && !value) { + setValue(mapDetailToFormValue(data)); + } + }, [data, value]); + + const { mutate: tryEditCommonBanner } = useEditCommonBannerForAdmin({ + successCallback: () => { + router.push('/admin/banner/common-banners'); + }, + errorCallback: (error) => { + console.error(error); + alert('통합 배너 수정에 실패했습니다.'); + }, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!value) return; + + // 노출 기간 검증 + if (!value.startDate || !value.endDate) { + alert('노출 시작일과 종료일을 모두 입력해주세요.'); + return; + } + if (new Date(value.startDate) >= new Date(value.endDate)) { + alert('노출 종료일은 시작일보다 이후여야 합니다.'); + return; + } + + // 노출 위치 선택 여부 + const { types } = value; + if ( + !types.HOME_TOP && + !types.HOME_BOTTOM && + !types.PROGRAM && + !types.MY_PAGE + ) { + alert('노출 위치를 하나 이상 선택해주세요.'); + return; + } + + // 필수 이미지 검증 (새 파일 또는 기존 fileId가 있어야 함) + const needsHome = types.HOME_TOP || types.HOME_BOTTOM; + const needsProgram = types.PROGRAM; + const needsMyPage = types.MY_PAGE; + + if (needsHome && !value.homePcFile && !value.homePcFileId) { + alert('홈 배너 (PC) 이미지를 업로드해주세요.'); + return; + } + if ( + (needsHome || needsMyPage) && + !value.homeMobileFile && + !value.homeMobileFileId + ) { + alert('홈 배너 (모바일) 이미지를 업로드해주세요.'); + return; + } + if (needsProgram && !value.programPcFile && !value.programPcFileId) { + alert('프로그램 배너 (PC) 이미지를 업로드해주세요.'); + return; + } + if ( + needsProgram && + !value.programMobileFile && + !value.programMobileFileId + ) { + alert('프로그램 배너 (모바일) 이미지를 업로드해주세요.'); + return; + } + + tryEditCommonBanner({ commonBannerId, form: value }); + }; + + return { value, setValue, isLoading, handleSubmit }; +}; + +export default useCommonBannerEdit; diff --git a/src/domain/admin/pages/banner/common-banner/useCommonBanners.ts b/src/domain/admin/pages/banner/common-banner/useCommonBanners.ts new file mode 100644 index 000000000..17bf83257 --- /dev/null +++ b/src/domain/admin/pages/banner/common-banner/useCommonBanners.ts @@ -0,0 +1,115 @@ +import { useMemo, useState } from 'react'; + +import { + CommonBannerAdminListItemType, + CommonBannerType, + useDeleteCommonBannerForAdmin, + useGetActiveBannersForAdmin, + useGetCommonBannerForAdmin, + useUpdateExpiredCommonBanners, +} from '@/api/banner'; + +export type TabType = 'active' | 'all'; + +const useCommonBanners = () => { + const [activeTab, setActiveTab] = useState('active'); + const [isDeleteModalShown, setIsDeleteModalShown] = useState(false); + const [bannerIdForDeleting, setBannerIdForDeleting] = useState(); + + // 노출 중 탭 API + const { + data: activeBannerData, + isLoading: isActiveLoading, + error: activeError, + } = useGetActiveBannersForAdmin({ enabled: activeTab === 'active' }); + + // 전체 탭 API + const { + data: allBannerData, + isLoading: isAllLoading, + error: allError, + } = useGetCommonBannerForAdmin({ enabled: activeTab === 'all' }); + + const isLoading = activeTab === 'active' ? isActiveLoading : isAllLoading; + const error = activeTab === 'active' ? activeError : allError; + + const { mutate: deleteCommonBanner } = useDeleteCommonBannerForAdmin({ + successCallback: () => { + setIsDeleteModalShown(false); + }, + }); + + const { mutate: updateExpiredBanners, isPending: isUpdatingExpired } = + useUpdateExpiredCommonBanners({ + successCallback: () => { + alert('만료된 배너가 업데이트되었습니다.'); + }, + errorCallback: (error) => { + alert('업데이트에 실패했습니다: ' + error.message); + }, + }); + + const handleDeleteButtonClicked = (bannerId: number) => { + setBannerIdForDeleting(bannerId); + setIsDeleteModalShown(true); + }; + + // 노출 중 탭: 위치별로 그룹화된 데이터 + const groupedActiveBanners = useMemo(() => { + const result: Record = { + HOME_TOP: [], + HOME_BOTTOM: [], + PROGRAM: [], + MY_PAGE: [], + }; + + if (activeTab !== 'active' || !activeBannerData?.commonBannerList) + return result; + + const bannerList = activeBannerData.commonBannerList; + + if (!Array.isArray(bannerList)) { + Object.entries(bannerList).forEach(([key, bannerArray]) => { + if (key in result) { + result[key as CommonBannerType] = bannerArray; + } + }); + } + + return result; + }, [activeTab, activeBannerData]); + + // 전체 탭: 배너 리스트 + const allBanners = useMemo(() => { + if (activeTab !== 'all' || !allBannerData?.commonBannerList) return []; + + const bannerList = allBannerData.commonBannerList; + + if (Array.isArray(bannerList)) { + return bannerList.map((banner) => ({ + ...banner, + uniqueId: `${banner.commonBannerId}`, + })); + } + + return []; + }, [activeTab, allBannerData]); + + return { + activeTab, + setActiveTab, + isLoading, + error, + groupedActiveBanners, + allBanners, + isDeleteModalShown, + setIsDeleteModalShown, + bannerIdForDeleting, + handleDeleteButtonClicked, + deleteCommonBanner, + isUpdatingExpired, + updateExpiredBanners, + }; +}; + +export default useCommonBanners;