diff --git a/src/api/admin/banner.api.ts b/src/api/admin/banner.api.ts new file mode 100644 index 00000000..f7086258 --- /dev/null +++ b/src/api/admin/banner.api.ts @@ -0,0 +1,63 @@ +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 { + 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 { + if (!bannerId) { + throw new Error('bannerId is required'); + } + + 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.patch(`/banner/${bannerId}`, formData); + return response.data; + } catch (error) { + console.error(error); + throw error; + } +}; + +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) { + 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..aa8cc0d9 --- /dev/null +++ b/src/components/admin/banner/BannerList.styled.ts @@ -0,0 +1,21 @@ +import styled from 'styled-components'; +import Button from '../../common/Button/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 TableBody = styled.tbody``; + +export const ButtonContainer = styled.div` + display: flex; + justify-content: center; + margin-top: 20px; +`; + +export const BannerButton = styled(Button)``; diff --git a/src/components/admin/banner/BannerList.tsx b/src/components/admin/banner/BannerList.tsx new file mode 100644 index 00000000..142a8434 --- /dev/null +++ b/src/components/admin/banner/BannerList.tsx @@ -0,0 +1,102 @@ +import { useState, useCallback } from 'react'; +import * as S from './BannerList.styled'; +import ScrollPreventor from '../../common/modal/ScrollPreventor'; +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 handleDelete = useCallback( + async (id: number) => { + if (window.confirm('정말 삭제하시겠습니까?')) { + try { + await deleteBannerMutate.mutateAsync(id); + } catch (error) { + console.error('삭제 실패:', error); + } + } + }, + [deleteBannerMutate] + ); + + const toggleCreating = useCallback(() => { + setIsCreating((prev) => !prev); + }, []); + + if (isLoading) { + return ; + } + + return ( + <> + + + + {TABLE_COLUMNS.map((col, index) => ( + + ))} + + + + + + {allBannersData?.data.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..28684967 --- /dev/null +++ b/src/components/admin/banner/bannerRow/BannerRow.styled.ts @@ -0,0 +1,76 @@ +import styled from 'styled-components'; + +export const TableRow = styled.tr` + border-bottom: 1px solid #e9ecef; + + &:nth-child(even) { + background-color: ${({ theme }) => theme.color.lightgrey}; + } +`; + +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: ${({ theme }) => theme.buttonScheme.primary.bg}; + color: white; + font-size: 12px; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover:not(:disabled) { + opacity: 0.8; + } + + &:disabled { + background-color: ${({ theme }) => theme.color.deepGrey}; + cursor: not-allowed; + opacity: 0.8; + } +`; + +export const DeleteButton = styled.button` + padding: 6px 12px; + border: none; + border-radius: 4px; + background-color: ${({ theme }) => theme.color.red}; + color: white; + font-size: 12px; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + opacity: 0.8; + } +`; + +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..b4371736 --- /dev/null +++ b/src/components/admin/banner/bannerRow/BannerRow.tsx @@ -0,0 +1,124 @@ +import { useState, useCallback } from 'react'; +import * as S from './BannerRow.styled'; +import type { 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 { formatDate } from '../../../../util/formatDate'; + +interface BannerRowProps { + banner: BannerItem; + index: number; + onDelete: (id: number) => void; +} + +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 = useCallback(() => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.style.display = 'none'; + + input.onchange = (event) => { + const file = (event.target as HTMLInputElement).files?.[0]; + if (file) { + handleImageChange(file); + } + }; + + document.body.appendChild(input); + input.click(); + document.body.removeChild(input); + }, [handleImageChange]); + + // 날짜 표시 렌더링 + const renderDateDisplay = () => { + if (currentValues.always) { + return -; + } + + if (currentValues.startDate && currentValues.endDate) { + return ( + + {formatDate(currentValues.startDate)} ~{' '} + {formatDate(currentValues.endDate)} + + ); + } + + return ( + + handleDateChange(date, currentValues.endDate) + } + onEndDateChange={(date) => + handleDateChange(currentValues.startDate, date) + } + placeholder={{ start: '시작일', end: '종료일' }} + /> + ); + }; + + return ( + + {index + 1} + + + setHoveredImageId(banner.id)} + onImageLeave={() => setHoveredImageId(null)} + showOverlay={showImageOverlay} + /> + + + + + + + + + + + {renderDateDisplay()} + + + + 수정 + + onDelete(banner.id)}> + 삭제 + + + + ); +}; + +export default BannerRow; diff --git a/src/components/admin/banner/bannerRow/useBannerRow.ts b/src/components/admin/banner/bannerRow/useBannerRow.ts new file mode 100644 index 00000000..e3ff7ca4 --- /dev/null +++ b/src/components/admin/banner/bannerRow/useBannerRow.ts @@ -0,0 +1,163 @@ +import { useState, useCallback } from 'react'; +import type { + 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; + + if (field === 'newImageFile') { + if (newValue && newValue instanceof File) { + actualChanges[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; + } + } + } + }); + + 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/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/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..18fa159d --- /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..76458a07 --- /dev/null +++ b/src/components/admin/banner/newBannerRow/NewBannerRow.tsx @@ -0,0 +1,97 @@ +import { useCallback, useEffect } from 'react'; +import * as S from './NewBannerRow.styled'; +import { useNewBannerRow } from './useNewBannerRow'; +import ImageUploadArea from '../imageUploadArea/ImageUploadArea'; +import ToggleSwitch from '../toggleSwitch/ToggleSwitch'; +import RadioGroup from '../radioGroup/RadioGroup'; +import DateRange from '../dateRange/DateRange'; + +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'; + input.accept = 'image/*'; + input.style.display = 'none'; + + 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]); + + return ( + + + - + + + + {}} + onImageLeave={() => {}} + showOverlay={false} + /> + + + + handleInputChange('visible', checked)} + /> + + + + handleInputChange('always', always)} + /> + + + + {newBanner.always ? ( + - + ) : ( + handleInputChange('startDate', date)} + onEndDateChange={(date) => handleInputChange('endDate', date)} + /> + )} + + + + + 생성하기 + + + + ); +}; + +export default NewBannerRow; diff --git a/src/components/admin/banner/newBannerRow/useNewBannerRow.ts b/src/components/admin/banner/newBannerRow/useNewBannerRow.ts new file mode 100644 index 00000000..e25c1752 --- /dev/null +++ b/src/components/admin/banner/newBannerRow/useNewBannerRow.ts @@ -0,0 +1,74 @@ +import { useState, useCallback } from 'react'; +import type { 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/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/useBannerMutations.ts b/src/hooks/admin/useBannerMutations.ts new file mode 100644 index 00000000..96b3a6f6 --- /dev/null +++ b/src/hooks/admin/useBannerMutations.ts @@ -0,0 +1,83 @@ +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: () => { + handleModalOpen(ADMIN_MODAL_MESSAGE.writeError); + console.error('배너 생성에 실패했습니다.'); + }, + }); + + const patchBannerMutate = useMutation< + void, + AxiosError, + { formData: FormData; bannerId: number } + >({ + mutationFn: ({ formData, bannerId }) => patchBanner(formData, bannerId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [Banners.allBanners], + }); + }, + onError: () => { + handleModalOpen(ADMIN_MODAL_MESSAGE.writeError); + console.error('배너 수정에 실패했습니다.'); + }, + }); + + const deleteBannerMutate = useMutation({ + mutationFn: (bannerId: number) => deleteBanner(bannerId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [Banners.allBanners], + }); + handleDeleteButtonState('success'); + }, + onError: () => { + handleModalOpen(ADMIN_MODAL_MESSAGE.writeError); + 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..c6a9c9c8 --- /dev/null +++ b/src/hooks/admin/useGetAllBannerList.ts @@ -0,0 +1,16 @@ +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, + } = useQuery({ + queryKey: [Banners.allBanners], + queryFn: () => getBannerList(), + }); + + return { allBannersData, isLoading, isFetching }; +}; diff --git a/src/hooks/admin/useImageManagement.ts b/src/hooks/admin/useImageManagement.ts new file mode 100644 index 00000000..c34bb3f8 --- /dev/null +++ b/src/hooks/admin/useImageManagement.ts @@ -0,0 +1,61 @@ +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.onerror = () => { + console.error('이미지 업로드에 실패했습니다.'); + }; + 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/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; 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..f52a7569 --- /dev/null +++ b/src/models/admin/banner.ts @@ -0,0 +1,31 @@ +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 BannerChanges { + visible?: boolean; + always?: boolean; + startDate?: string; + endDate?: string; + newImageFile?: File; +} + +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 ( + + + + + ); }