feat: 대시보드 통계 API 추가

- /api/admin/stats 라우트 추가 (멤버, 앨범, 사진, 일정, 트랙 수)
- AdminDashboard에서 단일 API 호출로 통계 조회
- useQuery로 변경하여 중복 요청 방지
- AnimatedNumber 컴포넌트에 3자리 쉼표 및 애니메이션 적용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-17 00:08:16 +09:00
parent 86220bdd3d
commit 428a74a703
4 changed files with 128 additions and 75 deletions

View file

@ -1,6 +1,7 @@
import authRoutes from './auth.js'; import authRoutes from './auth.js';
import membersRoutes from './members.js'; import membersRoutes from './members.js';
import albumsRoutes from './albums.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(albumsRoutes, { prefix: '/albums' });
// 통계 라우트
fastify.register(statsRoutes, { prefix: '/stats' });
} }

View file

@ -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: '통계 조회 실패' });
}
});
}

View file

@ -0,0 +1,9 @@
/**
* 어드민 통계 API
*/
import { fetchAdminApi } from "../index";
// 대시보드 통계 조회
export async function getStats() {
return fetchAdminApi("/api/admin/stats");
}

View file

@ -1,95 +1,77 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Disc3, Calendar, Users, Home, ChevronRight } from 'lucide-react'; import { Disc3, Calendar, Users, Home, ChevronRight } from 'lucide-react';
import AdminLayout from '../../../components/admin/AdminLayout'; import AdminLayout from '../../../components/admin/AdminLayout';
import useAdminAuth from '../../../hooks/useAdminAuth'; import useAdminAuth from '../../../hooks/useAdminAuth';
import { getMembers } from '../../../api/public/members'; import { getStats } from '../../../api/admin/stats';
import { getAlbums, getAlbum } from '../../../api/public/albums';
import { getSchedules } from '../../../api/public/schedules';
// ( ) // ( , 3 )
function AnimatedNumber({ value }) { function AnimatedNumber({ value }) {
const digits = String(value).split(''); // 3
const formatted = value.toLocaleString();
const chars = formatted.split('');
return ( return (
<span className="inline-flex overflow-hidden"> <span className="inline-flex overflow-hidden">
{digits.map((digit, i) => ( {chars.map((char, i) => {
<span key={i} className="relative h-[1.2em] overflow-hidden"> //
<motion.span if (char === ',') {
className="flex flex-col" return (
initial={{ y: '100%' }} <motion.span
animate={{ y: `-${parseInt(digit) * 10}%` }} key={i}
transition={{ className="h-[1.2em] flex items-center"
type: 'tween', initial={{ y: '100%', opacity: 0 }}
ease: 'easeOut', animate={{ y: 0, opacity: 1 }}
duration: 0.8, transition={{
delay: i * 0.2 type: 'tween',
}} ease: 'easeOut',
> duration: 0.8,
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => ( delay: i * 0.15
<span key={n} className="h-[1.2em] flex items-center justify-center"> }}
{n} >
</span> ,
))} </motion.span>
</motion.span> );
</span> }
))}
return (
<span key={i} className="relative h-[1.2em] overflow-hidden">
<motion.span
className="flex flex-col"
initial={{ y: '100%' }}
animate={{ y: `-${parseInt(char) * 10}%` }}
transition={{
type: 'tween',
ease: 'easeOut',
duration: 0.8,
delay: i * 0.15
}}
>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => (
<span key={n} className="h-[1.2em] flex items-center justify-center">
{n}
</span>
))}
</motion.span>
</span>
);
})}
</span> </span>
); );
} }
function AdminDashboard() { function AdminDashboard() {
const { user, isAuthenticated } = useAdminAuth(); const { user, isAuthenticated } = useAdminAuth();
const [stats, setStats] = useState({
albums: 0, // (useQuery)
photos: 0, const { data: stats = { members: 0, albums: 0, photos: 0, schedules: 0 } } = useQuery({
schedules: 0, queryKey: ['admin', 'stats'],
members: 0 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 = [ const menuItems = [
{ {