feat: 백엔드 API 연동 강화 및 통계 페이지 개편 #5
@ -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;
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user