Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion src/app/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ describe("App routes", () => {
renderRoute("/admin");

expect(await screen.findByRole("heading", { name: "대시보드" })).toBeInTheDocument();
expect(await screen.findByText("일별 방문자·신규 가입·회원 수")).toBeInTheDocument();
expect(await screen.findByText("일별 사용자 지표")).toBeInTheDocument();
expect(screen.getAllByText("방문자").length).toBeGreaterThan(0);
expect(screen.getByText("신규 가입")).toBeInTheDocument();
expect(screen.getByText("회원 수")).toBeInTheDocument();
expect(screen.getByText("7일")).toBeInTheDocument();
expect(screen.getByText("30일")).toBeInTheDocument();
expect(screen.getByText("전체")).toBeInTheDocument();
Expand All @@ -58,6 +61,15 @@ describe("App routes", () => {
expect(await screen.findByRole("cell", { name: "추천 컬렉션" })).toBeInTheDocument();
});

it("회원 관리 화면에 실제 회원 목록을 표시한다", async () => {
mockAdminFetch();
signInTestSession();
renderRoute("/admin/users");

expect(await screen.findByRole("heading", { name: "회원 관리" })).toBeInTheDocument();
expect(await screen.findByText("플린트")).toBeInTheDocument();
});

it("컬렉션 관리 화면에 실제 컬렉션 목록을 표시한다", async () => {
mockAdminFetch();
signInTestSession();
Expand Down Expand Up @@ -176,6 +188,28 @@ function mockAdminFetch() {
});
}

if (url.includes("/admin/users/10/moderations")) {
return jsonResponse(adminUserDetail);
}

if (url.includes("/admin/users/10")) {
return jsonResponse(adminUserDetail);
}

if (url.includes("/admin/users?") || url.endsWith("/admin/users")) {
return jsonResponse({
data: [adminUserSummary],
meta: {
type: "OFFSET",
returned: 1,
page: 1,
size: 10,
totalElements: 1,
totalPages: 1
}
});
}

if (url.includes("/admin/me")) {
return jsonResponse({
adminId: 1,
Expand Down Expand Up @@ -264,6 +298,38 @@ function mockAdminFetch() {
);
}

const adminUserSummary = {
userId: 10,
nickname: "플린트",
profileImageUrl: null,
userRole: "FLINER",
status: "ACTIVE",
warningCount: 1,
uploadRestricted: false,
uploadRestrictedUntil: null,
suspended: false,
suspendedUntil: null,
createdAt: "2026-05-01T09:00:00"
};

const adminUserDetail = {
...adminUserSummary,
uploadRestrictedAt: null,
suspendedAt: null,
deletedAt: null,
updatedAt: "2026-05-02T09:00:00",
recentModerations: [
{
historyId: 1,
adminId: 1,
action: "WARN",
actionExpiresAt: null,
adminMemo: "메모",
createdAt: "2026-05-02T10:00:00"
}
]
};

function jsonResponse<T>(data: T) {
return new Response(
JSON.stringify({
Expand Down
5 changes: 5 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ const navigationItems: MenuProps["items"] = [
icon: <DashboardOutlined />,
label: <Link to="/admin/overview">대시보드</Link>
},
{
key: "/admin/users",
icon: <UserOutlined />,
label: <Link to="/admin/users">회원 관리</Link>
},
{
key: "/admin/content",
icon: <VideoCameraOutlined />,
Expand Down
13 changes: 13 additions & 0 deletions src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@ textarea {
margin: 0;
}

.chart-panel {
display: grid;
gap: 12px;
}

.chart-panel h3 {
margin: 0;
}

.full-width-control,
.status-select {
width: 100%;
Expand All @@ -179,6 +188,10 @@ textarea {
min-width: 140px;
}

.user-filter-bar .ant-input-search {
width: 240px;
}

.login-page {
display: grid;
min-height: 100vh;
Expand Down
84 changes: 45 additions & 39 deletions src/pages/OverviewPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Line } from "@ant-design/charts";
import { useQuery } from "@tanstack/react-query";
import { Alert, Card, Segmented, Statistic, Typography } from "antd";
import { Alert, Card, Segmented, Space, Statistic, Typography } from "antd";
import { useState } from "react";

import {
Expand All @@ -11,14 +11,22 @@ import {
} from "../shared/api/adminEndpoints";
import type { AdminDailyUserMetricRes, AdminDailyUserMetricsRange } from "../shared/api/adminTypes";

type DailyUserMetricKey = "visitorCount" | "signupUserCount" | "memberCount";

const dailyUserMetricsRangeOptions: { label: string; value: AdminDailyUserMetricsRange }[] = [
{ label: "7일", value: "DAYS_7" },
{ label: "30일", value: "DAYS_30" },
{ label: "전체", value: "ALL" }
];
const dailyUserMetricOptions: { label: string; value: DailyUserMetricKey }[] = [
{ label: "방문자", value: "visitorCount" },
{ label: "신규 가입", value: "signupUserCount" },
{ label: "회원 수", value: "memberCount" }
];

export function OverviewPage() {
const [dailyUserMetricsRange, setDailyUserMetricsRange] = useState<AdminDailyUserMetricsRange>("DAYS_30");
const [selectedMetric, setSelectedMetric] = useState<DailyUserMetricKey>("visitorCount");
const dailyUserMetricsParams = { range: dailyUserMetricsRange };
const userStatisticsQuery = useQuery({
queryKey: adminQueryKeys.userStatistics,
Expand All @@ -32,7 +40,8 @@ export function OverviewPage() {
queryKey: adminQueryKeys.dailyUserMetrics(dailyUserMetricsParams),
queryFn: () => getAdminDailyUserMetrics(dailyUserMetricsParams)
});
const chartData = toChartData(dailyUserMetricsQuery.data?.dailyMetrics);
const selectedMetricLabel = dailyUserMetricOptions.find((option) => option.value === selectedMetric)?.label ?? "방문자";
const chartData = toChartData(dailyUserMetricsQuery.data?.dailyMetrics, selectedMetric);

return (
<div className="page-stack">
Expand Down Expand Up @@ -62,14 +71,22 @@ export function OverviewPage() {
</div>

<Card
title="일별 방문자·신규 가입·회원 수"
title="일별 사용자 지표"
extra={
<Segmented
aria-label="기간 선택"
options={dailyUserMetricsRangeOptions}
value={dailyUserMetricsRange}
onChange={(value) => setDailyUserMetricsRange(value as AdminDailyUserMetricsRange)}
/>
<Space wrap>
<Segmented
aria-label="지표 선택"
options={dailyUserMetricOptions}
value={selectedMetric}
onChange={(value) => setSelectedMetric(value as DailyUserMetricKey)}
/>
<Segmented
aria-label="기간 선택"
options={dailyUserMetricsRangeOptions}
value={dailyUserMetricsRange}
onChange={(value) => setDailyUserMetricsRange(value as AdminDailyUserMetricsRange)}
/>
</Space>
}
>
{dailyUserMetricsQuery.isError ? (
Expand All @@ -79,40 +96,29 @@ export function OverviewPage() {
<Typography.Text type="secondary">그래프를 불러오는 중입니다.</Typography.Text>
) : null}
{!dailyUserMetricsQuery.isPending && !dailyUserMetricsQuery.isError ? (
<Line
data={chartData}
xField="date"
yField="value"
colorField="metric"
height={320}
point={{ shapeField: "circle", sizeField: 3 }}
scale={{ y: { domainMin: 0 } }}
axis={{ y: { labelFormatter: (value: string) => `${value}명` } }}
interaction={{ tooltip: { marker: false } }}
style={{ lineWidth: 2 }}
/>
<div className="chart-panel">
<Typography.Title level={3}>{selectedMetricLabel}</Typography.Title>
<Line
data={chartData}
xField="date"
yField="value"
height={320}
shapeField="smooth"
scale={{ y: { domainMin: 0, nice: true } }}
axis={{ y: { labelFormatter: (value: string) => `${value}명` } }}
interaction={{ tooltip: { marker: false } }}
style={{ lineWidth: 2 }}
/>
</div>
) : null}
</Card>
</div>
);
}

function toChartData(metrics: AdminDailyUserMetricRes[] = []) {
return metrics.flatMap((metric) => [
{
date: metric.date,
metric: "방문자",
value: metric.visitorCount
},
{
date: metric.date,
metric: "신규 가입",
value: metric.signupUserCount
},
{
date: metric.date,
metric: "회원 수",
value: metric.memberCount
}
]);
function toChartData(metrics: AdminDailyUserMetricRes[] = [], metricKey: DailyUserMetricKey) {
return metrics.map((metric) => ({
date: metric.date,
value: metric[metricKey]
}));
}
Loading
Loading