refactor: Schedules.jsx, ScheduleForm.jsx 대형 파일 분리

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 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-22 23:28:57 +09:00
parent cfd14e01e5
commit cbce382d94
8 changed files with 673 additions and 672 deletions

View file

@ -42,13 +42,13 @@ components/
- `Schedules.jsx`에 유틸 함수들이 로컬로 재정의됨 - `Schedules.jsx`에 유틸 함수들이 로컬로 재정의됨
### 1.4 대형 파일 ### 1.4 대형 파일
| 파일 | 라인 수 | 문제 | | 파일 | 원래 라인 수 | 현재 라인 수 | 상태 |
|------|---------|------| |------|-------------|-------------|------|
| AlbumPhotos.jsx | 1536 | 업로드, 관리, 일괄편집이 한 파일에 | | AlbumPhotos.jsx | 1536 | 1536 | 미분리 |
| Schedules.jsx | 1471 | 중복 유틸 함수, 컴포넌트 미분리 | | Schedules.jsx | 1465 | 1159 | ✅ 분리 완료 |
| ScheduleForm.jsx | 1046 | 폼 로직과 UI가 섞여있음 | | ScheduleForm.jsx | 1047 | 765 | ✅ 분리 완료 |
| ScheduleDict.jsx | 714 | 테이블과 모달이 한 파일에 | | ScheduleDict.jsx | 714 | 714 | 미분리 |
| AlbumForm.jsx | 631 | 트랙/티저 관리가 인라인 | | AlbumForm.jsx | 631 | 631 | 미분리 |
--- ---
@ -90,7 +90,13 @@ components/
│ └── admin/ │ └── admin/
│ ├── layout/ # Layout, Header │ ├── layout/ # Layout, Header
│ ├── common/ # ConfirmDialog, DatePicker 등 │ ├── common/ # ConfirmDialog, DatePicker 등
│ ├── schedule/ # CategorySelector 등 │ ├── schedule/ # ✅ 추가된 컴포넌트들:
│ │ ├── AdminScheduleCard.jsx
│ │ ├── CategorySelector.jsx
│ │ ├── ScheduleItem.jsx # 일정 아이템 (리스트용)
│ │ ├── LocationSearchDialog.jsx # 장소 검색 모달
│ │ ├── MemberSelector.jsx # 멤버 선택 UI
│ │ └── ImageUploader.jsx # 이미지 업로드
│ └── album/ # PhotoUploader 등 │ └── album/ # PhotoUploader 등
``` ```
@ -162,12 +168,17 @@ pages/pc/admin/schedules/
- 페이지 stagger 애니메이션 - 페이지 stagger 애니메이션
- 통계 카드 AnimatedNumber - 통계 카드 AnimatedNumber
### Phase 2: 대형 파일 분리 ### Phase 2: 대형 파일 분리 (진행 중)
1. [ ] Schedules.jsx 분리 1. [x] Schedules.jsx 분리 (1465줄 → 1159줄, 306줄 감소)
2. [ ] ScheduleForm.jsx 분리 - `ScheduleItem.jsx` 컴포넌트 추출
3. [ ] AlbumPhotos.jsx 분리 - 검색 모드와 일반 모드에서 공통 사용
4. [ ] ScheduleDict.jsx 분리 2. [x] ScheduleForm.jsx 분리 (1047줄 → 765줄, 282줄 감소)
5. [ ] AlbumForm.jsx 분리 - `LocationSearchDialog.jsx` 추출 (장소 검색 모달)
- `MemberSelector.jsx` 추출 (멤버 선택 UI)
- `ImageUploader.jsx` 추출 (이미지 업로드 및 드래그앤드롭)
3. [ ] AlbumPhotos.jsx 분리 (1536줄, 구조 복잡)
4. [ ] ScheduleDict.jsx 분리 (714줄)
5. [ ] AlbumForm.jsx 분리 (631줄)
### Phase 3: 추가 개선 ### Phase 3: 추가 개선
1. [x] 관리자 페이지용 에러 페이지 추가 (404) 1. [x] 관리자 페이지용 에러 페이지 추가 (404)

View file

@ -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 (
<div className="bg-white rounded-2xl shadow-sm p-8">
<div className="flex items-center gap-2 mb-6">
<Image size={20} className="text-primary" />
<h2 className="text-lg font-bold text-gray-900">일정 이미지</h2>
<span className="text-sm text-gray-400 ml-2">여러 업로드 가능</span>
</div>
{/* 이미지 그리드 */}
<div className="grid grid-cols-4 gap-4">
{/* 이미지 추가 버튼 - 항상 첫번째 */}
<label className="aspect-square border-2 border-dashed border-gray-200 rounded-xl cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-colors flex flex-col items-center justify-center">
<input
type="file"
accept="image/*"
multiple
onChange={handleFileChange}
className="hidden"
/>
<Plus size={24} className="text-gray-400 mb-2" />
<p className="text-sm text-gray-500">이미지 추가</p>
</label>
{/* 이미지 목록 - 드래그 앤 드롭 가능 */}
{previews.map((preview, index) => (
<div
key={index}
className={`relative aspect-square rounded-xl overflow-hidden group cursor-grab active:cursor-grabbing transition-all duration-200 ${
draggedIndex === index ? 'opacity-50 scale-95' : ''
} ${
dragOverIndex === index && draggedIndex !== index
? 'ring-2 ring-primary ring-offset-2'
: ''
}`}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, index)}
>
{/* 이미지 클릭시 라이트박스 열기 */}
<img
src={preview}
alt={`업로드 ${index + 1}`}
className="w-full h-full object-cover"
onClick={() => onOpenLightbox(index)}
draggable={false}
/>
{/* 호버시 어두운 오버레이 */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors pointer-events-none" />
{/* 순서 표시 */}
<div className="absolute bottom-2 left-2 px-2 py-0.5 bg-black/50 rounded-full text-white text-xs font-medium">
{index + 1}
</div>
{/* 삭제 버튼 */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete(index);
}}
className="absolute top-2 right-2 w-7 h-7 bg-black/50 hover:bg-red-500 rounded-full flex items-center justify-center transition-all shadow-lg"
>
<X size={14} className="text-white" />
</button>
</div>
))}
</div>
</div>
);
});
export default ImageUploader;

View file

@ -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 (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={handleClose}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-white rounded-2xl p-6 max-w-lg w-full mx-4 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-gray-900">장소 검색</h3>
<button
type="button"
onClick={handleClose}
className="text-gray-400 hover:text-gray-600"
>
<X size={20} />
</button>
</div>
{/* 검색 입력 */}
<div className="flex gap-2 mb-4">
<div className="flex-1 relative">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => 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
/>
</div>
<button
type="button"
onClick={handleSearch}
disabled={searching}
className="px-4 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors disabled:opacity-50"
>
{searching ? (
<motion.div
animate={{ rotate: 360 }}
transition={{
duration: 1,
repeat: Infinity,
ease: 'linear',
}}
>
<Search size={18} />
</motion.div>
) : (
'검색'
)}
</button>
</div>
{/* 검색 결과 */}
<div className="max-h-80 overflow-y-auto pr-2">
{results.length > 0 ? (
<div className="space-y-2">
{results.map((place, index) => (
<button
key={index}
type="button"
onClick={() => handleSelectPlace(place)}
className="w-full p-3 text-left hover:bg-gray-50 rounded-xl flex items-start gap-3 border border-gray-100"
>
<MapPin size={18} className="text-gray-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900">{place.place_name}</p>
<p className="text-sm text-gray-500 truncate">
{place.road_address_name || place.address_name}
</p>
{place.category_name && (
<p className="text-xs text-gray-400 mt-1">{place.category_name}</p>
)}
</div>
</button>
))}
</div>
) : searchQuery && !searching ? (
<div className="text-center py-8 text-gray-500">
<MapPin size={32} className="mx-auto mb-2 text-gray-300" />
<p>검색어를 입력하고 검색 버튼을 눌러주세요</p>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<MapPin size={32} className="mx-auto mb-2 text-gray-300" />
<p>장소명을 입력하고 검색해주세요</p>
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
export default LocationSearchDialog;

View file

@ -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 (
<div className="bg-white rounded-2xl shadow-sm p-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Users size={20} className="text-primary" />
<h2 className="text-lg font-bold text-gray-900">참여 멤버</h2>
</div>
<button
type="button"
onClick={onToggleAll}
className="text-sm text-primary hover:underline"
>
{isAllSelected ? '전체 해제' : '전체 선택'}
</button>
</div>
<div className="grid grid-cols-5 gap-4">
{members.map((member) => {
const isSelected = selectedIds.includes(member.id);
return (
<button
key={member.id}
type="button"
onClick={() => onToggle(member.id)}
className={`relative rounded-xl overflow-hidden transition-all ${
isSelected
? 'ring-2 ring-primary ring-offset-2'
: 'hover:opacity-80'
}`}
>
<div className="aspect-[3/4] bg-gray-100">
{member.image_url ? (
<img
src={member.image_url}
alt={member.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-200">
<Users size={24} className="text-gray-400" />
</div>
)}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-3">
<p className="text-white text-sm font-medium">{member.name}</p>
</div>
{isSelected && (
<div className="absolute top-2 right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center">
<Check size={14} className="text-white" />
</div>
)}
</button>
);
})}
</div>
</div>
);
});
export default MemberSelector;

View file

@ -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 = (
<div className="flex items-start gap-4">
<div className="w-20 text-center flex-shrink-0">
{showYear && (
<div className="text-xs text-gray-400 mb-0.5">
{scheduleDate.getFullYear()}.{scheduleDate.getMonth() + 1}
</div>
)}
<div className="text-2xl font-bold text-gray-900">{scheduleDate.getDate()}</div>
<div className="text-sm text-gray-500">
{['일', '월', '화', '수', '목', '금', '토'][scheduleDate.getDay()]}요일
</div>
</div>
<div
className="w-1.5 rounded-full flex-shrink-0 self-stretch"
style={{ backgroundColor: categoryColor }}
/>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900">{decodeHtmlEntities(schedule.title)}</h3>
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
{timeStr && (
<span className="flex items-center gap-1">
<Clock size={14} />
{timeStr}
</span>
)}
<span className="flex items-center gap-1">
<Tag size={14} />
{categoryInfo.name}
</span>
{schedule.source?.name && (
<span className="flex items-center gap-1">
<Link2 size={14} />
{schedule.source?.name}
</span>
)}
</div>
{memberList.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{memberList.length >= 5 ? (
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
프로미스나인
</span>
) : (
memberList.map((name, i) => (
<span
key={i}
className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full"
>
{name.trim()}
</span>
))
)}
</div>
)}
</div>
{/* 생일 일정은 수정/삭제 불가 */}
{!isBirthday && (
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{schedule.source?.url && (
<a
href={schedule.source?.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500"
>
<ExternalLink size={18} />
</a>
)}
<button
onClick={() => navigate(getEditPath(schedule.id, categoryInfo.name))}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
>
<Edit2 size={18} />
</button>
<button
onClick={() => openDeleteDialog(schedule)}
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
>
<Trash2 size={18} />
</button>
</div>
)}
</div>
);
const baseClassName = `${showYear ? 'p-5' : 'p-6'} hover:bg-gray-50 transition-colors group ${className}`;
if (animated) {
return (
<motion.div
key={`${schedule.id}-${selectedDate || 'all'}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: Math.min(index, 10) * 0.03 }}
className={baseClassName}
>
{content}
</motion.div>
);
}
return <div className={baseClassName}>{content}</div>;
});
export default ScheduleItem;

View file

@ -1,2 +1,6 @@
export { default as AdminScheduleCard } from './AdminScheduleCard'; export { default as AdminScheduleCard } from './AdminScheduleCard';
export { default as CategorySelector } from './CategorySelector'; 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';

View file

@ -1,7 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate, Link, useParams } from 'react-router-dom'; import { useNavigate, Link, useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import { formatDate } from '@/utils/date'; import { formatDate } from '@/utils/date';
import { import {
Home, Home,
@ -9,16 +8,17 @@ import {
Save, Save,
X, X,
Link as LinkIcon, Link as LinkIcon,
Users,
Check,
Plus,
MapPin, MapPin,
Settings, Settings,
Search, Search,
Image,
} from 'lucide-react'; } from 'lucide-react';
import { Toast, Lightbox } from '@/components/common'; import { Toast, Lightbox } from '@/components/common';
import { AdminLayout, ConfirmDialog, DatePicker, TimePicker } from '@/components/pc/admin'; 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 { useAdminAuth } from '@/hooks/pc/admin';
import { useToast } from '@/hooks/common'; import { useToast } from '@/hooks/common';
import * as categoriesApi from '@/api/admin/categories'; import * as categoriesApi from '@/api/admin/categories';
@ -87,11 +87,8 @@ function ScheduleForm() {
// //
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// //
const [locationDialogOpen, setLocationDialogOpen] = useState(false); const [locationDialogOpen, setLocationDialogOpen] = useState(false);
const [locationSearch, setLocationSearch] = useState('');
const [locationResults, setLocationResults] = useState([]);
const [locationSearching, setLocationSearching] = useState(false);
// ID // ID
const [existingImageIds, setExistingImageIds] = useState([]); 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 = () => { const confirmDeleteImage = () => {
@ -262,96 +237,51 @@ function ScheduleForm() {
setLightboxOpen(true); setLightboxOpen(true);
}; };
// // (LocationSearchDialog )
const [draggedIndex, setDraggedIndex] = useState(null); const handleLocationSelect = (place) => {
const [dragOverIndex, setDragOverIndex] = useState(null); setFormData({
...formData,
// locationName: place.name,
const handleDragStart = (e, index) => { locationAddress: place.address,
setDraggedIndex(index); locationLat: place.lat,
e.dataTransfer.effectAllowed = 'move'; locationLng: place.lng,
// });
e.dataTransfer.setData('text/plain', index);
}; };
// // (ImageUploader )
const handleDragOver = (e, index) => { const handleImagesUploadFromUploader = (files) => {
e.preventDefault(); const newImageObjects = files.map((file) => ({ file }));
e.dataTransfer.dropEffect = 'move'; const newImages = [...formData.images, ...newImageObjects];
if (dragOverIndex !== index) { setFormData({ ...formData, images: newImages });
setDragOverIndex(index);
} files.forEach((file) => {
const reader = new FileReader();
reader.onloadend = () => {
setImagePreviews((prev) => [...prev, reader.result]);
};
reader.readAsDataURL(file);
});
}; };
// // (ImageUploader )
const handleDragEnd = () => { const handleImageDelete = (index) => {
setDraggedIndex(null); setDeleteTargetIndex(index);
setDragOverIndex(null); setDeleteDialogOpen(true);
}; };
// - // (ImageUploader )
const handleDrop = (e, dropIndex) => { const handleImageReorder = (fromIndex, toIndex) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === dropIndex) {
handleDragEnd();
return;
}
//
const newPreviews = [...imagePreviews]; const newPreviews = [...imagePreviews];
const newImages = [...formData.images]; const newImages = [...formData.images];
// const [movedPreview] = newPreviews.splice(fromIndex, 1);
const [movedPreview] = newPreviews.splice(draggedIndex, 1); const [movedImage] = newImages.splice(fromIndex, 1);
const [movedImage] = newImages.splice(draggedIndex, 1);
newPreviews.splice(dropIndex, 0, movedPreview); newPreviews.splice(toIndex, 0, movedPreview);
newImages.splice(dropIndex, 0, movedImage); newImages.splice(toIndex, 0, movedImage);
setImagePreviews(newPreviews); setImagePreviews(newPreviews);
setFormData({ ...formData, images: newImages }); 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() {
/> />
{/* 장소 검색 다이얼로그 */} {/* 장소 검색 다이얼로그 */}
<AnimatePresence> <LocationSearchDialog
{locationDialogOpen && ( isOpen={locationDialogOpen}
<motion.div onClose={() => setLocationDialogOpen(false)}
initial={{ opacity: 0 }} onSelect={handleLocationSelect}
animate={{ opacity: 1 }} />
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => {
setLocationDialogOpen(false);
setLocationSearch('');
setLocationResults([]);
}}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-white rounded-2xl p-6 max-w-lg w-full mx-4 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-gray-900">장소 검색</h3>
<button
type="button"
onClick={() => {
setLocationDialogOpen(false);
setLocationSearch('');
setLocationResults([]);
}}
className="text-gray-400 hover:text-gray-600"
>
<X size={20} />
</button>
</div>
{/* 검색 입력 */}
<div className="flex gap-2 mb-4">
<div className="flex-1 relative">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={locationSearch}
onChange={(e) => 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
/>
</div>
<button
type="button"
onClick={handleLocationSearch}
disabled={locationSearching}
className="px-4 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors disabled:opacity-50"
>
{locationSearching ? (
<motion.div
animate={{ rotate: 360 }}
transition={{
duration: 1,
repeat: Infinity,
ease: 'linear',
}}
>
<Search size={18} />
</motion.div>
) : (
'검색'
)}
</button>
</div>
{/* 검색 결과 */}
<div className="max-h-80 overflow-y-auto pr-2">
{locationResults.length > 0 ? (
<div className="space-y-2">
{locationResults.map((place, index) => (
<button
key={index}
type="button"
onClick={() => selectLocation(place)}
className="w-full p-3 text-left hover:bg-gray-50 rounded-xl flex items-start gap-3 border border-gray-100"
>
<MapPin size={18} className="text-gray-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900">{place.place_name}</p>
<p className="text-sm text-gray-500 truncate">
{place.road_address_name || place.address_name}
</p>
{place.category_name && (
<p className="text-xs text-gray-400 mt-1">{place.category_name}</p>
)}
</div>
</button>
))}
</div>
) : locationSearch && !locationSearching ? (
<div className="text-center py-8 text-gray-500">
<MapPin size={32} className="mx-auto mb-2 text-gray-300" />
<p>검색어를 입력하고 검색 버튼을 눌러주세요</p>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<MapPin size={32} className="mx-auto mb-2 text-gray-300" />
<p>장소명을 입력하고 검색해주세요</p>
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* 이미지 라이트박스 - 공통 컴포넌트 사용 */} {/* 이미지 라이트박스 - 공통 컴포넌트 사용 */}
<Lightbox <Lightbox
@ -895,120 +713,21 @@ function ScheduleForm() {
</div> </div>
{/* 멤버 선택 카드 */} {/* 멤버 선택 카드 */}
<div className="bg-white rounded-2xl shadow-sm p-8"> <MemberSelector
<div className="flex items-center justify-between mb-6"> members={members}
<div className="flex items-center gap-2"> selectedIds={formData.members}
<Users size={20} className="text-primary" /> onToggle={toggleMember}
<h2 className="text-lg font-bold text-gray-900">참여 멤버</h2> onToggleAll={toggleAllMembers}
</div> />
<button type="button" onClick={toggleAllMembers} className="text-sm text-primary hover:underline">
{formData.members.length === members.length ? '전체 해제' : '전체 선택'}
</button>
</div>
<div className="grid grid-cols-5 gap-4">
{members.map((member) => (
<button
key={member.id}
type="button"
onClick={() => toggleMember(member.id)}
className={`relative rounded-xl overflow-hidden transition-all ${
formData.members.includes(member.id)
? 'ring-2 ring-primary ring-offset-2'
: 'hover:opacity-80'
}`}
>
<div className="aspect-[3/4] bg-gray-100">
{member.image_url ? (
<img src={member.image_url} alt={member.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-200">
<Users size={24} className="text-gray-400" />
</div>
)}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-3">
<p className="text-white text-sm font-medium">{member.name}</p>
</div>
{formData.members.includes(member.id) && (
<div className="absolute top-2 right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center">
<Check size={14} className="text-white" />
</div>
)}
</button>
))}
</div>
</div>
{/* 다중 이미지 업로드 카드 */} {/* 다중 이미지 업로드 카드 */}
<div className="bg-white rounded-2xl shadow-sm p-8"> <ImageUploader
<div className="flex items-center gap-2 mb-6"> previews={imagePreviews}
<Image size={20} className="text-primary" /> onUpload={handleImagesUploadFromUploader}
<h2 className="text-lg font-bold text-gray-900">일정 이미지</h2> onDelete={handleImageDelete}
<span className="text-sm text-gray-400 ml-2">여러 업로드 가능</span> onReorder={handleImageReorder}
</div> onOpenLightbox={openLightbox}
/>
{/* 이미지 그리드 */}
<div className="grid grid-cols-4 gap-4">
{/* 이미지 추가 버튼 - 항상 첫번째 */}
<label className="aspect-square border-2 border-dashed border-gray-200 rounded-xl cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-colors flex flex-col items-center justify-center">
<input
type="file"
accept="image/*"
multiple
onChange={handleImagesUpload}
className="hidden"
/>
<Plus size={24} className="text-gray-400 mb-2" />
<p className="text-sm text-gray-500">이미지 추가</p>
</label>
{/* 이미지 목록 - 드래그 앤 드롭 가능 */}
{imagePreviews.map((preview, index) => (
<div
key={index}
className={`relative aspect-square rounded-xl overflow-hidden group cursor-grab active:cursor-grabbing transition-all duration-200 ${
draggedIndex === index ? 'opacity-50 scale-95' : ''
} ${
dragOverIndex === index && draggedIndex !== index
? 'ring-2 ring-primary ring-offset-2'
: ''
}`}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, index)}
>
{/* 이미지 클릭시 라이트박스 열기 */}
<img
src={preview}
alt={`업로드 ${index + 1}`}
className="w-full h-full object-cover"
onClick={() => openLightbox(index)}
draggable={false}
/>
{/* 호버시 어두운 오버레이 */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors pointer-events-none" />
{/* 순서 표시 */}
<div className="absolute bottom-2 left-2 px-2 py-0.5 bg-black/50 rounded-full text-white text-xs font-medium">
{index + 1}
</div>
{/* 삭제 버튼 */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openDeleteDialog(index);
}}
className="absolute top-2 right-2 w-7 h-7 bg-black/50 hover:bg-red-500 rounded-full flex items-center justify-center transition-all shadow-lg"
>
<X size={14} className="text-white" />
</button>
</div>
))}
</div>
</div>
{/* 버튼 */} {/* 버튼 */}
<div className="flex items-center justify-end gap-4"> <div className="flex items-center justify-end gap-4">

View file

@ -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 { useNavigate, Link } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
@ -6,242 +6,28 @@ import {
ChevronRight, ChevronRight,
Calendar, Calendar,
Plus, Plus,
Edit2,
Trash2,
ChevronLeft, ChevronLeft,
Search, Search,
ChevronDown, ChevronDown,
Bot, Bot,
Tag, Tag,
ArrowLeft, ArrowLeft,
ExternalLink,
Clock,
Link2,
Book, Book,
} from 'lucide-react'; } from 'lucide-react';
import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { useInView } from 'react-intersection-observer'; 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 { AdminLayout, ConfirmDialog } from '@/components/pc/admin';
import { ScheduleItem, getEditPath } from '@/components/pc/admin/schedule';
import useScheduleStore from '@/stores/useScheduleStore'; import useScheduleStore from '@/stores/useScheduleStore';
import { useAdminAuth } from '@/hooks/pc/admin'; import { useAdminAuth } from '@/hooks/pc/admin';
import { useToast } from '@/hooks/common'; 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'; 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 (
<motion.div
key={`${schedule.id}-${selectedDate || 'all'}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: Math.min(index, 10) * 0.03 }}
className="p-6 hover:bg-gray-50 transition-colors group"
>
<div className="flex items-start gap-4">
<div className="w-20 text-center flex-shrink-0">
<div className="text-2xl font-bold text-gray-900">{scheduleDate.getDate()}</div>
<div className="text-sm text-gray-500">
{['일', '월', '화', '수', '목', '금', '토'][scheduleDate.getDay()]}요일
</div>
</div>
<div
className="w-1.5 rounded-full flex-shrink-0 self-stretch"
style={{ backgroundColor: categoryColor }}
/>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900">{decodeHtmlEntities(schedule.title)}</h3>
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
{timeStr && (
<span className="flex items-center gap-1">
<Clock size={14} />
{timeStr}
</span>
)}
<span className="flex items-center gap-1">
<Tag size={14} />
{categoryInfo.name}
</span>
{schedule.source?.name && (
<span className="flex items-center gap-1">
<Link2 size={14} />
{schedule.source?.name}
</span>
)}
</div>
{memberList.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{memberList.length >= 5 ? (
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
프로미스나인
</span>
) : (
memberList.map((name, i) => (
<span
key={i}
className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full"
>
{name.trim()}
</span>
))
)}
</div>
)}
</div>
{/* 생일 일정은 수정/삭제 불가 */}
{!isBirthday && (
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{schedule.source?.url && (
<a
href={schedule.source?.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500"
>
<ExternalLink size={18} />
</a>
)}
<button
onClick={() => navigate(getEditPath(schedule.id, categoryInfo.name))}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
>
<Edit2 size={18} />
</button>
<button
onClick={() => openDeleteDialog(schedule)}
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
>
<Trash2 size={18} />
</button>
</div>
)}
</div>
</motion.div>
);
});
function Schedules() { function Schedules() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -1291,7 +1077,7 @@ function Schedules() {
className="flex-1 overflow-y-auto divide-y divide-gray-100 py-2" className="flex-1 overflow-y-auto divide-y divide-gray-100 py-2"
> >
{isSearchMode && searchTerm ? ( {isSearchMode && searchTerm ? (
/* 검색 모드: 가상 스크롤 */ /* 검색 모드: 가상 스크롤 + ScheduleItem 재사용 */
<> <>
<div <div
style={{ style={{
@ -1317,107 +1103,15 @@ function Schedules() {
transform: `translateY(${virtualItem.start}px)`, transform: `translateY(${virtualItem.start}px)`,
}} }}
> >
{(() => { <ScheduleItem
const scheduleDate = getScheduleDate(schedule); schedule={schedule}
const categoryInfo = getCategoryInfo(schedule, categories); getColorStyle={getColorStyle}
const categoryColor = navigate={navigate}
getColorStyle(categoryInfo.color)?.style?.backgroundColor || openDeleteDialog={openDeleteDialog}
categoryInfo.color || showYear
'#6b7280'; animated={false}
const timeStr = getScheduleTime(schedule); className="border-b border-gray-100"
const catId = getCategoryId(schedule); />
const memberList = getMemberList(schedule);
return (
<div className="p-5 hover:bg-gray-50 transition-colors group border-b border-gray-100">
<div className="flex items-start gap-4">
<div className="w-20 text-center flex-shrink-0">
<div className="text-xs text-gray-400 mb-0.5">
{scheduleDate.getFullYear()}.{scheduleDate.getMonth() + 1}
</div>
<div className="text-2xl font-bold text-gray-900">
{scheduleDate.getDate()}
</div>
<div className="text-sm text-gray-500">
{
['일', '월', '화', '수', '목', '금', '토'][
scheduleDate.getDay()
]
}
요일
</div>
</div>
<div
className="w-1.5 rounded-full flex-shrink-0 self-stretch"
style={{ backgroundColor: categoryColor }}
/>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900">
{decodeHtmlEntities(schedule.title)}
</h3>
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
{timeStr && (
<span className="flex items-center gap-1">
<Clock size={14} />
{timeStr}
</span>
)}
<span className="flex items-center gap-1">
<Tag size={14} />
{categoryInfo.name}
</span>
{schedule.source?.name && (
<span className="flex items-center gap-1">
<Link2 size={14} />
{schedule.source?.name}
</span>
)}
</div>
{memberList.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{memberList.map((name, i) => (
<span
key={i}
className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full"
>
{name}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{schedule.source?.url && (
<a
href={schedule.source?.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500"
>
<ExternalLink size={18} />
</a>
)}
<button
onClick={() => navigate(getEditPath(schedule.id, categoryInfo.name))}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
>
<Edit2 size={18} />
</button>
<button
onClick={() => openDeleteDialog(schedule)}
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
>
<Trash2 size={18} />
</button>
</div>
</div>
</div>
);
})()}
</div> </div>
); );
})} })}