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 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' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { 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 = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue