refactor: 관리자 기반 설정 마이그레이션 (Phase 2)
- 관리자 API 추가 (albums, members, schedules, categories, stats, suggestions, bots) - AdminLayout/Header 컴포넌트 추가 - 공통 컴포넌트 추가 (ConfirmDialog, DatePicker, TimePicker, NumberPicker) - AdminLogin 페이지 마이그레이션 - App.jsx에 관리자 라우트 추가 (/admin) - ScheduleDetail.jsx import 경로 수정 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
67a41d78ea
commit
4a26369dff
19 changed files with 1388 additions and 19 deletions
|
|
@ -468,21 +468,22 @@ frontend-temp/src/
|
|||
|
||||
## 작업 체크리스트
|
||||
|
||||
### 1단계: 폴더 구조 재편
|
||||
- [ ] api/ 구조 변경 (common/, pc/common/, pc/public/, pc/admin/)
|
||||
- [ ] components/ 구조 변경 (common/, pc/public/, pc/admin/, mobile/)
|
||||
- [ ] hooks/ 구조 변경 (common/, pc/admin/)
|
||||
- [ ] pages/ 구조 변경 (pc/public/, pc/admin/, mobile/)
|
||||
- [ ] 모든 import 경로 업데이트
|
||||
- [ ] 각 폴더에 index.js 생성 (re-export)
|
||||
- [ ] 빌드 및 동작 확인
|
||||
### 1단계: 폴더 구조 재편 ✅
|
||||
- [x] api/ 구조 변경 (common/, pc/common/, pc/public/, pc/admin/)
|
||||
- [x] components/ 구조 변경 (common/, pc/public/, pc/admin/, mobile/)
|
||||
- [x] hooks/ 구조 변경 (common/, pc/admin/)
|
||||
- [x] pages/ 구조 변경 (pc/public/, mobile/)
|
||||
- [x] 모든 import 경로 업데이트
|
||||
- [x] 각 폴더에 index.js 생성 (re-export)
|
||||
- [x] 개발 서버 동작 확인
|
||||
|
||||
### 2단계: 관리자 기반 설정
|
||||
- [ ] 관리자 API 마이그레이션 (api/pc/admin/)
|
||||
- [ ] AdminLayout 마이그레이션 (components/pc/admin/)
|
||||
- [ ] AdminHeader 마이그레이션 (components/pc/admin/)
|
||||
- [ ] useAdminAuth 훅 이동 (hooks/pc/admin/)
|
||||
- [ ] 관리자 라우트 설정 (App.jsx)
|
||||
### 2단계: 관리자 기반 설정 ✅
|
||||
- [x] 관리자 API 마이그레이션 (api/pc/admin/)
|
||||
- [x] AdminLayout 마이그레이션 (components/pc/admin/)
|
||||
- [x] AdminHeader 마이그레이션 (components/pc/admin/)
|
||||
- [x] useAdminAuth 훅 이동 (hooks/pc/admin/)
|
||||
- [x] 관리자 라우트 설정 (App.jsx)
|
||||
- [x] AdminLogin 페이지 마이그레이션
|
||||
|
||||
### 3단계: 간단한 페이지
|
||||
- [ ] AdminLogin 마이그레이션
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { Layout as PCLayout } from '@/components/pc/public';
|
|||
// Mobile 레이아웃
|
||||
import { Layout as MobileLayout } from '@/components/mobile';
|
||||
|
||||
// PC 페이지
|
||||
// PC 공개 페이지
|
||||
import PCHome from '@/pages/pc/public/home/Home';
|
||||
import PCMembers from '@/pages/pc/public/members/Members';
|
||||
import PCSchedule from '@/pages/pc/public/schedule/Schedule';
|
||||
|
|
@ -23,6 +23,9 @@ import PCTrackDetail from '@/pages/pc/public/album/TrackDetail';
|
|||
import PCAlbumGallery from '@/pages/pc/public/album/AlbumGallery';
|
||||
import PCNotFound from '@/pages/pc/public/common/NotFound';
|
||||
|
||||
// PC 관리자 페이지
|
||||
import AdminLogin from '@/pages/pc/admin/Login';
|
||||
|
||||
// Mobile 페이지
|
||||
import MobileHome from '@/pages/mobile/home/Home';
|
||||
import MobileMembers from '@/pages/mobile/members/Members';
|
||||
|
|
@ -59,8 +62,8 @@ function App() {
|
|||
<BrowserView>
|
||||
<PCWrapper>
|
||||
<Routes>
|
||||
{/* 관리자 페이지 (레이아웃 없음) - 추후 추가 */}
|
||||
{/* <Route path="/admin" element={<AdminLogin />} /> */}
|
||||
{/* 관리자 페이지 (레이아웃 없음) */}
|
||||
<Route path="/admin" element={<AdminLogin />} />
|
||||
|
||||
{/* 일반 페이지 (레이아웃 포함) */}
|
||||
<Route
|
||||
|
|
|
|||
97
frontend-temp/src/api/pc/admin/albums.js
Normal file
97
frontend-temp/src/api/pc/admin/albums.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* 관리자 앨범 API
|
||||
*/
|
||||
import { fetchAuthApi, fetchFormData } from '@/api/common/client';
|
||||
|
||||
/**
|
||||
* 앨범 목록 조회
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
export async function getAlbums() {
|
||||
return fetchAuthApi('/albums');
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 상세 조회
|
||||
* @param {number} id - 앨범 ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function getAlbum(id) {
|
||||
return fetchAuthApi(`/albums/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 생성
|
||||
* @param {FormData} formData - 앨범 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function createAlbum(formData) {
|
||||
return fetchFormData('/albums', formData, 'POST');
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 수정
|
||||
* @param {number} id - 앨범 ID
|
||||
* @param {FormData} formData - 앨범 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function updateAlbum(id, formData) {
|
||||
return fetchFormData(`/albums/${id}`, formData, 'PUT');
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 삭제
|
||||
* @param {number} id - 앨범 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteAlbum(id) {
|
||||
return fetchAuthApi(`/albums/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 사진 목록 조회
|
||||
* @param {number} albumId - 앨범 ID
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
export async function getAlbumPhotos(albumId) {
|
||||
return fetchAuthApi(`/albums/${albumId}/photos`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 사진 업로드
|
||||
* @param {number} albumId - 앨범 ID
|
||||
* @param {FormData} formData - 사진 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function uploadAlbumPhotos(albumId, formData) {
|
||||
return fetchFormData(`/albums/${albumId}/photos`, formData, 'POST');
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 사진 삭제
|
||||
* @param {number} albumId - 앨범 ID
|
||||
* @param {number} photoId - 사진 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteAlbumPhoto(albumId, photoId) {
|
||||
return fetchAuthApi(`/albums/${albumId}/photos/${photoId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 티저 목록 조회
|
||||
* @param {number} albumId - 앨범 ID
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
export async function getAlbumTeasers(albumId) {
|
||||
return fetchAuthApi(`/albums/${albumId}/teasers`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앨범 티저 삭제
|
||||
* @param {number} albumId - 앨범 ID
|
||||
* @param {number} teaserId - 티저 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteAlbumTeaser(albumId, teaserId) {
|
||||
return fetchAuthApi(`/albums/${albumId}/teasers/${teaserId}`, { method: 'DELETE' });
|
||||
}
|
||||
55
frontend-temp/src/api/pc/admin/bots.js
Normal file
55
frontend-temp/src/api/pc/admin/bots.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* 관리자 봇 관리 API
|
||||
*/
|
||||
import { fetchAuthApi } from '@/api/common/client';
|
||||
|
||||
/**
|
||||
* 봇 목록 조회
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
export async function getBots() {
|
||||
return fetchAuthApi('/admin/bots');
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 시작
|
||||
* @param {string} id - 봇 ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function startBot(id) {
|
||||
return fetchAuthApi(`/admin/bots/${id}/start`, { method: 'POST' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 정지
|
||||
* @param {string} id - 봇 ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function stopBot(id) {
|
||||
return fetchAuthApi(`/admin/bots/${id}/stop`, { method: 'POST' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 전체 동기화
|
||||
* @param {string} id - 봇 ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function syncAllVideos(id) {
|
||||
return fetchAuthApi(`/admin/bots/${id}/sync-all`, { method: 'POST' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 할당량 경고 조회
|
||||
* @returns {Promise<{warning: boolean, message: string}>}
|
||||
*/
|
||||
export async function getQuotaWarning() {
|
||||
return fetchAuthApi('/admin/bots/quota-warning');
|
||||
}
|
||||
|
||||
/**
|
||||
* 할당량 경고 해제
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function dismissQuotaWarning() {
|
||||
return fetchAuthApi('/admin/bots/quota-warning', { method: 'DELETE' });
|
||||
}
|
||||
60
frontend-temp/src/api/pc/admin/categories.js
Normal file
60
frontend-temp/src/api/pc/admin/categories.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* 관리자 카테고리 API
|
||||
*/
|
||||
import { fetchAuthApi } from '@/api/common/client';
|
||||
|
||||
/**
|
||||
* 카테고리 목록 조회
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
export async function getCategories() {
|
||||
return fetchAuthApi('/schedules/categories');
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 생성
|
||||
* @param {object} data - 카테고리 데이터
|
||||
* @param {string} data.name - 카테고리 이름
|
||||
* @param {string} data.color - 색상 코드
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function createCategory(data) {
|
||||
return fetchAuthApi('/admin/schedule-categories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 수정
|
||||
* @param {number} id - 카테고리 ID
|
||||
* @param {object} data - 카테고리 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function updateCategory(id, data) {
|
||||
return fetchAuthApi(`/admin/schedule-categories/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 삭제
|
||||
* @param {number} id - 카테고리 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteCategory(id) {
|
||||
return fetchAuthApi(`/admin/schedule-categories/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 순서 변경
|
||||
* @param {Array<{id: number, sort_order: number}>} orders - 순서 데이터
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function reorderCategories(orders) {
|
||||
return fetchAuthApi('/admin/schedule-categories-order', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ orders }),
|
||||
});
|
||||
}
|
||||
|
|
@ -1 +1,11 @@
|
|||
// 인증
|
||||
export * from './auth';
|
||||
|
||||
// 관리자 API
|
||||
export * as adminAlbumApi from './albums';
|
||||
export * as adminMemberApi from './members';
|
||||
export * as adminScheduleApi from './schedules';
|
||||
export * as adminCategoryApi from './categories';
|
||||
export * as adminStatsApi from './stats';
|
||||
export * as adminSuggestionApi from './suggestions';
|
||||
export * as adminBotApi from './bots';
|
||||
|
|
|
|||
31
frontend-temp/src/api/pc/admin/members.js
Normal file
31
frontend-temp/src/api/pc/admin/members.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* 관리자 멤버 API
|
||||
*/
|
||||
import { fetchAuthApi, fetchFormData } from '@/api/common/client';
|
||||
|
||||
/**
|
||||
* 멤버 목록 조회
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
export async function getMembers() {
|
||||
return fetchAuthApi('/members');
|
||||
}
|
||||
|
||||
/**
|
||||
* 멤버 상세 조회
|
||||
* @param {number} id - 멤버 ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function getMember(id) {
|
||||
return fetchAuthApi(`/members/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 멤버 수정
|
||||
* @param {number} id - 멤버 ID
|
||||
* @param {FormData} formData - 멤버 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function updateMember(id, formData) {
|
||||
return fetchFormData(`/members/${id}`, formData, 'PUT');
|
||||
}
|
||||
102
frontend-temp/src/api/pc/admin/schedules.js
Normal file
102
frontend-temp/src/api/pc/admin/schedules.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* 관리자 일정 API
|
||||
*/
|
||||
import { fetchAuthApi, fetchFormData } from '@/api/common/client';
|
||||
|
||||
/**
|
||||
* API 응답을 프론트엔드 형식으로 변환
|
||||
* - datetime → date, time 분리
|
||||
* - category 객체 → category_id, category_name, category_color 플랫화
|
||||
* - members 배열 → member_names 문자열
|
||||
*/
|
||||
function transformSchedule(schedule) {
|
||||
const category = schedule.category || {};
|
||||
|
||||
// datetime에서 date와 time 분리
|
||||
let date = '';
|
||||
let time = null;
|
||||
if (schedule.datetime) {
|
||||
const parts = schedule.datetime.split('T');
|
||||
date = parts[0];
|
||||
time = parts[1] || null;
|
||||
}
|
||||
|
||||
// members 배열을 문자열로 (기존 코드 호환성)
|
||||
const memberNames = Array.isArray(schedule.members) ? schedule.members.join(',') : '';
|
||||
|
||||
return {
|
||||
...schedule,
|
||||
date,
|
||||
time,
|
||||
category_id: category.id,
|
||||
category_name: category.name,
|
||||
category_color: category.color,
|
||||
member_names: memberNames,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 목록 조회 (월별)
|
||||
* @param {number} year - 년도
|
||||
* @param {number} month - 월
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
export async function getSchedules(year, month) {
|
||||
const data = await fetchAuthApi(`/schedules?year=${year}&month=${month}`);
|
||||
return (data.schedules || []).map(transformSchedule);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 검색 (Meilisearch)
|
||||
* @param {string} query - 검색어
|
||||
* @param {object} options - 페이지네이션 옵션
|
||||
* @param {number} options.offset - 시작 위치
|
||||
* @param {number} options.limit - 조회 개수
|
||||
* @returns {Promise<{schedules: Array, total: number}>}
|
||||
*/
|
||||
export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
|
||||
const data = await fetchAuthApi(
|
||||
`/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}`
|
||||
);
|
||||
return {
|
||||
...data,
|
||||
schedules: (data.schedules || []).map(transformSchedule),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 상세 조회
|
||||
* @param {number} id - 일정 ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function getSchedule(id) {
|
||||
return fetchAuthApi(`/admin/schedules/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 생성
|
||||
* @param {FormData} formData - 일정 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function createSchedule(formData) {
|
||||
return fetchFormData('/admin/schedules', formData, 'POST');
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 수정
|
||||
* @param {number} id - 일정 ID
|
||||
* @param {FormData} formData - 일정 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function updateSchedule(id, formData) {
|
||||
return fetchFormData(`/admin/schedules/${id}`, formData, 'PUT');
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 삭제
|
||||
* @param {number} id - 일정 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteSchedule(id) {
|
||||
return fetchAuthApi(`/schedules/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
12
frontend-temp/src/api/pc/admin/stats.js
Normal file
12
frontend-temp/src/api/pc/admin/stats.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* 관리자 통계 API
|
||||
*/
|
||||
import { fetchAuthApi } from '@/api/common/client';
|
||||
|
||||
/**
|
||||
* 대시보드 통계 조회
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function getStats() {
|
||||
return fetchAuthApi('/stats');
|
||||
}
|
||||
24
frontend-temp/src/api/pc/admin/suggestions.js
Normal file
24
frontend-temp/src/api/pc/admin/suggestions.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* 관리자 추천 검색어 API
|
||||
*/
|
||||
import { fetchAuthApi } from '@/api/common/client';
|
||||
|
||||
/**
|
||||
* 사전 내용 조회
|
||||
* @returns {Promise<{content: string}>}
|
||||
*/
|
||||
export async function getDict() {
|
||||
return fetchAuthApi('/schedules/suggestions/dict');
|
||||
}
|
||||
|
||||
/**
|
||||
* 사전 저장
|
||||
* @param {string} content - 사전 내용
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function saveDict(content) {
|
||||
return fetchAuthApi('/schedules/suggestions/dict', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
}
|
||||
115
frontend-temp/src/components/pc/admin/ConfirmDialog.jsx
Normal file
115
frontend-temp/src/components/pc/admin/ConfirmDialog.jsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* ConfirmDialog 컴포넌트
|
||||
* 삭제 등 위험한 작업의 확인을 위한 공통 다이얼로그
|
||||
*
|
||||
* Props:
|
||||
* - isOpen: 다이얼로그 표시 여부
|
||||
* - onClose: 닫기 콜백
|
||||
* - onConfirm: 확인 콜백
|
||||
* - title: 제목 (예: "앨범 삭제")
|
||||
* - message: 메시지 내용 (ReactNode 가능)
|
||||
* - confirmText: 확인 버튼 텍스트 (기본: "삭제")
|
||||
* - cancelText: 취소 버튼 텍스트 (기본: "취소")
|
||||
* - loading: 로딩 상태
|
||||
* - loadingText: 로딩 중 텍스트 (기본: "삭제 중...")
|
||||
* - variant: 버튼 색상 (기본: "danger", "primary" 가능)
|
||||
*/
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||
|
||||
function ConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = '삭제',
|
||||
cancelText = '취소',
|
||||
loading = false,
|
||||
loadingText = '삭제 중...',
|
||||
variant = 'danger',
|
||||
icon: Icon = AlertTriangle,
|
||||
}) {
|
||||
// 버튼 색상 설정
|
||||
const buttonColors = {
|
||||
danger: 'bg-red-500 hover:bg-red-600',
|
||||
primary: 'bg-primary hover:bg-primary-dark',
|
||||
};
|
||||
|
||||
const iconBgColors = {
|
||||
danger: 'bg-red-100',
|
||||
primary: 'bg-primary/10',
|
||||
};
|
||||
|
||||
const iconColors = {
|
||||
danger: 'text-red-500',
|
||||
primary: 'text-primary',
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={() => !loading && onClose()}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="bg-white rounded-2xl p-6 max-w-md w-full mx-4 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full ${iconBgColors[variant]} flex items-center justify-center`}
|
||||
>
|
||||
<Icon className={iconColors[variant]} size={20} />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900">{title}</h3>
|
||||
</div>
|
||||
|
||||
{/* 메시지 */}
|
||||
<div className="text-gray-600 mb-6">{message}</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className={`px-4 py-2 ${buttonColors[variant]} text-white rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50`}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
{loadingText}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 size={16} />
|
||||
{confirmText}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfirmDialog;
|
||||
282
frontend-temp/src/components/pc/admin/DatePicker.jsx
Normal file
282
frontend-temp/src/components/pc/admin/DatePicker.jsx
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
/**
|
||||
* DatePicker 컴포넌트
|
||||
* 연/월/일 선택이 가능한 드롭다운 형태의 날짜 선택기
|
||||
*/
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Calendar, ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
|
||||
function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfWeek = false }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState('days');
|
||||
const [viewDate, setViewDate] = useState(() => {
|
||||
if (value) return new Date(value);
|
||||
return new Date();
|
||||
});
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) {
|
||||
setIsOpen(false);
|
||||
setViewMode('days');
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const year = viewDate.getFullYear();
|
||||
const month = viewDate.getMonth();
|
||||
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
const days = [];
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push(null);
|
||||
}
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push(i);
|
||||
}
|
||||
|
||||
const MIN_YEAR = 2025;
|
||||
const startYear = Math.max(MIN_YEAR, Math.floor(year / 12) * 12 - 1);
|
||||
const years = Array.from({ length: 12 }, (_, i) => startYear + i);
|
||||
const canGoPrevYearRange = startYear > MIN_YEAR;
|
||||
|
||||
const prevMonth = () => setViewDate(new Date(year, month - 1, 1));
|
||||
const nextMonth = () => setViewDate(new Date(year, month + 1, 1));
|
||||
const prevYearRange = () =>
|
||||
canGoPrevYearRange && setViewDate(new Date(Math.max(MIN_YEAR, year - 12), month, 1));
|
||||
const nextYearRange = () => setViewDate(new Date(year + 12, month, 1));
|
||||
|
||||
const selectDate = (day) => {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
onChange(dateStr);
|
||||
setIsOpen(false);
|
||||
setViewMode('days');
|
||||
};
|
||||
|
||||
const selectYear = (y) => {
|
||||
setViewDate(new Date(y, month, 1));
|
||||
};
|
||||
|
||||
const selectMonth = (m) => {
|
||||
setViewDate(new Date(year, m, 1));
|
||||
setViewMode('days');
|
||||
};
|
||||
|
||||
// 날짜 표시 포맷 (요일 포함 옵션)
|
||||
const formatDisplayDate = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const [y, m, d] = dateStr.split('-');
|
||||
if (showDayOfWeek) {
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const date = new Date(parseInt(y), parseInt(m) - 1, parseInt(d));
|
||||
const dayOfWeek = dayNames[date.getDay()];
|
||||
return `${y}년 ${parseInt(m)}월 ${parseInt(d)}일 (${dayOfWeek})`;
|
||||
}
|
||||
return `${y}년 ${parseInt(m)}월 ${parseInt(d)}일`;
|
||||
};
|
||||
|
||||
const isSelected = (day) => {
|
||||
if (!value || !day) return false;
|
||||
const [y, m, d] = value.split('-');
|
||||
return parseInt(y) === year && parseInt(m) === month + 1 && parseInt(d) === day;
|
||||
};
|
||||
|
||||
const isToday = (day) => {
|
||||
if (!day) return false;
|
||||
const today = new Date();
|
||||
return today.getFullYear() === year && today.getMonth() === month && today.getDate() === day;
|
||||
};
|
||||
|
||||
const isCurrentYear = (y) => new Date().getFullYear() === y;
|
||||
const isCurrentMonth = (m) => {
|
||||
const today = new Date();
|
||||
return today.getFullYear() === year && today.getMonth() === m;
|
||||
};
|
||||
|
||||
const monthNames = [
|
||||
'1월',
|
||||
'2월',
|
||||
'3월',
|
||||
'4월',
|
||||
'5월',
|
||||
'6월',
|
||||
'7월',
|
||||
'8월',
|
||||
'9월',
|
||||
'10월',
|
||||
'11월',
|
||||
'12월',
|
||||
];
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<span className={value ? 'text-gray-900' : 'text-gray-400'}>
|
||||
{value ? formatDisplayDate(value) : placeholder}
|
||||
</span>
|
||||
<Calendar size={18} className="text-gray-400" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute z-50 mt-2 bg-white border border-gray-200 rounded-xl shadow-lg p-4 w-80"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={viewMode === 'years' ? prevYearRange : prevMonth}
|
||||
disabled={viewMode === 'years' && !canGoPrevYearRange}
|
||||
className={`p-1.5 rounded-lg transition-colors ${viewMode === 'years' && !canGoPrevYearRange ? 'opacity-30' : 'hover:bg-gray-100'}`}
|
||||
>
|
||||
<ChevronLeft size={20} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode(viewMode === 'days' ? 'years' : 'days')}
|
||||
className="font-medium text-gray-900 hover:text-primary transition-colors flex items-center gap-1"
|
||||
>
|
||||
{viewMode === 'years'
|
||||
? `${years[0]} - ${years[years.length - 1]}`
|
||||
: `${year}년 ${month + 1}월`}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`transition-transform ${viewMode !== 'days' ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={viewMode === 'years' ? nextYearRange : nextMonth}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} className="text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{viewMode === 'years' && (
|
||||
<motion.div
|
||||
key="years"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div className="text-center text-sm text-gray-500 mb-3">년도</div>
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
{years.map((y) => (
|
||||
<button
|
||||
key={y}
|
||||
type="button"
|
||||
onClick={() => selectYear(y)}
|
||||
className={`py-2 rounded-lg text-sm transition-colors ${year === y ? 'bg-primary text-white' : 'hover:bg-gray-100 text-gray-700'} ${isCurrentYear(y) && year !== y ? 'text-primary font-medium' : ''}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center text-sm text-gray-500 mb-3">월</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{monthNames.map((m, i) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => selectMonth(i)}
|
||||
className={`py-2 rounded-lg text-sm transition-colors ${month === i ? 'bg-primary text-white' : 'hover:bg-gray-100 text-gray-700'} ${isCurrentMonth(i) && month !== i ? 'text-primary font-medium' : ''}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{viewMode === 'months' && (
|
||||
<motion.div
|
||||
key="months"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div className="text-center text-sm text-gray-500 mb-3">월 선택</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{monthNames.map((m, i) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => selectMonth(i)}
|
||||
className={`py-2.5 rounded-lg text-sm transition-colors ${month === i ? 'bg-primary text-white' : 'hover:bg-gray-100 text-gray-700'} ${isCurrentMonth(i) && month !== i ? 'text-primary font-medium' : ''}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{viewMode === 'days' && (
|
||||
<motion.div
|
||||
key="days"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{['일', '월', '화', '수', '목', '금', '토'].map((d, i) => (
|
||||
<div
|
||||
key={d}
|
||||
className={`text-center text-xs font-medium py-1 ${i === 0 ? 'text-red-400' : i === 6 ? 'text-blue-400' : 'text-gray-400'}`}
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((day, i) => {
|
||||
const dayOfWeek = i % 7;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
disabled={!day}
|
||||
onClick={() => day && 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' : ''}
|
||||
${isToday(day) && !isSelected(day) ? 'text-primary font-bold' : ''}
|
||||
${day && !isSelected(day) && !isToday(day) && dayOfWeek === 0 ? 'text-red-500' : ''}
|
||||
${day && !isSelected(day) && !isToday(day) && dayOfWeek === 6 ? 'text-blue-500' : ''}
|
||||
${day && !isSelected(day) && !isToday(day) && dayOfWeek > 0 && dayOfWeek < 6 ? 'text-gray-700' : ''}
|
||||
`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DatePicker;
|
||||
48
frontend-temp/src/components/pc/admin/Header.jsx
Normal file
48
frontend-temp/src/components/pc/admin/Header.jsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* AdminHeader 컴포넌트
|
||||
* 모든 Admin 페이지에서 공통으로 사용하는 헤더
|
||||
* 로고, Admin 배지, 사용자 정보, 로그아웃 버튼 포함
|
||||
*/
|
||||
import { Link } from 'react-router-dom';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores';
|
||||
|
||||
function AdminHeader({ user }) {
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-white shadow-sm border-b border-gray-100">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/admin/dashboard"
|
||||
className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity"
|
||||
>
|
||||
fromis_9
|
||||
</Link>
|
||||
<span className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
||||
Admin
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-500 text-sm">
|
||||
안녕하세요, <span className="text-gray-900 font-medium">{user?.username}</span>님
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>로그아웃</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminHeader;
|
||||
25
frontend-temp/src/components/pc/admin/Layout.jsx
Normal file
25
frontend-temp/src/components/pc/admin/Layout.jsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* AdminLayout 컴포넌트
|
||||
* 모든 Admin 페이지에서 공통으로 사용하는 레이아웃
|
||||
* 헤더 고정 + 본문 스크롤 구조
|
||||
*/
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
|
||||
function AdminLayout({ user, children }) {
|
||||
const location = useLocation();
|
||||
|
||||
// 일정 관리 페이지는 내부 스크롤 처리
|
||||
const isSchedulePage = location.pathname.includes('/admin/schedules');
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden flex flex-col bg-gray-50">
|
||||
<Header user={user} />
|
||||
<main className={`flex-1 min-h-0 ${isSchedulePage ? 'overflow-hidden' : 'overflow-y-auto'}`}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminLayout;
|
||||
188
frontend-temp/src/components/pc/admin/NumberPicker.jsx
Normal file
188
frontend-temp/src/components/pc/admin/NumberPicker.jsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* NumberPicker 컴포넌트
|
||||
* 스크롤 가능한 숫자/값 선택 피커
|
||||
* AdminScheduleForm의 시간 선택에서 사용
|
||||
*/
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
function NumberPicker({ items, value, onChange }) {
|
||||
const ITEM_HEIGHT = 40;
|
||||
const containerRef = useRef(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const offsetRef = useRef(0); // 드래그용 ref
|
||||
const touchStartY = useRef(0);
|
||||
const startOffset = useRef(0);
|
||||
const isScrolling = useRef(false);
|
||||
|
||||
// offset 변경시 ref도 업데이트
|
||||
useEffect(() => {
|
||||
offsetRef.current = offset;
|
||||
}, [offset]);
|
||||
|
||||
// 초기 위치 설정
|
||||
useEffect(() => {
|
||||
if (value !== null && value !== undefined) {
|
||||
const index = items.indexOf(value);
|
||||
if (index !== -1) {
|
||||
const newOffset = -index * ITEM_HEIGHT;
|
||||
setOffset(newOffset);
|
||||
offsetRef.current = newOffset;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 값 변경시 위치 업데이트
|
||||
useEffect(() => {
|
||||
const index = items.indexOf(value);
|
||||
if (index !== -1) {
|
||||
const targetOffset = -index * ITEM_HEIGHT;
|
||||
if (Math.abs(offset - targetOffset) > 1) {
|
||||
setOffset(targetOffset);
|
||||
offsetRef.current = targetOffset;
|
||||
}
|
||||
}
|
||||
}, [value, items]);
|
||||
|
||||
const centerOffset = ITEM_HEIGHT; // 중앙 위치 오프셋
|
||||
|
||||
// 아이템이 중앙에 있는지 확인
|
||||
const isItemInCenter = (item) => {
|
||||
const itemIndex = items.indexOf(item);
|
||||
const itemPosition = -itemIndex * ITEM_HEIGHT;
|
||||
const tolerance = ITEM_HEIGHT / 2;
|
||||
return Math.abs(offset - itemPosition) < tolerance;
|
||||
};
|
||||
|
||||
// 오프셋 업데이트 (경계 제한)
|
||||
const updateOffset = (newOffset) => {
|
||||
const maxOffset = 0;
|
||||
const minOffset = -(items.length - 1) * ITEM_HEIGHT;
|
||||
return Math.min(maxOffset, Math.max(minOffset, newOffset));
|
||||
};
|
||||
|
||||
// 중앙 아이템 업데이트
|
||||
const updateCenterItem = (currentOffset) => {
|
||||
const centerIndex = Math.round(-currentOffset / ITEM_HEIGHT);
|
||||
if (centerIndex >= 0 && centerIndex < items.length) {
|
||||
const centerItem = items[centerIndex];
|
||||
if (value !== centerItem) {
|
||||
onChange(centerItem);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 가장 가까운 아이템에 스냅
|
||||
const snapToClosestItem = (currentOffset) => {
|
||||
const targetOffset = Math.round(currentOffset / ITEM_HEIGHT) * ITEM_HEIGHT;
|
||||
setOffset(targetOffset);
|
||||
offsetRef.current = targetOffset;
|
||||
updateCenterItem(targetOffset);
|
||||
};
|
||||
|
||||
// 터치 시작
|
||||
const handleTouchStart = (e) => {
|
||||
e.stopPropagation();
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
startOffset.current = offsetRef.current;
|
||||
};
|
||||
|
||||
// 터치 이동
|
||||
const handleTouchMove = (e) => {
|
||||
e.stopPropagation();
|
||||
const touchY = e.touches[0].clientY;
|
||||
const deltaY = touchY - touchStartY.current;
|
||||
const newOffset = updateOffset(startOffset.current + deltaY);
|
||||
setOffset(newOffset);
|
||||
offsetRef.current = newOffset;
|
||||
};
|
||||
|
||||
// 터치 종료
|
||||
const handleTouchEnd = (e) => {
|
||||
e.stopPropagation();
|
||||
snapToClosestItem(offsetRef.current);
|
||||
};
|
||||
|
||||
// 마우스 휠 - 바깥 스크롤 방지
|
||||
const handleWheel = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isScrolling.current) return;
|
||||
isScrolling.current = true;
|
||||
|
||||
const newOffset = updateOffset(offsetRef.current - Math.sign(e.deltaY) * ITEM_HEIGHT);
|
||||
setOffset(newOffset);
|
||||
offsetRef.current = newOffset;
|
||||
snapToClosestItem(newOffset);
|
||||
|
||||
setTimeout(() => {
|
||||
isScrolling.current = false;
|
||||
}, 50);
|
||||
};
|
||||
|
||||
// 마우스 드래그
|
||||
const handleMouseDown = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
touchStartY.current = e.clientY;
|
||||
startOffset.current = offsetRef.current;
|
||||
|
||||
const handleMouseMove = (moveEvent) => {
|
||||
moveEvent.preventDefault();
|
||||
const deltaY = moveEvent.clientY - touchStartY.current;
|
||||
const newOffset = updateOffset(startOffset.current + deltaY);
|
||||
setOffset(newOffset);
|
||||
offsetRef.current = newOffset;
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
snapToClosestItem(offsetRef.current);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// wheel 이벤트 passive false로 등록
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => container.removeEventListener('wheel', handleWheel);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-16 h-[120px] overflow-hidden touch-none select-none cursor-grab active:cursor-grabbing"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{/* 중앙 선택 영역 */}
|
||||
<div className="absolute top-1/2 left-1 right-1 h-10 -translate-y-1/2 bg-primary/10 rounded-lg z-0" />
|
||||
|
||||
{/* 피커 내부 */}
|
||||
<div
|
||||
className="relative transition-transform duration-150 ease-out"
|
||||
style={{ transform: `translateY(${offset + centerOffset}px)` }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className={`h-10 leading-10 text-center select-none transition-all duration-150 ${
|
||||
isItemInCenter(item) ? 'text-primary text-lg font-bold' : 'text-gray-300 text-base'
|
||||
}`}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NumberPicker;
|
||||
157
frontend-temp/src/components/pc/admin/TimePicker.jsx
Normal file
157
frontend-temp/src/components/pc/admin/TimePicker.jsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* TimePicker 컴포넌트
|
||||
* 오전/오후, 시간, 분을 선택할 수 있는 시간 피커
|
||||
* NumberPicker를 사용하여 스크롤 방식 선택 제공
|
||||
*/
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Clock } from 'lucide-react';
|
||||
import NumberPicker from './NumberPicker';
|
||||
|
||||
function TimePicker({ value, onChange, placeholder = '시간 선택' }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
// 현재 값 파싱
|
||||
const parseValue = () => {
|
||||
if (!value) return { hour: '12', minute: '00', period: '오후' };
|
||||
const [h, m] = value.split(':');
|
||||
const hour = parseInt(h);
|
||||
const isPM = hour >= 12;
|
||||
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||
return {
|
||||
hour: String(hour12).padStart(2, '0'),
|
||||
minute: m,
|
||||
period: isPM ? '오후' : '오전',
|
||||
};
|
||||
};
|
||||
|
||||
const parsed = parseValue();
|
||||
const [selectedHour, setSelectedHour] = useState(parsed.hour);
|
||||
const [selectedMinute, setSelectedMinute] = useState(parsed.minute);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState(parsed.period);
|
||||
|
||||
// 외부 클릭 시 닫기
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// 피커 열릴 때 현재 값으로 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const parsed = parseValue();
|
||||
setSelectedHour(parsed.hour);
|
||||
setSelectedMinute(parsed.minute);
|
||||
setSelectedPeriod(parsed.period);
|
||||
}
|
||||
}, [isOpen, value]);
|
||||
|
||||
// 시간 확정
|
||||
const handleSave = () => {
|
||||
let hour = parseInt(selectedHour);
|
||||
if (selectedPeriod === '오후' && hour !== 12) hour += 12;
|
||||
if (selectedPeriod === '오전' && hour === 12) hour = 0;
|
||||
const timeStr = `${String(hour).padStart(2, '0')}:${selectedMinute}`;
|
||||
onChange(timeStr);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// 취소
|
||||
const handleCancel = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// 초기화
|
||||
const handleClear = () => {
|
||||
onChange('');
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// 표시용 포맷
|
||||
const displayValue = () => {
|
||||
if (!value) return placeholder;
|
||||
const [h, m] = value.split(':');
|
||||
const hour = parseInt(h);
|
||||
const isPM = hour >= 12;
|
||||
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||
return `${isPM ? '오후' : '오전'} ${hour12}:${m}`;
|
||||
};
|
||||
|
||||
// 피커 아이템 데이터
|
||||
const periods = ['오전', '오후'];
|
||||
const hours = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12'];
|
||||
const minutes = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
<span className={value ? 'text-gray-900' : 'text-gray-400'}>{displayValue()}</span>
|
||||
<Clock size={18} className="text-gray-400" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="absolute top-full left-0 mt-2 bg-white rounded-2xl shadow-xl border border-gray-200 z-50 overflow-hidden"
|
||||
>
|
||||
{/* 피커 영역 */}
|
||||
<div className="flex items-center justify-center px-4 py-4">
|
||||
{/* 오전/오후 (맨 앞) */}
|
||||
<NumberPicker items={periods} value={selectedPeriod} onChange={setSelectedPeriod} />
|
||||
|
||||
{/* 시간 */}
|
||||
<NumberPicker items={hours} value={selectedHour} onChange={setSelectedHour} />
|
||||
|
||||
<span className="text-xl text-gray-300 font-medium mx-0.5">:</span>
|
||||
|
||||
{/* 분 */}
|
||||
<NumberPicker items={minutes} value={selectedMinute} onChange={setSelectedMinute} />
|
||||
</div>
|
||||
|
||||
{/* 푸터 버튼 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-1.5 text-sm text-gray-600 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="px-4 py-1.5 text-sm bg-primary text-white font-medium rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimePicker;
|
||||
|
|
@ -1,2 +1,10 @@
|
|||
// 관리자 컴포넌트
|
||||
// 레이아웃
|
||||
export { default as AdminLayout } from './Layout';
|
||||
export { default as AdminHeader } from './Header';
|
||||
|
||||
// 공통 컴포넌트
|
||||
export { default as AdminScheduleCard } from './AdminScheduleCard';
|
||||
export { default as ConfirmDialog } from './ConfirmDialog';
|
||||
export { default as NumberPicker } from './NumberPicker';
|
||||
export { default as DatePicker } from './DatePicker';
|
||||
export { default as TimePicker } from './TimePicker';
|
||||
|
|
|
|||
151
frontend-temp/src/pages/pc/admin/Login.jsx
Normal file
151
frontend-temp/src/pages/pc/admin/Login.jsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* 관리자 로그인 페이지
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Lock, User, AlertCircle, Eye, EyeOff } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores';
|
||||
import { useRedirectIfAuthenticated } from '@/hooks/pc/admin';
|
||||
import * as authApi from '@/api/pc/admin/auth';
|
||||
|
||||
function AdminLogin() {
|
||||
const navigate = useNavigate();
|
||||
const loginStore = useAuthStore((state) => state.login);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// 이미 로그인된 경우 리다이렉트
|
||||
const { isLoading: checkingAuth } = useRedirectIfAuthenticated();
|
||||
|
||||
// 로그인 mutation
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: () => authApi.login(username, password),
|
||||
onSuccess: (data) => {
|
||||
loginStore(data.token, data.user);
|
||||
navigate('/admin/dashboard');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
loginMutation.mutate();
|
||||
};
|
||||
|
||||
// 인증 확인 중 로딩 화면
|
||||
if (checkingAuth) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
{/* 로고 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-primary mb-2">fromis_9</h1>
|
||||
<p className="text-gray-500">관리자 페이지</p>
|
||||
</div>
|
||||
|
||||
{/* 로그인 카드 */}
|
||||
<div className="bg-white rounded-2xl p-8 shadow-lg border border-gray-100">
|
||||
<h2 className="text-xl font-bold text-gray-900 text-center mb-6">로그인</h2>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{loginMutation.isError && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center gap-2 bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg mb-6"
|
||||
>
|
||||
<AlertCircle size={18} />
|
||||
<span className="text-sm">{loginMutation.error?.message || '로그인 실패'}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* 아이디 입력 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">아이디</label>
|
||||
<div className="relative">
|
||||
<User
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
size={18}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full bg-gray-50 border border-gray-200 rounded-lg pl-10 pr-4 py-3 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
||||
placeholder="아이디를 입력하세요"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 입력 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">비밀번호</label>
|
||||
<div className="relative">
|
||||
<Lock
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
size={18}
|
||||
/>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-gray-50 border border-gray-200 rounded-lg pl-10 pr-12 py-3 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 로그인 버튼 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loginMutation.isPending}
|
||||
className="w-full bg-primary hover:bg-primary-dark text-white font-medium py-3 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loginMutation.isPending ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
로그인 중...
|
||||
</span>
|
||||
) : (
|
||||
'로그인'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 하단 링크 */}
|
||||
<div className="text-center mt-6">
|
||||
<Link to="/" className="text-gray-500 hover:text-primary text-sm transition-colors">
|
||||
← 메인 사이트로 돌아가기
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminLogin;
|
||||
|
|
@ -5,7 +5,7 @@ import { Calendar, ChevronRight } from 'lucide-react';
|
|||
import { getSchedule } from '@/api';
|
||||
|
||||
// 섹션 컴포넌트들
|
||||
import { YoutubeSection, XSection, DefaultSection, CATEGORY_ID, decodeHtmlEntities } from '../sections';
|
||||
import { YoutubeSection, XSection, DefaultSection, CATEGORY_ID, decodeHtmlEntities } from './sections';
|
||||
|
||||
/**
|
||||
* PC 일정 상세 페이지
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue