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:
parent
86220bdd3d
commit
428a74a703
4 changed files with 128 additions and 75 deletions
|
|
@ -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' });
|
||||
}
|
||||
|
|
|
|||
58
backend/src/routes/admin/stats.js
Normal file
58
backend/src/routes/admin/stats.js
Normal 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: '통계 조회 실패' });
|
||||
}
|
||||
});
|
||||
}
|
||||
9
frontend/src/api/admin/stats.js
Normal file
9
frontend/src/api/admin/stats.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* 어드민 통계 API
|
||||
*/
|
||||
import { fetchAdminApi } from "../index";
|
||||
|
||||
// 대시보드 통계 조회
|
||||
export async function getStats() {
|
||||
return fetchAdminApi("/api/admin/stats");
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<span className="inline-flex overflow-hidden">
|
||||
{digits.map((digit, i) => (
|
||||
<span key={i} className="relative h-[1.2em] overflow-hidden">
|
||||
<motion.span
|
||||
className="flex flex-col"
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: `-${parseInt(digit) * 10}%` }}
|
||||
transition={{
|
||||
type: 'tween',
|
||||
ease: 'easeOut',
|
||||
duration: 0.8,
|
||||
delay: i * 0.2
|
||||
}}
|
||||
>
|
||||
{[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>
|
||||
))}
|
||||
{chars.map((char, i) => {
|
||||
// 쉼표도 애니메이션으로 표시
|
||||
if (char === ',') {
|
||||
return (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="h-[1.2em] flex items-center"
|
||||
initial={{ y: '100%', opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{
|
||||
type: 'tween',
|
||||
ease: 'easeOut',
|
||||
duration: 0.8,
|
||||
delay: i * 0.15
|
||||
}}
|
||||
>
|
||||
,
|
||||
</motion.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>
|
||||
);
|
||||
}
|
||||
|
||||
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 = [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue