feat: 백엔드 API 연동 강화 및 통계 페이지 개편 #5

병합
htlee develop 에서 main 로 1 commits 를 머지했습니다 2026-02-14 23:12:40 +09:00
7개의 변경된 파일97개의 추가작업 그리고 54개의 파일을 삭제
Showing only changes of commit d72799011d - Show all commits

파일 보기

@ -291,8 +291,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
run: | run: |
git clone --depth=1 --branch=\$\{GITHUB_REF_NAME\} \\ git clone --depth=1 --branch=${'${GITHUB_REF_NAME}'} \\
http://gitea:3000/\$\{GITHUB_REPOSITORY\}.git . http://gitea:3000/${'${GITHUB_REPOSITORY}'}.git .
- name: Configure Maven settings - name: Configure Maven settings
run: | run: |

파일 보기

@ -1,5 +1,6 @@
import { lazy, Suspense } from 'react'; import { lazy, Suspense, useEffect } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { api } from '../utils/api';
const CONTENT_MAP: Record<string, React.LazyExoticComponent<React.ComponentType>> = { const CONTENT_MAP: Record<string, React.LazyExoticComponent<React.ComponentType>> = {
'env-intro': lazy(() => import('../content/DevEnvIntro')), 'env-intro': lazy(() => import('../content/DevEnvIntro')),
@ -17,6 +18,12 @@ export function GuidePage() {
const { section } = useParams<{ section: string }>(); const { section } = useParams<{ section: string }>();
const Content = section ? CONTENT_MAP[section] : null; const Content = section ? CONTENT_MAP[section] : null;
useEffect(() => {
if (section) {
api.post('/activity/track', { pagePath: `/dev/${section}` }).catch(() => {});
}
}, [section]);
if (!Content) { if (!Content) {
return ( return (
<div className="max-w-4xl mx-auto py-12 px-6"> <div className="max-w-4xl mx-auto py-12 px-6">

파일 보기

@ -1,9 +1,15 @@
import { useEffect } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { useAuth } from '../auth/useAuth'; import { useAuth } from '../auth/useAuth';
import { api } from '../utils/api';
export function HomePage() { export function HomePage() {
const { user } = useAuth(); const { user } = useAuth();
useEffect(() => {
api.post('/activity/track', { pagePath: '/' }).catch(() => {});
}, []);
return ( return (
<div className="max-w-4xl mx-auto py-12 px-6"> <div className="max-w-4xl mx-auto py-12 px-6">
<h1 className="text-3xl font-bold text-text-primary mb-4"> <h1 className="text-3xl font-bold text-text-primary mb-4">

파일 보기

@ -54,7 +54,7 @@ export function PermissionManagement() {
const handleDelete = async (permissionId: number) => { const handleDelete = async (permissionId: number) => {
try { try {
await api.delete(`/admin/permissions/${permissionId}`); await api.delete(`/admin/roles/permissions/${permissionId}`);
fetchPermissions(); fetchPermissions();
} catch { } catch {
// 에러 처리 // 에러 처리

파일 보기

@ -1,23 +1,31 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import type { PageStat, StatsResponse } from '../../types'; import type { LoginHistory, StatsResponse } from '../../types';
import { api } from '../../utils/api'; import { api } from '../../utils/api';
const LOGIN_HISTORY_LIMIT = 20;
export function StatsPage() { export function StatsPage() {
const [stats, setStats] = useState<StatsResponse | null>(null); const [stats, setStats] = useState<StatsResponse | null>(null);
const [pageStats] = useState<PageStat[]>([]); const [loginHistory, setLoginHistory] = useState<LoginHistory[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fetchStats = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
const data = await api.get<StatsResponse>('/admin/stats'); const [statsData, historyData] = await Promise.all([
setStats(data); api.get<StatsResponse>('/admin/stats'),
api.get<LoginHistory[]>('/activity/login-history'),
]);
setStats(statsData);
setLoginHistory(historyData.slice(0, LOGIN_HISTORY_LIMIT));
} catch { } catch {
// API 미연동 시 기본값
setStats({ setStats({
totalUsers: 0, totalUsers: 0,
activeUsers: 0, activeUsers: 0,
pendingUsers: 0, pendingUsers: 0,
totalPages: 7, rejectedUsers: 0,
disabledUsers: 0,
todayLogins: 0,
totalRoles: 0,
}); });
} finally { } finally {
setLoading(false); setLoading(false);
@ -25,8 +33,8 @@ export function StatsPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
fetchStats(); fetchData();
}, [fetchStats]); }, [fetchData]);
if (loading) { if (loading) {
return ( return (
@ -40,7 +48,10 @@ export function StatsPage() {
{ label: '전체 사용자', value: stats?.totalUsers ?? 0, color: 'text-accent' }, { label: '전체 사용자', value: stats?.totalUsers ?? 0, color: 'text-accent' },
{ label: '활성 사용자', value: stats?.activeUsers ?? 0, color: 'text-success' }, { label: '활성 사용자', value: stats?.activeUsers ?? 0, color: 'text-success' },
{ label: '승인 대기', value: stats?.pendingUsers ?? 0, color: 'text-warning' }, { label: '승인 대기', value: stats?.pendingUsers ?? 0, color: 'text-warning' },
{ label: '가이드 페이지', value: stats?.totalPages ?? 0, color: 'text-info' }, { label: '거절', value: stats?.rejectedUsers ?? 0, color: 'text-danger' },
{ label: '비활성', value: stats?.disabledUsers ?? 0, color: 'text-text-muted' },
{ label: '오늘 로그인', value: stats?.todayLogins ?? 0, color: 'text-info' },
{ label: '전체 롤', value: stats?.totalRoles ?? 0, color: 'text-accent' },
]; ];
return ( return (
@ -48,7 +59,7 @@ export function StatsPage() {
<h1 className="text-2xl font-bold text-text-primary mb-6"></h1> <h1 className="text-2xl font-bold text-text-primary mb-6"></h1>
{/* 통계 카드 */} {/* 통계 카드 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-8">
{statCards.map((card) => ( {statCards.map((card) => (
<div key={card.label} className="bg-surface border border-border-default rounded-xl p-5"> <div key={card.label} className="bg-surface border border-border-default rounded-xl p-5">
<p className="text-sm text-text-muted mb-1">{card.label}</p> <p className="text-sm text-text-muted mb-1">{card.label}</p>
@ -57,46 +68,38 @@ export function StatsPage() {
))} ))}
</div> </div>
{/* 인기 페이지 */} {/* 로그인 이력 */}
<h2 className="text-lg font-bold text-text-primary mb-4"> </h2> <h2 className="text-lg font-bold text-text-primary mb-4"> </h2>
<div className="bg-surface border border-border-default rounded-xl overflow-hidden"> <div className="bg-surface border border-border-default rounded-xl overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="bg-bg-tertiary"> <tr className="bg-bg-tertiary">
<th className="text-left px-4 py-3 font-semibold text-text-primary"></th> <th className="text-left px-4 py-3 font-semibold text-text-primary"></th>
<th className="text-left px-4 py-3 font-semibold text-text-primary"></th> <th className="text-left px-4 py-3 font-semibold text-text-primary">IP </th>
<th className="text-left px-4 py-3 font-semibold text-text-primary"></th> <th className="text-left px-4 py-3 font-semibold text-text-primary hidden sm:table-cell">User-Agent</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border-subtle"> <tbody className="divide-y divide-border-subtle">
{pageStats.length === 0 ? ( {loginHistory.length === 0 ? (
<tr> <tr>
<td colSpan={3} className="px-4 py-8 text-center text-text-muted"> <td colSpan={3} className="px-4 py-8 text-center text-text-muted">
. API . .
</td> </td>
</tr> </tr>
) : ( ) : (
pageStats.map((page) => { loginHistory.map((log) => (
const maxViews = Math.max(...pageStats.map((p) => p.viewCount), 1); <tr key={log.id}>
const percent = Math.round((page.viewCount / maxViews) * 100); <td className="px-4 py-3 text-text-primary whitespace-nowrap">
return ( {new Date(log.loginAt).toLocaleString('ko-KR')}
<tr key={page.pagePath}> </td>
<td className="px-4 py-3 font-mono text-text-primary">{page.pagePath}</td> <td className="px-4 py-3 font-mono text-text-secondary text-xs">
<td className="px-4 py-3 text-text-secondary">{page.viewCount}</td> {log.ipAddress}
<td className="px-4 py-3"> </td>
<div className="flex items-center gap-2"> <td className="px-4 py-3 text-text-muted text-xs max-w-xs truncate hidden sm:table-cell" title={log.userAgent}>
<div className="flex-1 h-2 bg-bg-tertiary rounded-full overflow-hidden"> {log.userAgent}
<div </td>
className="h-full bg-accent rounded-full" </tr>
style={{ width: `${percent}%` }} ))
/>
</div>
<span className="text-xs text-text-muted w-8 text-right">{percent}%</span>
</div>
</td>
</tr>
);
})
)} )}
</tbody> </tbody>
</table> </table>

파일 보기

@ -40,6 +40,19 @@ export function UserManagement() {
} }
}; };
const handleToggleAdmin = async (userId: number, isAdmin: boolean) => {
try {
if (isAdmin) {
await api.delete(`/admin/users/${userId}/admin`);
} else {
await api.post(`/admin/users/${userId}/admin`);
}
fetchData();
} catch {
// 에러 처리
}
};
const handleRoleSave = async () => { const handleRoleSave = async () => {
if (!roleModalUser) return; if (!roleModalUser) return;
try { try {
@ -136,7 +149,14 @@ export function UserManagement() {
</div> </div>
)} )}
<div> <div>
<p className="font-medium text-text-primary">{user.name}</p> <p className="font-medium text-text-primary">
{user.name}
{user.isAdmin && (
<span className="ml-1.5 px-1.5 py-0.5 bg-accent/10 text-accent rounded text-[10px] font-semibold align-middle">
ADMIN
</span>
)}
</p>
<p className="text-xs text-text-muted">{user.email}</p> <p className="text-xs text-text-muted">{user.email}</p>
</div> </div>
</div> </div>
@ -185,6 +205,18 @@ export function UserManagement() {
> >
</button> </button>
{user.status === 'ACTIVE' && (
<button
onClick={() => handleToggleAdmin(user.id, user.isAdmin)}
className={`px-2.5 py-1 rounded text-xs font-medium cursor-pointer ${
user.isAdmin
? 'bg-warning/10 text-warning hover:bg-warning/20'
: 'bg-info/10 text-info hover:bg-info/20'
}`}
>
{user.isAdmin ? '관리자 해제' : '관리자 지정'}
</button>
)}
</div> </div>
</td> </td>
</tr> </tr>

파일 보기

@ -61,20 +61,15 @@ export interface StatsResponse {
totalUsers: number; totalUsers: number;
activeUsers: number; activeUsers: number;
pendingUsers: number; pendingUsers: number;
totalPages: number; rejectedUsers: number;
} disabledUsers: number;
todayLogins: number;
export interface PageStat { totalRoles: number;
pagePath: string;
viewCount: number;
lastAccessed: string;
} }
export interface LoginHistory { export interface LoginHistory {
id: number; id: number;
userId: number;
userName: string;
email: string;
loginAt: string; loginAt: string;
ipAddress: string; ipAddress: string;
userAgent: string;
} }