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

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -1,23 +1,31 @@
import { useCallback, useEffect, useState } from 'react';
import type { PageStat, StatsResponse } from '../../types';
import type { LoginHistory, StatsResponse } from '../../types';
import { api } from '../../utils/api';
const LOGIN_HISTORY_LIMIT = 20;
export function StatsPage() {
const [stats, setStats] = useState<StatsResponse | null>(null);
const [pageStats] = useState<PageStat[]>([]);
const [loginHistory, setLoginHistory] = useState<LoginHistory[]>([]);
const [loading, setLoading] = useState(true);
const fetchStats = useCallback(async () => {
const fetchData = useCallback(async () => {
try {
const data = await api.get<StatsResponse>('/admin/stats');
setStats(data);
const [statsData, historyData] = await Promise.all([
api.get<StatsResponse>('/admin/stats'),
api.get<LoginHistory[]>('/activity/login-history'),
]);
setStats(statsData);
setLoginHistory(historyData.slice(0, LOGIN_HISTORY_LIMIT));
} catch {
// API 미연동 시 기본값
setStats({
totalUsers: 0,
activeUsers: 0,
pendingUsers: 0,
totalPages: 7,
rejectedUsers: 0,
disabledUsers: 0,
todayLogins: 0,
totalRoles: 0,
});
} finally {
setLoading(false);
@ -25,8 +33,8 @@ export function StatsPage() {
}, []);
useEffect(() => {
fetchStats();
}, [fetchStats]);
fetchData();
}, [fetchData]);
if (loading) {
return (
@ -40,7 +48,10 @@ export function StatsPage() {
{ label: '전체 사용자', value: stats?.totalUsers ?? 0, color: 'text-accent' },
{ label: '활성 사용자', value: stats?.activeUsers ?? 0, color: 'text-success' },
{ 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 (
@ -48,7 +59,7 @@ export function StatsPage() {
<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) => (
<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>
@ -57,46 +68,38 @@ export function StatsPage() {
))}
</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">
<table className="w-full text-sm">
<thead>
<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"></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 hidden sm:table-cell">User-Agent</th>
</tr>
</thead>
<tbody className="divide-y divide-border-subtle">
{pageStats.length === 0 ? (
{loginHistory.length === 0 ? (
<tr>
<td colSpan={3} className="px-4 py-8 text-center text-text-muted">
. API .
.
</td>
</tr>
) : (
pageStats.map((page) => {
const maxViews = Math.max(...pageStats.map((p) => p.viewCount), 1);
const percent = Math.round((page.viewCount / maxViews) * 100);
return (
<tr key={page.pagePath}>
<td className="px-4 py-3 font-mono text-text-primary">{page.pagePath}</td>
<td className="px-4 py-3 text-text-secondary">{page.viewCount}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-bg-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full"
style={{ width: `${percent}%` }}
/>
</div>
<span className="text-xs text-text-muted w-8 text-right">{percent}%</span>
</div>
loginHistory.map((log) => (
<tr key={log.id}>
<td className="px-4 py-3 text-text-primary whitespace-nowrap">
{new Date(log.loginAt).toLocaleString('ko-KR')}
</td>
<td className="px-4 py-3 font-mono text-text-secondary text-xs">
{log.ipAddress}
</td>
<td className="px-4 py-3 text-text-muted text-xs max-w-xs truncate hidden sm:table-cell" title={log.userAgent}>
{log.userAgent}
</td>
</tr>
);
})
))
)}
</tbody>
</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 () => {
if (!roleModalUser) return;
try {
@ -136,7 +149,14 @@ export function UserManagement() {
</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>
</div>
</div>
@ -185,6 +205,18 @@ export function UserManagement() {
>
</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>
</td>
</tr>

파일 보기

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