From 3833224592f9cf6f5d6201d9849cce43846ece79 Mon Sep 17 00:00:00 2001 From: Cho SeungYeon <111514472+layout-SY@users.noreply.github.com> Date: Sun, 6 Jul 2025 01:13:02 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat=20:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=B0=B0=EB=84=88=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/admin/banner.api.ts | 55 ++++ .../admin/banner/BannerList.styled.ts | 252 ++++++++++++++++++ src/components/admin/banner/BannerList.tsx | 111 ++++++++ .../banner/bannerRow/BannerRow.styled.ts | 75 ++++++ .../admin/banner/bannerRow/BannerRow.tsx | 125 +++++++++ .../admin/banner/dateRange/DateRange.tsx | 43 +++ .../imageUploadArea/ImageUploadArea.styled.ts | 69 +++++ .../imageUploadArea/ImageUploadArea.tsx | 41 +++ .../newBannerRow/NewBannerRow.styled.ts | 52 ++++ .../banner/newBannerRow/NewBannerRow.tsx | 106 ++++++++ .../banner/radioGroup/RadioGroup.styled.ts | 26 ++ .../admin/banner/radioGroup/RadioGroup.tsx | 30 +++ .../banner/tableHeader/TableHeader.styled.ts | 41 +++ .../admin/banner/tableHeader/TableHeader.tsx | 16 ++ .../toggleSwitch/ToggleSwitch.styled.ts | 46 ++++ .../banner/toggleSwitch/ToggleSwitch.tsx | 21 ++ src/hooks/admin/useBannerManagement.ts | 214 +++++++++++++++ src/hooks/admin/useBannerMutations.ts | 80 ++++++ src/hooks/admin/useGetAllBannerList.ts | 17 ++ src/hooks/admin/useImageManagement.ts | 58 ++++ src/main.tsx | 8 +- src/mock/adminMock.ts | 221 +++++++++++++++ src/mock/browser.ts | 8 + src/models/admin/banner.ts | 22 ++ .../admin/adminBanner/AdminBanner.styled.ts | 5 + src/pages/admin/adminBanner/AdminBanner.tsx | 11 +- 26 files changed, 1748 insertions(+), 5 deletions(-) create mode 100644 src/api/admin/banner.api.ts create mode 100644 src/components/admin/banner/BannerList.styled.ts create mode 100644 src/components/admin/banner/BannerList.tsx create mode 100644 src/components/admin/banner/bannerRow/BannerRow.styled.ts create mode 100644 src/components/admin/banner/bannerRow/BannerRow.tsx create mode 100644 src/components/admin/banner/dateRange/DateRange.tsx create mode 100644 src/components/admin/banner/imageUploadArea/ImageUploadArea.styled.ts create mode 100644 src/components/admin/banner/imageUploadArea/ImageUploadArea.tsx create mode 100644 src/components/admin/banner/newBannerRow/NewBannerRow.styled.ts create mode 100644 src/components/admin/banner/newBannerRow/NewBannerRow.tsx create mode 100644 src/components/admin/banner/radioGroup/RadioGroup.styled.ts create mode 100644 src/components/admin/banner/radioGroup/RadioGroup.tsx create mode 100644 src/components/admin/banner/tableHeader/TableHeader.styled.ts create mode 100644 src/components/admin/banner/tableHeader/TableHeader.tsx create mode 100644 src/components/admin/banner/toggleSwitch/ToggleSwitch.styled.ts create mode 100644 src/components/admin/banner/toggleSwitch/ToggleSwitch.tsx create mode 100644 src/hooks/admin/useBannerManagement.ts create mode 100644 src/hooks/admin/useBannerMutations.ts create mode 100644 src/hooks/admin/useGetAllBannerList.ts create mode 100644 src/hooks/admin/useImageManagement.ts create mode 100644 src/models/admin/banner.ts create mode 100644 src/pages/admin/adminBanner/AdminBanner.styled.ts diff --git a/src/api/admin/banner.api.ts b/src/api/admin/banner.api.ts new file mode 100644 index 00000000..badb6aef --- /dev/null +++ b/src/api/admin/banner.api.ts @@ -0,0 +1,55 @@ +import type { ApiBannerList } from '../../models/admin/banner'; +import { httpClient } from '../http.api'; + +export const getBannerList = async () => { + try { + const response = await httpClient.get('/banner'); + console.log(response.data); + return response.data; + } catch (error) { + console.error(error); + throw error; + } +}; + +export const postBanner = async (formData: FormData) => { + try { + for (const [key, value] of formData.entries()) { + if (value instanceof File) { + console.log(`${key}:`, { + name: value.name, + size: value.size, + type: value.type, + lastModified: value.lastModified, + }); + } else { + console.log(`${key}:`, value); + } + } + const response = await httpClient.post('/banner', formData); + return response.data; + } catch (error) { + console.error(error); + throw error; + } +}; + +export const patchBanner = async (formData: FormData, bannerId?: number) => { + try { + const response = await httpClient.patch(`/banner/${bannerId}`, formData); + return response.data; + } catch (error) { + console.error(error); + throw error; + } +}; + +export const deleteBanner = async (bannerId?: number) => { + try { + const response = await httpClient.delete(`/banner/${bannerId}`); + return response.data; + } catch (error) { + console.error(error); + throw error; + } +}; diff --git a/src/components/admin/banner/BannerList.styled.ts b/src/components/admin/banner/BannerList.styled.ts new file mode 100644 index 00000000..5d2aa1ed --- /dev/null +++ b/src/components/admin/banner/BannerList.styled.ts @@ -0,0 +1,252 @@ +import styled, { css } from 'styled-components'; +import Button from '../../common/Button/Button'; + +export const AddButtonContainer = styled.div``; + +export const AddButton = styled(Button)``; + +export const Container = styled.table<{ $header?: boolean }>` + border-collapse: collapse; + table-layout: fixed; + border-radius: 8px; + overflow: hidden; + width: 100%; + margin-top: 10rem; +`; + +export const TableHeader = styled.thead` + color: #ffffff; +`; + +export const TableHeaderCell = styled.th` + padding: 16px; + font-size: 14px; + text-align: center; +`; + +export const ScrollBody = styled.tbody``; + +export const TableRow = styled.tr<{ $header?: boolean }>` + &:nth-child(odd) { + background-color: ${({ $header, theme }) => + $header ? theme.buttonScheme.primary.bg : '#fafafa'}; + } +`; + +export const TableCell = styled.td` + padding: 14px; + font-size: 14px; + text-align: center; + color: #333; + vertical-align: middle; +`; + +export const ImageCell = styled(TableCell)` + position: relative; + height: 120px; + padding: 0; + width: 280px; +`; + +export const ImageUploadArea = styled.div` + position: relative; + width: 100%; + height: 120px; + cursor: pointer; + border-radius: 6px; + overflow: hidden; +`; + +export const Thumbnail = styled.img` + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 6px; +`; + +export const ImageOverlay = styled.div` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; +`; + +export const EditIcon = styled.div` + display: flex; + align-items: center; + justify-content: center; + color: white; +`; + +export const ImageLabel = styled.div` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: white; + font-weight: 600; + font-size: 15px; + text-shadow: 0 0 5px rgba(0, 0, 0, 0.6); +`; + +export const PlusButton = styled.div` + width: 100%; + height: 120px; + border: 2px dashed #ccc; + font-size: 32px; + color: #888; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease-in-out; + + &:hover { + border-color: #6c5ce7; + color: #6c5ce7; + background-color: rgba(108, 92, 231, 0.1); + } +`; + +export const ToggleSwitch = styled.div` + position: relative; + width: 50px; + height: 24px; + + input { + opacity: 0; + width: 0; + height: 0; + } + + label { + position: absolute; + background: #ccc; + border-radius: 999px; + width: 100%; + height: 100%; + cursor: pointer; + transition: background 0.2s ease-in-out; + } + + label::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s ease-in-out; + } + + input:checked + label { + background-color: #6c5ce7; + } + + input:checked + label::after { + transform: translateX(26px); + } +`; + +export const RadioGroup = styled.div` + display: flex; + justify-content: center; + gap: 12px; + font-size: 14px; + + input { + display: none; + } + + label { + position: relative; + padding-left: 22px; + cursor: pointer; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 2px; + width: 16px; + height: 16px; + border: 2px solid #666; + border-radius: 50%; + } + + &::after { + content: ''; + position: absolute; + left: 4px; + top: 6px; + width: 8px; + height: 8px; + background-color: #6c5ce7; + border-radius: 50%; + opacity: 0; + } + } + + input:checked + label::after { + opacity: 1; + } +`; + +export const DateRange = styled.div` + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + + span { + font-size: 14px; + } +`; +export const DateInput = styled.input` + width: 120px; + height: 36px; + padding: 0 12px; + font-size: 14px; + border-radius: 18px; + border: 1px solid #ccc; +`; + +const buttonBase = css` + padding: 8px 16px; + font-size: 14px; + border-radius: ${({ theme }) => theme.borderRadius.primary}; + color: ${({ theme }) => theme.color.white}; + border: 1px solid ${({ theme }) => theme.color.white}; +`; + +export const EditButton = styled.button` + ${buttonBase}; + background-color: ${({ theme }) => theme.buttonScheme.primary.bg}; + margin-right: 8px; +`; + +export const DeleteButton = styled.button` + ${buttonBase}; + background-color: ${({ theme }) => theme.color.red}; +`; + +export const TableBody = styled.tbody``; + +export const ButtonContainer = styled.div` + display: flex; + justify-content: center; + margin-top: 20px; +`; + +export const CreateButton = styled(Button)``; + +export const CancelButton = styled(Button)``; diff --git a/src/components/admin/banner/BannerList.tsx b/src/components/admin/banner/BannerList.tsx new file mode 100644 index 00000000..79e6fa21 --- /dev/null +++ b/src/components/admin/banner/BannerList.tsx @@ -0,0 +1,111 @@ +import * as S from './BannerList.styled'; +import ScrollPreventor from '../../common/modal/ScrollPreventor'; +import { useBannerManagement } from '../../../hooks/admin/useBannerManagement'; +import { useModal } from '../../../hooks/useModal'; +import TableHeader from './tableHeader/TableHeader'; +import BannerRow from './bannerRow/BannerRow'; +import NewBannerRow from './newBannerRow/NewBannerRow'; +import Modal from '../../common/modal/Modal'; + +export default function BannerList() { + const { isOpen, message, handleModalOpen, handleModalClose } = useModal(); + + const { + allBanners, + isLoading, + newBanner, + isCreating, + canCreateBanner, + hasNewBannerChanges, + handleToggle, + handleChangeType, + handleDateChange, + handleImageChange, + handleDelete, + handleCreate, + handleInputChange, + toggleCreating, + resetNewBanner, + } = useBannerManagement({ handleModalOpen }); + + const handleCancel = () => { + if (hasNewBannerChanges) { + const confirmed = window.confirm( + '입력 데이터가 날아 갈 수 있습니다. 계속 하시겠습니까?' + ); + if (confirmed) { + resetNewBanner(); + toggleCreating(); + } + } else { + toggleCreating(); + } + }; + + if (isLoading) { + return
Loading...
; + } + + return ( + <> + + + + + + + + + + + + + {allBanners.map((banner, index) => ( + + ))} + {isCreating && ( + + )} + + + + {!isCreating ? ( + + 배너 생성하기 + + ) : ( + + 취소하기 + + )} + + + {message} + + + ); +} diff --git a/src/components/admin/banner/bannerRow/BannerRow.styled.ts b/src/components/admin/banner/bannerRow/BannerRow.styled.ts new file mode 100644 index 00000000..362bae98 --- /dev/null +++ b/src/components/admin/banner/bannerRow/BannerRow.styled.ts @@ -0,0 +1,75 @@ +import styled from 'styled-components'; + +export const TableRow = styled.tr` + border-bottom: 1px solid #e9ecef; + + &:hover { + background-color: #f8f9fa; + } +`; + +export const TableCell = styled.td` + padding: 14px; + text-align: center; + vertical-align: middle; + font-size: 14px; + color: #333; +`; + +export const ImageCell = styled.td` + position: relative; + height: 120px; + padding: 0; + width: 280px; + text-align: center; + vertical-align: middle; + display: flex; + align-items: center; + justify-content: center; +`; + +export const EditButton = styled.button` + padding: 6px 12px; + margin-right: 8px; + border: none; + border-radius: 4px; + background-color: #007bff; + color: white; + font-size: 12px; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: #0056b3; + } + + &:disabled { + background-color: #6c757d; + cursor: not-allowed; + } +`; + +export const DeleteButton = styled.button` + padding: 6px 12px; + border: none; + border-radius: 4px; + background-color: #dc3545; + color: white; + font-size: 12px; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: #c82333; + } +`; + +export const DateDisplay = styled.span` + font-size: 14px; + color: #495057; +`; + +export const Placeholder = styled.span` + font-size: 14px; + color: #6c757d; +`; diff --git a/src/components/admin/banner/bannerRow/BannerRow.tsx b/src/components/admin/banner/bannerRow/BannerRow.tsx new file mode 100644 index 00000000..104b5547 --- /dev/null +++ b/src/components/admin/banner/bannerRow/BannerRow.tsx @@ -0,0 +1,125 @@ +import * as S from './BannerRow.styled'; +import { BannerItem } from '../../../../models/admin/banner'; +import ImageUploadArea from '../imageUploadArea/ImageUploadArea'; +import ToggleSwitch from '../toggleSwitch/ToggleSwitch'; +import RadioGroup from '../radioGroup/RadioGroup'; +import DateRange from '../dateRange/DateRange'; +import { useState } from 'react'; + +interface BannerRowProps { + banner: BannerItem; + index: number; + onToggle: (id: number, visible: boolean) => Promise; + onChangeType: (id: number, always: boolean) => Promise; + onDateChange: ( + id: number, + startDate?: string, + endDate?: string + ) => Promise; + onImageChange: (bannerId: number, file: File) => Promise; + onDelete: (id: number) => void; +} + +const BannerRow = ({ + banner, + index, + onToggle, + onChangeType, + onDateChange, + onImageChange, + onDelete, +}: BannerRowProps) => { + const [hoveredImageId, setHoveredImageId] = useState(null); + + const showImageOverlay = hoveredImageId === banner.id; + + const handleImageClick = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.style.display = 'none'; + + input.onchange = async (event) => { + const file = (event.target as HTMLInputElement).files?.[0]; + if (file) { + await onImageChange(banner.id, file); + } + }; + + document.body.appendChild(input); + input.click(); + document.body.removeChild(input); + }; + + const handleImageHover = (id: number) => { + setHoveredImageId(id); + }; + + const handleImageLeave = () => { + setHoveredImageId(null); + }; + + const formatDateRange = (start: string, end: string) => { + if (start && end) { + return `${start.replace(/-/g, '.')} ~ ${end.replace(/-/g, '.')}`; + } + return null; + }; + + const dateRangeDisplay = formatDateRange(banner.startDate, banner.endDate); + + return ( + + {index + 1} + + handleImageHover(banner.id)} + onImageLeave={handleImageLeave} + showOverlay={showImageOverlay} + /> + + + onToggle(banner.id, checked)} + /> + + + onChangeType(banner.id, always)} + /> + + + {banner.always ? ( + - + ) : dateRangeDisplay ? ( + {dateRangeDisplay} + ) : ( + + onDateChange(banner.id, date, banner.endDate) + } + onEndDateChange={(date) => + onDateChange(banner.id, banner.startDate, date) + } + placeholder={{ start: '시작일', end: '종료일' }} + /> + )} + + + onDelete(banner.id)}> + 삭제 + + + + ); +}; + +export default BannerRow; diff --git a/src/components/admin/banner/dateRange/DateRange.tsx b/src/components/admin/banner/dateRange/DateRange.tsx new file mode 100644 index 00000000..7c195d07 --- /dev/null +++ b/src/components/admin/banner/dateRange/DateRange.tsx @@ -0,0 +1,43 @@ +import * as S from './DateRange.styled'; +import { ChangeEvent } from 'react'; + +interface DateRangeProps { + startDate: string; + endDate: string; + onStartDateChange: (date: string) => void; + onEndDateChange: (date: string) => void; + placeholder?: { + start?: string; + end?: string; + }; +} + +const DateRange = ({ + startDate, + endDate, + onStartDateChange, + onEndDateChange, + placeholder, +}: DateRangeProps) => ( + + ) => + onStartDateChange(e.target.value) + } + /> + ~ + ) => + onEndDateChange(e.target.value) + } + /> + +); + +export default DateRange; diff --git a/src/components/admin/banner/imageUploadArea/ImageUploadArea.styled.ts b/src/components/admin/banner/imageUploadArea/ImageUploadArea.styled.ts new file mode 100644 index 00000000..0ccc07b7 --- /dev/null +++ b/src/components/admin/banner/imageUploadArea/ImageUploadArea.styled.ts @@ -0,0 +1,69 @@ +import styled from 'styled-components'; + +export const ImageUploadArea = styled.div` + position: relative; + width: 280px; + height: 120px; + border: 2px dashed #dee2e6; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + overflow: hidden; + + &:hover { + border-color: #007bff; + } +`; + +export const Thumbnail = styled.img` + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + border-radius: 6px; + display: block; + margin: 0 auto; +`; + +export const ImageOverlay = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; +`; + +export const EditIcon = styled.div` + color: white; + display: flex; + align-items: center; + justify-content: center; +`; + +export const PlusButton = styled.button` + width: 280px; + height: 120px; + border: 2px dashed #dee2e6; + border-radius: 8px; + background-color: transparent; + color: #6c757d; + font-size: 24px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + border-color: #007bff; + color: #007bff; + background-color: rgba(0, 123, 255, 0.05); + } +`; diff --git a/src/components/admin/banner/imageUploadArea/ImageUploadArea.tsx b/src/components/admin/banner/imageUploadArea/ImageUploadArea.tsx new file mode 100644 index 00000000..282b43d4 --- /dev/null +++ b/src/components/admin/banner/imageUploadArea/ImageUploadArea.tsx @@ -0,0 +1,41 @@ +import * as S from './ImageUploadArea.styled'; +import { PencilIcon } from '@heroicons/react/24/outline'; + +interface ImageUploadAreaProps { + imageUrl?: string; + onImageClick: () => void; + onImageHover?: () => void; + onImageLeave?: () => void; + showOverlay?: boolean; +} + +const ImageUploadArea = ({ + imageUrl, + onImageClick, + onImageHover, + onImageLeave, + showOverlay = false, +}: ImageUploadAreaProps) => { + if (imageUrl) { + return ( + + + {showOverlay && ( + + + + + + )} + + ); + } + + return ; +}; + +export default ImageUploadArea; diff --git a/src/components/admin/banner/newBannerRow/NewBannerRow.styled.ts b/src/components/admin/banner/newBannerRow/NewBannerRow.styled.ts new file mode 100644 index 00000000..6d989301 --- /dev/null +++ b/src/components/admin/banner/newBannerRow/NewBannerRow.styled.ts @@ -0,0 +1,52 @@ +import styled from 'styled-components'; + +export const TableRow = styled.tr` + border-bottom: 1px solid #e9ecef; + background-color: #f8f9fa; + + &:hover { + background-color: #e9ecef; + } +`; + +export const TableCell = styled.td` + padding: 14px; + text-align: center; + vertical-align: middle; + font-size: 14px; + color: #333; +`; + +export const ImageCell = styled.td` + position: relative; + height: 120px; + padding: 0; + width: 280px; + text-align: center; + vertical-align: middle; +`; + +export const CreateButton = styled.button` + padding: 6px 12px; + border: none; + border-radius: 4px; + background-color: #28a745; + color: white; + font-size: 12px; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: #218838; + } + + &:disabled { + background-color: #6c757d; + cursor: not-allowed; + } +`; + +export const Placeholder = styled.span` + font-size: 14px; + color: #6c757d; +`; diff --git a/src/components/admin/banner/newBannerRow/NewBannerRow.tsx b/src/components/admin/banner/newBannerRow/NewBannerRow.tsx new file mode 100644 index 00000000..b8a11c2d --- /dev/null +++ b/src/components/admin/banner/newBannerRow/NewBannerRow.tsx @@ -0,0 +1,106 @@ +import * as S from './NewBannerRow.styled'; +import { BannerFormData } from '../../../../models/admin/banner'; +import ImageUploadArea from '../imageUploadArea/ImageUploadArea'; +import ToggleSwitch from '../toggleSwitch/ToggleSwitch'; +import RadioGroup from '../radioGroup/RadioGroup'; +import DateRange from '../dateRange/DateRange'; + +interface NewBannerRowProps { + newBanner: BannerFormData; + canCreateBanner: boolean; + onInputChange: ( + field: keyof BannerFormData, + value: string | boolean | File + ) => void; + onCreate: () => void; +} + +const NewBannerRow = ({ + newBanner, + canCreateBanner, + onInputChange, + onCreate, +}: NewBannerRowProps) => { + const handleImageChange = (file: File) => { + onInputChange('imageUrl', file); + }; + + const handleFileInputChange = ( + event: React.ChangeEvent + ) => { + const file = event.target.files?.[0]; + if (file) { + handleImageChange(file); + } + }; + + const handleImageClick = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.style.display = 'none'; + + input.onchange = (event) => + handleFileInputChange( + event as unknown as React.ChangeEvent + ); + + document.body.appendChild(input); + input.click(); + document.body.removeChild(input); + }; + + const imageUrl = newBanner.imageUrl + ? URL.createObjectURL(newBanner.imageUrl) + : ''; + + return ( + + + - + + + {}} + onImageLeave={() => {}} + showOverlay={false} + /> + + + onInputChange('visible', checked)} + /> + + + onInputChange('always', always)} + /> + + + {newBanner.always ? ( + - + ) : ( + onInputChange('startDate', date)} + onEndDateChange={(date) => onInputChange('endDate', date)} + /> + )} + + + + 생성하기 + + + + ); +}; + +export default NewBannerRow; diff --git a/src/components/admin/banner/radioGroup/RadioGroup.styled.ts b/src/components/admin/banner/radioGroup/RadioGroup.styled.ts new file mode 100644 index 00000000..80f059b2 --- /dev/null +++ b/src/components/admin/banner/radioGroup/RadioGroup.styled.ts @@ -0,0 +1,26 @@ +import styled from 'styled-components'; + +export const RadioGroup = styled.div` + display: flex; + gap: 16px; + align-items: center; +`; + +export const RadioInput = styled.input` + margin: 0; + margin-right: 4px; + width: 16px; + height: 16px; + cursor: pointer; +`; + +export const RadioLabel = styled.label` + font-size: 14px; + color: #495057; + cursor: pointer; + white-space: nowrap; + + &:hover { + color: #007bff; + } +`; diff --git a/src/components/admin/banner/radioGroup/RadioGroup.tsx b/src/components/admin/banner/radioGroup/RadioGroup.tsx new file mode 100644 index 00000000..958b5a51 --- /dev/null +++ b/src/components/admin/banner/radioGroup/RadioGroup.tsx @@ -0,0 +1,30 @@ +import * as S from './RadioGroup.styled'; + +interface RadioGroupProps { + name: string; + value: boolean; + onChange: (value: boolean) => void; +} + +const RadioGroup = ({ name, value, onChange }: RadioGroupProps) => ( + + onChange(true)} + /> + 상시 노출 + onChange(false)} + /> + 기간 노출 + +); + +export default RadioGroup; diff --git a/src/components/admin/banner/tableHeader/TableHeader.styled.ts b/src/components/admin/banner/tableHeader/TableHeader.styled.ts new file mode 100644 index 00000000..59ebda74 --- /dev/null +++ b/src/components/admin/banner/tableHeader/TableHeader.styled.ts @@ -0,0 +1,41 @@ +import styled from 'styled-components'; + +export const TableHeader = styled.thead` + background-color: ${({ theme }) => theme.buttonScheme.primary.bg}; +`; + +export const TableRow = styled.tr<{ $header?: boolean }>` + border-bottom: 1px solid #e9ecef; + + &:hover { + background-color: ${({ $header }) => ($header ? 'transparent' : '#f8f9fa')}; + } +`; + +export const TableHeaderCell = styled.th` + padding: 16px; + text-align: center; + font-weight: 600; + font-size: 14px; + color: #ffffff; + border-bottom: 2px solid #dee2e6; + + &:nth-child(1) { + width: 50px; + } + &:nth-child(2) { + width: 280px; + } + &:nth-child(3) { + width: 100px; + } + &:nth-child(4) { + width: 160px; + } + &:nth-child(5) { + width: 240px; + } + &:nth-child(6) { + width: 140px; + } +`; diff --git a/src/components/admin/banner/tableHeader/TableHeader.tsx b/src/components/admin/banner/tableHeader/TableHeader.tsx new file mode 100644 index 00000000..8785a4e0 --- /dev/null +++ b/src/components/admin/banner/tableHeader/TableHeader.tsx @@ -0,0 +1,16 @@ +import * as S from './TableHeader.styled'; + +const TableHeader = () => ( + + + No + 이미지 + 노출 상태 + 노출 여부 + 노출 기간 + 관리 + + +); + +export default TableHeader; diff --git a/src/components/admin/banner/toggleSwitch/ToggleSwitch.styled.ts b/src/components/admin/banner/toggleSwitch/ToggleSwitch.styled.ts new file mode 100644 index 00000000..0c21436e --- /dev/null +++ b/src/components/admin/banner/toggleSwitch/ToggleSwitch.styled.ts @@ -0,0 +1,46 @@ +import styled from 'styled-components'; + +export const ToggleSwitch = styled.div` + position: relative; + display: inline-block; + width: 48px; + height: 24px; +`; + +export const HiddenInput = styled.input` + opacity: 0; + width: 0; + height: 0; +`; + +export const ToggleLabel = styled.label` + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + border-radius: 24px; + transition: 0.3s; + + &:before { + position: absolute; + content: ''; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + border-radius: 50%; + transition: 0.3s; + } + + ${HiddenInput}:checked + & { + background-color: #007bff; + } + + ${HiddenInput}:checked + &:before { + transform: translateX(24px); + } +`; diff --git a/src/components/admin/banner/toggleSwitch/ToggleSwitch.tsx b/src/components/admin/banner/toggleSwitch/ToggleSwitch.tsx new file mode 100644 index 00000000..f1140bac --- /dev/null +++ b/src/components/admin/banner/toggleSwitch/ToggleSwitch.tsx @@ -0,0 +1,21 @@ +import * as S from './ToggleSwitch.styled'; + +interface ToggleSwitchProps { + id: string; + checked: boolean; + onChange: (checked: boolean) => void; +} + +const ToggleSwitch = ({ id, checked, onChange }: ToggleSwitchProps) => ( + + onChange(e.target.checked)} + /> + + +); + +export default ToggleSwitch; diff --git a/src/hooks/admin/useBannerManagement.ts b/src/hooks/admin/useBannerManagement.ts new file mode 100644 index 00000000..d6e0a33c --- /dev/null +++ b/src/hooks/admin/useBannerManagement.ts @@ -0,0 +1,214 @@ +import { useState } from 'react'; +import { useGetAllBannerList } from './useGetAllBannerList'; +import { useBannerMutations } from './useBannerMutations'; +import { BannerItem, BannerFormData } from '../../models/admin/banner'; + +interface UseBannerManagementProps { + handleModalOpen: (message: string) => void; +} + +const createInitialBannerState = (): BannerFormData => ({ + imageUrl: undefined, + visible: false, + always: true, + startDate: '', + endDate: '', +}); + +const confirmFormData = (formData: FormData, title: string = 'FormData') => { + console.log(`=== ${title} 내용 ===`); + for (const [key, value] of formData.entries()) { + if (value instanceof File) { + console.log(`${key}:`, { + name: value.name, + size: value.size, + type: value.type, + lastModified: value.lastModified, + }); + } else { + console.log(`${key}:`, value); + } + } + console.log('=== FormData 끝 ==='); +}; + +export const useBannerManagement = ({ + handleModalOpen, +}: UseBannerManagementProps) => { + const { allBannersData, isLoading, refetch } = useGetAllBannerList(); + const { postBannerMutate, patchBannerMutate, deleteBannerMutate } = + useBannerMutations({ + handleModalOpen, + }); + + const [isCreating, setIsCreating] = useState(false); + const [newBanner, setNewBanner] = useState( + createInitialBannerState + ); + + const allBanners = allBannersData?.data ?? []; + + // 즉시 API 호출하는 헬퍼 함수 + const updateBannerImmediately = async ( + bannerId: number, + updates: Partial + ) => { + try { + const currentBanner = allBanners.find((banner) => banner.id === bannerId); + if (!currentBanner) return; + + const formData = new FormData(); + formData.append( + 'visible', + (updates.visible ?? currentBanner.visible).toString() + ); + formData.append( + 'always', + (updates.always ?? currentBanner.always).toString() + ); + formData.append( + 'startDate', + updates.startDate ?? currentBanner.startDate + ); + formData.append('endDate', updates.endDate ?? currentBanner.endDate); + + await patchBannerMutate.mutateAsync({ formData, bannerId }); + await refetch(); + } catch (error) { + console.error('배너 업데이트 실패:', error); + } + }; + + const handleToggle = async (id: number, visible: boolean) => { + await updateBannerImmediately(id, { visible }); + }; + + const handleChangeType = async (id: number, always: boolean) => { + const updates: Partial = { always }; + if (always) { + updates.startDate = ''; + updates.endDate = ''; + } + await updateBannerImmediately(id, updates); + }; + + const handleDateChange = async ( + id: number, + startDate?: string, + endDate?: string + ) => { + await updateBannerImmediately(id, { + startDate: startDate ?? '', + endDate: endDate ?? '', + }); + }; + + // 기존 배너 이미지 변경 함수 + const handleImageChange = async (bannerId: number, file: File) => { + try { + const formData = new FormData(); + formData.append('imageUrl', file); + + // 기존 배너 정보도 함께 전송 + const existingBanner = allBanners.find( + (banner) => banner.id === bannerId + ); + if (existingBanner) { + formData.append('visible', existingBanner.visible.toString()); + formData.append('always', existingBanner.always.toString()); + formData.append('startDate', existingBanner.startDate); + formData.append('endDate', existingBanner.endDate); + } + + confirmFormData(formData, '배너 이미지 변경'); + + await patchBannerMutate.mutateAsync({ formData, bannerId }); + await refetch(); + } catch (error) { + console.error('이미지 변경 실패:', error); + } + }; + + const handleDelete = async (id: number) => { + if (window.confirm('정말 삭제하시겠습니까?')) { + try { + await deleteBannerMutate.mutateAsync(id); + await refetch(); + } catch (error) { + console.error('삭제 실패:', error); + } + } + }; + + const handleCreate = async () => { + if (!newBanner.imageUrl) { + handleModalOpen('이미지를 선택해주세요.'); + return; + } + + try { + const formData = new FormData(); + formData.append('imageUrl', newBanner.imageUrl); + formData.append('visible', newBanner.visible.toString()); + formData.append('always', newBanner.always.toString()); + formData.append('startDate', newBanner.startDate); + formData.append('endDate', newBanner.endDate); + + confirmFormData(formData, '배너 생성'); + + await postBannerMutate.mutateAsync(formData); + + setIsCreating(false); + setNewBanner(createInitialBannerState()); + + await refetch(); + + handleModalOpen('배너가 생성되었습니다.'); + } catch (error) { + console.error('배너 생성 실패:', error); + handleModalOpen('배너 생성에 실패했습니다.'); + } + }; + + const handleInputChange = ( + field: keyof BannerFormData, + value: string | boolean | File + ) => { + setNewBanner((prev) => ({ ...prev, [field]: value } as BannerFormData)); + }; + + const toggleCreating = () => { + if (isCreating) { + setNewBanner(createInitialBannerState()); + } + setIsCreating((prev) => !prev); + }; + + const resetNewBanner = () => { + setNewBanner(createInitialBannerState()); + }; + + const canCreateBanner = Boolean(newBanner.imageUrl); + + const hasNewBannerChanges = Boolean( + newBanner.imageUrl || newBanner.startDate || newBanner.endDate + ); + + return { + allBanners, + isLoading, + newBanner, + isCreating, + canCreateBanner, + hasNewBannerChanges, + handleToggle, + handleChangeType, + handleDateChange, + handleImageChange, + handleDelete, + handleCreate, + handleInputChange, + toggleCreating, + resetNewBanner, + }; +}; diff --git a/src/hooks/admin/useBannerMutations.ts b/src/hooks/admin/useBannerMutations.ts new file mode 100644 index 00000000..09db1941 --- /dev/null +++ b/src/hooks/admin/useBannerMutations.ts @@ -0,0 +1,80 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { + postBanner, + patchBanner, + deleteBanner, +} from '../../api/admin/banner.api'; +import { Banners } from '../queries/keys'; +import { ADMIN_MODAL_MESSAGE } from '../../constants/admin/adminModal'; + +export type State = 'success' | 'fail'; + +export const useBannerMutations = ({ + handleModalOpen, +}: { + handleModalOpen: (message: string) => void; +}) => { + const queryClient = useQueryClient(); + + const handleDeleteButtonState = (state: State) => { + switch (state) { + case 'success': + handleModalOpen(ADMIN_MODAL_MESSAGE.writeDeleteSuccess); + break; + case 'fail': + handleModalOpen(ADMIN_MODAL_MESSAGE.writeDeleteFail); + break; + default: + handleModalOpen(ADMIN_MODAL_MESSAGE.writeError); + break; + } + }; + + const postBannerMutate = useMutation({ + mutationFn: (formData: FormData) => postBanner(formData), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: Banners.allBanners, + }); + }, + onError: () => { + console.error('배너 생성에 실패했습니다.'); + }, + }); + + const patchBannerMutate = useMutation< + void, + AxiosError, + { formData: FormData; bannerId: number } + >({ + mutationFn: ({ formData, bannerId }) => patchBanner(formData, bannerId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: Banners.allBanners, + }); + }, + onError: () => { + console.error('배너 수정에 실패했습니다.'); + }, + }); + + const deleteBannerMutate = useMutation({ + mutationFn: (bannerId: number) => deleteBanner(bannerId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: Banners.allBanners, + }); + handleDeleteButtonState('success'); + }, + onError: () => { + handleDeleteButtonState('fail'); + }, + }); + + return { + postBannerMutate, + patchBannerMutate, + deleteBannerMutate, + }; +}; diff --git a/src/hooks/admin/useGetAllBannerList.ts b/src/hooks/admin/useGetAllBannerList.ts new file mode 100644 index 00000000..290d3bd9 --- /dev/null +++ b/src/hooks/admin/useGetAllBannerList.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import { Banners } from '../queries/keys'; +import { getBannerList } from '../../api/admin/banner.api'; + +export const useGetAllBannerList = () => { + const { + data: allBannersData, + isLoading, + isFetching, + refetch, + } = useQuery({ + queryKey: [Banners.allBanners], + queryFn: () => getBannerList(), + }); + + return { allBannersData, isLoading, isFetching, refetch }; +}; diff --git a/src/hooks/admin/useImageManagement.ts b/src/hooks/admin/useImageManagement.ts new file mode 100644 index 00000000..58abcdf4 --- /dev/null +++ b/src/hooks/admin/useImageManagement.ts @@ -0,0 +1,58 @@ +import { useState, useRef } from 'react'; + +export const useImageManagement = () => { + const fileInputRef = useRef(null); + const [hoveredImageId, setHoveredImageId] = useState(null); + + const handleImageUpload = ( + file: File, + bannerId?: number, + onImageChange?: (imageUrl: string, bannerId?: number) => void + ) => { + const reader = new FileReader(); + reader.onload = (event) => { + const base64 = event.target?.result as string; + if (onImageChange) { + onImageChange(base64, bannerId); + } + }; + reader.readAsDataURL(file); + }; + + const handleImageClick = ( + bannerId?: number, + onImageChange?: (imageUrl: string, bannerId?: number) => void + ) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.style.display = 'none'; + + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + handleImageUpload(file, bannerId, onImageChange); + } + }; + + document.body.appendChild(input); + input.click(); + document.body.removeChild(input); + }; + + const handleImageHover = (id: number) => { + setHoveredImageId(id); + }; + + const handleImageLeave = () => { + setHoveredImageId(null); + }; + + return { + fileInputRef, + hoveredImageId, + handleImageClick, + handleImageHover, + handleImageLeave, + }; +}; diff --git a/src/main.tsx b/src/main.tsx index fc6a5ab3..32585982 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,10 +2,10 @@ import { createRoot } from 'react-dom/client'; import App from './App.tsx'; async function mountApp() { - // if (process.env.NODE_ENV === 'development') { - // const { worker } = await import('./mock/browser'); - // await worker.start(); - // } + if (process.env.NODE_ENV === 'development') { + const { worker } = await import('./mock/browser'); + await worker.start(); + } createRoot(document.getElementById('root')!).render( <> diff --git a/src/mock/adminMock.ts b/src/mock/adminMock.ts index ab8da6f6..cd71102e 100644 --- a/src/mock/adminMock.ts +++ b/src/mock/adminMock.ts @@ -2,6 +2,45 @@ import { http, HttpResponse, passthrough } from 'msw'; import mockReports from './mockReports.json'; import mockUsers from './mockUsers.json'; import mockAllUsers from './mockAllUser.json'; +import { BannerItem } from '../models/admin/banner'; + +const mockBanners: BannerItem[] = [ + { + id: 1, + imageUrl: 'https://picsum.photos/300/100?random=1', + visible: true, + always: false, + startDate: '2024-01-01', + endDate: '2024-01-31', + }, + { + id: 2, + imageUrl: 'https://picsum.photos/300/100?random=2', + visible: false, + always: true, + startDate: '', + endDate: '', + }, + { + id: 3, + imageUrl: 'https://picsum.photos/300/100?random=3', + visible: true, + always: false, + startDate: '2024-02-01', + endDate: '2024-02-28', + }, +]; + +const uploadedImages = new Map(); + +const fileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +}; export const reportsAll = http.get( `${import.meta.env.VITE_APP_API_BASE_URL}reports`, @@ -24,6 +63,188 @@ export const userAll = http.get( } ); +export const getBannerList = http.get( + `${import.meta.env.VITE_APP_API_BASE_URL}banner`, + () => { + console.log('배너 리스트 조회:', mockBanners); + + const bannersWithUploadedImages = mockBanners.map((banner) => ({ + ...banner, + imageUrl: uploadedImages.get(banner.id) || banner.imageUrl, + })); + + return HttpResponse.json( + { + status: 200, + message: '배너 리스트 조회 성공', + data: bannersWithUploadedImages, + }, + { status: 200 } + ); + } +); + +export const postBanner = http.post( + `${import.meta.env.VITE_APP_API_BASE_URL}banner`, + async ({ request }) => { + const formData = await request.formData(); + const imageFile = formData.get('imageUrl') as File; + + console.log('배너 생성 요청:', { + imageFile: imageFile + ? { + name: imageFile.name, + size: imageFile.size, + type: imageFile.type, + } + : null, + visible: formData.get('visible'), + always: formData.get('always'), + startDate: formData.get('startDate'), + endDate: formData.get('endDate'), + }); + + let imageUrl = ''; + + if (imageFile && imageFile.size > 0) { + try { + imageUrl = await fileToBase64(imageFile); + console.log('이미지 변환 완료 - 크기:', imageFile.size, 'bytes'); + } catch (error) { + console.error('이미지 변환 실패:', error); + imageUrl = 'https://picsum.photos/300/100?random=' + Date.now(); + } + } else { + imageUrl = 'https://picsum.photos/300/100?random=' + Date.now(); + } + + const newBanner: BannerItem = { + id: Date.now(), + imageUrl, + visible: formData.get('visible') === 'true', + always: formData.get('always') === 'true', + startDate: (formData.get('startDate') as string) || '', + endDate: (formData.get('endDate') as string) || '', + }; + + mockBanners.push(newBanner); + console.log('배너 생성 완료:', { + ...newBanner, + imageUrl: imageUrl.substring(0, 50) + '...', + }); + + return HttpResponse.json( + { + status: 200, + message: '배너 생성 성공', + data: newBanner, + }, + { status: 200 } + ); + } +); + +export const patchBanner = http.patch( + `${import.meta.env.VITE_APP_API_BASE_URL}banner/:id`, + async ({ request, params }) => { + const bannerId = parseInt(params.id as string); + const formData = await request.formData(); + const imageFile = formData.get('imageUrl') as File; + + console.log('배너 수정 요청:', { + bannerId, + imageFile: imageFile + ? { + name: imageFile.name, + size: imageFile.size, + type: imageFile.type, + } + : null, + visible: formData.get('visible'), + always: formData.get('always'), + startDate: formData.get('startDate'), + endDate: formData.get('endDate'), + }); + + const bannerIndex = mockBanners.findIndex( + (banner) => banner.id === bannerId + ); + if (bannerIndex === -1) { + return HttpResponse.json( + { status: 404, message: '배너를 찾을 수 없습니다.' }, + { status: 404 } + ); + } + + let imageUrl = mockBanners[bannerIndex].imageUrl; + if (imageFile && imageFile.size > 0) { + try { + imageUrl = await fileToBase64(imageFile); + console.log('배너 이미지 수정 완료 - 크기:', imageFile.size, 'bytes'); + } catch (error) { + console.error('이미지 변환 실패:', error); + } + } + + mockBanners[bannerIndex] = { + ...mockBanners[bannerIndex], + imageUrl, + visible: formData.get('visible') === 'true', + always: formData.get('always') === 'true', + startDate: (formData.get('startDate') as string) || '', + endDate: (formData.get('endDate') as string) || '', + }; + + console.log('🎯 [Mock] 배너 수정 완료:', { + ...mockBanners[bannerIndex], + imageUrl: imageUrl.substring(0, 50) + '...', + }); + + return HttpResponse.json( + { + status: 200, + message: '배너 수정 성공', + data: mockBanners[bannerIndex], + }, + { status: 200 } + ); + } +); + +export const deleteBanner = http.delete( + `${import.meta.env.VITE_APP_API_BASE_URL}banner/:id`, + ({ params }) => { + const bannerId = parseInt(params.id as string); + + console.log('배너 삭제 요청:', bannerId); + + const bannerIndex = mockBanners.findIndex( + (banner) => banner.id === bannerId + ); + if (bannerIndex === -1) { + return HttpResponse.json( + { status: 404, message: '배너를 찾을 수 없습니다.' }, + { status: 404 } + ); + } + + const deletedBanner = mockBanners.splice(bannerIndex, 1)[0]; + + uploadedImages.delete(bannerId); + + console.log('배너 삭제 완료:', deletedBanner); + + return HttpResponse.json( + { + status: 200, + message: '배너 삭제 성공', + data: deletedBanner, + }, + { status: 200 } + ); + } +); + export const passthroughAllGet = http.get(`*`, () => { return passthrough(); }); diff --git a/src/mock/browser.ts b/src/mock/browser.ts index d0319fd9..f4a548aa 100644 --- a/src/mock/browser.ts +++ b/src/mock/browser.ts @@ -31,6 +31,10 @@ import { reportsAll, userAll, userAllPreview, + getBannerList, + postBanner, + patchBanner, + deleteBanner, } from './adminMock.ts'; export const handlers = [ @@ -61,6 +65,10 @@ export const handlers = [ reportsAll, userAll, userAllPreview, + getBannerList, + postBanner, + patchBanner, + deleteBanner, passthroughAllGet, passthroughAllPost, ]; diff --git a/src/models/admin/banner.ts b/src/models/admin/banner.ts new file mode 100644 index 00000000..de94fa3a --- /dev/null +++ b/src/models/admin/banner.ts @@ -0,0 +1,22 @@ +import { ApiCommonType } from '../apiCommon'; + +export interface BannerItem { + id: number; + imageUrl: string; + visible: boolean; + always: boolean; + startDate: string; + endDate: string; +} + +export interface BannerFormData { + imageUrl?: File; + visible: boolean; + always: boolean; + startDate: string; + endDate: string; +} + +export interface ApiBannerList extends ApiCommonType { + data: BannerItem[]; +} diff --git a/src/pages/admin/adminBanner/AdminBanner.styled.ts b/src/pages/admin/adminBanner/AdminBanner.styled.ts new file mode 100644 index 00000000..be2e15f6 --- /dev/null +++ b/src/pages/admin/adminBanner/AdminBanner.styled.ts @@ -0,0 +1,5 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + width: 1300px; +`; diff --git a/src/pages/admin/adminBanner/AdminBanner.tsx b/src/pages/admin/adminBanner/AdminBanner.tsx index a1812dec..96090250 100644 --- a/src/pages/admin/adminBanner/AdminBanner.tsx +++ b/src/pages/admin/adminBanner/AdminBanner.tsx @@ -1,3 +1,12 @@ +import BannerList from '../../../components/admin/banner/BannerList'; +import AdminTitle from '../../../components/common/admin/title/AdminTitle'; +import * as S from './AdminBanner.styled'; + export default function AdminBanner() { - return
; + return ( + + + + + ); } From cabb1db010d699eee33418ec435c47ab19546f06 Mon Sep 17 00:00:00 2001 From: Cho SeungYeon <111514472+layout-SY@users.noreply.github.com> Date: Sun, 6 Jul 2025 01:13:20 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat=20:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=B0=B0=EB=84=88=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../banner/dateRange/DateRange.styled.ts | 30 +++++++++++++++++++ src/hooks/queries/keys.ts | 5 ++++ 2 files changed, 35 insertions(+) create mode 100644 src/components/admin/banner/dateRange/DateRange.styled.ts diff --git a/src/components/admin/banner/dateRange/DateRange.styled.ts b/src/components/admin/banner/dateRange/DateRange.styled.ts new file mode 100644 index 00000000..25812a77 --- /dev/null +++ b/src/components/admin/banner/dateRange/DateRange.styled.ts @@ -0,0 +1,30 @@ +import styled from 'styled-components'; + +export const DateRange = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +export const DateSeparator = styled.span` + color: #6c757d; + font-size: 14px; +`; + +export const DateInput = styled.input` + padding: 8px 12px; + border: 1px solid #dee2e6; + border-radius: 4px; + font-size: 14px; + width: 140px; + + &:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); + } + + &:hover { + border-color: #adb5bd; + } +`; diff --git a/src/hooks/queries/keys.ts b/src/hooks/queries/keys.ts index ec42a7aa..96f8ce77 100644 --- a/src/hooks/queries/keys.ts +++ b/src/hooks/queries/keys.ts @@ -51,6 +51,7 @@ export const ActivityLog = { export const Inquiries = { allInquiries: ['AllInquiries'], + inquiriesPreview: ['InquiriesPreview'], } as const; export const CustomerService = { @@ -80,3 +81,7 @@ export const Tag = { positionTag: ['positionsData'], method: ['fetchMethodTag'], } as const; + +export const Banners = { + allBanners: ['AllBanners'], +} as const; From 275d5734be12edf267d1c2ac00b519cda79c1509 Mon Sep 17 00:00:00 2001 From: Cho SeungYeon <111514472+layout-SY@users.noreply.github.com> Date: Sun, 6 Jul 2025 23:24:42 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat=20:=20=EB=B0=B0=EB=84=88=EC=9D=98=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C=20=EA=B8=B0=EA=B0=84=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/banner/bannerRow/BannerRow.tsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/components/admin/banner/bannerRow/BannerRow.tsx b/src/components/admin/banner/bannerRow/BannerRow.tsx index 104b5547..af497192 100644 --- a/src/components/admin/banner/bannerRow/BannerRow.tsx +++ b/src/components/admin/banner/bannerRow/BannerRow.tsx @@ -5,6 +5,7 @@ import ToggleSwitch from '../toggleSwitch/ToggleSwitch'; import RadioGroup from '../radioGroup/RadioGroup'; import DateRange from '../dateRange/DateRange'; import { useState } from 'react'; +import { formatDate } from '../../../../util/formatDate'; interface BannerRowProps { banner: BannerItem; @@ -59,15 +60,6 @@ const BannerRow = ({ setHoveredImageId(null); }; - const formatDateRange = (start: string, end: string) => { - if (start && end) { - return `${start.replace(/-/g, '.')} ~ ${end.replace(/-/g, '.')}`; - } - return null; - }; - - const dateRangeDisplay = formatDateRange(banner.startDate, banner.endDate); - return ( {index + 1} @@ -97,8 +89,10 @@ const BannerRow = ({ {banner.always ? ( - - ) : dateRangeDisplay ? ( - {dateRangeDisplay} + ) : banner.startDate && banner.endDate ? ( + + {formatDate(banner.startDate)} ~ {formatDate(banner.endDate)} + ) : ( Date: Tue, 8 Jul 2025 16:35:14 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat=20:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=B0=B0=EB=84=88=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EC=A0=84=EA=B3=BC=20=ED=9B=84=EB=A5=BC=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90=ED=95=98=EC=97=AC=20"=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC"=20=ED=99=9C=EC=84=B1=ED=99=94/=EB=B9=84?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/admin/banner.api.ts | 20 +- .../admin/banner/BannerList.styled.ts | 7 +- src/components/admin/banner/BannerList.tsx | 95 ++++---- .../banner/bannerRow/BannerRow.styled.ts | 17 +- .../admin/banner/bannerRow/BannerRow.tsx | 119 +++++----- .../admin/banner/bannerRow/useBannerRow.ts | 155 +++++++++++++ .../banner/newBannerRow/NewBannerRow.tsx | 63 ++---- .../banner/newBannerRow/useNewBannerRow.ts | 74 ++++++ src/hooks/admin/useBannerManagement.ts | 214 ------------------ src/hooks/admin/useBannerMutations.ts | 6 +- src/hooks/admin/useGetAllBannerList.ts | 3 +- src/main.tsx | 8 +- src/models/admin/banner.ts | 9 + 13 files changed, 394 insertions(+), 396 deletions(-) create mode 100644 src/components/admin/banner/bannerRow/useBannerRow.ts create mode 100644 src/components/admin/banner/newBannerRow/useNewBannerRow.ts delete mode 100644 src/hooks/admin/useBannerManagement.ts diff --git a/src/api/admin/banner.api.ts b/src/api/admin/banner.api.ts index badb6aef..f00f4662 100644 --- a/src/api/admin/banner.api.ts +++ b/src/api/admin/banner.api.ts @@ -13,6 +13,16 @@ export const getBannerList = async () => { }; export const postBanner = async (formData: FormData) => { + try { + const response = await httpClient.post('/banner', formData); + return response.data; + } catch (error) { + console.error(error); + throw error; + } +}; + +export const patchBanner = async (formData: FormData, bannerId?: number) => { try { for (const [key, value] of formData.entries()) { if (value instanceof File) { @@ -26,16 +36,6 @@ export const postBanner = async (formData: FormData) => { console.log(`${key}:`, value); } } - const response = await httpClient.post('/banner', formData); - return response.data; - } catch (error) { - console.error(error); - throw error; - } -}; - -export const patchBanner = async (formData: FormData, bannerId?: number) => { - try { const response = await httpClient.patch(`/banner/${bannerId}`, formData); return response.data; } catch (error) { diff --git a/src/components/admin/banner/BannerList.styled.ts b/src/components/admin/banner/BannerList.styled.ts index 5d2aa1ed..e72afcd3 100644 --- a/src/components/admin/banner/BannerList.styled.ts +++ b/src/components/admin/banner/BannerList.styled.ts @@ -26,12 +26,7 @@ export const TableHeaderCell = styled.th` export const ScrollBody = styled.tbody``; -export const TableRow = styled.tr<{ $header?: boolean }>` - &:nth-child(odd) { - background-color: ${({ $header, theme }) => - $header ? theme.buttonScheme.primary.bg : '#fafafa'}; - } -`; +export const TableRow = styled.tr``; export const TableCell = styled.td` padding: 14px; diff --git a/src/components/admin/banner/BannerList.tsx b/src/components/admin/banner/BannerList.tsx index 79e6fa21..0f927684 100644 --- a/src/components/admin/banner/BannerList.tsx +++ b/src/components/admin/banner/BannerList.tsx @@ -1,49 +1,49 @@ +import { useState, useCallback } from 'react'; import * as S from './BannerList.styled'; import ScrollPreventor from '../../common/modal/ScrollPreventor'; -import { useBannerManagement } from '../../../hooks/admin/useBannerManagement'; +import { useGetAllBannerList } from '../../../hooks/admin/useGetAllBannerList'; +import { useBannerMutations } from '../../../hooks/admin/useBannerMutations'; import { useModal } from '../../../hooks/useModal'; import TableHeader from './tableHeader/TableHeader'; import BannerRow from './bannerRow/BannerRow'; import NewBannerRow from './newBannerRow/NewBannerRow'; import Modal from '../../common/modal/Modal'; +import { Spinner } from '../../common/loadingSpinner/LoadingSpinner.styled'; + +const TABLE_COLUMNS = [ + { width: '50px' }, + { width: '280px' }, + { width: '100px' }, + { width: '160px' }, + { width: '240px' }, + { width: '140px' }, +]; export default function BannerList() { const { isOpen, message, handleModalOpen, handleModalClose } = useModal(); + const { allBannersData, isLoading } = useGetAllBannerList(); + const { deleteBannerMutate } = useBannerMutations({ handleModalOpen }); + const [isCreating, setIsCreating] = useState(false); - const { - allBanners, - isLoading, - newBanner, - isCreating, - canCreateBanner, - hasNewBannerChanges, - handleToggle, - handleChangeType, - handleDateChange, - handleImageChange, - handleDelete, - handleCreate, - handleInputChange, - toggleCreating, - resetNewBanner, - } = useBannerManagement({ handleModalOpen }); - - const handleCancel = () => { - if (hasNewBannerChanges) { - const confirmed = window.confirm( - '입력 데이터가 날아 갈 수 있습니다. 계속 하시겠습니까?' - ); - if (confirmed) { - resetNewBanner(); - toggleCreating(); + const handleDelete = useCallback( + async (id: number) => { + if (window.confirm('정말 삭제하시겠습니까?')) { + try { + await deleteBannerMutate.mutateAsync(id); + } catch (error) { + console.error('삭제 실패:', error); + } } - } else { - toggleCreating(); - } - }; + }, + [deleteBannerMutate] + ); + + const toggleCreating = useCallback(() => { + setIsCreating((prev) => !prev); + }, []); if (isLoading) { - return
Loading...
; + return ; } return ( @@ -51,37 +51,27 @@ export default function BannerList() { - - - - - - + {TABLE_COLUMNS.map((col, index) => ( + + ))} + + - {allBanners.map((banner, index) => ( + {allBannersData?.data.map((banner, index) => ( ))} - {isCreating && ( - - )} + + {isCreating && } + {!isCreating ? ( 취소하기 )} + {message} diff --git a/src/components/admin/banner/bannerRow/BannerRow.styled.ts b/src/components/admin/banner/bannerRow/BannerRow.styled.ts index 362bae98..28684967 100644 --- a/src/components/admin/banner/bannerRow/BannerRow.styled.ts +++ b/src/components/admin/banner/bannerRow/BannerRow.styled.ts @@ -3,8 +3,8 @@ import styled from 'styled-components'; export const TableRow = styled.tr` border-bottom: 1px solid #e9ecef; - &:hover { - background-color: #f8f9fa; + &:nth-child(even) { + background-color: ${({ theme }) => theme.color.lightgrey}; } `; @@ -33,19 +33,20 @@ export const EditButton = styled.button` margin-right: 8px; border: none; border-radius: 4px; - background-color: #007bff; + background-color: ${({ theme }) => theme.buttonScheme.primary.bg}; color: white; font-size: 12px; cursor: pointer; transition: background-color 0.2s ease; - &:hover { - background-color: #0056b3; + &:hover:not(:disabled) { + opacity: 0.8; } &:disabled { - background-color: #6c757d; + background-color: ${({ theme }) => theme.color.deepGrey}; cursor: not-allowed; + opacity: 0.8; } `; @@ -53,14 +54,14 @@ export const DeleteButton = styled.button` padding: 6px 12px; border: none; border-radius: 4px; - background-color: #dc3545; + background-color: ${({ theme }) => theme.color.red}; color: white; font-size: 12px; cursor: pointer; transition: background-color 0.2s ease; &:hover { - background-color: #c82333; + opacity: 0.8; } `; diff --git a/src/components/admin/banner/bannerRow/BannerRow.tsx b/src/components/admin/banner/bannerRow/BannerRow.tsx index af497192..f07446d2 100644 --- a/src/components/admin/banner/bannerRow/BannerRow.tsx +++ b/src/components/admin/banner/bannerRow/BannerRow.tsx @@ -1,113 +1,118 @@ +import { useState, useCallback } from 'react'; import * as S from './BannerRow.styled'; import { BannerItem } from '../../../../models/admin/banner'; +import { useBannerRow } from './useBannerRow'; import ImageUploadArea from '../imageUploadArea/ImageUploadArea'; import ToggleSwitch from '../toggleSwitch/ToggleSwitch'; import RadioGroup from '../radioGroup/RadioGroup'; import DateRange from '../dateRange/DateRange'; -import { useState } from 'react'; import { formatDate } from '../../../../util/formatDate'; interface BannerRowProps { banner: BannerItem; index: number; - onToggle: (id: number, visible: boolean) => Promise; - onChangeType: (id: number, always: boolean) => Promise; - onDateChange: ( - id: number, - startDate?: string, - endDate?: string - ) => Promise; - onImageChange: (bannerId: number, file: File) => Promise; onDelete: (id: number) => void; } -const BannerRow = ({ - banner, - index, - onToggle, - onChangeType, - onDateChange, - onImageChange, - onDelete, -}: BannerRowProps) => { - const [hoveredImageId, setHoveredImageId] = useState(null); +const BannerRow = ({ banner, index, onDelete }: BannerRowProps) => { + const { + currentValues, + hasChanges, + handleToggle, + handleChangeType, + handleDateChange, + handleImageChange, + handleUpdate, + } = useBannerRow(banner); + const [hoveredImageId, setHoveredImageId] = useState(null); const showImageOverlay = hoveredImageId === banner.id; - const handleImageClick = () => { + // 이미지 관련 핸들러 + const handleImageClick = useCallback(() => { const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.style.display = 'none'; - input.onchange = async (event) => { + input.onchange = (event) => { const file = (event.target as HTMLInputElement).files?.[0]; if (file) { - await onImageChange(banner.id, file); + handleImageChange(file); } }; document.body.appendChild(input); input.click(); document.body.removeChild(input); - }; + }, [handleImageChange]); - const handleImageHover = (id: number) => { - setHoveredImageId(id); - }; + // 날짜 표시 렌더링 + const renderDateDisplay = () => { + if (currentValues.always) { + return -; + } + + if (currentValues.startDate && currentValues.endDate) { + return ( + + {formatDate(currentValues.startDate)} ~{' '} + {formatDate(currentValues.endDate)} + + ); + } - const handleImageLeave = () => { - setHoveredImageId(null); + return ( + + handleDateChange(date, currentValues.endDate) + } + onEndDateChange={(date) => + handleDateChange(currentValues.startDate, date) + } + placeholder={{ start: '시작일', end: '종료일' }} + /> + ); }; return ( - + {index + 1} + handleImageHover(banner.id)} - onImageLeave={handleImageLeave} + onImageHover={() => setHoveredImageId(banner.id)} + onImageLeave={() => setHoveredImageId(null)} showOverlay={showImageOverlay} /> + onToggle(banner.id, checked)} + checked={currentValues.visible} + onChange={handleToggle} /> + onChangeType(banner.id, always)} + value={currentValues.always} + onChange={handleChangeType} /> + + {renderDateDisplay()} + - {banner.always ? ( - - - ) : banner.startDate && banner.endDate ? ( - - {formatDate(banner.startDate)} ~ {formatDate(banner.endDate)} - - ) : ( - - onDateChange(banner.id, date, banner.endDate) - } - onEndDateChange={(date) => - onDateChange(banner.id, banner.startDate, date) - } - placeholder={{ start: '시작일', end: '종료일' }} - /> - )} - - + + 수정 + onDelete(banner.id)}> 삭제 diff --git a/src/components/admin/banner/bannerRow/useBannerRow.ts b/src/components/admin/banner/bannerRow/useBannerRow.ts new file mode 100644 index 00000000..fd212013 --- /dev/null +++ b/src/components/admin/banner/bannerRow/useBannerRow.ts @@ -0,0 +1,155 @@ +import { useState, useCallback } from 'react'; +import { BannerItem, BannerChanges } from '../../../../models/admin/banner'; +import { useBannerMutations } from '../../../../hooks/admin/useBannerMutations'; +import { useModal } from '../../../../hooks/useModal'; + +interface CurrentValues { + visible: boolean; + always: boolean; + startDate: string; + endDate: string; + imageUrl: string; +} + +export const useBannerRow = (banner: BannerItem) => { + const { handleModalOpen } = useModal(); + const { patchBannerMutate } = useBannerMutations({ handleModalOpen }); + + const [changes, setChanges] = useState({}); + + // 현재 표시할 값들 계산 + const currentValues: CurrentValues = { + visible: changes.visible ?? banner.visible, + always: changes.always ?? banner.always, + startDate: changes.startDate ?? banner.startDate, + endDate: changes.endDate ?? banner.endDate, + imageUrl: changes.newImageFile + ? URL.createObjectURL(changes.newImageFile) + : banner.imageUrl, + }; + + // 변경사항 업데이트 + const updateChanges = useCallback( + (updates: Partial) => { + setChanges((prev) => { + const newChanges = { ...prev, ...updates }; + + // 실제 변경사항만 필터링 + const actualChanges: BannerChanges = {}; + + Object.entries(newChanges).forEach(([key, newValue]) => { + const field = key as keyof BannerChanges; + const originalValue = banner[field as keyof BannerItem]; + + if (field === 'newImageFile') { + if (newValue && newValue instanceof File) { + actualChanges[field] = newValue; + } + } else if (newValue !== originalValue) { + (actualChanges as any)[field] = newValue; + } + }); + + return actualChanges; + }); + }, + [banner] + ); + + // 변경사항 초기화 + const resetChanges = useCallback(() => { + setChanges({}); + }, []); + + // 이벤트 핸들러들 + const handleToggle = useCallback( + (visible: boolean) => { + updateChanges({ visible }); + }, + [updateChanges] + ); + + const handleChangeType = useCallback( + (always: boolean) => { + const updates: Partial = { always }; + + if (always) { + // 상시 노출로 변경할 때는 무조건 원본 배너의 startDate, endDate 값으로 되돌림 + updates.startDate = banner.startDate; + updates.endDate = banner.endDate; + } else { + const currentStartDate = changes.startDate ?? banner.startDate; + const currentEndDate = changes.endDate ?? banner.endDate; + + if (banner.startDate !== '' || currentStartDate !== '') { + updates.startDate = currentStartDate; + } + if (banner.endDate !== '' || currentEndDate !== '') { + updates.endDate = currentEndDate; + } + } + + updateChanges(updates); + }, + [updateChanges, changes, banner] + ); + + const handleDateChange = useCallback( + (startDate?: string, endDate?: string) => { + if (currentValues.always) return; + + updateChanges({ + startDate: startDate ?? '', + endDate: endDate ?? '', + }); + }, + [currentValues.always, updateChanges] + ); + + const handleImageChange = useCallback( + (file: File) => { + updateChanges({ newImageFile: file }); + }, + [updateChanges] + ); + + const handleUpdate = useCallback(async () => { + if (Object.keys(changes).length === 0) return; + + try { + const formData = new FormData(); + + if (changes.newImageFile) { + formData.append('image', changes.newImageFile); + } + + const updatedData = { + visible: changes.visible ?? banner.visible, + always: changes.always ?? banner.always, + startDate: changes.startDate ?? banner.startDate, + endDate: changes.endDate ?? banner.endDate, + }; + + formData.append('request', JSON.stringify(updatedData)); + await patchBannerMutate.mutateAsync({ formData, bannerId: banner.id }); + + resetChanges(); + handleModalOpen('배너가 수정되었습니다.'); + } catch (error) { + console.error('배너 업데이트 실패:', error); + handleModalOpen('배너 수정에 실패했습니다.'); + } + }, [changes, banner, patchBannerMutate, resetChanges, handleModalOpen]); + + const hasChanges = Object.keys(changes).length > 0; + + return { + currentValues, + hasChanges, + handleToggle, + handleChangeType, + handleDateChange, + handleImageChange, + handleUpdate, + }; +}; diff --git a/src/components/admin/banner/newBannerRow/NewBannerRow.tsx b/src/components/admin/banner/newBannerRow/NewBannerRow.tsx index b8a11c2d..86f3a82a 100644 --- a/src/components/admin/banner/newBannerRow/NewBannerRow.tsx +++ b/src/components/admin/banner/newBannerRow/NewBannerRow.tsx @@ -1,54 +1,32 @@ +import { useCallback } from 'react'; import * as S from './NewBannerRow.styled'; -import { BannerFormData } from '../../../../models/admin/banner'; +import { useNewBannerRow } from './useNewBannerRow'; import ImageUploadArea from '../imageUploadArea/ImageUploadArea'; import ToggleSwitch from '../toggleSwitch/ToggleSwitch'; import RadioGroup from '../radioGroup/RadioGroup'; import DateRange from '../dateRange/DateRange'; -interface NewBannerRowProps { - newBanner: BannerFormData; - canCreateBanner: boolean; - onInputChange: ( - field: keyof BannerFormData, - value: string | boolean | File - ) => void; - onCreate: () => void; -} +const NewBannerRow = () => { + const { newBanner, canCreateBanner, handleInputChange, handleCreate } = + useNewBannerRow(); -const NewBannerRow = ({ - newBanner, - canCreateBanner, - onInputChange, - onCreate, -}: NewBannerRowProps) => { - const handleImageChange = (file: File) => { - onInputChange('imageUrl', file); - }; - - const handleFileInputChange = ( - event: React.ChangeEvent - ) => { - const file = event.target.files?.[0]; - if (file) { - handleImageChange(file); - } - }; - - const handleImageClick = () => { + const handleImageClick = useCallback(() => { const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.style.display = 'none'; - input.onchange = (event) => - handleFileInputChange( - event as unknown as React.ChangeEvent - ); + input.onchange = (event) => { + const file = (event.target as HTMLInputElement).files?.[0]; + if (file) { + handleInputChange('imageUrl', file); + } + }; document.body.appendChild(input); input.click(); document.body.removeChild(input); - }; + }, [handleInputChange]); const imageUrl = newBanner.imageUrl ? URL.createObjectURL(newBanner.imageUrl) @@ -59,6 +37,7 @@ const NewBannerRow = ({ - + + onInputChange('visible', checked)} + onChange={(checked) => handleInputChange('visible', checked)} /> + onInputChange('always', always)} + onChange={(always) => handleInputChange('always', always)} /> + {newBanner.always ? ( - @@ -89,13 +71,14 @@ const NewBannerRow = ({ onInputChange('startDate', date)} - onEndDateChange={(date) => onInputChange('endDate', date)} + onStartDateChange={(date) => handleInputChange('startDate', date)} + onEndDateChange={(date) => handleInputChange('endDate', date)} /> )} + - + 생성하기 diff --git a/src/components/admin/banner/newBannerRow/useNewBannerRow.ts b/src/components/admin/banner/newBannerRow/useNewBannerRow.ts new file mode 100644 index 00000000..a2439dfa --- /dev/null +++ b/src/components/admin/banner/newBannerRow/useNewBannerRow.ts @@ -0,0 +1,74 @@ +import { useState, useCallback } from 'react'; +import { BannerFormData } from '../../../../models/admin/banner'; +import { useBannerMutations } from '../../../../hooks/admin/useBannerMutations'; +import { useModal } from '../../../../hooks/useModal'; + +const createInitialBannerState = (): BannerFormData => ({ + imageUrl: undefined, + visible: false, + always: true, + startDate: '', + endDate: '', +}); + +export const useNewBannerRow = () => { + const { handleModalOpen } = useModal(); + const { postBannerMutate } = useBannerMutations({ handleModalOpen }); + + const [newBanner, setNewBanner] = useState( + createInitialBannerState + ); + + const handleInputChange = useCallback( + (field: keyof BannerFormData, value: string | boolean | File) => { + setNewBanner((prev) => ({ ...prev, [field]: value } as BannerFormData)); + }, + [] + ); + + const handleCreate = useCallback(async () => { + if (!newBanner.imageUrl) { + handleModalOpen('이미지를 선택해주세요.'); + return; + } + + try { + const formData = new FormData(); + formData.append('image', newBanner.imageUrl); + formData.append( + 'request', + JSON.stringify({ + visible: newBanner.visible, + always: newBanner.always, + startDate: newBanner.startDate, + endDate: newBanner.endDate, + }) + ); + + await postBannerMutate.mutateAsync(formData); + setNewBanner(createInitialBannerState()); + handleModalOpen('배너가 생성되었습니다.'); + } catch (error) { + console.error('배너 생성 실패:', error); + handleModalOpen('배너 생성에 실패했습니다.'); + } + }, [newBanner, postBannerMutate, handleModalOpen]); + + const resetNewBanner = useCallback(() => { + setNewBanner(createInitialBannerState()); + }, []); + + const canCreateBanner = Boolean(newBanner.imageUrl); + const hasNewBannerChanges = Boolean( + newBanner.imageUrl || newBanner.startDate || newBanner.endDate + ); + + return { + newBanner, + canCreateBanner, + hasNewBannerChanges, + handleInputChange, + handleCreate, + resetNewBanner, + }; +}; diff --git a/src/hooks/admin/useBannerManagement.ts b/src/hooks/admin/useBannerManagement.ts deleted file mode 100644 index d6e0a33c..00000000 --- a/src/hooks/admin/useBannerManagement.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { useState } from 'react'; -import { useGetAllBannerList } from './useGetAllBannerList'; -import { useBannerMutations } from './useBannerMutations'; -import { BannerItem, BannerFormData } from '../../models/admin/banner'; - -interface UseBannerManagementProps { - handleModalOpen: (message: string) => void; -} - -const createInitialBannerState = (): BannerFormData => ({ - imageUrl: undefined, - visible: false, - always: true, - startDate: '', - endDate: '', -}); - -const confirmFormData = (formData: FormData, title: string = 'FormData') => { - console.log(`=== ${title} 내용 ===`); - for (const [key, value] of formData.entries()) { - if (value instanceof File) { - console.log(`${key}:`, { - name: value.name, - size: value.size, - type: value.type, - lastModified: value.lastModified, - }); - } else { - console.log(`${key}:`, value); - } - } - console.log('=== FormData 끝 ==='); -}; - -export const useBannerManagement = ({ - handleModalOpen, -}: UseBannerManagementProps) => { - const { allBannersData, isLoading, refetch } = useGetAllBannerList(); - const { postBannerMutate, patchBannerMutate, deleteBannerMutate } = - useBannerMutations({ - handleModalOpen, - }); - - const [isCreating, setIsCreating] = useState(false); - const [newBanner, setNewBanner] = useState( - createInitialBannerState - ); - - const allBanners = allBannersData?.data ?? []; - - // 즉시 API 호출하는 헬퍼 함수 - const updateBannerImmediately = async ( - bannerId: number, - updates: Partial - ) => { - try { - const currentBanner = allBanners.find((banner) => banner.id === bannerId); - if (!currentBanner) return; - - const formData = new FormData(); - formData.append( - 'visible', - (updates.visible ?? currentBanner.visible).toString() - ); - formData.append( - 'always', - (updates.always ?? currentBanner.always).toString() - ); - formData.append( - 'startDate', - updates.startDate ?? currentBanner.startDate - ); - formData.append('endDate', updates.endDate ?? currentBanner.endDate); - - await patchBannerMutate.mutateAsync({ formData, bannerId }); - await refetch(); - } catch (error) { - console.error('배너 업데이트 실패:', error); - } - }; - - const handleToggle = async (id: number, visible: boolean) => { - await updateBannerImmediately(id, { visible }); - }; - - const handleChangeType = async (id: number, always: boolean) => { - const updates: Partial = { always }; - if (always) { - updates.startDate = ''; - updates.endDate = ''; - } - await updateBannerImmediately(id, updates); - }; - - const handleDateChange = async ( - id: number, - startDate?: string, - endDate?: string - ) => { - await updateBannerImmediately(id, { - startDate: startDate ?? '', - endDate: endDate ?? '', - }); - }; - - // 기존 배너 이미지 변경 함수 - const handleImageChange = async (bannerId: number, file: File) => { - try { - const formData = new FormData(); - formData.append('imageUrl', file); - - // 기존 배너 정보도 함께 전송 - const existingBanner = allBanners.find( - (banner) => banner.id === bannerId - ); - if (existingBanner) { - formData.append('visible', existingBanner.visible.toString()); - formData.append('always', existingBanner.always.toString()); - formData.append('startDate', existingBanner.startDate); - formData.append('endDate', existingBanner.endDate); - } - - confirmFormData(formData, '배너 이미지 변경'); - - await patchBannerMutate.mutateAsync({ formData, bannerId }); - await refetch(); - } catch (error) { - console.error('이미지 변경 실패:', error); - } - }; - - const handleDelete = async (id: number) => { - if (window.confirm('정말 삭제하시겠습니까?')) { - try { - await deleteBannerMutate.mutateAsync(id); - await refetch(); - } catch (error) { - console.error('삭제 실패:', error); - } - } - }; - - const handleCreate = async () => { - if (!newBanner.imageUrl) { - handleModalOpen('이미지를 선택해주세요.'); - return; - } - - try { - const formData = new FormData(); - formData.append('imageUrl', newBanner.imageUrl); - formData.append('visible', newBanner.visible.toString()); - formData.append('always', newBanner.always.toString()); - formData.append('startDate', newBanner.startDate); - formData.append('endDate', newBanner.endDate); - - confirmFormData(formData, '배너 생성'); - - await postBannerMutate.mutateAsync(formData); - - setIsCreating(false); - setNewBanner(createInitialBannerState()); - - await refetch(); - - handleModalOpen('배너가 생성되었습니다.'); - } catch (error) { - console.error('배너 생성 실패:', error); - handleModalOpen('배너 생성에 실패했습니다.'); - } - }; - - const handleInputChange = ( - field: keyof BannerFormData, - value: string | boolean | File - ) => { - setNewBanner((prev) => ({ ...prev, [field]: value } as BannerFormData)); - }; - - const toggleCreating = () => { - if (isCreating) { - setNewBanner(createInitialBannerState()); - } - setIsCreating((prev) => !prev); - }; - - const resetNewBanner = () => { - setNewBanner(createInitialBannerState()); - }; - - const canCreateBanner = Boolean(newBanner.imageUrl); - - const hasNewBannerChanges = Boolean( - newBanner.imageUrl || newBanner.startDate || newBanner.endDate - ); - - return { - allBanners, - isLoading, - newBanner, - isCreating, - canCreateBanner, - hasNewBannerChanges, - handleToggle, - handleChangeType, - handleDateChange, - handleImageChange, - handleDelete, - handleCreate, - handleInputChange, - toggleCreating, - resetNewBanner, - }; -}; diff --git a/src/hooks/admin/useBannerMutations.ts b/src/hooks/admin/useBannerMutations.ts index 09db1941..f3542c4f 100644 --- a/src/hooks/admin/useBannerMutations.ts +++ b/src/hooks/admin/useBannerMutations.ts @@ -35,7 +35,7 @@ export const useBannerMutations = ({ mutationFn: (formData: FormData) => postBanner(formData), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: Banners.allBanners, + queryKey: [Banners.allBanners], }); }, onError: () => { @@ -51,7 +51,7 @@ export const useBannerMutations = ({ mutationFn: ({ formData, bannerId }) => patchBanner(formData, bannerId), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: Banners.allBanners, + queryKey: [Banners.allBanners], }); }, onError: () => { @@ -63,7 +63,7 @@ export const useBannerMutations = ({ mutationFn: (bannerId: number) => deleteBanner(bannerId), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: Banners.allBanners, + queryKey: [Banners.allBanners], }); handleDeleteButtonState('success'); }, diff --git a/src/hooks/admin/useGetAllBannerList.ts b/src/hooks/admin/useGetAllBannerList.ts index 290d3bd9..c6a9c9c8 100644 --- a/src/hooks/admin/useGetAllBannerList.ts +++ b/src/hooks/admin/useGetAllBannerList.ts @@ -7,11 +7,10 @@ export const useGetAllBannerList = () => { data: allBannersData, isLoading, isFetching, - refetch, } = useQuery({ queryKey: [Banners.allBanners], queryFn: () => getBannerList(), }); - return { allBannersData, isLoading, isFetching, refetch }; + return { allBannersData, isLoading, isFetching }; }; diff --git a/src/main.tsx b/src/main.tsx index 32585982..fc6a5ab3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,10 +2,10 @@ import { createRoot } from 'react-dom/client'; import App from './App.tsx'; async function mountApp() { - if (process.env.NODE_ENV === 'development') { - const { worker } = await import('./mock/browser'); - await worker.start(); - } + // if (process.env.NODE_ENV === 'development') { + // const { worker } = await import('./mock/browser'); + // await worker.start(); + // } createRoot(document.getElementById('root')!).render( <> diff --git a/src/models/admin/banner.ts b/src/models/admin/banner.ts index de94fa3a..f52a7569 100644 --- a/src/models/admin/banner.ts +++ b/src/models/admin/banner.ts @@ -17,6 +17,15 @@ export interface BannerFormData { endDate: string; } +// 편집 중인 배너의 변경사항을 추적하기 위한 타입 +export interface BannerChanges { + visible?: boolean; + always?: boolean; + startDate?: string; + endDate?: string; + newImageFile?: File; +} + export interface ApiBannerList extends ApiCommonType { data: BannerItem[]; } From 6df70664f45b8f34564ec7265a9362462e234a26 Mon Sep 17 00:00:00 2001 From: Cho SeungYeon <111514472+layout-SY@users.noreply.github.com> Date: Fri, 11 Jul 2025 21:52:07 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor=20:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/admin/banner.api.ts | 8 + .../admin/banner/BannerList.styled.ts | 230 +----------------- src/components/admin/banner/BannerList.tsx | 8 +- .../admin/banner/bannerRow/BannerRow.tsx | 2 +- .../admin/banner/bannerRow/useBannerRow.ts | 18 +- .../imageUploadArea/ImageUploadArea.tsx | 2 +- .../banner/newBannerRow/NewBannerRow.tsx | 18 +- .../banner/newBannerRow/useNewBannerRow.ts | 2 +- src/hooks/admin/useBannerMutations.ts | 3 + src/hooks/admin/useImageManagement.ts | 3 + 10 files changed, 49 insertions(+), 245 deletions(-) diff --git a/src/api/admin/banner.api.ts b/src/api/admin/banner.api.ts index f00f4662..f7086258 100644 --- a/src/api/admin/banner.api.ts +++ b/src/api/admin/banner.api.ts @@ -24,6 +24,10 @@ export const postBanner = async (formData: FormData) => { export const patchBanner = async (formData: FormData, bannerId?: number) => { try { + if (!bannerId) { + throw new Error('bannerId is required'); + } + for (const [key, value] of formData.entries()) { if (value instanceof File) { console.log(`${key}:`, { @@ -46,6 +50,10 @@ export const patchBanner = async (formData: FormData, bannerId?: number) => { export const deleteBanner = async (bannerId?: number) => { try { + if (!bannerId) { + throw new Error('bannerId is required'); + } + const response = await httpClient.delete(`/banner/${bannerId}`); return response.data; } catch (error) { diff --git a/src/components/admin/banner/BannerList.styled.ts b/src/components/admin/banner/BannerList.styled.ts index e72afcd3..aa8cc0d9 100644 --- a/src/components/admin/banner/BannerList.styled.ts +++ b/src/components/admin/banner/BannerList.styled.ts @@ -1,10 +1,6 @@ -import styled, { css } from 'styled-components'; +import styled from 'styled-components'; import Button from '../../common/Button/Button'; -export const AddButtonContainer = styled.div``; - -export const AddButton = styled(Button)``; - export const Container = styled.table<{ $header?: boolean }>` border-collapse: collapse; table-layout: fixed; @@ -14,226 +10,6 @@ export const Container = styled.table<{ $header?: boolean }>` margin-top: 10rem; `; -export const TableHeader = styled.thead` - color: #ffffff; -`; - -export const TableHeaderCell = styled.th` - padding: 16px; - font-size: 14px; - text-align: center; -`; - -export const ScrollBody = styled.tbody``; - -export const TableRow = styled.tr``; - -export const TableCell = styled.td` - padding: 14px; - font-size: 14px; - text-align: center; - color: #333; - vertical-align: middle; -`; - -export const ImageCell = styled(TableCell)` - position: relative; - height: 120px; - padding: 0; - width: 280px; -`; - -export const ImageUploadArea = styled.div` - position: relative; - width: 100%; - height: 120px; - cursor: pointer; - border-radius: 6px; - overflow: hidden; -`; - -export const Thumbnail = styled.img` - width: 100%; - height: 100%; - object-fit: cover; - border-radius: 6px; -`; - -export const ImageOverlay = styled.div` - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - border-radius: 6px; -`; - -export const EditIcon = styled.div` - display: flex; - align-items: center; - justify-content: center; - color: white; -`; - -export const ImageLabel = styled.div` - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - color: white; - font-weight: 600; - font-size: 15px; - text-shadow: 0 0 5px rgba(0, 0, 0, 0.6); -`; - -export const PlusButton = styled.div` - width: 100%; - height: 120px; - border: 2px dashed #ccc; - font-size: 32px; - color: #888; - display: flex; - align-items: center; - justify-content: center; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s ease-in-out; - - &:hover { - border-color: #6c5ce7; - color: #6c5ce7; - background-color: rgba(108, 92, 231, 0.1); - } -`; - -export const ToggleSwitch = styled.div` - position: relative; - width: 50px; - height: 24px; - - input { - opacity: 0; - width: 0; - height: 0; - } - - label { - position: absolute; - background: #ccc; - border-radius: 999px; - width: 100%; - height: 100%; - cursor: pointer; - transition: background 0.2s ease-in-out; - } - - label::after { - content: ''; - position: absolute; - top: 2px; - left: 2px; - width: 20px; - height: 20px; - background: #fff; - border-radius: 50%; - transition: transform 0.2s ease-in-out; - } - - input:checked + label { - background-color: #6c5ce7; - } - - input:checked + label::after { - transform: translateX(26px); - } -`; - -export const RadioGroup = styled.div` - display: flex; - justify-content: center; - gap: 12px; - font-size: 14px; - - input { - display: none; - } - - label { - position: relative; - padding-left: 22px; - cursor: pointer; - - &::before { - content: ''; - position: absolute; - left: 0; - top: 2px; - width: 16px; - height: 16px; - border: 2px solid #666; - border-radius: 50%; - } - - &::after { - content: ''; - position: absolute; - left: 4px; - top: 6px; - width: 8px; - height: 8px; - background-color: #6c5ce7; - border-radius: 50%; - opacity: 0; - } - } - - input:checked + label::after { - opacity: 1; - } -`; - -export const DateRange = styled.div` - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - - span { - font-size: 14px; - } -`; -export const DateInput = styled.input` - width: 120px; - height: 36px; - padding: 0 12px; - font-size: 14px; - border-radius: 18px; - border: 1px solid #ccc; -`; - -const buttonBase = css` - padding: 8px 16px; - font-size: 14px; - border-radius: ${({ theme }) => theme.borderRadius.primary}; - color: ${({ theme }) => theme.color.white}; - border: 1px solid ${({ theme }) => theme.color.white}; -`; - -export const EditButton = styled.button` - ${buttonBase}; - background-color: ${({ theme }) => theme.buttonScheme.primary.bg}; - margin-right: 8px; -`; - -export const DeleteButton = styled.button` - ${buttonBase}; - background-color: ${({ theme }) => theme.color.red}; -`; - export const TableBody = styled.tbody``; export const ButtonContainer = styled.div` @@ -242,6 +18,4 @@ export const ButtonContainer = styled.div` margin-top: 20px; `; -export const CreateButton = styled(Button)``; - -export const CancelButton = styled(Button)``; +export const BannerButton = styled(Button)``; diff --git a/src/components/admin/banner/BannerList.tsx b/src/components/admin/banner/BannerList.tsx index 0f927684..142a8434 100644 --- a/src/components/admin/banner/BannerList.tsx +++ b/src/components/admin/banner/BannerList.tsx @@ -74,23 +74,23 @@ export default function BannerList() { {!isCreating ? ( - 배너 생성하기 - + ) : ( - 취소하기 - + )} diff --git a/src/components/admin/banner/bannerRow/BannerRow.tsx b/src/components/admin/banner/bannerRow/BannerRow.tsx index f07446d2..b4371736 100644 --- a/src/components/admin/banner/bannerRow/BannerRow.tsx +++ b/src/components/admin/banner/bannerRow/BannerRow.tsx @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react'; import * as S from './BannerRow.styled'; -import { BannerItem } from '../../../../models/admin/banner'; +import type { BannerItem } from '../../../../models/admin/banner'; import { useBannerRow } from './useBannerRow'; import ImageUploadArea from '../imageUploadArea/ImageUploadArea'; import ToggleSwitch from '../toggleSwitch/ToggleSwitch'; diff --git a/src/components/admin/banner/bannerRow/useBannerRow.ts b/src/components/admin/banner/bannerRow/useBannerRow.ts index fd212013..e3ff7ca4 100644 --- a/src/components/admin/banner/bannerRow/useBannerRow.ts +++ b/src/components/admin/banner/bannerRow/useBannerRow.ts @@ -1,5 +1,8 @@ import { useState, useCallback } from 'react'; -import { BannerItem, BannerChanges } from '../../../../models/admin/banner'; +import type { + BannerItem, + BannerChanges, +} from '../../../../models/admin/banner'; import { useBannerMutations } from '../../../../hooks/admin/useBannerMutations'; import { useModal } from '../../../../hooks/useModal'; @@ -34,19 +37,24 @@ export const useBannerRow = (banner: BannerItem) => { setChanges((prev) => { const newChanges = { ...prev, ...updates }; - // 실제 변경사항만 필터링 const actualChanges: BannerChanges = {}; Object.entries(newChanges).forEach(([key, newValue]) => { const field = key as keyof BannerChanges; - const originalValue = banner[field as keyof BannerItem]; if (field === 'newImageFile') { if (newValue && newValue instanceof File) { actualChanges[field] = newValue; } - } else if (newValue !== originalValue) { - (actualChanges as any)[field] = newValue; + } else { + const originalValue = banner[field as keyof BannerItem]; + if (newValue !== originalValue) { + if (field === 'visible' || field === 'always') { + actualChanges[field] = newValue as boolean; + } else if (field === 'startDate' || field === 'endDate') { + actualChanges[field] = newValue as string; + } + } } }); diff --git a/src/components/admin/banner/imageUploadArea/ImageUploadArea.tsx b/src/components/admin/banner/imageUploadArea/ImageUploadArea.tsx index 282b43d4..18fa159d 100644 --- a/src/components/admin/banner/imageUploadArea/ImageUploadArea.tsx +++ b/src/components/admin/banner/imageUploadArea/ImageUploadArea.tsx @@ -23,7 +23,7 @@ const ImageUploadArea = ({ onMouseLeave={onImageLeave} onClick={onImageClick} > - + {showOverlay && ( diff --git a/src/components/admin/banner/newBannerRow/NewBannerRow.tsx b/src/components/admin/banner/newBannerRow/NewBannerRow.tsx index 86f3a82a..76458a07 100644 --- a/src/components/admin/banner/newBannerRow/NewBannerRow.tsx +++ b/src/components/admin/banner/newBannerRow/NewBannerRow.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; import * as S from './NewBannerRow.styled'; import { useNewBannerRow } from './useNewBannerRow'; import ImageUploadArea from '../imageUploadArea/ImageUploadArea'; @@ -10,6 +10,18 @@ const NewBannerRow = () => { const { newBanner, canCreateBanner, handleInputChange, handleCreate } = useNewBannerRow(); + const imageUrl = newBanner.imageUrl + ? URL.createObjectURL(newBanner.imageUrl) + : ''; + + useEffect(() => { + return () => { + if (imageUrl) { + URL.revokeObjectURL(imageUrl); + } + }; + }, [imageUrl]); + const handleImageClick = useCallback(() => { const input = document.createElement('input'); input.type = 'file'; @@ -28,10 +40,6 @@ const NewBannerRow = () => { document.body.removeChild(input); }, [handleInputChange]); - const imageUrl = newBanner.imageUrl - ? URL.createObjectURL(newBanner.imageUrl) - : ''; - return ( diff --git a/src/components/admin/banner/newBannerRow/useNewBannerRow.ts b/src/components/admin/banner/newBannerRow/useNewBannerRow.ts index a2439dfa..e25c1752 100644 --- a/src/components/admin/banner/newBannerRow/useNewBannerRow.ts +++ b/src/components/admin/banner/newBannerRow/useNewBannerRow.ts @@ -1,5 +1,5 @@ import { useState, useCallback } from 'react'; -import { BannerFormData } from '../../../../models/admin/banner'; +import type { BannerFormData } from '../../../../models/admin/banner'; import { useBannerMutations } from '../../../../hooks/admin/useBannerMutations'; import { useModal } from '../../../../hooks/useModal'; diff --git a/src/hooks/admin/useBannerMutations.ts b/src/hooks/admin/useBannerMutations.ts index f3542c4f..96b3a6f6 100644 --- a/src/hooks/admin/useBannerMutations.ts +++ b/src/hooks/admin/useBannerMutations.ts @@ -39,6 +39,7 @@ export const useBannerMutations = ({ }); }, onError: () => { + handleModalOpen(ADMIN_MODAL_MESSAGE.writeError); console.error('배너 생성에 실패했습니다.'); }, }); @@ -55,6 +56,7 @@ export const useBannerMutations = ({ }); }, onError: () => { + handleModalOpen(ADMIN_MODAL_MESSAGE.writeError); console.error('배너 수정에 실패했습니다.'); }, }); @@ -68,6 +70,7 @@ export const useBannerMutations = ({ handleDeleteButtonState('success'); }, onError: () => { + handleModalOpen(ADMIN_MODAL_MESSAGE.writeError); handleDeleteButtonState('fail'); }, }); diff --git a/src/hooks/admin/useImageManagement.ts b/src/hooks/admin/useImageManagement.ts index 58abcdf4..c34bb3f8 100644 --- a/src/hooks/admin/useImageManagement.ts +++ b/src/hooks/admin/useImageManagement.ts @@ -16,6 +16,9 @@ export const useImageManagement = () => { onImageChange(base64, bannerId); } }; + reader.onerror = () => { + console.error('이미지 업로드에 실패했습니다.'); + }; reader.readAsDataURL(file); };