diff --git a/backend/src/routes/admin/index.js b/backend/src/routes/admin/index.js index f93a7e6..79235a3 100644 --- a/backend/src/routes/admin/index.js +++ b/backend/src/routes/admin/index.js @@ -1,6 +1,7 @@ import authRoutes from './auth.js'; import membersRoutes from './members.js'; import albumsRoutes from './albums.js'; +import statsRoutes from './stats.js'; /** * 어드민 라우트 통합 @@ -14,4 +15,7 @@ export default async function adminRoutes(fastify, opts) { // 앨범 관리 라우트 fastify.register(albumsRoutes, { prefix: '/albums' }); + + // 통계 라우트 + fastify.register(statsRoutes, { prefix: '/stats' }); } diff --git a/backend/src/routes/admin/stats.js b/backend/src/routes/admin/stats.js new file mode 100644 index 0000000..1763ab0 --- /dev/null +++ b/backend/src/routes/admin/stats.js @@ -0,0 +1,58 @@ +/** + * 어드민 통계 라우트 + */ +export default async function statsRoutes(fastify, opts) { + const { db } = fastify; + + // 모든 라우트에 인증 적용 + fastify.addHook('preHandler', fastify.authenticate); + + /** + * 대시보드 통계 조회 + * GET /api/admin/stats + */ + fastify.get('/', async (request, reply) => { + try { + // 멤버 수 (현재 활동 중인 멤버만) + const [[{ memberCount }]] = await db.query( + 'SELECT COUNT(*) as memberCount FROM members WHERE is_former = 0' + ); + + // 앨범 수 + const [[{ albumCount }]] = await db.query( + 'SELECT COUNT(*) as albumCount FROM albums' + ); + + // 컨셉 포토 수 + const [[{ photoCount }]] = await db.query( + 'SELECT COUNT(*) as photoCount FROM album_photos' + ); + + // 티저 수 + const [[{ teaserCount }]] = await db.query( + 'SELECT COUNT(*) as teaserCount FROM album_teasers' + ); + + // 일정 수 (전체) + const [[{ scheduleCount }]] = await db.query( + 'SELECT COUNT(*) as scheduleCount FROM schedules' + ); + + // 트랙 수 + const [[{ trackCount }]] = await db.query( + 'SELECT COUNT(*) as trackCount FROM tracks' + ); + + return { + members: memberCount, + albums: albumCount, + photos: photoCount + teaserCount, + schedules: scheduleCount, + tracks: trackCount, + }; + } catch (err) { + fastify.log.error(err); + return reply.status(500).send({ error: '통계 조회 실패' }); + } + }); +} diff --git a/frontend/src/api/admin/stats.js b/frontend/src/api/admin/stats.js new file mode 100644 index 0000000..6ff1ca3 --- /dev/null +++ b/frontend/src/api/admin/stats.js @@ -0,0 +1,9 @@ +/** + * 어드민 통계 API + */ +import { fetchAdminApi } from "../index"; + +// 대시보드 통계 조회 +export async function getStats() { + return fetchAdminApi("/api/admin/stats"); +} diff --git a/frontend/src/pages/pc/admin/AdminDashboard.jsx b/frontend/src/pages/pc/admin/AdminDashboard.jsx index eb28c70..ce591d4 100644 --- a/frontend/src/pages/pc/admin/AdminDashboard.jsx +++ b/frontend/src/pages/pc/admin/AdminDashboard.jsx @@ -1,95 +1,77 @@ -import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import { motion } from 'framer-motion'; import { Disc3, Calendar, Users, Home, ChevronRight } from 'lucide-react'; import AdminLayout from '../../../components/admin/AdminLayout'; import useAdminAuth from '../../../hooks/useAdminAuth'; -import { getMembers } from '../../../api/public/members'; -import { getAlbums, getAlbum } from '../../../api/public/albums'; -import { getSchedules } from '../../../api/public/schedules'; +import { getStats } from '../../../api/admin/stats'; -// 슬롯머신 스타일 롤링 숫자 컴포넌트 (아래에서 위로) +// 슬롯머신 스타일 롤링 숫자 컴포넌트 (아래에서 위로, 3자리 쉼표 포함) function AnimatedNumber({ value }) { - const digits = String(value).split(''); - + // 3자리마다 쉼표가 들어갈 위치 계산 + const formatted = value.toLocaleString(); + const chars = formatted.split(''); + return ( - {digits.map((digit, i) => ( - - - {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => ( - - {n} - - ))} - - - ))} + {chars.map((char, i) => { + // 쉼표도 애니메이션으로 표시 + if (char === ',') { + return ( + + , + + ); + } + + return ( + + + {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => ( + + {n} + + ))} + + + ); + })} ); } function AdminDashboard() { const { user, isAuthenticated } = useAdminAuth(); - const [stats, setStats] = useState({ - albums: 0, - photos: 0, - schedules: 0, - members: 0 + + // 통계 조회 (useQuery) + const { data: stats = { members: 0, albums: 0, photos: 0, schedules: 0 } } = useQuery({ + queryKey: ['admin', 'stats'], + queryFn: getStats, + enabled: isAuthenticated, + staleTime: 30 * 1000, // 30초 캐시 }); - useEffect(() => { - if (isAuthenticated) { - fetchStats(); - } - }, [isAuthenticated]); - - const fetchStats = async () => { - // 각 통계를 개별적으로 가져와서 하나가 실패해도 다른 것은 표시 - try { - const members = await getMembers(); - setStats(prev => ({ ...prev, members: members.filter(m => !m.is_former).length })); - } catch (e) { console.error('멤버 통계 오류:', e); } - - try { - const albums = await getAlbums(); - setStats(prev => ({ ...prev, albums: albums.length })); - - // 사진 수 계산 - let totalPhotos = 0; - for (const album of albums) { - try { - const detail = await getAlbum(album.id); - if (detail.conceptPhotos) { - Object.values(detail.conceptPhotos).forEach(photos => { - totalPhotos += photos.length; - }); - } - if (detail.teasers) { - totalPhotos += detail.teasers.length; - } - } catch (e) { /* 개별 앨범 오류 무시 */ } - } - setStats(prev => ({ ...prev, photos: totalPhotos })); - } catch (e) { console.error('앨범 통계 오류:', e); } - - try { - const today = new Date(); - const schedules = await getSchedules(today.getFullYear(), today.getMonth() + 1); - setStats(prev => ({ ...prev, schedules: Array.isArray(schedules) ? schedules.length : 0 })); - } catch (e) { console.error('일정 통계 오류:', e); } - }; - // 메뉴 아이템 const menuItems = [ {