From f387eb452e02fc6891406b09b1d5ae5effc8be0b Mon Sep 17 00:00:00 2001 From: hojooo Date: Wed, 20 May 2026 11:09:39 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EA=B7=B8=EB=9E=98=ED=94=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EB=9D=BC=EC=9D=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/App.test.tsx | 68 +++++- src/app/App.tsx | 5 + src/app/styles.css | 13 + src/pages/OverviewPage.tsx | 84 ++++--- src/pages/UsersPage.tsx | 399 +++++++++++++++++++++++++++++-- src/shared/api/adminEndpoints.ts | 32 ++- src/shared/api/adminLabels.ts | 15 +- src/shared/api/adminTypes.ts | 50 ++++ tests/e2e/admin-routes.spec.ts | 106 +++++++- 9 files changed, 705 insertions(+), 67 deletions(-) diff --git a/src/app/App.test.tsx b/src/app/App.test.tsx index ed99e3d..c3c24c5 100644 --- a/src/app/App.test.tsx +++ b/src/app/App.test.tsx @@ -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(); @@ -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(); @@ -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, @@ -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(data: T) { return new Response( JSON.stringify({ diff --git a/src/app/App.tsx b/src/app/App.tsx index bac9aad..15bb35e 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -32,6 +32,11 @@ const navigationItems: MenuProps["items"] = [ icon: , label: 대시보드 }, + { + key: "/admin/users", + icon: , + label: 회원 관리 + }, { key: "/admin/content", icon: , diff --git a/src/app/styles.css b/src/app/styles.css index 4f5d758..513657e 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -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%; @@ -179,6 +188,10 @@ textarea { min-width: 140px; } +.user-filter-bar .ant-input-search { + width: 240px; +} + .login-page { display: grid; min-height: 100vh; diff --git a/src/pages/OverviewPage.tsx b/src/pages/OverviewPage.tsx index 8c7e6f3..09d78a5 100644 --- a/src/pages/OverviewPage.tsx +++ b/src/pages/OverviewPage.tsx @@ -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 { @@ -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("DAYS_30"); + const [selectedMetric, setSelectedMetric] = useState("visitorCount"); const dailyUserMetricsParams = { range: dailyUserMetricsRange }; const userStatisticsQuery = useQuery({ queryKey: adminQueryKeys.userStatistics, @@ -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 (
@@ -62,14 +71,22 @@ export function OverviewPage() {
setDailyUserMetricsRange(value as AdminDailyUserMetricsRange)} - /> + + setSelectedMetric(value as DailyUserMetricKey)} + /> + setDailyUserMetricsRange(value as AdminDailyUserMetricsRange)} + /> + } > {dailyUserMetricsQuery.isError ? ( @@ -79,40 +96,29 @@ export function OverviewPage() { 그래프를 불러오는 중입니다. ) : null} {!dailyUserMetricsQuery.isPending && !dailyUserMetricsQuery.isError ? ( - `${value}명` } }} - interaction={{ tooltip: { marker: false } }} - style={{ lineWidth: 2 }} - /> +
+ {selectedMetricLabel} + `${value}명` } }} + interaction={{ tooltip: { marker: false } }} + style={{ lineWidth: 2 }} + /> +
) : null}
); } -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] + })); } diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx index c8a34d5..6ef8e52 100644 --- a/src/pages/UsersPage.tsx +++ b/src/pages/UsersPage.tsx @@ -1,37 +1,392 @@ -import { useQuery } from "@tanstack/react-query"; -import { Alert, Card, Statistic, Typography } from "antd"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + App as AntApp, + Alert, + Avatar, + Button, + Card, + DatePicker, + Descriptions, + Drawer, + Form, + Input, + Select, + Space, + Table, + Tag, + Typography +} from "antd"; +import type { ColumnsType } from "antd/es/table"; +import type { Dayjs } from "dayjs"; +import { useState } from "react"; -import { adminQueryKeys, getAdminUserStatistics } from "../shared/api/adminEndpoints"; +import { + adminQueryKeys, + getAdminUser, + getAdminUsers, + moderateAdminUser +} from "../shared/api/adminEndpoints"; +import { + userActionLabels, + userRoleLabels, + userStatusLabels +} from "../shared/api/adminLabels"; +import type { + AdminUserModerationHistoryRes, + AdminUserSummaryRes, + UserModerationAction, + UserStatus +} from "../shared/api/adminTypes"; + +type UserStatusFilter = UserStatus | "ALL"; +type DirectUserAction = Exclude; + +interface UserModerationFormValues { + action: DirectUserAction; + expiresAt?: Dayjs; + adminMemo?: string; +} + +const DEFAULT_PAGE_SIZE = 10; +const PAGE_SIZE_OPTIONS = [10, 20, 50]; +const directUserActionOptions: { label: string; value: DirectUserAction }[] = [ + { label: userActionLabels.WARN, value: "WARN" }, + { label: userActionLabels.RESTRICT_UPLOAD, value: "RESTRICT_UPLOAD" }, + { label: userActionLabels.SUSPEND, value: "SUSPEND" } +]; export function UsersPage() { - const userStatisticsQuery = useQuery({ - queryKey: adminQueryKeys.userStatistics, - queryFn: getAdminUserStatistics + const { message } = AntApp.useApp(); + const queryClient = useQueryClient(); + const [keywordInput, setKeywordInput] = useState(""); + const [keyword, setKeyword] = useState(); + const [status, setStatus] = useState("ALL"); + const [createdRange, setCreatedRange] = useState<[Dayjs, Dayjs] | null>(null); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + const [selectedUserId, setSelectedUserId] = useState(null); + const [form] = Form.useForm(); + const selectedAction = Form.useWatch("action", form); + const listParams = { + keyword, + status: status === "ALL" ? undefined : status, + createdFrom: createdRange?.[0]?.format("YYYY-MM-DD"), + createdTo: createdRange?.[1]?.format("YYYY-MM-DD"), + page, + size: pageSize + }; + const usersQuery = useQuery({ + queryKey: adminQueryKeys.users(listParams), + queryFn: () => getAdminUsers(listParams) + }); + const detailQuery = useQuery({ + queryKey: selectedUserId ? adminQueryKeys.user(selectedUserId) : ["admin", "users", "empty"], + queryFn: () => getAdminUser(selectedUserId!), + enabled: selectedUserId !== null }); + const moderationMutation = useMutation({ + mutationFn: (values: UserModerationFormValues) => + moderateAdminUser(selectedUserId!, { + action: values.action, + expiresAt: values.expiresAt ? values.expiresAt.format("YYYY-MM-DDTHH:mm:ss") : null, + adminMemo: normalizeOptionalText(values.adminMemo) ?? undefined + }), + onSuccess: async (user) => { + void message.success("회원 조치를 저장했습니다."); + form.resetFields(); + queryClient.setQueryData(adminQueryKeys.user(user.userId), user); + await queryClient.invalidateQueries({ queryKey: ["admin", "users"] }); + }, + onError: () => { + void message.error("회원 조치를 저장하지 못했습니다."); + } + }); + + const columns: ColumnsType = [ + { + title: "닉네임", + dataIndex: "nickname", + key: "nickname", + render: (nickname: string, user) => ( + + {nickname.slice(0, 1)} + {nickname} + + ) + }, + { + title: "계정 상태", + dataIndex: "status", + key: "status", + width: 110, + render: (value: UserStatus) => {userStatusLabels[value]} + }, + { + title: "회원 유형", + dataIndex: "userRole", + key: "userRole", + width: 120, + render: (value: keyof typeof userRoleLabels) => userRoleLabels[value] + }, + { + title: "경고 횟수", + dataIndex: "warningCount", + key: "warningCount", + width: 100, + render: (value: number) => `${value}회` + }, + { + title: "업로드 제한", + dataIndex: "uploadRestricted", + key: "uploadRestricted", + width: 140, + render: (value: boolean, user) => renderRestriction(value, user.uploadRestrictedUntil) + }, + { + title: "이용 정지", + dataIndex: "suspended", + key: "suspended", + width: 140, + render: (value: boolean, user) => renderRestriction(value, user.suspendedUntil) + }, + { + title: "가입일", + dataIndex: "createdAt", + key: "createdAt", + width: 130, + render: formatDate + }, + { + title: "작업", + key: "action", + width: 100, + render: (_, user) => ( + + ) + } + ]; + const selectedUser = detailQuery.data; + + const handleSearch = (value: string) => { + setKeyword(normalizeOptionalText(value)); + setPage(1); + }; return (
- 사용자 - 현재 가입된 회원 수를 확인합니다. + 회원 관리 + 회원 정보를 확인하고 필요한 조치를 기록합니다.
- {userStatisticsQuery.isError ? ( - - ) : null} - -
- - - -
+ + setKeywordInput(event.target.value)} + onSearch={handleSearch} + /> + + aria-label="계정 상태" + value={status} + className="status-select" + onChange={(value) => { + setStatus(value); + setPage(1); + }} + options={[ + { label: "전체", value: "ALL" }, + { label: "정상", value: "ACTIVE" }, + { label: "탈퇴", value: "WITHDRAWN" } + ]} + /> + { + setCreatedRange(value as [Dayjs, Dayjs] | null); + setPage(1); + }} + /> + + } + > + {usersQuery.isError ? : null} + `${range[0]}-${range[1]} / 전체 ${total}명`, + onChange: (nextPage, nextPageSize) => { + setPage(nextPage); + setPageSize(nextPageSize); + } + }} + /> + + + { + setSelectedUserId(null); + form.resetFields(); + }} + destroyOnHidden + > + {detailQuery.isPending ? 회원 정보를 불러오는 중입니다. : null} + {detailQuery.isError ? : null} + {selectedUser ? ( +
+ + {selectedUser.nickname} + {userStatusLabels[selectedUser.status]} + {userRoleLabels[selectedUser.userRole]} + {formatDateTime(selectedUser.createdAt)} + {formatDateTime(selectedUser.deletedAt)} + + + + {selectedUser.warningCount}회 + + {renderRestriction(selectedUser.uploadRestricted, selectedUser.uploadRestrictedUntil)} + + + {renderRestriction(selectedUser.suspended, selectedUser.suspendedUntil)} + + + + + + form={form} + layout="vertical" + requiredMark={false} + initialValues={{ action: "WARN" }} + onFinish={moderationMutation.mutate} + > + +