From cbce382d9454b575d5c638b1305b9df111545aaa Mon Sep 17 00:00:00 2001 From: caadiq Date: Thu, 22 Jan 2026 23:28:57 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20Schedules.jsx,=20ScheduleForm.jsx?= =?UTF-8?q?=20=EB=8C=80=ED=98=95=20=ED=8C=8C=EC=9D=BC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 대형 파일 분리 작업: Schedules.jsx (1465줄 → 1159줄, 306줄 감소) - ScheduleItem.jsx 컴포넌트 추출 - 검색 모드와 일반 모드에서 공통 사용 ScheduleForm.jsx (1047줄 → 765줄, 282줄 감소) - LocationSearchDialog.jsx 추출 (장소 검색 모달) - MemberSelector.jsx 추출 (멤버 선택 UI) - ImageUploader.jsx 추출 (이미지 업로드) 새 컴포넌트 (components/pc/admin/schedule/): - ScheduleItem.jsx - LocationSearchDialog.jsx - MemberSelector.jsx - ImageUploader.jsx Co-Authored-By: Claude Opus 4.5 --- docs/frontend-improvement.md | 39 +- .../pc/admin/schedule/ImageUploader.jsx | 141 +++++++ .../admin/schedule/LocationSearchDialog.jsx | 178 ++++++++ .../pc/admin/schedule/MemberSelector.jsx | 82 ++++ .../pc/admin/schedule/ScheduleItem.jsx | 172 ++++++++ .../src/components/pc/admin/schedule/index.js | 4 + .../pages/pc/admin/schedules/ScheduleForm.jsx | 393 +++--------------- .../pages/pc/admin/schedules/Schedules.jsx | 336 +-------------- 8 files changed, 673 insertions(+), 672 deletions(-) create mode 100644 frontend-temp/src/components/pc/admin/schedule/ImageUploader.jsx create mode 100644 frontend-temp/src/components/pc/admin/schedule/LocationSearchDialog.jsx create mode 100644 frontend-temp/src/components/pc/admin/schedule/MemberSelector.jsx create mode 100644 frontend-temp/src/components/pc/admin/schedule/ScheduleItem.jsx diff --git a/docs/frontend-improvement.md b/docs/frontend-improvement.md index 710c7ea..ecbcd49 100644 --- a/docs/frontend-improvement.md +++ b/docs/frontend-improvement.md @@ -42,13 +42,13 @@ components/ - `Schedules.jsx`에 유틸 함수들이 로컬로 재정의됨 ### 1.4 대형 파일 -| 파일 | 라인 수 | 문제 | -|------|---------|------| -| AlbumPhotos.jsx | 1536 | 업로드, 관리, 일괄편집이 한 파일에 | -| Schedules.jsx | 1471 | 중복 유틸 함수, 컴포넌트 미분리 | -| ScheduleForm.jsx | 1046 | 폼 로직과 UI가 섞여있음 | -| ScheduleDict.jsx | 714 | 테이블과 모달이 한 파일에 | -| AlbumForm.jsx | 631 | 트랙/티저 관리가 인라인 | +| 파일 | 원래 라인 수 | 현재 라인 수 | 상태 | +|------|-------------|-------------|------| +| AlbumPhotos.jsx | 1536 | 1536 | 미분리 | +| Schedules.jsx | 1465 | 1159 | ✅ 분리 완료 | +| ScheduleForm.jsx | 1047 | 765 | ✅ 분리 완료 | +| ScheduleDict.jsx | 714 | 714 | 미분리 | +| AlbumForm.jsx | 631 | 631 | 미분리 | --- @@ -90,7 +90,13 @@ components/ │ └── admin/ │ ├── layout/ # Layout, Header │ ├── common/ # ConfirmDialog, DatePicker 등 -│ ├── schedule/ # CategorySelector 등 +│ ├── schedule/ # ✅ 추가된 컴포넌트들: +│ │ ├── AdminScheduleCard.jsx +│ │ ├── CategorySelector.jsx +│ │ ├── ScheduleItem.jsx # 일정 아이템 (리스트용) +│ │ ├── LocationSearchDialog.jsx # 장소 검색 모달 +│ │ ├── MemberSelector.jsx # 멤버 선택 UI +│ │ └── ImageUploader.jsx # 이미지 업로드 │ └── album/ # PhotoUploader 등 ``` @@ -162,12 +168,17 @@ pages/pc/admin/schedules/ - 페이지 stagger 애니메이션 - 통계 카드 AnimatedNumber -### Phase 2: 대형 파일 분리 -1. [ ] Schedules.jsx 분리 -2. [ ] ScheduleForm.jsx 분리 -3. [ ] AlbumPhotos.jsx 분리 -4. [ ] ScheduleDict.jsx 분리 -5. [ ] AlbumForm.jsx 분리 +### Phase 2: 대형 파일 분리 (진행 중) +1. [x] Schedules.jsx 분리 (1465줄 → 1159줄, 306줄 감소) + - `ScheduleItem.jsx` 컴포넌트 추출 + - 검색 모드와 일반 모드에서 공통 사용 +2. [x] ScheduleForm.jsx 분리 (1047줄 → 765줄, 282줄 감소) + - `LocationSearchDialog.jsx` 추출 (장소 검색 모달) + - `MemberSelector.jsx` 추출 (멤버 선택 UI) + - `ImageUploader.jsx` 추출 (이미지 업로드 및 드래그앤드롭) +3. [ ] AlbumPhotos.jsx 분리 (1536줄, 구조 복잡) +4. [ ] ScheduleDict.jsx 분리 (714줄) +5. [ ] AlbumForm.jsx 분리 (631줄) ### Phase 3: 추가 개선 1. [x] 관리자 페이지용 에러 페이지 추가 (404) diff --git a/frontend-temp/src/components/pc/admin/schedule/ImageUploader.jsx b/frontend-temp/src/components/pc/admin/schedule/ImageUploader.jsx new file mode 100644 index 0000000..43ffd13 --- /dev/null +++ b/frontend-temp/src/components/pc/admin/schedule/ImageUploader.jsx @@ -0,0 +1,141 @@ +/** + * 이미지 업로드 컴포넌트 + * - 다중 이미지 업로드 및 드래그 앤 드롭 정렬 + */ +import { useState, memo } from 'react'; +import { Image, Plus, X } from 'lucide-react'; + +/** + * @param {Object} props + * @param {Array} props.previews - 이미지 미리보기 URL 배열 + * @param {Function} props.onUpload - 파일 업로드 핸들러 (files) + * @param {Function} props.onDelete - 이미지 삭제 핸들러 (index) + * @param {Function} props.onReorder - 이미지 순서 변경 핸들러 (fromIndex, toIndex) + * @param {Function} props.onOpenLightbox - 라이트박스 열기 핸들러 (index) + */ +const ImageUploader = memo(function ImageUploader({ + previews, + onUpload, + onDelete, + onReorder, + onOpenLightbox, +}) { + const [draggedIndex, setDraggedIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + + // 파일 선택 + const handleFileChange = (e) => { + const files = Array.from(e.target.files); + if (files.length > 0) { + onUpload(files); + } + // input 초기화 (같은 파일 다시 선택 가능하도록) + e.target.value = ''; + }; + + // 드래그 시작 + const handleDragStart = (e, index) => { + setDraggedIndex(index); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', index); + }; + + // 드래그 오버 + const handleDragOver = (e, index) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (dragOverIndex !== index) { + setDragOverIndex(index); + } + }; + + // 드래그 종료 + const handleDragEnd = () => { + setDraggedIndex(null); + setDragOverIndex(null); + }; + + // 드롭 - 이미지 순서 변경 + const handleDrop = (e, dropIndex) => { + e.preventDefault(); + if (draggedIndex === null || draggedIndex === dropIndex) { + handleDragEnd(); + return; + } + onReorder(draggedIndex, dropIndex); + handleDragEnd(); + }; + + return ( +
+
+ +

일정 이미지

+ 여러 장 업로드 가능 +
+ + {/* 이미지 그리드 */} +
+ {/* 이미지 추가 버튼 - 항상 첫번째 */} + + + {/* 이미지 목록 - 드래그 앤 드롭 가능 */} + {previews.map((preview, index) => ( +
handleDragStart(e, index)} + onDragOver={(e) => handleDragOver(e, index)} + onDragEnd={handleDragEnd} + onDrop={(e) => handleDrop(e, index)} + > + {/* 이미지 클릭시 라이트박스 열기 */} + {`업로드 onOpenLightbox(index)} + draggable={false} + /> + {/* 호버시 어두운 오버레이 */} +
+ {/* 순서 표시 */} +
+ {index + 1} +
+ {/* 삭제 버튼 */} + +
+ ))} +
+
+ ); +}); + +export default ImageUploader; diff --git a/frontend-temp/src/components/pc/admin/schedule/LocationSearchDialog.jsx b/frontend-temp/src/components/pc/admin/schedule/LocationSearchDialog.jsx new file mode 100644 index 0000000..0016fe6 --- /dev/null +++ b/frontend-temp/src/components/pc/admin/schedule/LocationSearchDialog.jsx @@ -0,0 +1,178 @@ +/** + * 장소 검색 다이얼로그 컴포넌트 + * - 카카오 장소 검색 API를 사용 + */ +import { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { X, Search, MapPin } from 'lucide-react'; + +/** + * @param {Object} props + * @param {boolean} props.isOpen - 다이얼로그 열림 여부 + * @param {Function} props.onClose - 닫기 핸들러 + * @param {Function} props.onSelect - 장소 선택 핸들러 (place 객체 전달) + */ +function LocationSearchDialog({ isOpen, onClose, onSelect }) { + const [searchQuery, setSearchQuery] = useState(''); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + + // 다이얼로그 닫기 시 상태 초기화 + const handleClose = () => { + setSearchQuery(''); + setResults([]); + onClose(); + }; + + // 카카오 장소 검색 API 호출 + const handleSearch = async () => { + if (!searchQuery.trim()) { + setResults([]); + return; + } + + setSearching(true); + try { + const token = localStorage.getItem('adminToken'); + const response = await fetch(`/api/admin/kakao/places?query=${encodeURIComponent(searchQuery)}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + setResults(data.documents || []); + } + } catch (error) { + console.error('장소 검색 오류:', error); + } finally { + setSearching(false); + } + }; + + // 장소 선택 + const handleSelectPlace = (place) => { + onSelect({ + name: place.place_name, + address: place.road_address_name || place.address_name, + lat: parseFloat(place.y), + lng: parseFloat(place.x), + }); + handleClose(); + }; + + return ( + + {isOpen && ( + + e.stopPropagation()} + > +
+

장소 검색

+ +
+ + {/* 검색 입력 */} +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSearch(); + } + }} + placeholder="장소명을 입력하세요" + className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + autoFocus + /> +
+ +
+ + {/* 검색 결과 */} +
+ {results.length > 0 ? ( +
+ {results.map((place, index) => ( + + ))} +
+ ) : searchQuery && !searching ? ( +
+ +

검색어를 입력하고 검색 버튼을 눌러주세요

+
+ ) : ( +
+ +

장소명을 입력하고 검색해주세요

+
+ )} +
+
+
+ )} +
+ ); +} + +export default LocationSearchDialog; diff --git a/frontend-temp/src/components/pc/admin/schedule/MemberSelector.jsx b/frontend-temp/src/components/pc/admin/schedule/MemberSelector.jsx new file mode 100644 index 0000000..49a1b73 --- /dev/null +++ b/frontend-temp/src/components/pc/admin/schedule/MemberSelector.jsx @@ -0,0 +1,82 @@ +/** + * 멤버 선택 컴포넌트 + * - 일정 폼에서 참여 멤버 선택용 + */ +import { memo } from 'react'; +import { Users, Check } from 'lucide-react'; + +/** + * @param {Object} props + * @param {Array} props.members - 전체 멤버 목록 + * @param {Array} props.selectedIds - 선택된 멤버 ID 배열 + * @param {Function} props.onToggle - 멤버 토글 핸들러 + * @param {Function} props.onToggleAll - 전체 선택/해제 핸들러 + */ +const MemberSelector = memo(function MemberSelector({ + members, + selectedIds, + onToggle, + onToggleAll, +}) { + const isAllSelected = selectedIds.length === members.length; + + return ( +
+
+
+ +

참여 멤버

+
+ +
+ +
+ {members.map((member) => { + const isSelected = selectedIds.includes(member.id); + return ( + + ); + })} +
+
+ ); +}); + +export default MemberSelector; diff --git a/frontend-temp/src/components/pc/admin/schedule/ScheduleItem.jsx b/frontend-temp/src/components/pc/admin/schedule/ScheduleItem.jsx new file mode 100644 index 0000000..0161105 --- /dev/null +++ b/frontend-temp/src/components/pc/admin/schedule/ScheduleItem.jsx @@ -0,0 +1,172 @@ +/** + * 일정 아이템 컴포넌트 + * - 일정 목록에서 사용되는 개별 아이템 + * - 일반 모드와 검색 모드에서 공통 사용 + */ +import { memo } from 'react'; +import { motion } from 'framer-motion'; +import { Edit2, Trash2, ExternalLink, Clock, Tag, Link2 } from 'lucide-react'; +import { decodeHtmlEntities } from '@/utils'; +import { + getMemberList, + getScheduleDate, + getScheduleTime, + getCategoryInfo, +} from '@/utils/schedule'; + +/** + * 카테고리별 수정 경로 반환 + */ +export const getEditPath = (scheduleId, categoryName) => { + switch (categoryName) { + case '유튜브': + return `/admin/schedule/${scheduleId}/edit/youtube`; + case 'X': + return `/admin/schedule/${scheduleId}/edit/x`; + default: + return `/admin/schedule/${scheduleId}/edit`; + } +}; + +/** + * 일정 아이템 컴포넌트 - React.memo로 불필요한 리렌더링 방지 + * @param {Object} props + * @param {Object} props.schedule - 일정 데이터 + * @param {number} props.index - 목록 인덱스 (애니메이션 지연용) + * @param {string} props.selectedDate - 선택된 날짜 + * @param {Function} props.getColorStyle - 색상 스타일 함수 + * @param {Function} props.navigate - 네비게이션 함수 + * @param {Function} props.openDeleteDialog - 삭제 다이얼로그 열기 함수 + * @param {boolean} props.showYear - 연도 표시 여부 (검색 모드용) + * @param {boolean} props.animated - 애니메이션 적용 여부 (기본: true) + * @param {string} props.className - 추가 클래스명 + */ +const ScheduleItem = memo(function ScheduleItem({ + schedule, + index = 0, + selectedDate, + getColorStyle, + navigate, + openDeleteDialog, + showYear = false, + animated = true, + className = '', +}) { + const scheduleDate = new Date(getScheduleDate(schedule)); + const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-'); + const categoryInfo = getCategoryInfo(schedule); + const categoryColor = + getColorStyle(categoryInfo.color)?.style?.backgroundColor || categoryInfo.color || '#6b7280'; + const memberList = getMemberList(schedule); + const timeStr = getScheduleTime(schedule); + + const content = ( +
+
+ {showYear && ( +
+ {scheduleDate.getFullYear()}.{scheduleDate.getMonth() + 1} +
+ )} +
{scheduleDate.getDate()}
+
+ {['일', '월', '화', '수', '목', '금', '토'][scheduleDate.getDay()]}요일 +
+
+ +
+ +
+

{decodeHtmlEntities(schedule.title)}

+
+ {timeStr && ( + + + {timeStr} + + )} + + + {categoryInfo.name} + + {schedule.source?.name && ( + + + {schedule.source?.name} + + )} +
+ {memberList.length > 0 && ( +
+ {memberList.length >= 5 ? ( + + 프로미스나인 + + ) : ( + memberList.map((name, i) => ( + + {name.trim()} + + )) + )} +
+ )} +
+ + {/* 생일 일정은 수정/삭제 불가 */} + {!isBirthday && ( +
+ {schedule.source?.url && ( + e.stopPropagation()} + className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500" + > + + + )} + + +
+ )} +
+ ); + + const baseClassName = `${showYear ? 'p-5' : 'p-6'} hover:bg-gray-50 transition-colors group ${className}`; + + if (animated) { + return ( + + {content} + + ); + } + + return
{content}
; +}); + +export default ScheduleItem; diff --git a/frontend-temp/src/components/pc/admin/schedule/index.js b/frontend-temp/src/components/pc/admin/schedule/index.js index e36d19c..e7c9d91 100644 --- a/frontend-temp/src/components/pc/admin/schedule/index.js +++ b/frontend-temp/src/components/pc/admin/schedule/index.js @@ -1,2 +1,6 @@ export { default as AdminScheduleCard } from './AdminScheduleCard'; export { default as CategorySelector } from './CategorySelector'; +export { default as ScheduleItem, getEditPath } from './ScheduleItem'; +export { default as LocationSearchDialog } from './LocationSearchDialog'; +export { default as MemberSelector } from './MemberSelector'; +export { default as ImageUploader } from './ImageUploader'; diff --git a/frontend-temp/src/pages/pc/admin/schedules/ScheduleForm.jsx b/frontend-temp/src/pages/pc/admin/schedules/ScheduleForm.jsx index 220d651..4f47463 100644 --- a/frontend-temp/src/pages/pc/admin/schedules/ScheduleForm.jsx +++ b/frontend-temp/src/pages/pc/admin/schedules/ScheduleForm.jsx @@ -1,7 +1,6 @@ import { useState, useEffect } from 'react'; import { useNavigate, Link, useParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; -import { motion, AnimatePresence } from 'framer-motion'; import { formatDate } from '@/utils/date'; import { Home, @@ -9,16 +8,17 @@ import { Save, X, Link as LinkIcon, - Users, - Check, - Plus, MapPin, Settings, Search, - Image, } from 'lucide-react'; import { Toast, Lightbox } from '@/components/common'; import { AdminLayout, ConfirmDialog, DatePicker, TimePicker } from '@/components/pc/admin'; +import { + LocationSearchDialog, + MemberSelector, + ImageUploader, +} from '@/components/pc/admin/schedule'; import { useAdminAuth } from '@/hooks/pc/admin'; import { useToast } from '@/hooks/common'; import * as categoriesApi from '@/api/admin/categories'; @@ -87,11 +87,8 @@ function ScheduleForm() { // 저장 중 상태 const [saving, setSaving] = useState(false); - // 장소 검색 관련 상태 + // 장소 검색 다이얼로그 상태 const [locationDialogOpen, setLocationDialogOpen] = useState(false); - const [locationSearch, setLocationSearch] = useState(''); - const [locationResults, setLocationResults] = useState([]); - const [locationSearching, setLocationSearching] = useState(false); // 수정 모드용 기존 이미지 ID 추적 const [existingImageIds, setExistingImageIds] = useState([]); @@ -215,28 +212,6 @@ function ScheduleForm() { } }; - // 다중 이미지 업로드 - const handleImagesUpload = (e) => { - const files = Array.from(e.target.files); - // 파일을 {file: File} 형태로 저장 (제출 시 image.file로 접근하기 위함) - const newImageObjects = files.map((file) => ({ file })); - const newImages = [...formData.images, ...newImageObjects]; - setFormData({ ...formData, images: newImages }); - - files.forEach((file) => { - const reader = new FileReader(); - reader.onloadend = () => { - setImagePreviews((prev) => [...prev, reader.result]); - }; - reader.readAsDataURL(file); - }); - }; - - // 이미지 삭제 다이얼로그 열기 - const openDeleteDialog = (index) => { - setDeleteTargetIndex(index); - setDeleteDialogOpen(true); - }; // 이미지 삭제 확인 const confirmDeleteImage = () => { @@ -262,96 +237,51 @@ function ScheduleForm() { setLightboxOpen(true); }; - // 드래그 앤 드롭 상태 - const [draggedIndex, setDraggedIndex] = useState(null); - const [dragOverIndex, setDragOverIndex] = useState(null); - - // 드래그 시작 - const handleDragStart = (e, index) => { - setDraggedIndex(index); - e.dataTransfer.effectAllowed = 'move'; - // 드래그 이미지 설정 - e.dataTransfer.setData('text/plain', index); + // 장소 선택 핸들러 (LocationSearchDialog에서 호출) + const handleLocationSelect = (place) => { + setFormData({ + ...formData, + locationName: place.name, + locationAddress: place.address, + locationLat: place.lat, + locationLng: place.lng, + }); }; - // 드래그 오버 - const handleDragOver = (e, index) => { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - if (dragOverIndex !== index) { - setDragOverIndex(index); - } + // 이미지 업로드 핸들러 (ImageUploader에서 호출) + const handleImagesUploadFromUploader = (files) => { + const newImageObjects = files.map((file) => ({ file })); + const newImages = [...formData.images, ...newImageObjects]; + setFormData({ ...formData, images: newImages }); + + files.forEach((file) => { + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreviews((prev) => [...prev, reader.result]); + }; + reader.readAsDataURL(file); + }); }; - // 드래그 종료 - const handleDragEnd = () => { - setDraggedIndex(null); - setDragOverIndex(null); + // 이미지 삭제 핸들러 (ImageUploader에서 호출) + const handleImageDelete = (index) => { + setDeleteTargetIndex(index); + setDeleteDialogOpen(true); }; - // 드롭 - 이미지 순서 변경 - const handleDrop = (e, dropIndex) => { - e.preventDefault(); - if (draggedIndex === null || draggedIndex === dropIndex) { - handleDragEnd(); - return; - } - - // 새 배열 생성 + // 이미지 순서 변경 핸들러 (ImageUploader에서 호출) + const handleImageReorder = (fromIndex, toIndex) => { const newPreviews = [...imagePreviews]; const newImages = [...formData.images]; - // 드래그된 아이템 제거 후 새 위치에 삽입 - const [movedPreview] = newPreviews.splice(draggedIndex, 1); - const [movedImage] = newImages.splice(draggedIndex, 1); + const [movedPreview] = newPreviews.splice(fromIndex, 1); + const [movedImage] = newImages.splice(fromIndex, 1); - newPreviews.splice(dropIndex, 0, movedPreview); - newImages.splice(dropIndex, 0, movedImage); + newPreviews.splice(toIndex, 0, movedPreview); + newImages.splice(toIndex, 0, movedImage); setImagePreviews(newPreviews); setFormData({ ...formData, images: newImages }); - handleDragEnd(); - }; - - // 카카오 장소 검색 API 호출 (엔터 키로 검색) - const handleLocationSearch = async () => { - if (!locationSearch.trim()) { - setLocationResults([]); - return; - } - - setLocationSearching(true); - try { - const token = localStorage.getItem('adminToken'); - const response = await fetch(`/api/admin/kakao/places?query=${encodeURIComponent(locationSearch)}`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (response.ok) { - const data = await response.json(); - setLocationResults(data.documents || []); - } - } catch (error) { - console.error('장소 검색 오류:', error); - } finally { - setLocationSearching(false); - } - }; - - // 장소 선택 - const selectLocation = (place) => { - setFormData({ - ...formData, - locationName: place.place_name, - locationAddress: place.road_address_name || place.address_name, - locationLat: parseFloat(place.y), - locationLng: parseFloat(place.x), - }); - setLocationDialogOpen(false); - setLocationSearch(''); - setLocationResults([]); }; // 폼 제출 @@ -475,123 +405,11 @@ function ScheduleForm() { /> {/* 장소 검색 다이얼로그 */} - - {locationDialogOpen && ( - { - setLocationDialogOpen(false); - setLocationSearch(''); - setLocationResults([]); - }} - > - e.stopPropagation()} - > -
-

장소 검색

- -
- - {/* 검색 입력 */} -
-
- - setLocationSearch(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleLocationSearch(); - } - }} - placeholder="장소명을 입력하세요" - className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" - autoFocus - /> -
- -
- - {/* 검색 결과 */} -
- {locationResults.length > 0 ? ( -
- {locationResults.map((place, index) => ( - - ))} -
- ) : locationSearch && !locationSearching ? ( -
- -

검색어를 입력하고 검색 버튼을 눌러주세요

-
- ) : ( -
- -

장소명을 입력하고 검색해주세요

-
- )} -
-
-
- )} -
+ setLocationDialogOpen(false)} + onSelect={handleLocationSelect} + /> {/* 이미지 라이트박스 - 공통 컴포넌트 사용 */} {/* 멤버 선택 카드 */} -
-
-
- -

참여 멤버

-
- -
- -
- {members.map((member) => ( - - ))} -
-
+ {/* 다중 이미지 업로드 카드 */} -
-
- -

일정 이미지

- 여러 장 업로드 가능 -
- - {/* 이미지 그리드 */} -
- {/* 이미지 추가 버튼 - 항상 첫번째 */} - - - {/* 이미지 목록 - 드래그 앤 드롭 가능 */} - {imagePreviews.map((preview, index) => ( -
handleDragStart(e, index)} - onDragOver={(e) => handleDragOver(e, index)} - onDragEnd={handleDragEnd} - onDrop={(e) => handleDrop(e, index)} - > - {/* 이미지 클릭시 라이트박스 열기 */} - {`업로드 openLightbox(index)} - draggable={false} - /> - {/* 호버시 어두운 오버레이 */} -
- {/* 순서 표시 */} -
- {index + 1} -
- {/* 삭제 버튼 */} - -
- ))} -
-
+ {/* 버튼 */}
diff --git a/frontend-temp/src/pages/pc/admin/schedules/Schedules.jsx b/frontend-temp/src/pages/pc/admin/schedules/Schedules.jsx index e82ac36..8eb1f17 100644 --- a/frontend-temp/src/pages/pc/admin/schedules/Schedules.jsx +++ b/frontend-temp/src/pages/pc/admin/schedules/Schedules.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo, memo, useDeferredValue } from 'react'; +import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { @@ -6,242 +6,28 @@ import { ChevronRight, Calendar, Plus, - Edit2, - Trash2, ChevronLeft, Search, ChevronDown, Bot, Tag, ArrowLeft, - ExternalLink, - Clock, - Link2, Book, } from 'lucide-react'; import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useInView } from 'react-intersection-observer'; -import { Toast, Tooltip } from '@/components/common'; +import { Toast } from '@/components/common'; import { AdminLayout, ConfirmDialog } from '@/components/pc/admin'; +import { ScheduleItem, getEditPath } from '@/components/pc/admin/schedule'; import useScheduleStore from '@/stores/useScheduleStore'; import { useAdminAuth } from '@/hooks/pc/admin'; import { useToast } from '@/hooks/common'; -import { getTodayKST, formatDate } from '@/utils/date'; +import { getTodayKST, formatDate } from '@/utils'; +import { getCategoryId, getScheduleDate } from '@/utils/schedule'; import * as schedulesApi from '@/api/admin/schedules'; -// HTML 엔티티 디코딩 함수 -const decodeHtmlEntities = (text) => { - if (!text) return ''; - const textarea = document.createElement('textarea'); - textarea.innerHTML = text; - return textarea.value; -}; - -// 멤버 리스트 추출 (검색 결과와 일반 데이터 모두 처리) -const getMemberList = (schedule) => { - // member_names 문자열이 있으면 사용 - if (schedule.member_names) { - return schedule.member_names - .split(',') - .map((n) => n.trim()) - .filter(Boolean); - } - // members 배열이 있으면 - if (Array.isArray(schedule.members) && schedule.members.length > 0) { - // 문자열 배열인 경우 (검색 결과) - if (typeof schedule.members[0] === 'string') { - return schedule.members.filter(Boolean); - } - // 객체 배열인 경우 (일반 데이터) - return schedule.members.map((m) => m.name).filter(Boolean); - } - return []; -}; - -// 일정 날짜 추출 (검색 결과와 일반 데이터 모두 처리) -const getScheduleDate = (schedule) => { - // datetime이 있으면 (검색 결과) - if (schedule.datetime) { - return new Date(schedule.datetime); - } - // date가 있으면 (일반 데이터) - if (schedule.date) { - return new Date(schedule.date); - } - return new Date(); -}; - -// 일정 시간 추출 (검색 결과와 일반 데이터 모두 처리) -const getScheduleTime = (schedule) => { - // time이 있으면 (일반 데이터) - if (schedule.time) { - return schedule.time.slice(0, 5); - } - // datetime에서 시간 추출 (검색 결과) - if (schedule.datetime && schedule.datetime.includes('T')) { - const timePart = schedule.datetime.split('T')[1]; - if (timePart) { - return timePart.slice(0, 5); - } - } - return null; -}; - -// 카테고리 ID 추출 (검색 결과와 일반 데이터 모두 처리) -const getCategoryId = (schedule) => { - // category_id가 있으면 (일반 데이터) - if (schedule.category_id !== undefined) { - return schedule.category_id; - } - // category.id가 있으면 (검색 결과) - if (schedule.category?.id !== undefined) { - return schedule.category.id; - } - return null; -}; - -// 카테고리 정보 추출 (검색 결과와 일반 데이터 모두 처리) -const getCategoryInfo = (schedule, categories) => { - const catId = getCategoryId(schedule); - // 검색 결과에 category 객체가 있으면 직접 사용 - if (schedule.category?.name && schedule.category?.color) { - return { - id: schedule.category.id, - name: schedule.category.name, - color: schedule.category.color, - }; - } - // categories 배열에서 찾기 - const found = categories.find((c) => c.id === catId); - return found || { id: catId, name: '미분류', color: '#6b7280' }; -}; - -// 카테고리별 수정 경로 반환 -const getEditPath = (scheduleId, categoryName) => { - switch (categoryName) { - case '유튜브': - return `/admin/schedule/${scheduleId}/edit/youtube`; - case 'X': - return `/admin/schedule/${scheduleId}/edit/x`; - default: - return `/admin/schedule/${scheduleId}/edit`; - } -}; - -// 일정 아이템 컴포넌트 - React.memo로 불필요한 리렌더링 방지 -const ScheduleItem = memo(function ScheduleItem({ - schedule, - index, - selectedDate, - categories, - getColorStyle, - navigate, - openDeleteDialog, -}) { - const scheduleDate = getScheduleDate(schedule); - const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-'); - const categoryInfo = getCategoryInfo(schedule, categories); - const categoryColor = - getColorStyle(categoryInfo.color)?.style?.backgroundColor || categoryInfo.color || '#6b7280'; - const memberList = getMemberList(schedule); - const timeStr = getScheduleTime(schedule); - - return ( - -
-
-
{scheduleDate.getDate()}
-
- {['일', '월', '화', '수', '목', '금', '토'][scheduleDate.getDay()]}요일 -
-
- -
- -
-

{decodeHtmlEntities(schedule.title)}

-
- {timeStr && ( - - - {timeStr} - - )} - - - {categoryInfo.name} - - {schedule.source?.name && ( - - - {schedule.source?.name} - - )} -
- {memberList.length > 0 && ( -
- {memberList.length >= 5 ? ( - - 프로미스나인 - - ) : ( - memberList.map((name, i) => ( - - {name.trim()} - - )) - )} -
- )} -
- - {/* 생일 일정은 수정/삭제 불가 */} - {!isBirthday && ( -
- {schedule.source?.url && ( - e.stopPropagation()} - className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500" - > - - - )} - - -
- )} -
- - ); -}); - function Schedules() { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -1291,7 +1077,7 @@ function Schedules() { className="flex-1 overflow-y-auto divide-y divide-gray-100 py-2" > {isSearchMode && searchTerm ? ( - /* 검색 모드: 가상 스크롤 */ + /* 검색 모드: 가상 스크롤 + ScheduleItem 재사용 */ <>
- {(() => { - const scheduleDate = getScheduleDate(schedule); - const categoryInfo = getCategoryInfo(schedule, categories); - const categoryColor = - getColorStyle(categoryInfo.color)?.style?.backgroundColor || - categoryInfo.color || - '#6b7280'; - const timeStr = getScheduleTime(schedule); - const catId = getCategoryId(schedule); - const memberList = getMemberList(schedule); - - return ( -
-
-
-
- {scheduleDate.getFullYear()}.{scheduleDate.getMonth() + 1} -
-
- {scheduleDate.getDate()} -
-
- { - ['일', '월', '화', '수', '목', '금', '토'][ - scheduleDate.getDay() - ] - } - 요일 -
-
- -
- -
-

- {decodeHtmlEntities(schedule.title)} -

-
- {timeStr && ( - - - {timeStr} - - )} - - - {categoryInfo.name} - - {schedule.source?.name && ( - - - {schedule.source?.name} - - )} -
- {memberList.length > 0 && ( -
- {memberList.map((name, i) => ( - - {name} - - ))} -
- )} -
- -
- {schedule.source?.url && ( - e.stopPropagation()} - className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500" - > - - - )} - - -
-
-
- ); - })()} +
); })}