- {monthNames.map((m, i) => (
-
@@ -240,7 +292,9 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW
{['일', '월', '화', '수', '목', '금', '토'].map((d, i) => (
{d}
@@ -254,7 +308,7 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW
key={i}
type="button"
disabled={!day}
- onClick={() => day && selectDate(day)}
+ onClick={(e) => day && handleButtonClick(e, () => selectDate(day))}
className={`aspect-square rounded-full text-sm font-medium flex items-center justify-center transition-all
${!day ? '' : 'hover:bg-gray-100'}
${isSelected(day) ? 'bg-primary text-white hover:bg-primary' : ''}
diff --git a/frontend-temp/src/components/pc/admin/Layout.jsx b/frontend-temp/src/components/pc/admin/Layout.jsx
index 99aebba..8a3fe64 100644
--- a/frontend-temp/src/components/pc/admin/Layout.jsx
+++ b/frontend-temp/src/components/pc/admin/Layout.jsx
@@ -10,7 +10,7 @@ function AdminLayout({ user, children }) {
const location = useLocation();
// 일정 관리 페이지는 내부 스크롤 처리
- const isSchedulePage = location.pathname.includes('/admin/schedules');
+ const isSchedulePage = location.pathname.includes('/admin/schedule');
return (
diff --git a/frontend-temp/src/pages/pc/admin/albums/AlbumForm.jsx b/frontend-temp/src/pages/pc/admin/albums/AlbumForm.jsx
new file mode 100644
index 0000000..0ef066d
--- /dev/null
+++ b/frontend-temp/src/pages/pc/admin/albums/AlbumForm.jsx
@@ -0,0 +1,631 @@
+/**
+ * 관리자 앨범 추가/수정 페이지
+ */
+import { useState, useRef, useEffect } from 'react';
+import { useNavigate, useParams, Link } from 'react-router-dom';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Save, Home, ChevronRight, Music, Trash2, Plus, Image, Star, ChevronDown } from 'lucide-react';
+import { Toast } from '@/components/common';
+import { AdminLayout, DatePicker } from '@/components/pc/admin';
+import { useAdminAuth } from '@/hooks/pc/admin';
+import { useToast } from '@/hooks/common';
+import { adminAlbumApi } from '@/api/pc/admin';
+import { fetchFormData } from '@/api/common/client';
+
+// 커스텀 드롭다운 컴포넌트
+function CustomSelect({ value, onChange, options, placeholder }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = (e) => {
+ if (ref.current && !ref.current.contains(e.target)) {
+ setIsOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ return (
+
+
+
+
+ {isOpen && (
+
+ {options.map((option) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+function AdminAlbumForm() {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { id } = useParams();
+ const isEditMode = !!id;
+ const coverInputRef = useRef(null);
+ const { user, isAuthenticated } = useAdminAuth();
+
+ const [saving, setSaving] = useState(false);
+ const [coverPreview, setCoverPreview] = useState(null);
+ const [coverFile, setCoverFile] = useState(null);
+ const { toast, setToast } = useToast();
+
+ const [formData, setFormData] = useState({
+ title: '',
+ album_type: '',
+ album_type_short: '',
+ release_date: '',
+ cover_original_url: '',
+ cover_medium_url: '',
+ cover_thumb_url: '',
+ folder_name: '',
+ description: '',
+ });
+
+ const [tracks, setTracks] = useState([]);
+
+ // 수정 모드일 때 앨범 데이터 로드
+ const {
+ data: albumData,
+ isLoading: loading,
+ error: albumError,
+ } = useQuery({
+ queryKey: ['admin', 'album', id],
+ queryFn: () => adminAlbumApi.getAlbum(id),
+ enabled: isAuthenticated && isEditMode && !!id,
+ staleTime: 0,
+ });
+
+ // 앨범 데이터 로드 시 폼에 반영
+ useEffect(() => {
+ if (albumData) {
+ setFormData({
+ title: albumData.title || '',
+ album_type: albumData.album_type || '',
+ album_type_short: albumData.album_type_short || '',
+ release_date: albumData.release_date ? albumData.release_date.split('T')[0] : '',
+ cover_original_url: albumData.cover_original_url || '',
+ cover_medium_url: albumData.cover_medium_url || '',
+ cover_thumb_url: albumData.cover_thumb_url || '',
+ folder_name: albumData.folder_name || '',
+ description: albumData.description || '',
+ });
+ if (albumData.cover_medium_url || albumData.cover_original_url) {
+ setCoverPreview(albumData.cover_medium_url || albumData.cover_original_url);
+ }
+ setTracks(albumData.tracks || []);
+ }
+ }, [albumData]);
+
+ // 에러 처리
+ useEffect(() => {
+ if (albumError) {
+ console.error('앨범 로드 오류:', albumError);
+ setToast({ message: '앨범 로드 중 오류가 발생했습니다.', type: 'error' });
+ }
+ }, [albumError, setToast]);
+
+ const handleInputChange = (e) => {
+ const { name, value } = e.target;
+
+ // 앨범명 변경 시 RustFS 폴더명 자동 생성
+ if (name === 'title') {
+ const folderName = value
+ .toLowerCase()
+ .replace(/[\s.]+/g, '-')
+ .replace(/[^a-z0-9가-힣-]/g, '')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+ setFormData((prev) => ({ ...prev, title: value, folder_name: folderName }));
+ } else {
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ }
+ };
+
+ const handleCoverChange = (e) => {
+ const file = e.target.files[0];
+ if (file) {
+ setCoverFile(file);
+ const reader = new FileReader();
+ reader.onloadend = () => {
+ setCoverPreview(reader.result);
+ };
+ reader.readAsDataURL(file);
+ }
+ };
+
+ const addTrack = () => {
+ setTracks((prev) => [
+ ...prev,
+ {
+ track_number: prev.length + 1,
+ title: '',
+ is_title_track: false,
+ duration: '',
+ },
+ ]);
+ };
+
+ const removeTrack = (index) => {
+ setTracks((prev) =>
+ prev
+ .filter((_, i) => i !== index)
+ .map((track, i) => ({
+ ...track,
+ track_number: i + 1,
+ }))
+ );
+ };
+
+ const updateTrack = (index, field, value) => {
+ // 작사/작곡/편곡 필드에서 '|' (전각 세로 막대)를 ', '로 자동 변환
+ let processedValue = value;
+ if (['lyricist', 'composer', 'arranger'].includes(field)) {
+ processedValue = value.replace(/[||]/g, ', ');
+ }
+
+ setTracks((prev) =>
+ prev.map((track, i) => (i === index ? { ...track, [field]: processedValue } : track))
+ );
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ // 커스텀 검증
+ if (!formData.title.trim()) {
+ setToast({ message: '앨범명을 입력해주세요.', type: 'warning' });
+ return;
+ }
+ if (!formData.folder_name.trim()) {
+ setToast({ message: 'RustFS 폴더명을 입력해주세요.', type: 'warning' });
+ return;
+ }
+ if (!formData.album_type_short) {
+ setToast({ message: '앨범 타입을 선택해주세요.', type: 'warning' });
+ return;
+ }
+ if (!formData.release_date) {
+ setToast({ message: '발매일을 선택해주세요.', type: 'warning' });
+ return;
+ }
+ if (!formData.album_type.trim()) {
+ setToast({ message: '앨범 유형을 입력해주세요.', type: 'warning' });
+ return;
+ }
+
+ setSaving(true);
+
+ try {
+ const form = new FormData();
+ form.append('data', JSON.stringify({ ...formData, tracks }));
+ if (coverFile) {
+ form.append('cover', coverFile);
+ }
+
+ const url = isEditMode ? `/albums/${id}` : '/albums';
+ const method = isEditMode ? 'PUT' : 'POST';
+
+ await fetchFormData(url, form, method);
+
+ // 앨범 목록 캐시 무효화
+ queryClient.invalidateQueries({ queryKey: ['admin', 'albums'] });
+ navigate('/admin/albums');
+ } catch (error) {
+ console.error('저장 오류:', error);
+ setToast({ message: '저장 중 오류가 발생했습니다.', type: 'error' });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const albumTypes = ['정규', '미니', '싱글'];
+
+ return (
+
+ setToast(null)} />
+
+
+ {/* 브레드크럼 */}
+
+
+
+
+
+
+ 앨범 관리
+
+
+ {isEditMode ? '앨범 수정' : '새 앨범 추가'}
+
+
+ {/* 타이틀 */}
+
+
+ {isEditMode ? '앨범 수정' : '새 앨범 추가'}
+
+
앨범 정보와 트랙을 입력하세요
+
+
+ {loading ? (
+
+ ) : (
+
+ {/* 앨범 기본 정보 */}
+
+
앨범 정보
+
+
+ {/* 커버 이미지 */}
+
+
+
+
coverInputRef.current?.click()}
+ className="w-40 h-40 rounded-xl border-2 border-dashed border-gray-200 flex items-center justify-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors overflow-hidden"
+ >
+ {coverPreview ? (
+

+ ) : (
+
+ )}
+
+
+
+
권장 크기: 1000x1000px
+
지원 형식: JPG, PNG, WebP
+ {coverPreview && (
+
+ )}
+
+
+
+
+ {/* 앨범명 */}
+
+
+
+
+
+ {/* 폴더명 */}
+
+
+
+ fromis-9/album/
+
+
+
영문 소문자, 숫자, 하이픈만 사용
+
+
+ {/* 앨범 타입 */}
+
+
+ setFormData((prev) => ({ ...prev, album_type_short: val }))}
+ options={albumTypes}
+ placeholder="타입 선택"
+ />
+
+
+ {/* 앨범 유형 */}
+
+
+
+
+
+ {/* 발매일 */}
+
+
+ setFormData((prev) => ({ ...prev, release_date: val }))}
+ minYear={2017}
+ />
+
+
+ {/* 설명 */}
+
+
+
+
+
+
+
+ {/* 트랙 목록 */}
+
+
+
+ {tracks.length === 0 ? (
+
+ ) : (
+
+ {tracks.map((track, index) => (
+
+
+
+
+ {String(track.track_number).padStart(2, '0')}
+
+
+
+
+
+
+
+
+ {/* 상세 정보 토글 */}
+
+
+
+
+
+ {track.showDetails && (
+
+ {/* 작사/작곡/편곡 */}
+
+
+ {/* MV URL */}
+
+
+ updateTrack(index, 'music_video_url', e.target.value)}
+ className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
+ placeholder="https://youtube.com/watch?v=..."
+ />
+
+
+ {/* 가사 */}
+
+
+
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+ {/* 버튼 */}
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+export default AdminAlbumForm;
diff --git a/frontend-temp/src/pages/pc/admin/albums/AlbumPhotos.jsx b/frontend-temp/src/pages/pc/admin/albums/AlbumPhotos.jsx
new file mode 100644
index 0000000..23c0634
--- /dev/null
+++ b/frontend-temp/src/pages/pc/admin/albums/AlbumPhotos.jsx
@@ -0,0 +1,1536 @@
+/**
+ * 관리자 앨범 사진 관리 페이지
+ */
+import { useState, useEffect, useRef } from 'react';
+import { useNavigate, Link, useParams } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { motion, AnimatePresence, Reorder } from 'framer-motion';
+import {
+ Upload,
+ Trash2,
+ Image,
+ X,
+ Check,
+ Plus,
+ Home,
+ ChevronRight,
+ GripVertical,
+ Users,
+ User,
+ Users2,
+ Tag,
+ FolderOpen,
+ Save,
+} from 'lucide-react';
+import { Toast } from '@/components/common';
+import { AdminLayout, ConfirmDialog } from '@/components/pc/admin';
+import { useAdminAuth } from '@/hooks/pc/admin';
+import { useToast } from '@/hooks/common';
+import { adminAlbumApi, adminMemberApi } from '@/api/pc/admin';
+
+function AdminAlbumPhotos() {
+ const { albumId } = useParams();
+ const navigate = useNavigate();
+ const fileInputRef = useRef(null);
+ const photoListRef = useRef(null);
+
+ const { user, isAuthenticated } = useAdminAuth();
+ const { toast, setToast } = useToast();
+ const [selectedPhotos, setSelectedPhotos] = useState([]);
+ const [uploading, setUploading] = useState(false);
+ const [uploadProgress, setUploadProgress] = useState(0);
+ const [deleteDialog, setDeleteDialog] = useState({ show: false, photos: [] });
+ const [deleting, setDeleting] = useState(false);
+ const [previewPhoto, setPreviewPhoto] = useState(null);
+ const [dragOver, setDragOver] = useState(false);
+
+ // 업로드 대기 중인 파일들
+ const [pendingFiles, setPendingFiles] = useState([]);
+ const [photoType, setPhotoType] = useState('concept');
+ const [conceptName, setConceptName] = useState('');
+ const [startNumber, setStartNumber] = useState(1);
+ const [saving, setSaving] = useState(false);
+ const [processingStatus, setProcessingStatus] = useState('');
+ const [processingProgress, setProcessingProgress] = useState({ current: 0, total: 0 });
+ const [pendingDeleteId, setPendingDeleteId] = useState(null);
+ const [uploadConfirmDialog, setUploadConfirmDialog] = useState(false);
+ const [activeTab, setActiveTab] = useState('upload');
+ const [manageSubTab, setManageSubTab] = useState('concept');
+
+ // 일괄 편집 도구 상태
+ const [bulkEdit, setBulkEdit] = useState({
+ range: '',
+ groupType: '',
+ members: [],
+ conceptName: '',
+ });
+
+ // 범위 문자열 파싱
+ const parseRange = (rangeStr, baseNumber = 1) => {
+ if (!rangeStr.trim()) return [];
+ const indices = new Set();
+ const parts = rangeStr.split(',').map((s) => s.trim());
+
+ for (const part of parts) {
+ if (part.includes('-')) {
+ const [start, end] = part.split('-').map((n) => parseInt(n.trim()));
+ if (!isNaN(start) && !isNaN(end)) {
+ for (let i = Math.min(start, end); i <= Math.max(start, end); i++) {
+ const idx = i - baseNumber;
+ if (idx >= 0) indices.add(idx);
+ }
+ }
+ } else {
+ const num = parseInt(part);
+ if (!isNaN(num)) {
+ const idx = num - baseNumber;
+ if (idx >= 0) indices.add(idx);
+ }
+ }
+ }
+ return Array.from(indices).sort((a, b) => a - b);
+ };
+
+ // 일괄 편집 적용
+ const applyBulkEdit = () => {
+ const indices = parseRange(bulkEdit.range, startNumber);
+ if (indices.length === 0) {
+ setToast({ message: '적용할 번호 범위를 입력하세요.', type: 'warning' });
+ return;
+ }
+
+ const validIndices = indices.filter((i) => i < pendingFiles.length);
+ if (validIndices.length === 0) {
+ setToast({ message: '유효한 번호가 없습니다.', type: 'error' });
+ return;
+ }
+
+ setPendingFiles((prev) =>
+ prev.map((file, idx) => {
+ if (!validIndices.includes(idx)) return file;
+
+ const updates = {};
+ if (bulkEdit.groupType) {
+ updates.groupType = bulkEdit.groupType;
+ if (bulkEdit.groupType === 'group') {
+ updates.members = [];
+ } else if (bulkEdit.members.length > 0) {
+ updates.members =
+ bulkEdit.groupType === 'solo' ? [bulkEdit.members[0]] : [...bulkEdit.members];
+ }
+ } else if (bulkEdit.members.length > 0) {
+ updates.members = [...bulkEdit.members];
+ }
+
+ if (bulkEdit.conceptName) {
+ updates.conceptName = bulkEdit.conceptName;
+ }
+
+ return { ...file, ...updates };
+ })
+ );
+
+ setToast({ message: `${validIndices.length}개 사진에 일괄 적용되었습니다.`, type: 'success' });
+ setBulkEdit({ range: '', groupType: '', members: [], conceptName: '' });
+ };
+
+ // 일괄 편집 멤버 토글
+ const toggleBulkMember = (memberId) => {
+ setBulkEdit((prev) => ({
+ ...prev,
+ members: prev.members.includes(memberId)
+ ? prev.members.filter((m) => m !== memberId)
+ : [...prev.members, memberId],
+ }));
+ };
+
+ // 앨범 정보 로드
+ const {
+ data: album,
+ isLoading: albumLoading,
+ error: albumError,
+ refetch: refetchAlbum,
+ } = useQuery({
+ queryKey: ['admin', 'album', albumId],
+ queryFn: () => adminAlbumApi.getAlbum(albumId),
+ enabled: isAuthenticated && !!albumId,
+ staleTime: 0,
+ });
+
+ // 멤버 목록 로드
+ const { data: members = [] } = useQuery({
+ queryKey: ['admin', 'members'],
+ queryFn: adminMemberApi.getMembers,
+ enabled: isAuthenticated,
+ staleTime: 5 * 60 * 1000,
+ });
+
+ // 컨셉 포토 목록 로드
+ const { data: photos = [], refetch: refetchPhotos } = useQuery({
+ queryKey: ['admin', 'album', albumId, 'photos'],
+ queryFn: () => adminAlbumApi.getAlbumPhotos(albumId),
+ enabled: isAuthenticated && !!albumId,
+ staleTime: 0,
+ });
+
+ // 티저 이미지 목록 로드
+ const { data: teasers = [], refetch: refetchTeasers } = useQuery({
+ queryKey: ['admin', 'album', albumId, 'teasers'],
+ queryFn: () => adminAlbumApi.getAlbumTeasers(albumId),
+ enabled: isAuthenticated && !!albumId,
+ staleTime: 0,
+ });
+
+ const loading = albumLoading;
+
+ // 에러 처리
+ useEffect(() => {
+ if (albumError) {
+ console.error('앨범 로드 오류:', albumError);
+ setToast({ message: albumError.message || '앨범 로드 중 오류가 발생했습니다.', type: 'error' });
+ }
+ }, [albumError, setToast]);
+
+ // 데이터 새로고침 함수
+ const fetchAlbumData = async () => {
+ await Promise.all([refetchAlbum(), refetchPhotos(), refetchTeasers()]);
+ };
+
+ // 타입 변경 시 시작 번호 자동 업데이트
+ useEffect(() => {
+ if (photoType === 'concept') {
+ const maxOrder = photos.length > 0 ? Math.max(...photos.map((p) => p.sort_order || 0)) : 0;
+ setStartNumber(maxOrder + 1);
+ } else if (photoType === 'teaser') {
+ const maxOrder = teasers.length > 0 ? Math.max(...teasers.map((t) => t.sort_order || 0)) : 0;
+ setStartNumber(maxOrder + 1);
+ }
+ }, [photoType, photos, teasers]);
+
+ // 파일 선택
+ const handleFileSelect = (e) => {
+ const files = Array.from(e.target.files);
+ addFilesToPending(files);
+ e.target.value = '';
+ };
+
+ // 드래그 앤 드롭
+ const dragCounterRef = useRef(0);
+
+ const handleDragEnter = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ dragCounterRef.current++;
+ if (e.dataTransfer.types.includes('Files')) {
+ setDragOver(true);
+ }
+ };
+
+ const handleDragOver = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ };
+
+ const handleDragLeave = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ dragCounterRef.current--;
+ if (dragCounterRef.current === 0) {
+ setDragOver(false);
+ }
+ };
+
+ const handleDrop = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ dragCounterRef.current = 0;
+ setDragOver(false);
+ const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
+ if (files.length > 0) {
+ addFilesToPending(files);
+ }
+ };
+
+ // 대기 목록에 파일 추가
+ const addFilesToPending = (files) => {
+ const existingKeys = new Set(pendingFiles.map((f) => `${f.filename}_${f.file.size}`));
+
+ const uniqueFiles = files.filter((file) => {
+ const key = `${file.name}_${file.size}`;
+ if (existingKeys.has(key)) {
+ return false;
+ }
+ existingKeys.add(key);
+ return true;
+ });
+
+ if (uniqueFiles.length < files.length) {
+ const duplicateCount = files.length - uniqueFiles.length;
+ setToast({
+ message: `${duplicateCount}개의 중복 파일이 제외되었습니다.`,
+ type: 'warning',
+ });
+ }
+
+ if (uniqueFiles.length === 0) return;
+
+ const newFiles = uniqueFiles.map((file, index) => ({
+ id: Date.now() + index,
+ file,
+ preview: URL.createObjectURL(file),
+ filename: file.name,
+ isVideo: file.type === 'video/mp4',
+ groupType: 'group',
+ members: [],
+ conceptName: '',
+ }));
+ setPendingFiles((prev) => [...prev, ...newFiles]);
+ };
+
+ // 대기 파일 순서 변경
+ const handleReorder = (newOrder) => {
+ setPendingFiles(newOrder);
+ };
+
+ // 직접 순서 변경
+ const moveToPosition = (fileId, newPosition) => {
+ const pos = parseInt(newPosition, 10);
+ if (isNaN(pos) || pos < startNumber) return;
+
+ setPendingFiles((prev) => {
+ const targetIndex = Math.min(pos - startNumber, prev.length - 1);
+ const currentIndex = prev.findIndex((f) => f.id === fileId);
+ if (currentIndex === -1 || currentIndex === targetIndex) return prev;
+
+ const newFiles = [...prev];
+ const [removed] = newFiles.splice(currentIndex, 1);
+ newFiles.splice(targetIndex, 0, removed);
+ return newFiles;
+ });
+ };
+
+ // 대기 파일 삭제
+ const confirmDeletePendingFile = () => {
+ if (!pendingDeleteId) return;
+ setPendingFiles((prev) => {
+ const file = prev.find((f) => f.id === pendingDeleteId);
+ if (file) URL.revokeObjectURL(file.preview);
+ return prev.filter((f) => f.id !== pendingDeleteId);
+ });
+ setPendingDeleteId(null);
+ };
+
+ // 대기 파일 메타 정보 수정
+ const updatePendingFile = (id, field, value) => {
+ setPendingFiles((prev) => prev.map((f) => (f.id === id ? { ...f, [field]: value } : f)));
+ };
+
+ // 멤버 토글
+ const toggleMember = (fileId, member) => {
+ setPendingFiles((prev) =>
+ prev.map((f) => {
+ if (f.id !== fileId) return f;
+
+ if (f.groupType === 'solo') {
+ return { ...f, members: f.members.includes(member) ? [] : [member] };
+ }
+
+ const members = f.members.includes(member)
+ ? f.members.filter((m) => m !== member)
+ : [...f.members, member];
+ return { ...f, members };
+ })
+ );
+ };
+
+ // 타입 변경
+ const changeGroupType = (fileId, newType) => {
+ setPendingFiles((prev) =>
+ prev.map((f) => {
+ if (f.id !== fileId) return f;
+ if (newType === 'group') {
+ return { ...f, groupType: newType, members: [] };
+ }
+ if (newType === 'solo' && f.members.length > 1) {
+ return { ...f, groupType: newType, members: [f.members[0]] };
+ }
+ return { ...f, groupType: newType };
+ })
+ );
+ };
+
+ // 업로드 처리
+ const handleUpload = async () => {
+ if (pendingFiles.length === 0) {
+ setToast({ message: '업로드할 사진을 선택해주세요.', type: 'warning' });
+ return;
+ }
+
+ if (photoType === 'concept') {
+ const missingMembers = pendingFiles.some(
+ (f) => (f.groupType === 'solo' || f.groupType === 'unit') && f.members.length === 0
+ );
+ if (missingMembers) {
+ setToast({ message: '개인/유닛 사진에는 멤버를 선택해주세요.', type: 'warning' });
+ return;
+ }
+ }
+
+ setSaving(true);
+ setUploadProgress(0);
+ setProcessingProgress({ current: 0, total: pendingFiles.length });
+ setProcessingStatus('');
+
+ try {
+ const token = localStorage.getItem('adminToken');
+
+ const formData = new FormData();
+ const metadata = pendingFiles.map((pf) => ({
+ groupType: pf.groupType,
+ members: pf.members,
+ conceptName: pf.conceptName,
+ }));
+
+ pendingFiles.forEach((pf) => {
+ formData.append('photos', pf.file);
+ });
+ formData.append('metadata', JSON.stringify(metadata));
+ formData.append('startNumber', startNumber);
+ formData.append('photoType', photoType);
+
+ const response = await fetch(`/api/albums/${albumId}/photos`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ body: formData,
+ });
+
+ setUploadProgress(100);
+
+ // SSE 응답 읽기
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let result = null;
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ const text = decoder.decode(value);
+ const lines = text.split('\n');
+
+ for (const line of lines) {
+ if (line.startsWith('data: ')) {
+ try {
+ const data = JSON.parse(line.slice(6));
+ if (data.done) {
+ result = data;
+ } else if (data.error) {
+ throw new Error(data.error);
+ } else if (data.current) {
+ setProcessingProgress({ current: data.current, total: data.total });
+ setProcessingStatus(data.message);
+ }
+ } catch (e) {
+ // JSON 파싱 실패 무시
+ }
+ }
+ }
+ }
+
+ if (result) {
+ setToast({ message: result.message, type: 'success' });
+ }
+
+ pendingFiles.forEach((f) => URL.revokeObjectURL(f.preview));
+ setPendingFiles([]);
+ setConceptName('');
+
+ fetchAlbumData();
+ } catch (error) {
+ console.error('업로드 오류:', error);
+ setToast({ message: error.message || '업로드 중 오류가 발생했습니다.', type: 'error' });
+ } finally {
+ setSaving(false);
+ setUploadProgress(0);
+ setProcessingProgress({ current: 0, total: 0 });
+ setProcessingStatus('');
+ }
+ };
+
+ // 삭제 처리
+ const handleDelete = async () => {
+ setDeleting(true);
+
+ try {
+ const photoIds = deleteDialog.photos.filter(
+ (id) => typeof id === 'number' || !String(id).startsWith('teaser-')
+ );
+ const teaserIds = deleteDialog.photos
+ .filter((id) => String(id).startsWith('teaser-'))
+ .map((id) => parseInt(String(id).replace('teaser-', '')));
+
+ for (const photoId of photoIds) {
+ await adminAlbumApi.deleteAlbumPhoto(albumId, photoId);
+ }
+
+ for (const teaserId of teaserIds) {
+ await adminAlbumApi.deleteAlbumTeaser(albumId, teaserId);
+ }
+
+ if (photoIds.length > 0) await refetchPhotos();
+ if (teaserIds.length > 0) await refetchTeasers();
+ setSelectedPhotos([]);
+
+ const totalDeleted = photoIds.length + teaserIds.length;
+ setToast({ message: `${totalDeleted}개 항목이 삭제되었습니다.`, type: 'success' });
+ } catch (error) {
+ console.error('삭제 오류:', error);
+ setToast({ message: '삭제 중 오류가 발생했습니다.', type: 'error' });
+ } finally {
+ setDeleteDialog({ show: false, photos: [] });
+ setDeleting(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ setToast(null)} />
+
+ {/* 삭제 확인 다이얼로그 */}
+ setDeleteDialog({ show: false, photos: [] })}
+ onConfirm={handleDelete}
+ title="사진 삭제"
+ message={
+ <>
+ {deleteDialog.photos.length}개의 사진을
+ 삭제하시겠습니까?
+
+ 이 작업은 되돌릴 수 없습니다.
+ >
+ }
+ loading={deleting}
+ />
+
+ {/* 이미지 미리보기 */}
+
+ {previewPhoto && (
+ setPreviewPhoto(null)}
+ >
+
+ {previewPhoto.isVideo ? (
+ e.stopPropagation()}
+ controls
+ autoPlay
+ />
+ ) : (
+ e.stopPropagation()}
+ />
+ )}
+
+ )}
+
+
+
+ {/* 브레드크럼 */}
+
+
+
+
+
+
+ 앨범 관리
+
+
+ {album?.title} - 사진 관리
+
+
+ {/* 타이틀 + 액션 버튼 */}
+
+
+

+
+
{album?.title}
+
사진 업로드 및 관리
+
+
+ {pendingFiles.length > 0 && (
+
+
+
+
+ )}
+
+
+ {/* 탭 UI */}
+
+
+
+
+
+ {/* 업로드 탭 */}
+ {activeTab === 'upload' && (
+ <>
+ {/* 업로드 설정 */}
+
+
+
+ 업로드 설정
+
+
+ {/* 타입 선택 */}
+
+
+
+
+
+
+
+ {pendingFiles.length > 0
+ ? '파일이 추가된 상태에서는 타입을 변경할 수 없습니다.'
+ : photoType === 'teaser'
+ ? '티저 이미지는 순서만 지정하면 됩니다.'
+ : '컨셉/티저 이름은 각 사진별로 입력합니다.'}
+
+
+
+ {/* 시작 번호 설정 */}
+
+
+
+ setStartNumber(Math.max(1, parseInt(e.target.value) || 1))}
+ className="w-20 px-3 py-2 border border-gray-200 rounded-lg text-center focus:outline-none focus:ring-2 focus:ring-primary [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
+ />
+
+ → {String(startNumber).padStart(2, '0')}.webp ~{' '}
+ {String(startNumber + Math.max(0, pendingFiles.length - 1)).padStart(2, '0')}.webp
+
+
+
+ 추가 업로드 시 기존 사진 다음 번호로 설정하세요.
+
+
+
+
+ {/* 업로드 진행률 */}
+ {saving && (
+
+
+
+
+ {uploadProgress < 100 ? (
+ 파일 업로드 중...
+ ) : processingProgress.current > 0 ? (
+
+ {processingStatus ||
+ `${processingProgress.current}/${processingProgress.total} 처리 중...`}
+
+ ) : (
+ 서버 연결 중...
+ )}
+
+
+ {uploadProgress < 100
+ ? `${uploadProgress}%`
+ : processingProgress.total > 0
+ ? `${Math.round((processingProgress.current / processingProgress.total) * 100)}%`
+ : '0%'}
+
+
+
+ 0
+ ? `${(processingProgress.current / processingProgress.total) * 100}%`
+ : '0%',
+ }}
+ transition={{ duration: 0.3 }}
+ className="h-full bg-primary rounded-full"
+ />
+
+
+ )}
+
+ {/* 2-column 레이아웃 */}
+
+ {/* 드래그 앤 드롭 영역 + 파일 목록 */}
+
+ {/* 드래그 오버레이 */}
+ {dragOver && (
+
+ )}
+
+ {pendingFiles.length === 0 ? (
+
+
+
사진을 드래그하여 업로드하세요
+
+ {photoType === 'teaser' ? 'JPG, PNG, WebP, MP4 지원' : 'JPG, PNG, WebP 지원'}
+
+
+
+ ) : (
+
+
+
+ 드래그하여 순서를 변경할 수 있습니다. 순서대로{' '}
+ 01.webp,{' '}
+ 02.webp... 로 저장됩니다.
+
+
+
+
+
+ {pendingFiles.map((file, index) => (
+
+
+ {/* 드래그 핸들 + 순서 번호 */}
+
+
+ {
+ const val = e.target.value.trim();
+ const scrollY = window.scrollY;
+ const currentIndex = pendingFiles.findIndex((f) => f.id === file.id);
+ const currentOrder = startNumber + currentIndex;
+
+ if (val && !isNaN(val) && parseInt(val) !== currentOrder) {
+ moveToPosition(file.id, val);
+ }
+
+ const newIndex = pendingFiles.findIndex((f) => f.id === file.id);
+ e.target.value = String(startNumber + newIndex).padStart(2, '0');
+
+ requestAnimationFrame(() => {
+ window.scrollTo(0, scrollY);
+ });
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.target.blur();
+ }
+ }}
+ className="w-10 h-8 bg-primary/10 text-primary rounded-lg text-center text-sm font-bold border-0 focus:outline-none focus:ring-2 focus:ring-primary [appearance:textfield]"
+ />
+
+
+ {/* 썸네일 */}
+ {file.isVideo ? (
+
+
+ ) : (
+

setPreviewPhoto(file)}
+ />
+ )}
+
+ {/* 메타 정보 */}
+
+
+ {file.filename}
+
+
+ {photoType === 'concept' && (
+ <>
+ {/* 단체/솔로/유닛 선택 */}
+
+
타입:
+
+ {[
+ { value: 'group', icon: Users, label: '단체' },
+ { value: 'solo', icon: User, label: '개인' },
+ { value: 'unit', icon: Users2, label: '유닛' },
+ ].map(({ value, icon: Icon, label }) => (
+
+ ))}
+
+
+
+ {/* 멤버 태깅 */}
+
+
+ 멤버:
+ {file.groupType === 'group' ? (
+
+ 단체 사진은 멤버 태깅이 필요 없습니다
+
+ ) : (
+ <>
+ {members
+ .filter((m) => !m.is_former)
+ .map((member) => (
+
+ ))}
+ >
+ )}
+
+ {file.groupType !== 'group' &&
+ members.filter((m) => m.is_former).length > 0 && (
+
+
+ {members
+ .filter((m) => m.is_former)
+ .map((member) => (
+
+ ))}
+
+ )}
+
+
+ {/* 컨셉명 */}
+
+ 컨셉명:
+
+ updatePendingFile(file.id, 'conceptName', e.target.value)
+ }
+ className="flex-1 px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary"
+ placeholder="컨셉명을 입력하세요"
+ />
+
+ >
+ )}
+
+
+ {/* 삭제 버튼 */}
+
+
+
+ ))}
+
+
+ )}
+
+
+ {/* 일괄 편집 도구 */}
+ {pendingFiles.length > 0 && photoType === 'concept' && (
+
+
+
+
+ 일괄 편집
+
+
+ {/* 번호 범위 */}
+
+
+
setBulkEdit((prev) => ({ ...prev, range: e.target.value }))}
+ placeholder={`예: ${startNumber}-${startNumber + 4}, ${startNumber + 7}`}
+ className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
+ />
+
+ {startNumber}~{startNumber + pendingFiles.length - 1}번 중{' '}
+ {parseRange(bulkEdit.range, startNumber).filter((i) => i < pendingFiles.length).length}
+ 개 선택
+
+
+
+ {/* 타입 선택 */}
+
+
+
+ {[
+ { value: 'group', icon: Users, label: '단체' },
+ { value: 'solo', icon: User, label: '개인' },
+ { value: 'unit', icon: Users2, label: '유닛' },
+ ].map(({ value, icon: Icon, label }) => (
+
+ ))}
+
+
+
+ {/* 멤버 선택 */}
+ {bulkEdit.groupType !== 'group' && (
+
+
+
+ {members
+ .filter((m) => !m.is_former)
+ .map((member) => (
+
+ ))}
+ {members.filter((m) => m.is_former).length > 0 && (
+ |
+ )}
+ {members
+ .filter((m) => m.is_former)
+ .map((member) => (
+
+ ))}
+
+
+ )}
+
+ {/* 컨셉명 */}
+
+
+
+ setBulkEdit((prev) => ({ ...prev, conceptName: e.target.value }))
+ }
+ placeholder="컨셉명 입력"
+ className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
+ />
+
+
+ {/* 적용 버튼 */}
+
+
+
+ )}
+
+ >
+ )}
+
+ {/* 관리 탭 */}
+ {activeTab === 'manage' && (
+
+ {/* 헤더 + 서브탭 */}
+
+
+
+
+
등록된 미디어
+
+ 컨셉 포토 {photos.length}장 / 티저 {teasers.length}장
+
+
+
+ {selectedPhotos.length > 0 && (
+
+ )}
+
+
+ {/* 서브탭 + 전체 선택 */}
+
+
+
+
+
+
+
+
+ {/* 컨셉 포토 그리드 */}
+ {manageSubTab === 'concept' && (
+ <>
+ {photos.length === 0 ? (
+
+
+
등록된 컨셉 포토가 없습니다
+
업로드 탭에서 사진을 추가하세요
+
+ ) : (
+
+ {photos.map((photo, index) => (
+
{
+ setSelectedPhotos((prev) =>
+ prev.includes(photo.id)
+ ? prev.filter((id) => id !== photo.id)
+ : [...prev, photo.id]
+ );
+ }}
+ >
+
+
+
+
+ {selectedPhotos.includes(photo.id) && (
+
+ )}
+
+
+
+ {String(photo.sort_order).padStart(2, '0')}
+
+
+
+ {photo.concept_name && (
+
+ {photo.concept_name}
+
+ )}
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+ {/* 티저 이미지 그리드 */}
+ {manageSubTab === 'teaser' && (
+ <>
+ {teasers.length === 0 ? (
+
+
+
등록된 티저 이미지가 없습니다
+
업로드 탭에서 티저를 추가하세요
+
+ ) : (
+
+ {teasers.map((teaser, index) => (
+
{
+ const teaserId = `teaser-${teaser.id}`;
+ setSelectedPhotos((prev) =>
+ prev.includes(teaserId)
+ ? prev.filter((id) => id !== teaserId)
+ : [...prev, teaserId]
+ );
+ }}
+ >
+ {teaser.media_type === 'video' ? (
+
+ ))}
+
+ )}
+ >
+ )}
+
+ )}
+
+ {/* 삭제 확인 다이얼로그 */}
+
+ {pendingDeleteId && (
+ setPendingDeleteId(null)}
+ >
+ e.stopPropagation()}
+ className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 shadow-xl"
+ >
+
+
+
+
+
사진을 삭제할까요?
+
이 사진을 목록에서 제거합니다.
+
+
+
+
+
+
+
+ )}
+
+
+ {/* 업로드 확인 다이얼로그 */}
+
+ {uploadConfirmDialog && (
+ setUploadConfirmDialog(false)}
+ >
+ e.stopPropagation()}
+ className="bg-white rounded-2xl p-6 max-w-md w-full mx-4 shadow-xl"
+ >
+
+
+
+
+
사진을 업로드할까요?
+
+
+ 사진 타입:
+
+ {photoType === 'concept' ? '컨셉 포토' : '티저 이미지'}
+
+
+
+ 파일 개수:
+ {pendingFiles.length}개
+
+
+ 파일명 범위:
+
+ {String(startNumber).padStart(2, '0')}.webp ~{' '}
+ {String(startNumber + pendingFiles.length - 1).padStart(2, '0')}.webp
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+ );
+}
+
+export default AdminAlbumPhotos;
diff --git a/frontend-temp/src/pages/pc/admin/albums/Albums.jsx b/frontend-temp/src/pages/pc/admin/albums/Albums.jsx
new file mode 100644
index 0000000..ce04662
--- /dev/null
+++ b/frontend-temp/src/pages/pc/admin/albums/Albums.jsx
@@ -0,0 +1,231 @@
+/**
+ * 관리자 앨범 목록 페이지
+ */
+import { useState } from 'react';
+import { useNavigate, Link } from 'react-router-dom';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { motion } from 'framer-motion';
+import { Plus, Search, Edit2, Trash2, Image, Music, Home, ChevronRight, Calendar } from 'lucide-react';
+import { Toast, Tooltip } from '@/components/common';
+import { AdminLayout, ConfirmDialog } from '@/components/pc/admin';
+import { useAdminAuth } from '@/hooks/pc/admin';
+import { useToast } from '@/hooks/common';
+import { adminAlbumApi } from '@/api/pc/admin';
+
+function AdminAlbums() {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { user, isAuthenticated } = useAdminAuth();
+ const { toast, setToast } = useToast();
+
+ const [searchQuery, setSearchQuery] = useState('');
+ const [deleteDialog, setDeleteDialog] = useState({ show: false, album: null });
+ const [deleting, setDeleting] = useState(false);
+
+ // 앨범 목록 조회
+ const { data: albums = [], isLoading: loading } = useQuery({
+ queryKey: ['admin', 'albums'],
+ queryFn: adminAlbumApi.getAlbums,
+ enabled: isAuthenticated,
+ staleTime: 0,
+ });
+
+ const handleDelete = async () => {
+ if (!deleteDialog.album) return;
+
+ setDeleting(true);
+ try {
+ await adminAlbumApi.deleteAlbum(deleteDialog.album.id);
+ setToast({ message: `"${deleteDialog.album.title}" 앨범이 삭제되었습니다.`, type: 'success' });
+ setDeleteDialog({ show: false, album: null });
+ queryClient.invalidateQueries({ queryKey: ['admin', 'albums'] });
+ } catch (error) {
+ console.error('삭제 오류:', error);
+ setToast({ message: '앨범 삭제 중 오류가 발생했습니다.', type: 'error' });
+ } finally {
+ setDeleting(false);
+ }
+ };
+
+ // 날짜 포맷팅
+ const formatDate = (dateStr) => {
+ if (!dateStr) return '';
+ const date = new Date(dateStr);
+ return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
+ };
+
+ // 검색 필터링
+ const filteredAlbums = albums.filter((album) =>
+ album.title.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ return (
+
+ setToast(null)} />
+
+ {/* 삭제 확인 다이얼로그 */}
+ setDeleteDialog({ show: false, album: null })}
+ onConfirm={handleDelete}
+ title="앨범 삭제"
+ message={
+ <>
+ "{deleteDialog.album?.title}" 앨범을
+ 삭제하시겠습니까?
+
+
+ 이 작업은 되돌릴 수 없으며, 모든 트랙과 커버 이미지가 함께 삭제됩니다.
+
+ >
+ }
+ loading={deleting}
+ />
+
+
+ {/* 브레드크럼 */}
+
+
+
+
+
+ 앨범 관리
+
+
+ {/* 타이틀 & 액션 */}
+
+
+
앨범 관리
+
앨범, 트랙, 사진을 관리합니다
+
+
+
+
+ {/* 검색 */}
+
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="앨범 검색..."
+ className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
+ />
+
+
+
+ {/* 앨범 목록 */}
+ {loading ? (
+
+ ) : (
+
+
+
+
+ | 앨범 |
+ 타입 |
+ 발매일 |
+ 트랙 |
+ 관리 |
+
+
+
+ {filteredAlbums.map((album, index) => (
+
+
+
+ 
+
+
+ |
+
+
+ {album.album_type}
+
+ |
+
+
+
+ {formatDate(album.release_date)}
+
+ |
+
+
+
+ {album.tracks?.length || 0}곡
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ {filteredAlbums.length === 0 && (
+
+ {searchQuery ? '검색 결과가 없습니다.' : '등록된 앨범이 없습니다.'}
+
+ )}
+
+ )}
+
+
+ );
+}
+
+export default AdminAlbums;
diff --git a/frontend-temp/src/pages/pc/admin/Dashboard.jsx b/frontend-temp/src/pages/pc/admin/dashboard/Dashboard.jsx
similarity index 99%
rename from frontend-temp/src/pages/pc/admin/Dashboard.jsx
rename to frontend-temp/src/pages/pc/admin/dashboard/Dashboard.jsx
index 40031ae..ca05777 100644
--- a/frontend-temp/src/pages/pc/admin/Dashboard.jsx
+++ b/frontend-temp/src/pages/pc/admin/dashboard/Dashboard.jsx
@@ -85,7 +85,7 @@ function AdminDashboard() {
icon: Calendar,
label: '일정 관리',
description: '일정 추가 및 관리',
- path: '/admin/schedules',
+ path: '/admin/schedule',
color: 'bg-blue-500',
},
];
diff --git a/frontend-temp/src/pages/pc/admin/Login.jsx b/frontend-temp/src/pages/pc/admin/login/Login.jsx
similarity index 100%
rename from frontend-temp/src/pages/pc/admin/Login.jsx
rename to frontend-temp/src/pages/pc/admin/login/Login.jsx
diff --git a/frontend-temp/src/pages/pc/admin/MemberEdit.jsx b/frontend-temp/src/pages/pc/admin/members/MemberEdit.jsx
similarity index 99%
rename from frontend-temp/src/pages/pc/admin/MemberEdit.jsx
rename to frontend-temp/src/pages/pc/admin/members/MemberEdit.jsx
index a6df4ca..359f3da 100644
--- a/frontend-temp/src/pages/pc/admin/MemberEdit.jsx
+++ b/frontend-temp/src/pages/pc/admin/members/MemberEdit.jsx
@@ -253,6 +253,7 @@ function AdminMemberEdit() {
setFormData({ ...formData, birth_date: date })}
+ minYear={1995}
/>
diff --git a/frontend-temp/src/pages/pc/admin/Members.jsx b/frontend-temp/src/pages/pc/admin/members/Members.jsx
similarity index 100%
rename from frontend-temp/src/pages/pc/admin/Members.jsx
rename to frontend-temp/src/pages/pc/admin/members/Members.jsx
diff --git a/frontend-temp/src/pages/pc/admin/schedules/ScheduleBots.jsx b/frontend-temp/src/pages/pc/admin/schedules/ScheduleBots.jsx
new file mode 100644
index 0000000..d08ff43
--- /dev/null
+++ b/frontend-temp/src/pages/pc/admin/schedules/ScheduleBots.jsx
@@ -0,0 +1,507 @@
+import { useState, useEffect } from 'react';
+import { Link } from 'react-router-dom';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { motion } from 'framer-motion';
+import {
+ Home,
+ ChevronRight,
+ Bot,
+ Play,
+ Square,
+ Youtube,
+ CheckCircle,
+ XCircle,
+ RefreshCw,
+ Download,
+} from 'lucide-react';
+import { Toast, Tooltip } from '@/components/common';
+import { AdminLayout } from '@/components/pc/admin';
+import { useAdminAuth } from '@/hooks/pc/admin';
+import { useToast } from '@/hooks/common';
+import * as botsApi from '@/api/pc/admin/bots';
+
+// X 아이콘 컴포넌트
+const XIcon = ({ size = 20, fill = 'currentColor' }) => (
+
+);
+
+// Meilisearch 아이콘 컴포넌트
+const MeilisearchIcon = ({ size = 20 }) => (
+
+);
+
+function ScheduleBots() {
+ const queryClient = useQueryClient();
+ const { user, isAuthenticated } = useAdminAuth();
+ const { toast, setToast } = useToast();
+ const [isInitialLoad, setIsInitialLoad] = useState(true); // 첫 로드 여부 (애니메이션용)
+ const [syncing, setSyncing] = useState(null); // 동기화 중인 봇 ID
+ const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태
+
+ // 봇 목록 조회
+ const {
+ data: bots = [],
+ isLoading: loading,
+ isError,
+ refetch: fetchBots,
+ } = useQuery({
+ queryKey: ['admin', 'bots'],
+ queryFn: botsApi.getBots,
+ enabled: isAuthenticated,
+ staleTime: 30000,
+ });
+
+ // 할당량 경고 상태 조회
+ const { data: quotaData } = useQuery({
+ queryKey: ['admin', 'bots', 'quota'],
+ queryFn: botsApi.getQuotaWarning,
+ enabled: isAuthenticated,
+ staleTime: 60000,
+ });
+
+ // 에러 처리
+ useEffect(() => {
+ if (isError) {
+ setToast({ type: 'error', message: '봇 목록을 불러올 수 없습니다.' });
+ }
+ }, [isError, setToast]);
+
+ // 할당량 경고 상태 업데이트
+ useEffect(() => {
+ if (quotaData?.active) {
+ setQuotaWarning(quotaData);
+ }
+ }, [quotaData]);
+
+ // 할당량 경고 해제
+ const handleDismissQuotaWarning = async () => {
+ try {
+ await botsApi.dismissQuotaWarning();
+ setQuotaWarning(null);
+ } catch (error) {
+ console.error('할당량 경고 해제 오류:', error);
+ }
+ };
+
+ // 봇 시작/정지 토글
+ const toggleBot = async (botId, currentStatus, botName) => {
+ try {
+ const action = currentStatus === 'running' ? 'stop' : 'start';
+
+ if (action === 'start') {
+ await botsApi.startBot(botId);
+ } else {
+ await botsApi.stopBot(botId);
+ }
+
+ // 캐시 업데이트 (전체 목록 새로고침 대신)
+ queryClient.setQueryData(['admin', 'bots'], (prev) =>
+ prev?.map((bot) =>
+ bot.id === botId ? { ...bot, status: action === 'start' ? 'running' : 'stopped' } : bot
+ )
+ );
+ setToast({
+ type: 'success',
+ message:
+ action === 'start' ? `${botName} 봇이 시작되었습니다.` : `${botName} 봇이 정지되었습니다.`,
+ });
+ } catch (error) {
+ console.error('봇 토글 오류:', error);
+ setToast({ type: 'error', message: error.message || '작업 중 오류가 발생했습니다.' });
+ }
+ };
+
+ // 전체 동기화
+ const handleSyncAllVideos = async (botId) => {
+ setSyncing(botId);
+ try {
+ const data = await botsApi.syncAllVideos(botId);
+ setToast({
+ type: 'success',
+ message: `${data.addedCount}개 일정이 추가되었습니다. (전체 ${data.total}개)`,
+ });
+ fetchBots();
+ } catch (error) {
+ console.error('전체 동기화 오류:', error);
+ setToast({ type: 'error', message: error.message || '동기화 중 오류가 발생했습니다.' });
+ fetchBots();
+ } finally {
+ setSyncing(null);
+ }
+ };
+
+ // 상태 아이콘 및 색상
+ const getStatusInfo = (status) => {
+ switch (status) {
+ case 'running':
+ return {
+ icon:
,
+ text: '실행 중',
+ color: 'text-green-500',
+ bg: 'bg-green-50',
+ dot: 'bg-green-500',
+ };
+ case 'stopped':
+ return {
+ icon:
,
+ text: '정지됨',
+ color: 'text-gray-400',
+ bg: 'bg-gray-50',
+ dot: 'bg-gray-400',
+ };
+ case 'error':
+ return {
+ icon:
,
+ text: '오류',
+ color: 'text-red-500',
+ bg: 'bg-red-50',
+ dot: 'bg-red-500',
+ };
+ default:
+ return {
+ icon: null,
+ text: '알 수 없음',
+ color: 'text-gray-400',
+ bg: 'bg-gray-50',
+ dot: 'bg-gray-400',
+ };
+ }
+ };
+
+ // 시간 포맷 (UTC → KST 변환)
+ const formatTime = (dateString) => {
+ if (!dateString) return '-';
+ const date = new Date(dateString);
+ return date.toLocaleString('ko-KR', {
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ };
+
+ // 간격 포맷 (분 → 분/시간/일)
+ const formatInterval = (minutes) => {
+ if (!minutes) return '-';
+ if (minutes >= 1440) {
+ const days = Math.floor(minutes / 1440);
+ return `${days}일`;
+ } else if (minutes >= 60) {
+ const hours = Math.floor(minutes / 60);
+ return `${hours}시간`;
+ }
+ return `${minutes}분`;
+ };
+
+ return (
+
+ setToast(null)} />
+
+ {/* 메인 콘텐츠 */}
+
+ {/* 브레드크럼 */}
+
+
+
+
+
+
+ 일정 관리
+
+
+ 봇 관리
+
+
+ {/* 타이틀 */}
+
+
봇 관리
+
일정 자동화 봇을 관리합니다
+
+
+ {/* 봇 통계 */}
+
+
+
+
실행 중
+
+ {bots.filter((b) => b.status === 'running').length}
+
+
+
+
정지됨
+
+ {bots.filter((b) => b.status === 'stopped').length}
+
+
+
+
오류
+
+ {bots.filter((b) => b.status === 'error').length}
+
+
+
+
+ {/* API 할당량 경고 배너 */}
+ {quotaWarning && (
+
+
+
+
+
+
+
YouTube API 할당량 경고
+
{quotaWarning.message}
+
+
+
+
+ )}
+
+ {/* 봇 목록 */}
+
+
+
봇 목록
+
+
+
+
+
+ {loading ? (
+
+ ) : bots.length === 0 ? (
+
+
+
등록된 봇이 없습니다
+
위의 버튼을 클릭하여 봇을 추가하세요
+
+ ) : (
+
+ {bots.map((bot, index) => {
+ const statusInfo = getStatusInfo(bot.status);
+
+ return (
+
+ isInitialLoad && index === bots.length - 1 && setIsInitialLoad(false)
+ }
+ className="relative bg-gradient-to-br from-gray-50 to-white rounded-xl border border-gray-200 overflow-hidden hover:shadow-md transition-all"
+ >
+ {/* 상단 헤더 */}
+
+
+
+ {bot.type === 'x' ? (
+
+ ) : bot.type === 'meilisearch' ? (
+
+ ) : (
+
+ )}
+
+
+
{bot.name}
+
+ {bot.last_check_at
+ ? `${formatTime(bot.last_check_at)}에 업데이트됨`
+ : '아직 업데이트 없음'}
+
+
+
+
+
+ {statusInfo.text}
+
+
+
+ {/* 통계 정보 */}
+
+ {bot.type === 'meilisearch' ? (
+ <>
+
+
+ {bot.schedules_added || 0}
+
+
동기화 수
+
+
+
+ {bot.last_added_count
+ ? `${(bot.last_added_count / 1000 || 0).toFixed(1)}초`
+ : '-'}
+
+
소요 시간
+
+ >
+ ) : (
+ <>
+
+
{bot.schedules_added}
+
총 추가
+
+
+
0 ? 'text-green-500' : 'text-gray-400'}`}
+ >
+ +{bot.last_added_count || 0}
+
+
마지막
+
+ >
+ )}
+
+
+ {formatInterval(bot.check_interval)}
+
+
업데이트 간격
+
+
+
+ {/* 오류 메시지 */}
+ {bot.status === 'error' && bot.error_message && (
+
+ {bot.error_message}
+
+ )}
+
+ {/* 액션 버튼 */}
+
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+}
+
+export default ScheduleBots;
diff --git a/frontend-temp/src/pages/pc/admin/schedules/ScheduleCategory.jsx b/frontend-temp/src/pages/pc/admin/schedules/ScheduleCategory.jsx
new file mode 100644
index 0000000..49920df
--- /dev/null
+++ b/frontend-temp/src/pages/pc/admin/schedules/ScheduleCategory.jsx
@@ -0,0 +1,466 @@
+import { useState, useEffect } from 'react';
+import { Link } from 'react-router-dom';
+import { motion, AnimatePresence, Reorder } from 'framer-motion';
+import { Home, ChevronRight, Plus, Edit3, Trash2, GripVertical } from 'lucide-react';
+import { HexColorPicker } from 'react-colorful';
+import { Toast } from '@/components/common';
+import { AdminLayout, ConfirmDialog } from '@/components/pc/admin';
+import { useAdminAuth } from '@/hooks/pc/admin';
+import { useToast } from '@/hooks/common';
+import * as categoriesApi from '@/api/pc/admin/categories';
+
+// 기본 색상 (8개)
+const colorOptions = [
+ { id: 'blue', name: '파란색', bg: 'bg-blue-500', hex: '#3b82f6' },
+ { id: 'green', name: '초록색', bg: 'bg-green-500', hex: '#22c55e' },
+ { id: 'purple', name: '보라색', bg: 'bg-purple-500', hex: '#a855f7' },
+ { id: 'red', name: '빨간색', bg: 'bg-red-500', hex: '#ef4444' },
+ { id: 'pink', name: '분홍색', bg: 'bg-pink-500', hex: '#ec4899' },
+ { id: 'yellow', name: '노란색', bg: 'bg-yellow-500', hex: '#eab308' },
+ { id: 'orange', name: '주황색', bg: 'bg-orange-500', hex: '#f97316' },
+ { id: 'gray', name: '회색', bg: 'bg-gray-500', hex: '#6b7280' },
+];
+
+// 색상 헬퍼 (커스텀 HEX 지원)
+const getColorStyle = (colorValue) => {
+ // 기본 색상인지 확인
+ const preset = colorOptions.find((c) => c.id === colorValue);
+ if (preset) {
+ return { className: preset.bg };
+ }
+ // HEX 색상인 경우
+ if (colorValue?.startsWith('#')) {
+ return { style: { backgroundColor: colorValue } };
+ }
+ return { className: 'bg-gray-500' };
+};
+
+function ScheduleCategory() {
+ const { user, isAuthenticated } = useAdminAuth();
+ const [categories, setCategories] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const { toast, setToast, showSuccess, showError } = useToast();
+
+ // 모달 상태
+ const [modalOpen, setModalOpen] = useState(false);
+ const [editingCategory, setEditingCategory] = useState(null);
+ const [formData, setFormData] = useState({ name: '', color: 'blue' });
+
+ // 삭제 다이얼로그
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+
+ // 카스텀 컴러 피커 팝업
+ const [colorPickerOpen, setColorPickerOpen] = useState(false);
+
+ // 카테고리 로드
+ useEffect(() => {
+ if (isAuthenticated) {
+ fetchCategories();
+ }
+ }, [isAuthenticated]);
+
+ // 카테고리 목록 조회
+ const fetchCategories = async () => {
+ try {
+ const data = await categoriesApi.getCategories();
+ setCategories(data);
+ } catch (error) {
+ console.error('카테고리 조회 오류:', error);
+ showError('카테고리를 불러오는데 실패했습니다.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 모달 열기 (추가/수정)
+ const openModal = (category = null) => {
+ if (category) {
+ setEditingCategory(category);
+ setFormData({ name: category.name, color: category.color });
+ } else {
+ setEditingCategory(null);
+ setFormData({ name: '', color: 'blue' });
+ }
+ setColorPickerOpen(false); // 컬러 피커는 닫힌 상태로
+ setModalOpen(true);
+ };
+
+ // 카테고리 저장
+ const handleSave = async () => {
+ if (!formData.name.trim()) {
+ showError('카테고리 이름을 입력해주세요.');
+ return;
+ }
+
+ // 중복 체크 (수정시 자기 자신 제외)
+ const isDuplicate = categories.some(
+ (cat) =>
+ cat.name.toLowerCase() === formData.name.trim().toLowerCase() && cat.id !== editingCategory?.id
+ );
+ if (isDuplicate) {
+ showError('이미 존재하는 카테고리입니다.');
+ return;
+ }
+
+ try {
+ if (editingCategory) {
+ await categoriesApi.updateCategory(editingCategory.id, formData);
+ } else {
+ await categoriesApi.createCategory(formData);
+ }
+ showSuccess(editingCategory ? '카테고리가 수정되었습니다.' : '카테고리가 추가되었습니다.');
+ setModalOpen(false);
+ fetchCategories();
+ } catch (error) {
+ console.error('저장 오류:', error);
+ showError(error.message || '저장에 실패했습니다.');
+ }
+ };
+
+ // 삭제 다이얼로그 열기
+ const openDeleteDialog = (category) => {
+ setDeleteTarget(category);
+ setDeleteDialogOpen(true);
+ };
+
+ // 카테고리 삭제
+ const handleDelete = async () => {
+ if (!deleteTarget) return;
+
+ try {
+ await categoriesApi.deleteCategory(deleteTarget.id);
+ showSuccess('카테고리가 삭제되었습니다.');
+ setDeleteDialogOpen(false);
+ setDeleteTarget(null);
+ fetchCategories();
+ } catch (error) {
+ console.error('삭제 오류:', error);
+ showError(error.message || '삭제에 실패했습니다.');
+ }
+ };
+
+ // Reorder 핸들러 (부드러운 애니메이션)
+ const handleReorder = async (newOrder) => {
+ setCategories(newOrder);
+
+ // 순서 업데이트 API 호출
+ const orders = newOrder.map((cat, idx) => ({
+ id: cat.id,
+ sort_order: idx + 1,
+ }));
+
+ try {
+ await categoriesApi.reorderCategories(orders);
+ } catch (error) {
+ console.error('순서 업데이트 오류:', error);
+ fetchCategories(); // 실패시 원래 데이터 다시 불러오기
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ setToast(null)} />
+
+ {/* 메인 콘텐츠 */}
+
+ {/* 브레드크럼 */}
+
+
+
+ 대시보드
+
+
+
+ 일정 관리
+
+
+ 카테고리 관리
+
+
+ {/* 타이틀 */}
+
+
+
카테고리 관리
+
일정 카테고리를 추가, 수정, 삭제할 수 있습니다.
+
+
+
+
+ {/* 카테고리 목록 */}
+
+ {categories.length === 0 ? (
+
등록된 카테고리가 없습니다.
+ ) : (
+
+ {categories.map((category) => (
+
+ {/* 드래그 핸들 */}
+
+
+
+
+ {/* 색상 표시 */}
+ {(() => {
+ const colorStyle = getColorStyle(category.color);
+ return (
+
+ );
+ })()}
+
+ {/* 이름 */}
+ {category.name}
+
+ {/* 액션 버튼 */}
+
+
+ {category.is_default ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+ {/* 추가/수정 모달 */}
+
+ {modalOpen && (
+ setModalOpen(false)}
+ >
+ e.stopPropagation()}
+ >
+
+ {editingCategory ? '카테고리 수정' : '카테고리 추가'}
+
+
+ {/* 카테고리 이름 */}
+
+
+ setFormData({ ...formData, name: e.target.value })}
+ placeholder="예: 방송, 이벤트"
+ className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
+ />
+
+
+ {/* 색상 선택 */}
+
+
+
+ {colorOptions.map((color) => (
+
+
+ {/* 버튼 */}
+
+ setModalOpen(false)}
+ className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
+ >
+ 취소
+
+
+ {editingCategory ? '수정' : '추가'}
+
+
+
+
+ )}
+
+
+ {/* 삭제 확인 다이얼로그 */}
+
setDeleteDialogOpen(false)}
+ onConfirm={handleDelete}
+ title="카테고리 삭제"
+ message={
+ <>
+ "{deleteTarget?.name}" 카테고리를
+ 삭제하시겠습니까?
+
+ 이 작업은 되돌릴 수 없습니다.
+ >
+ }
+ />
+
+ );
+}
+
+export default ScheduleCategory;
diff --git a/frontend-temp/src/pages/pc/admin/schedules/ScheduleDict.jsx b/frontend-temp/src/pages/pc/admin/schedules/ScheduleDict.jsx
new file mode 100644
index 0000000..d34e3ec
--- /dev/null
+++ b/frontend-temp/src/pages/pc/admin/schedules/ScheduleDict.jsx
@@ -0,0 +1,714 @@
+import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
+import { Link } from 'react-router-dom';
+import { motion, AnimatePresence } from 'framer-motion';
+import { useQuery } from '@tanstack/react-query';
+import { Home, ChevronRight, Book, Plus, Trash2, Search, ChevronDown } from 'lucide-react';
+import { Toast } from '@/components/common';
+import { AdminLayout, ConfirmDialog } from '@/components/pc/admin';
+import { useAdminAuth } from '@/hooks/pc/admin';
+import { useToast } from '@/hooks/common';
+import * as suggestionsApi from '@/api/pc/admin/suggestions';
+
+// 애니메이션 variants
+const containerVariants = {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: {
+ staggerChildren: 0.08,
+ },
+ },
+};
+
+const itemVariants = {
+ hidden: { opacity: 0, y: 20 },
+ visible: {
+ opacity: 1,
+ y: 0,
+ transition: { duration: 0.4, ease: 'easeOut' },
+ },
+};
+
+const cardVariants = {
+ hidden: { opacity: 0, scale: 0.95 },
+ visible: {
+ opacity: 1,
+ scale: 1,
+ transition: { duration: 0.3, ease: 'easeOut' },
+ },
+};
+
+// 품사 태그 옵션
+const POS_TAGS = [
+ {
+ value: 'NNP',
+ label: '고유명사 (NNP)',
+ description: '사람, 그룹, 프로그램 이름 등',
+ examples: '프로미스나인, 송하영, 뮤직뱅크',
+ },
+ { value: 'NNG', label: '일반명사 (NNG)', description: '일반적인 명사', examples: '직캠, 팬미팅, 콘서트' },
+ {
+ value: 'SL',
+ label: '외국어 (SL)',
+ description: '영어 등 외국어 단어',
+ examples: 'fromis_9, YouTube, fromm',
+ },
+];
+
+// 단어 항목 컴포넌트
+function WordItem({ id, word, pos, index, onUpdate, onDelete }) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [editWord, setEditWord] = useState(word);
+ const [editPos, setEditPos] = useState(pos);
+ const [showPosDropdown, setShowPosDropdown] = useState(false);
+ const dropdownRef = useRef(null);
+
+ // 외부 클릭 시 드롭다운 닫기
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
+ setShowPosDropdown(false);
+ }
+ };
+
+ if (showPosDropdown) {
+ document.addEventListener('mousedown', handleClickOutside);
+ }
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [showPosDropdown]);
+
+ const handleSave = () => {
+ if (editWord.trim() && (editWord.trim() !== word || editPos !== pos)) {
+ onUpdate(id, editWord.trim(), editPos);
+ }
+ setIsEditing(false);
+ };
+
+ const handleKeyDown = (e) => {
+ if (e.key === 'Enter') {
+ handleSave();
+ } else if (e.key === 'Escape') {
+ setEditWord(word);
+ setEditPos(pos);
+ setIsEditing(false);
+ }
+ };
+
+ return (
+
+ {index + 1} |
+
+ {isEditing ? (
+ setEditWord(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onBlur={handleSave}
+ autoFocus
+ className="w-full px-3 py-1.5 border border-primary rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20"
+ />
+ ) : (
+ setIsEditing(true)}
+ className="cursor-pointer hover:text-primary transition-colors font-medium"
+ >
+ {word}
+
+ )}
+ |
+
+
+ setShowPosDropdown(!showPosDropdown)}
+ className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm transition-colors w-full justify-between"
+ >
+
+ {POS_TAGS.find((t) => t.value === (isEditing ? editPos : pos))?.label.split(' ')[0] || pos}
+
+
+
+
+ {showPosDropdown && (
+
+ {POS_TAGS.map((tag) => (
+ {
+ if (isEditing) {
+ setEditPos(tag.value);
+ } else {
+ onUpdate(id, word, tag.value);
+ }
+ setShowPosDropdown(false);
+ }}
+ className={`w-full px-4 py-2.5 text-left hover:bg-gray-50 transition-colors ${
+ (isEditing ? editPos : pos) === tag.value ? 'bg-primary/5 text-primary' : ''
+ }`}
+ >
+ {tag.label}
+ {tag.description}
+ 예: {tag.examples}
+
+ ))}
+
+ )}
+
+
+ |
+
+ onDelete(index)}
+ className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
+ >
+
+
+ |
+
+ );
+}
+
+function ScheduleDict() {
+ const { user, isAuthenticated } = useAdminAuth();
+ const { toast, setToast } = useToast();
+ const [entries, setEntries] = useState([]); // [{word, pos, isComment, id}]
+ const [saving, setSaving] = useState(false);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [filterPos, setFilterPos] = useState('all');
+ const [showFilterDropdown, setShowFilterDropdown] = useState(false);
+
+ // 새 단어 입력
+ const [newWord, setNewWord] = useState('');
+ const [newPos, setNewPos] = useState('NNP');
+ const [showNewPosDropdown, setShowNewPosDropdown] = useState(false);
+
+ // 드롭다운 refs
+ const newPosDropdownRef = useRef(null);
+ const filterDropdownRef = useRef(null);
+
+ // 다이얼로그 상태
+ const [addDialogOpen, setAddDialogOpen] = useState(false);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [wordToDelete, setWordToDelete] = useState(null); // { index, word, id }
+
+ // 외부 클릭 시 드롭다운 닫기
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (newPosDropdownRef.current && !newPosDropdownRef.current.contains(event.target)) {
+ setShowNewPosDropdown(false);
+ }
+ if (filterDropdownRef.current && !filterDropdownRef.current.contains(event.target)) {
+ setShowFilterDropdown(false);
+ }
+ };
+
+ if (showNewPosDropdown || showFilterDropdown) {
+ document.addEventListener('mousedown', handleClickOutside);
+ }
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [showNewPosDropdown, showFilterDropdown]);
+
+ // 필터링된 항목
+ const filteredEntries = useMemo(() => {
+ return entries.filter((entry) => {
+ if (entry.isComment) return true; // 주석은 항상 포함 (but 표시 안함)
+
+ const matchesSearch =
+ !searchQuery || entry.word.toLowerCase().includes(searchQuery.toLowerCase());
+ const matchesPos = filterPos === 'all' || entry.pos === filterPos;
+
+ return matchesSearch && matchesPos;
+ });
+ }, [entries, searchQuery, filterPos]);
+
+ // 실제 단어 항목만 (주석 제외)
+ const wordEntries = useMemo(() => {
+ return filteredEntries.filter((e) => !e.isComment);
+ }, [filteredEntries]);
+
+ // 품사별 통계
+ const posStats = useMemo(() => {
+ const stats = { total: 0 };
+ entries.forEach((e) => {
+ if (!e.isComment) {
+ stats.total++;
+ stats[e.pos] = (stats[e.pos] || 0) + 1;
+ }
+ });
+ return stats;
+ }, [entries]);
+
+ // 고유 ID 생성
+ const generateId = useCallback(
+ () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ []
+ );
+
+ // 사전 파일 파싱
+ const parseDict = useCallback((content) => {
+ const lines = content.split('\n');
+ return lines
+ .map((line) => {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#')) {
+ return {
+ isComment: true,
+ raw: line,
+ id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ };
+ }
+ const parts = trimmed.split('\t');
+ return {
+ word: parts[0] || '',
+ pos: parts[1] || 'NNP',
+ isComment: false,
+ id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ };
+ })
+ .filter((e) => e.isComment || e.word); // 빈 줄 제거하되 주석은 유지
+ }, []);
+
+ // 사전 파일 생성
+ const serializeDict = useCallback((entries) => {
+ return entries
+ .map((e) => {
+ if (e.isComment) return e.raw;
+ return `${e.word}\t${e.pos}`;
+ })
+ .join('\n');
+ }, []);
+
+ // 사전 내용 조회 (useQuery)
+ const {
+ data: dictContent,
+ isLoading: loading,
+ isError,
+ } = useQuery({
+ queryKey: ['admin', 'dict'],
+ queryFn: async () => {
+ const data = await suggestionsApi.getDict();
+ return data.content || '';
+ },
+ enabled: isAuthenticated,
+ });
+
+ // 사전 데이터 로드 후 파싱
+ useEffect(() => {
+ if (dictContent !== undefined) {
+ const parsed = parseDict(dictContent);
+ setEntries(parsed);
+ }
+ }, [dictContent, parseDict]);
+
+ // 에러 처리
+ useEffect(() => {
+ if (isError) {
+ setToast({ type: 'error', message: '사전을 불러올 수 없습니다.' });
+ }
+ }, [isError, setToast]);
+
+ // 사전 저장 (entries 배열을 받아서 저장)
+ const saveDict = async (newEntries) => {
+ try {
+ const content = serializeDict(newEntries);
+ await suggestionsApi.saveDict(content);
+ return true;
+ } catch (error) {
+ console.error('사전 저장 오류:', error);
+ setToast({ type: 'error', message: error.message || '저장 중 오류가 발생했습니다.' });
+ return false;
+ }
+ };
+
+ // 단어 추가 다이얼로그 열기
+ const openAddDialog = () => {
+ if (!newWord.trim()) return;
+
+ // 중복 확인
+ const isDuplicate = entries.some(
+ (e) => !e.isComment && e.word.toLowerCase() === newWord.trim().toLowerCase()
+ );
+ if (isDuplicate) {
+ setToast({ type: 'error', message: '이미 존재하는 단어입니다.' });
+ return;
+ }
+
+ setAddDialogOpen(true);
+ };
+
+ // 단어 추가 확인
+ const handleAddWord = async () => {
+ setSaving(true);
+ const wordToAdd = newWord.trim();
+ const newEntry = { word: wordToAdd, pos: newPos, isComment: false, id: generateId() };
+ const newEntries = [...entries, newEntry];
+
+ const success = await saveDict(newEntries);
+ if (success) {
+ setEntries(newEntries);
+ setNewWord('');
+ setToast({ type: 'success', message: `"${wordToAdd}" 단어가 추가되었습니다.` });
+ }
+ setAddDialogOpen(false);
+ setSaving(false);
+ };
+
+ // 단어 수정 (id 기반)
+ const handleUpdateWord = async (id, word, pos) => {
+ const entryIndex = entries.findIndex((e) => e.id === id);
+ if (entryIndex === -1) return;
+
+ const newEntries = [...entries];
+ newEntries[entryIndex] = { ...newEntries[entryIndex], word, pos };
+
+ const success = await saveDict(newEntries);
+ if (success) {
+ setEntries(newEntries);
+ }
+ };
+
+ // 단어 삭제 다이얼로그 열기
+ const openDeleteDialog = (id, word) => {
+ setWordToDelete({ id, word });
+ setDeleteDialogOpen(true);
+ };
+
+ // 단어 삭제 확인
+ const handleDeleteWord = async () => {
+ if (!wordToDelete) return;
+
+ setSaving(true);
+ const deletedWord = wordToDelete.word;
+ const newEntries = entries.filter((e) => e.id !== wordToDelete.id);
+
+ const success = await saveDict(newEntries);
+ if (success) {
+ setEntries(newEntries);
+ setToast({ type: 'success', message: `"${deletedWord}" 단어가 삭제되었습니다.` });
+ }
+ setDeleteDialogOpen(false);
+ setWordToDelete(null);
+ setSaving(false);
+ };
+
+ // 엔터키로 추가 다이얼로그 열기
+ const handleKeyDown = (e) => {
+ if (e.key === 'Enter') {
+ openAddDialog();
+ }
+ };
+
+ return (
+
+ setToast(null)} />
+
+ {/* 단어 추가 확인 다이얼로그 */}
+ !saving && setAddDialogOpen(false)}
+ onConfirm={handleAddWord}
+ title="단어 추가"
+ message={
+ <>
+ 다음 단어를 추가하시겠습니까?
+
+
{newWord}
+
+ {POS_TAGS.find((t) => t.value === newPos)?.label}
+
+
+ >
+ }
+ confirmText="추가"
+ loadingText="추가 중..."
+ loading={saving}
+ variant="primary"
+ icon={Plus}
+ />
+
+ {/* 단어 삭제 확인 다이얼로그 */}
+ {
+ if (!saving) {
+ setDeleteDialogOpen(false);
+ setWordToDelete(null);
+ }
+ }}
+ onConfirm={handleDeleteWord}
+ title="단어 삭제"
+ message={
+ <>
+ 다음 단어를 삭제하시겠습니까?
+ {wordToDelete?.word}
+ >
+ }
+ confirmText="삭제"
+ loadingText="삭제 중..."
+ loading={saving}
+ variant="danger"
+ />
+
+ {/* 메인 콘텐츠 */}
+
+ {/* 브레드크럼 */}
+
+
+
+
+
+
+ 일정 관리
+
+
+ 사전 관리
+
+
+ {/* 타이틀 */}
+
+ 사전 관리
+ 형태소 분석기 사용자 사전을 관리합니다
+
+
+ {/* 통계 카드 */}
+
+
+ {posStats.total || 0}
+ 전체 단어
+
+
+ {posStats.NNP || 0}
+ 고유명사
+
+
+ {posStats.NNG || 0}
+ 일반명사
+
+
+ {posStats.SL || 0}
+ 외국어
+
+
+
+ {/* 단어 추가 영역 */}
+
+ 단어 추가
+
+
+ setNewWord(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="추가할 단어 입력..."
+ className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
+ />
+
+
+
setShowNewPosDropdown(!showNewPosDropdown)}
+ className="flex items-center gap-2 px-4 py-3 bg-gray-100 hover:bg-gray-200 rounded-xl text-sm transition-colors w-full justify-between"
+ >
+ {POS_TAGS.find((t) => t.value === newPos)?.label.split(' ')[0]}
+
+
+
+ {showNewPosDropdown && (
+
+ {POS_TAGS.map((tag) => (
+ {
+ setNewPos(tag.value);
+ setShowNewPosDropdown(false);
+ }}
+ className={`w-full px-4 py-2.5 text-left hover:bg-gray-50 transition-colors ${
+ newPos === tag.value ? 'bg-primary/5 text-primary' : ''
+ }`}
+ >
+ {tag.label}
+ {tag.description}
+ 예: {tag.examples}
+
+ ))}
+
+ )}
+
+
+
+
+ 추가
+
+
+
+
+ {/* 단어 목록 */}
+
+ {/* 검색 및 필터 */}
+
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="단어 검색..."
+ className="w-full pl-11 pr-4 py-2.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
+ />
+
+
+
setShowFilterDropdown(!showFilterDropdown)}
+ className="flex items-center gap-2 px-4 py-2.5 bg-gray-100 hover:bg-gray-200 rounded-xl text-sm transition-colors"
+ >
+
+ {filterPos === 'all'
+ ? '전체 품사'
+ : POS_TAGS.find((t) => t.value === filterPos)?.label.split(' ')[0]}
+
+
+
+
+ {showFilterDropdown && (
+
+ {
+ setFilterPos('all');
+ setShowFilterDropdown(false);
+ }}
+ className={`w-full px-4 py-2 text-left hover:bg-gray-50 transition-colors text-sm ${
+ filterPos === 'all' ? 'bg-primary/5 text-primary' : ''
+ }`}
+ >
+ 전체 품사
+
+ {POS_TAGS.map((tag) => (
+ {
+ setFilterPos(tag.value);
+ setShowFilterDropdown(false);
+ }}
+ className={`w-full px-4 py-2 text-left hover:bg-gray-50 transition-colors text-sm ${
+ filterPos === tag.value ? 'bg-primary/5 text-primary' : ''
+ }`}
+ >
+ {tag.label.split(' ')[0]}
+
+ ))}
+
+ )}
+
+
+
+
+ {/* 테이블 */}
+ {loading ? (
+
+ ) : wordEntries.length === 0 ? (
+
+
+
{searchQuery || filterPos !== 'all' ? '검색 결과가 없습니다' : '등록된 단어가 없습니다'}
+
위의 입력창에서 단어를 추가하세요
+
+ ) : (
+
+
+
+
+ |
+ #
+ |
+
+ 단어
+ |
+
+ 품사
+ |
+ |
+
+
+
+
+ {wordEntries.map((entry, index) => (
+ openDeleteDialog(entry.id, entry.word)}
+ />
+ ))}
+
+
+
+
+ )}
+
+ {/* 푸터 */}
+ {wordEntries.length > 0 && (
+
+ {searchQuery || filterPos !== 'all' ? (
+
+ {wordEntries.length}개 검색됨 (전체 {posStats.total}개)
+
+ ) : (
+ 총 {posStats.total}개 단어
+ )}
+
+ )}
+
+
+
+ );
+}
+
+export default ScheduleDict;
diff --git a/frontend-temp/src/pages/pc/admin/schedules/ScheduleForm.jsx b/frontend-temp/src/pages/pc/admin/schedules/ScheduleForm.jsx
new file mode 100644
index 0000000..7e9dc7c
--- /dev/null
+++ b/frontend-temp/src/pages/pc/admin/schedules/ScheduleForm.jsx
@@ -0,0 +1,1046 @@
+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,
+ ChevronRight,
+ 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 { useAdminAuth } from '@/hooks/pc/admin';
+import { useToast } from '@/hooks/common';
+import * as categoriesApi from '@/api/pc/admin/categories';
+import * as schedulesApi from '@/api/pc/admin/schedules';
+import { getMembers } from '@/api/pc/common/members';
+
+function ScheduleForm() {
+ const navigate = useNavigate();
+ const { id } = useParams();
+ const isEditMode = !!id;
+ const { user, isAuthenticated } = useAdminAuth();
+
+ const { toast, setToast } = useToast();
+ const [loading, setLoading] = useState(false);
+
+ // 폼 데이터 (날짜/시간 범위 지원)
+ const [formData, setFormData] = useState({
+ title: '',
+ startDate: '',
+ endDate: '',
+ startTime: '',
+ endTime: '',
+ isRange: false, // 범위 설정 여부
+ category: '',
+ description: '',
+ url: '',
+ sourceName: '',
+ members: [],
+ images: [],
+ // 장소 정보
+ locationName: '', // 장소 이름
+ locationAddress: '', // 주소
+ locationDetail: '', // 상세주소 (예: 3관, N열 등)
+ locationLat: null, // 위도
+ locationLng: null, // 경도
+ });
+
+ // 이미지 미리보기
+ const [imagePreviews, setImagePreviews] = useState([]);
+
+ // 라이트박스 상태
+ const [lightboxOpen, setLightboxOpen] = useState(false);
+ const [lightboxIndex, setLightboxIndex] = useState(0);
+
+ // 삭제 다이얼로그 상태
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [deleteTargetIndex, setDeleteTargetIndex] = useState(null);
+
+ // 멤버 목록 조회
+ const { data: membersData = [] } = useQuery({
+ queryKey: ['members'],
+ queryFn: getMembers,
+ enabled: isAuthenticated,
+ staleTime: 5 * 60 * 1000,
+ });
+ const members = membersData.filter((m) => !m.is_former);
+
+ // 카테고리 목록 조회
+ const { data: categories = [] } = useQuery({
+ queryKey: ['admin', 'categories'],
+ queryFn: categoriesApi.getCategories,
+ enabled: isAuthenticated,
+ staleTime: 5 * 60 * 1000,
+ });
+
+ // 저장 중 상태
+ 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([]);
+
+ // 카테고리 색상 맵핑
+ const colorMap = {
+ blue: 'bg-blue-500',
+ green: 'bg-green-500',
+ purple: 'bg-purple-500',
+ red: 'bg-red-500',
+ pink: 'bg-pink-500',
+ yellow: 'bg-yellow-500',
+ orange: 'bg-orange-500',
+ gray: 'bg-gray-500',
+ cyan: 'bg-cyan-500',
+ indigo: 'bg-indigo-500',
+ };
+
+ // 색상 스타일 (기본 색상 또는 커스텀 HEX)
+ const getColorStyle = (color) => {
+ if (!color) return { className: 'bg-gray-500' };
+ if (color.startsWith('#')) {
+ return { style: { backgroundColor: color } };
+ }
+ return { className: colorMap[color] || 'bg-gray-500' };
+ };
+
+ // 첫 번째 카테고리를 기본값으로 설정
+ useEffect(() => {
+ if (categories.length > 0 && !formData.category && !isEditMode) {
+ setFormData((prev) => ({ ...prev, category: categories[0].id }));
+ }
+ }, [categories, isEditMode]);
+
+ // 수정 모드일 경우 기존 데이터 로드
+ useEffect(() => {
+ if (isAuthenticated && isEditMode && id) {
+ fetchSchedule();
+ }
+ }, [isAuthenticated, isEditMode, id]);
+
+ // 기존 일정 데이터 로드 (수정 모드)
+ const fetchSchedule = async () => {
+ setLoading(true);
+ try {
+ const data = await schedulesApi.getSchedule(id);
+
+ // 폼 데이터 설정
+ setFormData({
+ title: data.title || '',
+ startDate: data.date ? formatDate(data.date) : '',
+ endDate: data.end_date ? formatDate(data.end_date) : '',
+ startTime: data.time?.slice(0, 5) || '',
+ endTime: data.end_time?.slice(0, 5) || '',
+ isRange: !!data.end_date,
+ category: data.category_id || '',
+ description: data.description || '',
+ url: data.source?.url || '',
+ sourceName: data.source?.name || '',
+ members: data.members?.map((m) => m.id) || [],
+ images: [],
+ locationName: data.location_name || '',
+ locationAddress: data.location_address || '',
+ locationDetail: data.location_detail || '',
+ locationLat: data.location_lat || null,
+ locationLng: data.location_lng || null,
+ });
+
+ // 기존 이미지 설정
+ if (data.images && data.images.length > 0) {
+ // 기존 이미지를 formData.images에 저장 (id 포함)
+ setFormData((prev) => ({
+ ...prev,
+ title: data.title || '',
+ isRange: data.is_range || false,
+ startDate: data.date?.split('T')[0] || '',
+ endDate: data.end_date?.split('T')[0] || '',
+ startTime: data.time?.slice(0, 5) || '',
+ endTime: data.end_time?.slice(0, 5) || '',
+ category: data.category_id || 1,
+ description: data.description || '',
+ url: data.source?.url || '',
+ sourceName: data.source?.name || '',
+ members: data.members?.map((m) => m.id) || [],
+ images: data.images.map((img) => ({ id: img.id, url: img.image_url })),
+ locationName: data.location_name || '',
+ locationAddress: data.location_address || '',
+ locationDetail: data.location_detail || '',
+ locationLat: data.location_lat || null,
+ locationLng: data.location_lng || null,
+ }));
+ setImagePreviews(data.images.map((img) => img.image_url));
+ setExistingImageIds(data.images.map((img) => img.id));
+ }
+ } catch (error) {
+ console.error('일정 로드 오류:', error);
+ setToast({
+ type: 'error',
+ message: error.message || '일정을 불러오는 중 오류가 발생했습니다.',
+ });
+ navigate('/admin/schedule');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 멤버 토글
+ const toggleMember = (memberId) => {
+ const newMembers = formData.members.includes(memberId)
+ ? formData.members.filter((id) => id !== memberId)
+ : [...formData.members, memberId];
+ setFormData({ ...formData, members: newMembers });
+ };
+
+ // 전체 선택/해제
+ const toggleAllMembers = () => {
+ if (formData.members.length === members.length) {
+ setFormData({ ...formData, members: [] });
+ } else {
+ setFormData({ ...formData, members: members.map((m) => m.id) });
+ }
+ };
+
+ // 다중 이미지 업로드
+ 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 = () => {
+ if (deleteTargetIndex !== null) {
+ const deletedImage = formData.images[deleteTargetIndex];
+ const newImages = formData.images.filter((_, i) => i !== deleteTargetIndex);
+ const newPreviews = imagePreviews.filter((_, i) => i !== deleteTargetIndex);
+ setFormData({ ...formData, images: newImages });
+ setImagePreviews(newPreviews);
+
+ // 기존 이미지(서버에 있는)를 삭제한 경우 existingImageIds에서도 제거
+ if (deletedImage && deletedImage.id) {
+ setExistingImageIds((prev) => prev.filter((id) => id !== deletedImage.id));
+ }
+ }
+ setDeleteDialogOpen(false);
+ setDeleteTargetIndex(null);
+ };
+
+ // 라이트박스 열기
+ const openLightbox = (index) => {
+ setLightboxIndex(index);
+ 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);
+ };
+
+ // 드래그 오버
+ 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;
+ }
+
+ // 새 배열 생성
+ const newPreviews = [...imagePreviews];
+ const newImages = [...formData.images];
+
+ // 드래그된 아이템 제거 후 새 위치에 삽입
+ const [movedPreview] = newPreviews.splice(draggedIndex, 1);
+ const [movedImage] = newImages.splice(draggedIndex, 1);
+
+ newPreviews.splice(dropIndex, 0, movedPreview);
+ newImages.splice(dropIndex, 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([]);
+ };
+
+ // 폼 제출
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ // 유효성 검사
+ if (!formData.title.trim()) {
+ setToast({ type: 'error', message: '제목을 입력해주세요.' });
+ return;
+ }
+ // 날짜 검증: 단일/기간 모드 모두 startDate를 사용함
+ if (!formData.startDate) {
+ setToast({ type: 'error', message: '날짜를 선택해주세요.' });
+ return;
+ }
+
+ if (!formData.category) {
+ setToast({ type: 'error', message: '카테고리를 선택해주세요.' });
+ return;
+ }
+
+ setSaving(true);
+
+ try {
+ const token = localStorage.getItem('adminToken');
+
+ // FormData 생성
+ const submitData = new FormData();
+
+ // JSON 데이터 - 항상 startDate를 date로 사용 (UI에서 단일/기간 모드 모두 startDate 사용)
+ const jsonData = {
+ title: formData.title.trim(),
+ date: formData.startDate,
+ time: formData.startTime || null,
+ endDate: formData.isRange ? formData.endDate : null,
+ endTime: formData.isRange ? formData.endTime : null,
+ isRange: formData.isRange,
+ category: formData.category,
+ description: formData.description.trim() || null,
+ url: formData.url.trim() || null,
+ sourceName: formData.sourceName.trim() || null,
+ members: formData.members,
+ locationName: formData.locationName.trim() || null,
+ locationAddress: formData.locationAddress.trim() || null,
+ locationDetail: formData.locationDetail?.trim() || null,
+ locationLat: formData.locationLat,
+ locationLng: formData.locationLng,
+ };
+
+ // 수정 모드일 경우 유지할 기존 이미지 ID 추가
+ if (isEditMode) {
+ jsonData.existingImages = existingImageIds;
+ }
+
+ submitData.append('data', JSON.stringify(jsonData));
+
+ // 이미지 파일 추가 (새로 추가된 이미지만)
+ for (const image of formData.images) {
+ if (image.file) {
+ submitData.append('images', image.file);
+ }
+ }
+
+ // 수정 모드면 PUT, 생성 모드면 POST
+ const url = isEditMode ? `/api/admin/schedules/${id}` : '/api/admin/schedules';
+ const method = isEditMode ? 'PUT' : 'POST';
+
+ const response = await fetch(url, {
+ method,
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ body: submitData,
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(
+ error.error || (isEditMode ? '일정 수정에 실패했습니다.' : '일정 생성에 실패했습니다.')
+ );
+ }
+
+ // 성공 메시지를 sessionStorage에 저장하고 목록 페이지로 이동
+ sessionStorage.setItem(
+ 'scheduleToast',
+ JSON.stringify({
+ type: 'success',
+ message: isEditMode ? '일정이 수정되었습니다.' : '일정이 추가되었습니다.',
+ })
+ );
+ navigate('/admin/schedule');
+ } catch (error) {
+ console.error('일정 저장 오류:', error);
+ setToast({
+ type: 'error',
+ message: error.message || '일정 저장 중 오류가 발생했습니다.',
+ });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+ setToast(null)} />
+
+ {/* 삭제 확인 다이얼로그 */}
+ setDeleteDialogOpen(false)}
+ onConfirm={confirmDeleteImage}
+ title="이미지 삭제"
+ message={
+ <>
+ 이 이미지를 삭제하시겠습니까?
+
+ 이 작업은 되돌릴 수 없습니다.
+ >
+ }
+ />
+
+ {/* 장소 검색 다이얼로그 */}
+
+ {locationDialogOpen && (
+ {
+ setLocationDialogOpen(false);
+ setLocationSearch('');
+ setLocationResults([]);
+ }}
+ >
+ e.stopPropagation()}
+ >
+
+
장소 검색
+ {
+ setLocationDialogOpen(false);
+ setLocationSearch('');
+ setLocationResults([]);
+ }}
+ className="text-gray-400 hover:text-gray-600"
+ >
+
+
+
+
+ {/* 검색 입력 */}
+
+
+
+ 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
+ />
+
+
+ {locationSearching ? (
+
+
+
+ ) : (
+ '검색'
+ )}
+
+
+
+ {/* 검색 결과 */}
+
+ {locationResults.length > 0 ? (
+
+ {locationResults.map((place, index) => (
+
selectLocation(place)}
+ className="w-full p-3 text-left hover:bg-gray-50 rounded-xl flex items-start gap-3 border border-gray-100"
+ >
+
+
+
{place.place_name}
+
+ {place.road_address_name || place.address_name}
+
+ {place.category_name && (
+
{place.category_name}
+ )}
+
+
+ ))}
+
+ ) : locationSearch && !locationSearching ? (
+
+
+
검색어를 입력하고 검색 버튼을 눌러주세요
+
+ ) : (
+
+ )}
+
+
+
+ )}
+
+
+ {/* 이미지 라이트박스 - 공통 컴포넌트 사용 */}
+ setLightboxOpen(false)}
+ onIndexChange={setLightboxIndex}
+ />
+
+ {/* 메인 콘텐츠 */}
+
+ {/* 브레드크럼 */}
+
+
+
+
+
+
+ 일정 관리
+
+
+ {isEditMode ? '일정 수정' : '일정 추가'}
+
+
+ {/* 타이틀 */}
+
+
+ {isEditMode ? '일정 수정' : '일정 추가'}
+
+
새로운 일정을 등록합니다
+
+
+ {/* 폼 */}
+
+
+
+ );
+}
+
+export default ScheduleForm;
diff --git a/frontend-temp/src/pages/pc/admin/schedules/Schedules.jsx b/frontend-temp/src/pages/pc/admin/schedules/Schedules.jsx
new file mode 100644
index 0000000..7eea575
--- /dev/null
+++ b/frontend-temp/src/pages/pc/admin/schedules/Schedules.jsx
@@ -0,0 +1,1471 @@
+import { useState, useEffect, useRef, useMemo, memo, useDeferredValue } from 'react';
+import { useNavigate, Link } from 'react-router-dom';
+import { motion, AnimatePresence } from 'framer-motion';
+import {
+ Home,
+ 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 { AdminLayout, ConfirmDialog } from '@/components/pc/admin';
+import useScheduleStore from '@/stores/useScheduleStore';
+import { useAdminAuth } from '@/hooks/pc/admin';
+import { useToast } from '@/hooks/common';
+import { getTodayKST, formatDate } from '@/utils/date';
+import * as schedulesApi from '@/api/pc/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' };
+};
+
+// 카테고리 ID 상수
+const CATEGORY_IDS = {
+ YOUTUBE: 2,
+ X: 3,
+};
+
+// 카테고리별 수정 경로 반환
+const getEditPath = (scheduleId, categoryId) => {
+ switch (categoryId) {
+ case CATEGORY_IDS.YOUTUBE:
+ return `/admin/schedule/${scheduleId}/edit/youtube`;
+ case CATEGORY_IDS.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 && (
+
+ )}
+
+
+ );
+});
+
+function Schedules() {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+
+ // Zustand 스토어에서 상태 가져오기
+ const {
+ searchInput,
+ setSearchInput,
+ searchTerm,
+ setSearchTerm,
+ isSearchMode,
+ setIsSearchMode,
+ selectedCategories,
+ setSelectedCategories,
+ selectedDate,
+ setSelectedDate,
+ currentDate,
+ setCurrentDate,
+ scrollPosition,
+ setScrollPosition,
+ } = useScheduleStore();
+
+ const { user, isAuthenticated } = useAdminAuth();
+
+ // 로컬 상태 (페이지 이동 시 유지할 필요 없는 것들)
+ const { toast, setToast } = useToast();
+ const scrollContainerRef = useRef(null);
+ const searchContainerRef = useRef(null); // 검색 컨테이너 (외부 클릭 감지용)
+
+ // 검색 추천 관련 상태
+ const [showSuggestions, setShowSuggestions] = useState(false);
+ const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
+ const [originalSearchQuery, setOriginalSearchQuery] = useState(''); // 필터링용 원본 쿼리
+ const [suggestions, setSuggestions] = useState([]); // 추천 검색어 목록
+ const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
+
+ const SEARCH_LIMIT = 20; // 페이지당 20개
+ const ESTIMATED_ITEM_HEIGHT = 100; // 아이템 추정 높이 (동적 측정)
+
+ // Intersection Observer for infinite scroll
+ const { ref: loadMoreRef, inView } = useInView({
+ threshold: 0,
+ rootMargin: '100px',
+ });
+
+ // useInfiniteQuery for search
+ const {
+ data: searchData,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isLoading: searchLoading,
+ } = useInfiniteQuery({
+ queryKey: ['adminScheduleSearch', searchTerm],
+ queryFn: async ({ pageParam = 0 }) => {
+ return schedulesApi.searchSchedules(searchTerm, { offset: pageParam, limit: SEARCH_LIMIT });
+ },
+ getNextPageParam: (lastPage) => {
+ if (lastPage.hasMore) {
+ return lastPage.offset + lastPage.schedules.length;
+ }
+ return undefined;
+ },
+ enabled: !!searchTerm && isSearchMode,
+ });
+
+ // Flatten search results
+ const searchResults = useMemo(() => {
+ if (!searchData?.pages) return [];
+ return searchData.pages.flatMap((page) => page.schedules);
+ }, [searchData]);
+
+ // Auto fetch next page when scrolled to bottom
+ // inView가 true로 변경될 때만 fetch (중복 요청 방지)
+ const prevInViewRef = useRef(false);
+ useEffect(() => {
+ // inView가 false→true로 변경될 때만 fetch
+ if (inView && !prevInViewRef.current && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) {
+ fetchNextPage();
+ }
+ prevInViewRef.current = inView;
+ }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]);
+
+ // 검색어 자동완성 API 호출 (debounce 적용)
+ useEffect(() => {
+ // 검색어가 비어있으면 초기화
+ if (!originalSearchQuery || originalSearchQuery.trim().length === 0) {
+ setSuggestions([]);
+ return;
+ }
+
+ // debounce: 200ms 후에 API 호출
+ const timeoutId = setTimeout(async () => {
+ setIsLoadingSuggestions(true);
+ try {
+ const response = await fetch(
+ `/api/schedules/suggestions?q=${encodeURIComponent(originalSearchQuery)}&limit=10`
+ );
+ if (response.ok) {
+ const data = await response.json();
+ setSuggestions(data.suggestions || []);
+ }
+ } catch (error) {
+ console.error('추천 검색어 API 오류:', error);
+ setSuggestions([]);
+ } finally {
+ setIsLoadingSuggestions(false);
+ }
+ }, 200);
+
+ return () => clearTimeout(timeoutId);
+ }, [originalSearchQuery]);
+
+ // selectedDate가 없으면 오늘 날짜로 초기화
+ useEffect(() => {
+ if (!selectedDate) {
+ setSelectedDate(getTodayKST());
+ }
+ }, []);
+
+ const [slideDirection, setSlideDirection] = useState(0);
+
+ // 년월 선택 관련 (Schedule.jsx와 동일한 패턴)
+ const [showYearMonthPicker, setShowYearMonthPicker] = useState(false);
+ const [showCategoryTooltip, setShowCategoryTooltip] = useState(false);
+ const [viewMode, setViewMode] = useState('yearMonth'); // 'yearMonth' | 'months'
+ const pickerRef = useRef(null);
+ const categoryTooltipRef = useRef(null);
+
+ // 달력 관련
+ const year = currentDate.getFullYear();
+ const month = currentDate.getMonth();
+ const firstDay = new Date(year, month, 1).getDay();
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
+ const days = ['일', '월', '화', '수', '목', '금', '토'];
+ const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
+
+ // 년도 범위 (2017년부터 시작, 12년 단위)
+ const MIN_YEAR = 2017;
+ const groupIndex = Math.floor((year - MIN_YEAR) / 12);
+ const startYear = MIN_YEAR + groupIndex * 12;
+ const yearRange = Array.from({ length: 12 }, (_, i) => startYear + i);
+ const canGoPrevYearRange = startYear > MIN_YEAR;
+
+ // 현재 년도/월 확인 함수
+ const currentYear = new Date().getFullYear();
+ const currentMonth = new Date().getMonth();
+ const isCurrentYear = (y) => currentYear === y;
+ const isCurrentMonth = (m) => currentYear === year && currentMonth === m;
+
+ const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate();
+
+ // 일정 목록 (React Query로 캐싱)
+ const { data: schedules = [], isLoading: loading } = useQuery({
+ queryKey: ['adminSchedules', year, month + 1],
+ queryFn: () => schedulesApi.getSchedules(year, month + 1),
+ enabled: isAuthenticated,
+ });
+
+ // 카테고리는 일정 데이터에서 추출
+ const categories = useMemo(() => {
+ const categoryMap = new Map();
+ schedules.forEach((s) => {
+ if (s.category_id && !categoryMap.has(s.category_id)) {
+ categoryMap.set(s.category_id, {
+ id: s.category_id,
+ name: s.category_name,
+ color: s.category_color,
+ });
+ }
+ });
+ return [{ id: 'all', name: '전체', color: 'gray' }, ...Array.from(categoryMap.values())];
+ }, [schedules]);
+
+ // 카테고리 색상 맵핑
+ const colorMap = {
+ blue: 'bg-blue-500',
+ green: 'bg-green-500',
+ purple: 'bg-purple-500',
+ red: 'bg-red-500',
+ pink: 'bg-pink-500',
+ yellow: 'bg-yellow-500',
+ orange: 'bg-orange-500',
+ gray: 'bg-gray-500',
+ };
+
+ // 색상 스타일 (기본 색상 또는 커스텀 HEX)
+ const getColorStyle = (color) => {
+ if (!color) return { className: 'bg-gray-500' };
+ if (color.startsWith('#')) {
+ return { style: { backgroundColor: color } };
+ }
+ return { className: colorMap[color] || 'bg-gray-500' };
+ };
+
+ // 일정 데이터를 지연 처리하여 달력 UI 응답성 향상
+ const deferredSchedules = useDeferredValue(schedules);
+
+ // 일정 날짜별 맵 (O(1) 조회용) - 지연된 데이터로 점 표시
+ const scheduleDateMap = useMemo(() => {
+ const map = new Map();
+ deferredSchedules.forEach((s) => {
+ const dateStr = formatDate(s.date);
+ if (!map.has(dateStr)) {
+ map.set(dateStr, s);
+ }
+ });
+ return map;
+ }, [deferredSchedules]);
+
+ // 해당 날짜에 일정이 있는지 확인 (O(1))
+ const hasSchedule = (day) => {
+ const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+ return scheduleDateMap.has(dateStr);
+ };
+
+ useEffect(() => {
+ if (!isAuthenticated) return;
+
+ // sessionStorage에서 토스트 메시지 확인 (일정 추가/수정 완료 시)
+ const savedToast = sessionStorage.getItem('scheduleToast');
+ if (savedToast) {
+ setToast(JSON.parse(savedToast));
+ sessionStorage.removeItem('scheduleToast');
+ // 추가/수정 후 돌아왔을 때 캐시 무효화
+ queryClient.invalidateQueries({ queryKey: ['adminSchedules'] });
+ }
+ }, [isAuthenticated, queryClient]);
+
+ // 스크롤 위치 복원
+ useEffect(() => {
+ if (scrollContainerRef.current && scrollPosition > 0) {
+ scrollContainerRef.current.scrollTop = scrollPosition;
+ }
+ }, [loading]); // 로딩이 끝나면 스크롤 복원
+
+ // 날짜 변경 시 스크롤 맨 위로 초기화
+ useEffect(() => {
+ if (scrollContainerRef.current) {
+ scrollContainerRef.current.scrollTop = 0;
+ }
+ }, [selectedDate]);
+
+ // 스크롤 위치 저장
+ const handleScroll = (e) => {
+ setScrollPosition(e.target.scrollTop);
+ };
+
+ // 외부 클릭 시 피커 닫기
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (pickerRef.current && !pickerRef.current.contains(event.target)) {
+ setShowYearMonthPicker(false);
+ setViewMode('yearMonth');
+ }
+ if (categoryTooltipRef.current && !categoryTooltipRef.current.contains(event.target)) {
+ setShowCategoryTooltip(false);
+ }
+ // 검색 추천 드롭다운 외부 클릭 시 닫기
+ if (searchContainerRef.current && !searchContainerRef.current.contains(event.target)) {
+ setShowSuggestions(false);
+ setSelectedSuggestionIndex(-1);
+ }
+ };
+
+ if (showYearMonthPicker || showCategoryTooltip) {
+ document.addEventListener('mousedown', handleClickOutside);
+ }
+
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [showYearMonthPicker, showCategoryTooltip]);
+
+ // 2017년 1월 이전으로 이동 불가
+ const canGoPrevMonth = !(year === 2017 && month === 0);
+
+ // 월 이동
+ const prevMonth = () => {
+ if (!canGoPrevMonth) return;
+ setSlideDirection(-1);
+ const newDate = new Date(year, month - 1, 1);
+ setCurrentDate(newDate);
+ // 이번달이면 오늘, 다른 달이면 1일 선택
+ const today = new Date();
+ if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) {
+ setSelectedDate(getTodayKST());
+ } else {
+ const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
+ setSelectedDate(firstDay);
+ }
+ };
+
+ const nextMonth = () => {
+ setSlideDirection(1);
+ const newDate = new Date(year, month + 1, 1);
+ setCurrentDate(newDate);
+ // 이번달이면 오늘, 다른 달이면 1일 선택
+ const today = new Date();
+ if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) {
+ setSelectedDate(getTodayKST());
+ } else {
+ const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
+ setSelectedDate(firstDay);
+ }
+ };
+
+ // 년도 범위 이동 (12년 단위, 2017년 이전 불가)
+ const prevYearRange = () => canGoPrevYearRange && setCurrentDate(new Date(startYear - 12, month, 1));
+ const nextYearRange = () => setCurrentDate(new Date(startYear + 12, month, 1));
+
+ // 년도 선택
+ const selectYear = (newYear) => {
+ setCurrentDate(new Date(newYear, month, 1));
+ };
+
+ // 월 선택 시 적용 후 닫기
+ const selectMonth = (newMonth) => {
+ const newDate = new Date(year, newMonth, 1);
+ setCurrentDate(newDate);
+ // 이번달이면 오늘, 다른 달이면 1일 선택
+ const today = new Date();
+ if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) {
+ setSelectedDate(getTodayKST());
+ } else {
+ const firstDay = `${year}-${String(newMonth + 1).padStart(2, '0')}-01`;
+ setSelectedDate(firstDay);
+ }
+ setShowYearMonthPicker(false);
+ setViewMode('yearMonth');
+ };
+
+ // 날짜 선택 (토글 없이 항상 선택)
+ const selectDate = (day) => {
+ const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+ setSelectedDate(dateStr);
+ };
+
+ // 삭제 관련 상태
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [scheduleToDelete, setScheduleToDelete] = useState(null);
+ const [deleting, setDeleting] = useState(false);
+
+ // 삭제 확인 다이얼로그 열기
+ const openDeleteDialog = (schedule) => {
+ setScheduleToDelete(schedule);
+ setDeleteDialogOpen(true);
+ };
+
+ // 일정 삭제
+ const handleDelete = async () => {
+ if (!scheduleToDelete) return;
+
+ setDeleting(true);
+ try {
+ await schedulesApi.deleteSchedule(scheduleToDelete.id);
+ setToast({ type: 'success', message: '일정이 삭제되었습니다.' });
+ // 캐시 무효화하여 목록 새로고침
+ queryClient.invalidateQueries({ queryKey: ['adminSchedules', year, month + 1] });
+ } catch (error) {
+ console.error('삭제 오류:', error);
+ setToast({ type: 'error', message: error.message || '삭제 중 오류가 발생했습니다.' });
+ } finally {
+ setDeleting(false);
+ setDeleteDialogOpen(false);
+ setScheduleToDelete(null);
+ }
+ };
+
+ // 일정 목록 (검색 모드일 때 searchResults, 일반 모드일 때 로컬 필터링) - useMemo로 최적화
+ const filteredSchedules = useMemo(() => {
+ let result;
+ if (isSearchMode) {
+ if (!searchTerm) return [];
+ // 카테고리 필터링 적용
+ if (selectedCategories.length === 0) {
+ result = [...searchResults];
+ } else {
+ result = searchResults.filter((s) => selectedCategories.includes(getCategoryId(s)));
+ }
+ } else {
+ // 일반 모드: 로컬 필터링
+ result = schedules.filter((schedule) => {
+ const matchesCategory =
+ selectedCategories.length === 0 || selectedCategories.includes(schedule.category_id);
+ const scheduleDate = formatDate(schedule.date);
+ const matchesDate = !selectedDate || scheduleDate === selectedDate;
+ return matchesCategory && matchesDate;
+ });
+ }
+ // 생일 일정을 맨 위로 정렬
+ return result.sort((a, b) => {
+ const aIsBirthday = a.is_birthday || String(a.id).startsWith('birthday-');
+ const bIsBirthday = b.is_birthday || String(b.id).startsWith('birthday-');
+ if (aIsBirthday && !bIsBirthday) return -1;
+ if (!aIsBirthday && bIsBirthday) return 1;
+ return 0;
+ });
+ }, [isSearchMode, searchTerm, searchResults, schedules, selectedCategories, selectedDate]);
+
+ // 가상 스크롤 설정 (검색 모드에서만 활성화, 동적 높이 지원)
+ const virtualizer = useVirtualizer({
+ count: isSearchMode && searchTerm ? filteredSchedules.length : 0,
+ getScrollElement: () => scrollContainerRef.current,
+ estimateSize: () => ESTIMATED_ITEM_HEIGHT,
+ overscan: 5, // 버퍼 아이템 수
+ });
+
+ // 카테고리별 카운트 맵 (useMemo로 미리 계산) - 선택된 날짜 기준
+ const categoryCounts = useMemo(() => {
+ // 검색어가 있을 때만 검색 결과 사용, 아니면 기존 schedules 사용
+ const source = isSearchMode && searchTerm ? searchResults : schedules;
+ const counts = new Map();
+ let total = 0;
+
+ source.forEach((s) => {
+ // 검색 모드에서 검색어가 있을 때는 전체 대상
+ // 그 외에는 선택된 날짜 기준으로 필터링
+ if (!(isSearchMode && searchTerm) && selectedDate) {
+ const sDate = getScheduleDate(s);
+ const scheduleDate = formatDate(sDate);
+ if (scheduleDate !== selectedDate) return;
+ }
+
+ const catId = getCategoryId(s);
+ counts.set(catId, (counts.get(catId) || 0) + 1);
+ total++;
+ });
+
+ counts.set('total', total);
+ return counts;
+ }, [schedules, searchResults, isSearchMode, searchTerm, selectedDate]);
+
+ // 정렬된 카테고리 목록 (메모이제이션으로 깜빡임 방지)
+ const sortedCategories = useMemo(() => {
+ const total = categoryCounts.get('total') || 0;
+
+ return categories
+ .map((category) => ({
+ ...category,
+ count: category.id === 'all' ? total : categoryCounts.get(category.id) || 0,
+ }))
+ .filter((category) => category.id === 'all' || category.count > 0)
+ .sort((a, b) => {
+ if (a.id === 'all') return -1;
+ if (b.id === 'all') return 1;
+ if (a.name === '기타') return 1;
+ if (b.name === '기타') return -1;
+ return b.count - a.count;
+ });
+ }, [categories, categoryCounts]);
+
+ // 연도 버튼 클래스
+ const getYearButtonClass = (y) => {
+ if (year === y) {
+ return 'bg-primary text-white';
+ }
+ if (isCurrentYear(y)) {
+ return 'text-primary font-medium hover:bg-primary/10';
+ }
+ return 'hover:bg-gray-100 text-gray-700';
+ };
+
+ // 월 버튼 클래스
+ const getMonthButtonClass = (m) => {
+ if (month === m) {
+ return 'bg-primary text-white';
+ }
+ if (isCurrentMonth(m)) {
+ return 'text-primary font-medium hover:bg-primary/10';
+ }
+ return 'hover:bg-gray-100 text-gray-700';
+ };
+
+ return (
+
+ setToast(null)} />
+
+ {/* 삭제 확인 다이얼로그 */}
+ setDeleteDialogOpen(false)}
+ onConfirm={handleDelete}
+ title="일정 삭제"
+ message={
+ <>
+ 다음 일정을 삭제하시겠습니까?
+
+ {scheduleToDelete?.title}
+
+ 이 작업은 되돌릴 수 없습니다.
+ >
+ }
+ loading={deleting}
+ />
+
+ {/* 메인 콘텐츠 - 전체 높이 차지 */}
+
+ {/* 브레드크럼 */}
+
+
+
+
+
+ 일정 관리
+
+
+ {/* 타이틀 + 추가 버튼 */}
+
+
+
일정 관리
+
fromis_9의 일정을 관리합니다
+
+
+
navigate('/admin/schedule/dict')}
+ className="flex items-center gap-2 px-5 py-3 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 transition-colors font-medium"
+ >
+
+ 사전 관리
+
+
navigate('/admin/schedule/bots')}
+ className="flex items-center gap-2 px-5 py-3 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 transition-colors font-medium"
+ >
+
+ 봇 관리
+
+
navigate('/admin/schedule/new')}
+ className="flex items-center gap-2 px-5 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors font-medium shadow-sm"
+ >
+
+ 일정 추가
+
+
+
+
+
+ {/* 왼쪽: 달력 + 카테고리 필터 */}
+
+ {/* 달력 (Schedule.jsx와 동일한 패턴) */}
+
+ {/* 달력 헤더 */}
+
+
+
+
+ !isSearchMode && setShowYearMonthPicker(!showYearMonthPicker)}
+ disabled={isSearchMode}
+ className={`flex items-center gap-1 text-xl font-bold transition-colors ${isSearchMode ? 'cursor-not-allowed' : 'hover:text-primary'}`}
+ >
+
+ {year}년 {month + 1}월
+
+
+
+
+
+
+
+
+ {/* 년/월 선택 팝업 (Schedule.jsx와 동일한 스타일) */}
+
+ {showYearMonthPicker && (
+
+ {/* 헤더 - 년도 범위 이동 */}
+
+
+
+
+
+ {viewMode === 'yearMonth'
+ ? `${yearRange[0]} - ${yearRange[yearRange.length - 1]}`
+ : `${year}년`}
+
+
+
+
+
+
+
+ {viewMode === 'yearMonth' && (
+
+ {/* 년도 선택 */}
+ 년도
+
+ {yearRange.map((y) => (
+ selectYear(y)}
+ className={`py-2 text-sm rounded-lg transition-colors ${getYearButtonClass(y)}`}
+ >
+ {y}
+
+ ))}
+
+
+ {/* 월 선택 */}
+ 월
+
+ {monthNames.map((m, i) => (
+ selectMonth(i)}
+ className={`py-2 text-sm rounded-lg transition-colors ${getMonthButtonClass(i)}`}
+ >
+ {m}
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+
+ {/* 요일 헤더 + 날짜 그리드 */}
+
+
+ {/* 요일 헤더 */}
+
+ {days.map((day, i) => (
+
+ {day}
+
+ ))}
+
+
+ {/* 날짜 그리드 */}
+
+ {/* 전달 날짜 */}
+ {Array.from({ length: firstDay }).map((_, i) => {
+ const prevMonthDays = getDaysInMonth(year, month - 1);
+ const day = prevMonthDays - firstDay + i + 1;
+ return (
+
+ {day}
+
+ );
+ })}
+
+ {/* 현재 달 날짜 */}
+ {Array.from({ length: daysInMonth }).map((_, i) => {
+ const day = i + 1;
+ const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+ const isSelected = selectedDate === dateStr;
+ const dayOfWeek = (firstDay + i) % 7;
+ const isToday =
+ new Date().toDateString() === new Date(year, month, day).toDateString();
+
+ // 해당 날짜의 일정 목록 (점 표시용, 최대 3개)
+ const daySchedules = schedules
+ .filter((s) => {
+ const scheduleDate = s.date ? s.date.split('T')[0] : '';
+ const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+ return scheduleDate === dateStr;
+ })
+ .slice(0, 3);
+
+ return (
+
!isSearchMode && selectDate(day)}
+ disabled={isSearchMode}
+ className={`aspect-square flex flex-col items-center justify-center rounded-full text-base font-medium transition-all relative
+ ${isSearchMode ? 'cursor-not-allowed opacity-50' : 'hover:bg-gray-100'}
+ ${isSelected && !isSearchMode ? 'bg-primary text-white shadow-lg hover:bg-primary' : ''}
+ ${isToday && !isSelected ? 'text-primary font-bold' : ''}
+ ${dayOfWeek === 0 && !isSelected && !isToday ? 'text-red-500' : ''}
+ ${dayOfWeek === 6 && !isSelected && !isToday ? 'text-blue-500' : ''}
+ `}
+ >
+ {day}
+ {/* 점: 선택되지 않은 날짜에만 표시, 최대 3개 */}
+ {!isSelected && daySchedules.length > 0 && (
+
+ {daySchedules.map((schedule, idx) => (
+ c.id === schedule.category_id)?.color
+ )?.style?.backgroundColor || '#6b7280',
+ }}
+ />
+ ))}
+
+ )}
+
+ );
+ })}
+
+ {/* 다음달 날짜 */}
+ {(() => {
+ const totalCells = firstDay + daysInMonth;
+ const remainder = totalCells % 7;
+ const nextDays = remainder === 0 ? 0 : 7 - remainder;
+ return Array.from({ length: nextDays }).map((_, i) => (
+
+ {i + 1}
+
+ ));
+ })()}
+
+
+
+ {/* 범례 */}
+
+
+
+ {/* 카테고리 필터 */}
+
+ 카테고리
+
+ {/* 카테고리 - useMemo로 정렬됨 */}
+ {sortedCategories.map((category) => {
+ const isSelected =
+ category.id === 'all'
+ ? selectedCategories.length === 0
+ : selectedCategories.includes(category.id);
+
+ const handleClick = () => {
+ if (category.id === 'all') {
+ setSelectedCategories([]);
+ } else {
+ if (selectedCategories.includes(category.id)) {
+ setSelectedCategories(selectedCategories.filter((id) => id !== category.id));
+ } else {
+ setSelectedCategories([...selectedCategories, category.id]);
+ }
+ }
+ };
+
+ return (
+
+
+ {category.name}
+ {category.count}
+
+ );
+ })}
+
+
+
+
+ {/* 오른쪽: 일정 목록 */}
+
+ {/* 일정 목록 */}
+
+
+
+
+ {isSearchMode ? (
+ /* 검색 모드 */
+
+ {
+ setIsSearchMode(false);
+ setSearchInput('');
+ setOriginalSearchQuery('');
+ setSearchTerm('');
+ setShowSuggestions(false);
+ setSelectedSuggestionIndex(-1);
+ }}
+ className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
+ >
+
+
+
+ {/* 검색 입력 컨테이너 (드롭다운 포함) */}
+
+
+ {
+ setSearchInput(e.target.value);
+ setOriginalSearchQuery(e.target.value);
+ setShowSuggestions(true);
+ setSelectedSuggestionIndex(-1);
+ }}
+ onFocus={() => setShowSuggestions(true)}
+ onKeyDown={(e) => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ const newIndex =
+ selectedSuggestionIndex < suggestions.length - 1
+ ? selectedSuggestionIndex + 1
+ : 0;
+ setSelectedSuggestionIndex(newIndex);
+ if (suggestions[newIndex]) {
+ setSearchInput(suggestions[newIndex]);
+ }
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ const newIndex =
+ selectedSuggestionIndex > 0
+ ? selectedSuggestionIndex - 1
+ : suggestions.length - 1;
+ setSelectedSuggestionIndex(newIndex);
+ if (suggestions[newIndex]) {
+ setSearchInput(suggestions[newIndex]);
+ }
+ } else if (e.key === 'Enter') {
+ if (
+ selectedSuggestionIndex >= 0 &&
+ suggestions[selectedSuggestionIndex]
+ ) {
+ setSearchInput(suggestions[selectedSuggestionIndex]);
+ setSearchTerm(suggestions[selectedSuggestionIndex]);
+ } else if (searchInput.trim()) {
+ setSearchTerm(searchInput);
+ }
+ setShowSuggestions(false);
+ setSelectedSuggestionIndex(-1);
+ } else if (e.key === 'Escape') {
+ setIsSearchMode(false);
+ setSearchInput('');
+ setOriginalSearchQuery('');
+ setSearchTerm('');
+ setShowSuggestions(false);
+ setSelectedSuggestionIndex(-1);
+ }
+ }}
+ className="flex-1 bg-transparent focus:outline-none text-gray-700 placeholder-gray-400"
+ />
+ {
+ if (searchInput.trim()) {
+ setSearchTerm(searchInput);
+ setShowSuggestions(false);
+ setSelectedSuggestionIndex(-1);
+ }
+ }}
+ disabled={searchLoading}
+ className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
+ >
+
+
+
+
+ {/* 검색어 추천 드롭다운 */}
+ {showSuggestions &&
+ !isLoadingSuggestions &&
+ suggestions.length > 0 && (
+
+ {suggestions.map((suggestion, index) => (
+ {
+ setSearchInput(suggestion);
+ setSearchTerm(suggestion);
+ setShowSuggestions(false);
+ setSelectedSuggestionIndex(-1);
+ }}
+ className={`w-full px-4 py-2 text-left text-sm flex items-center gap-3 transition-colors ${
+ index === selectedSuggestionIndex
+ ? 'bg-primary/10 text-primary'
+ : 'text-gray-700 hover:bg-gray-50'
+ }`}
+ >
+
+ {suggestion}
+
+ ))}
+
+ )}
+
+
+ ) : (
+ /* 일반 모드 */
+
+ setIsSearchMode(true)}
+ className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
+ >
+
+
+
+ {selectedDate
+ ? (() => {
+ const d = new Date(selectedDate);
+ const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
+ return `${d.getMonth() + 1}월 ${d.getDate()}일 ${dayNames[d.getDay()]}요일`;
+ })()
+ : `${month + 1}월 전체 일정`}
+
+
+ {/* 카테고리 필터 */}
+ {selectedCategories.length > 0 && (
+
+
setShowCategoryTooltip(!showCategoryTooltip)}
+ className="flex items-center gap-1 px-2 py-1 bg-gray-100 rounded-md text-sm text-gray-600 hover:bg-gray-200 transition-colors"
+ >
+
+ {selectedCategories.length}개 일정
+
+
+ {showCategoryTooltip && (
+
+ {selectedCategories.map((id) => {
+ const cat = categories.find((c) => c.id === id);
+ if (!cat) return null;
+ return (
+
+
+ {cat.name}
+
+ );
+ })}
+
+ )}
+
+
+ )}
+ {filteredSchedules.length}개 일정
+
+ )}
+
+
+
+
+ {loading ? (
+
+ ) : filteredSchedules.length === 0 ? (
+ // 검색 모드에서는 빈 메시지 표시 안 함
+ !isSearchMode && (
+
+
+ 등록된 일정이 없습니다
+
+ )
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+}
+
+export default Schedules;