Skip to content
Open
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
67 changes: 67 additions & 0 deletions src/app/(admin)/admin/(app)/users/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"use client";

import React, { useState } from "react";
import AdminUserProfile from "@/components/base-ui/Admin/users/AdminUserProfile";
import AdminCategory from "@/components/base-ui/Admin/users/AdminCategory";
import AdminUserTabs, {
type AdminUserTabId,
} from "@/components/base-ui/Admin/users/AdminUserTab";

import MeetingList from "@/components/base-ui/Admin/users/MeetingList";
import BookStoryList from "@/components/base-ui/Admin/users/BookStoryList";
import NewsList from "@/components/base-ui/Admin/users/NewsList";
import ReportList from "@/components/base-ui/Admin/users/ReportList";


export default function Page() {
const [activeTab, setActiveTab] =
useState<AdminUserTabId>("meetings");

// 더미
const user = {
userId: "hy_0716",
name: "윤현일",
email: "yh9839@naver.com",
phone: "010-1234-5678",
intro:
"이건 다양한 책을 읽고 서로의 생각을 나누는 책무새라서 시작했습니다. 한 권의 책이 주는 작은 울림이 일상에 변화를 만든다고 믿어요.",
profileImageUrl: null as string | null,
};
Comment on lines +21 to +29
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 user 객체가 더미 데이터로 하드코딩되어 있습니다. PR 설명에 따라 API 연동 후 실제 사용자 데이터를 기반으로 이 부분을 대체해야 합니다. [id] 라우트 파라미터를 활용하여 특정 사용자의 정보를 가져오도록 구현해야 합니다.


const selectedCategories = [
"KOREAN_NOVEL",
"ESSAY",
"HUMANITIES",
"SELF_IMPROVEMENT",
];
Comment on lines +31 to +36
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

selectedCategories 또한 더미 데이터로 하드코딩되어 있습니다. 이 부분도 API 연동 시 실제 사용자 카테고리 정보로 대체되어야 합니다.


return (
<main className="w-full bg-background">
<section className="w-[1040px] mx-auto pt-[80px]">
<div className="flex items-start justify-between gap-10">
<div className="flex-1">
<AdminUserProfile />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

AdminUserProfile 컴포넌트가 현재 page.tsx에서 정의된 user 객체를 props로 받지 않고 있습니다. AdminUserProfile 내부에서 useProfileQuery를 사용하여 데이터를 가져오는 것으로 보입니다. 만약 AdminUserProfile[id] 라우트 파라미터에 해당하는 특정 사용자의 프로필을 표시해야 한다면, useProfileQueryid를 전달하거나 page.tsx에서 가져온 user 데이터를 props로 전달하여 일관성을 유지해야 합니다. 현재 page.tsxuser 객체는 사용되지 않고 있습니다.

</div>

<aside className="w-[520px]">
<AdminCategory
selectedCategories={selectedCategories}
/>
Comment on lines +47 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

AdminCategory 컴포넌트도 page.tsx에서 정의된 selectedCategories를 props로 받고 있지만, AdminUserProfile과 마찬가지로 [id] 라우트 파라미터에 해당하는 특정 사용자의 카테고리 정보를 가져와야 합니다. 현재는 page.tsx의 하드코딩된 selectedCategories를 사용하고 있습니다.

</aside>
</div>

<div className="flex flex-col items-center w-full gap-[40px] mt-[80px]">
<AdminUserTabs
activeTab={activeTab}
onTabChange={setActiveTab}
/>

{activeTab === "meetings" && <MeetingList />}
{activeTab === "stories" && <BookStoryList />}
{activeTab === "posts" && <NewsList />}
{activeTab === "reports" && <ReportList />}
</div>
</section>
</main>
);
}
8 changes: 6 additions & 2 deletions src/app/(admin)/admin/(app)/users/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useMemo, useState } from "react";
import AdminSearchHeader from "@/components/layout/AdminSearchHeader";
import Link from "next/link";

type UserRow = {
id: string;
Expand Down Expand Up @@ -129,9 +130,12 @@ export default function UsersPage() {
<td className="pl-[12px] py-0 body_1_2 text-Gray-7">{u.email}</td>
<td className="pl-[12px] py-0 body_1_2 text-Gray-7">{u.phone}</td>
<td className="pl-[12px] py-0">
<button className="body_1_2 text-Gray-7 underline underline-offset-2 hover:opacity-70">
<Link
href={`/admin/users/${u.id}`}
className="body_1_2 text-Gray-7 underline underline-offset-2 hover:opacity-70"
>
상세보기
</button>
</Link>
</td>
</tr>
))}
Expand Down
47 changes: 47 additions & 0 deletions src/components/base-ui/Admin/users/AdminCategory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"use client";

import { CATEGORIES } from "@/constants/categories";

interface Props {
selectedCategories?: string[];
}

export default function AdminCategory({
selectedCategories = [],
}: Props) {
return (
<div className="w-[526px]">
{/* 4 x 4 grid */}
<div className="grid grid-cols-4 gap-x-[12px] gap-y-[8px]">
{CATEGORIES.map((cat) => {
const isSelected = selectedCategories.includes(cat.value);

return (
<div
key={cat.value}
className={`
w-[122px] h-[44px]
flex items-center justify-center gap-[8px]
px-[16px] py-[12px]
rounded-[400px]
border border-Subbrown-3
transition-colors
${isSelected ? "bg-Subbrown-1" : "bg-background"}
`}
>
<span
className={
isSelected
? "body_1_2 text-white"
: "body_1_3 text-Gray-5"
}
>
{cat.label}
</span>
</div>
);
})}
</div>
</div>
);
}
66 changes: 66 additions & 0 deletions src/components/base-ui/Admin/users/AdminUserProfile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";

import React from "react";
import Image from "next/image";
import { DUMMY_USER_PROFILE } from "@/constants/mocks/mypage";
import { useProfileQuery } from "@/hooks/queries/useMemberQueries";

const AdminUserProfile = () => {
const { data: profileData } = useProfileQuery();

const user = {
...DUMMY_USER_PROFILE,
name: profileData?.nickname || DUMMY_USER_PROFILE.name,
intro: profileData?.description || DUMMY_USER_PROFILE.intro,
profileImage:
profileData?.profileImageUrl || DUMMY_USER_PROFILE.profileImage,
};

const email = (profileData as any)?.email || "example@email.com";
const phone = (profileData as any)?.phone || "010-0000-0000";
Comment on lines +19 to +20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

profileData as any와 같은 any 타입 단언은 TypeScript의 타입 안전성을 우회합니다. profileData의 타입 정의에 emailphone 속성을 포함하거나, 해당 속성이 undefined일 경우를 명시적으로 처리하는 것이 좋습니다.

Suggested change
const email = (profileData as any)?.email || "example@email.com";
const phone = (profileData as any)?.phone || "010-0000-0000";
const email = profileData?.email ?? "example@email.com";
const phone = profileData?.phone ?? "010-0000-0000";


return (
<div className="w-[479px] h-[212px]">
<div className="grid grid-rows-[auto_1fr] h-full">
{/* 1행 */}
<div className="flex items-start gap-[24px]">
{/* 프로필 이미지 */}
<div className="w-[112px] h-[112px] rounded-full overflow-hidden relative shrink-0 bg-Subbrown-4">
{user.profileImage ? (
<Image
src={user.profileImage}
alt="Profile"
fill
className="object-cover"
/>
) : (
<div className="w-full h-full bg-Subbrown-4" />
)}
</div>

{/* 정보 영역 */}
<dl className="grid grid-cols-[64px_1fr] gap-x-[18px] gap-y-[8px] pt-[6px]">
<dt className="body_1_3 text-Gray-4">아이디</dt>
<dd className="body_1_2 text-Gray-7">{user.name}</dd>

<dt className="body_1_3 text-Gray-4">이름</dt>
<dd className="body_1_2 text-Gray-7">윤현일</dd>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

이름 필드가 "윤현일"로 하드코딩되어 있습니다. user.name 또는 profileData.nickname을 사용하여 동적으로 표시해야 합니다. 현재 user.name아이디 필드에 사용되고 있어 혼란을 줄 수 있습니다.

Suggested change
<dd className="body_1_2 text-Gray-7">윤현일</dd>
<dd className="body_1_2 text-Gray-7">{user.name}</dd>


<dt className="body_1_3 text-Gray-4">이메일</dt>
<dd className="body_1_2 text-Gray-7">{email}</dd>

<dt className="body_1_3 text-Gray-4">전화번호</dt>
<dd className="body_1_2 text-Gray-7">{phone}</dd>
</dl>
</div>

{/* 2행 소개글 */}
<p className="body_1_3 text-Gray-4 leading-[145%] mt-[12px] line-clamp-3">
{user.intro}
</p>
</div>
</div>
);
};

export default AdminUserProfile;
41 changes: 41 additions & 0 deletions src/components/base-ui/Admin/users/AdminUserTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"use client";

import React from "react";

export type AdminUserTabId = "meetings" | "stories" | "posts" | "reports";

const USER_TABS: Array<{ id: AdminUserTabId; label: string }> = [
{ id: "meetings", label: "가입 모임" },
{ id: "stories", label: "책 이야기" },
{ id: "posts", label: "등록 소식" },
{ id: "reports", label: "신고 목록" },
];

interface AdminUserTabsProps {
activeTab: AdminUserTabId;
onTabChange: (id: AdminUserTabId) => void;
}

const AdminUserTabs = ({ activeTab, onTabChange }: AdminUserTabsProps) => {
return (
<div className="flex items-center w-full md:w-[768px] lg:w-[1440px] px-0 md:px-[60px] lg:px-[197px] border-b-2 border-Gray-2">
{USER_TABS.map((tab) => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`flex-1 flex justify-center items-center gap-[10px] p-[10px]
body_1_2 md:subhead_3
transition-colors border-b-2 ${
activeTab === tab.id
? "text-primary-3 border-primary-3 -mb-[2px]"
: "text-Gray-3 border-transparent"
}`}
>
{tab.label}
</button>
))}
</div>
);
};

export default AdminUserTabs;
69 changes: 69 additions & 0 deletions src/components/base-ui/Admin/users/BookStoryList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"use client";

import React, { useEffect } from "react";
import BookStoryCard from "./items/AdminBookStoryCard";
import { useMyInfiniteStoriesQuery } from "@/hooks/queries/useStoryQueries";
import { useInView } from "react-intersection-observer";

const MyBookStoryList = () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

컴포넌트 이름이 MyBookStoryList로 되어 있지만, 파일 이름은 BookStoryList.tsx입니다. 일관성을 위해 컴포넌트 이름을 파일 이름과 동일하게 BookStoryList로 변경하는 것이 좋습니다.

Suggested change
const MyBookStoryList = () => {
const BookStoryList = () => {

const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
} = useMyInfiniteStoriesQuery();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

useMyInfiniteStoriesQuery()는 현재 로그인한 사용자의 책 이야기를 가져오는 훅으로 보입니다. 관리자 페이지에서 특정 사용자의 상세 정보를 볼 때는 [id] 라우트 파라미터에 해당하는 사용자의 책 이야기를 가져오도록 수정해야 합니다. 이는 데이터의 정확성에 영향을 미칠 수 있는 중요한 부분입니다.


const { ref, inView } = useInView();

useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);

const stories = data?.pages.flatMap((page) => page.basicInfoList) || [];

return (
<div className="flex flex-col items-center w-full max-w-[1048px] mx-auto gap-[20px] px-[18px] md:px-[40px] lg:px-0">

{isLoading && <p className="text-Gray-4 text-center py-4">로딩 중...</p>}

{!isLoading && isError && (
<p className="text-red-500 text-center py-4">책 이야기를 불러오는 데 실패했습니다.</p>
)}

{!isLoading && !isError && stories.length === 0 && (
<p className="text-Gray-4 text-center py-4">작성한 책 이야기가 없습니다.</p>
)}

<div className="grid grid-cols-2 min-[540px]:grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-[20px] md:gap-[12px] lg:gap-[20px] w-fit">
{stories.map((story) => (
<BookStoryCard
key={story.bookStoryId}
authorName={story.authorInfo.nickname}
createdAt={story.createdAt}
viewCount={story.viewCount}
title={story.bookStoryTitle}
content={story.description}
likeCount={story.likes}
commentCount={story.commentCount}
coverImgSrc={story.bookInfo.imgUrl}
profileImgSrc={story.authorInfo.profileImageUrl}
hideSubscribeButton={true}
/>
))}
</div>

{/* Infinite Scroll Trigger */}
<div ref={ref} className="h-4 w-full" />

{isFetchingNextPage && (
<p className="text-Gray-4 text-center py-4">추가 이야기를 불러오는 중...</p>
)}
</div>
);
};

export default MyBookStoryList;
46 changes: 46 additions & 0 deletions src/components/base-ui/Admin/users/MeetingList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"use client";

import React from "react";
import MyMeetingCard from "./items/AdminMeetingCard";
import { useMyClubsQuery } from "@/hooks/queries/useClubQueries";

const MyMeetingList = () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

컴포넌트 이름이 MyMeetingList로 되어 있지만, 파일 이름은 MeetingList.tsx입니다. 일관성을 위해 컴포넌트 이름을 파일 이름과 동일하게 MeetingList로 변경하는 것이 좋습니다.

Suggested change
const MyMeetingList = () => {
const MeetingList = () => {

const { data, isLoading, isError } = useMyClubsQuery();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

useMyClubsQuery()는 현재 로그인한 사용자의 모임 목록을 가져오는 훅으로 보입니다. 관리자 페이지에서 특정 사용자의 상세 정보를 볼 때는 [id] 라우트 파라미터에 해당하는 사용자의 모임 목록을 가져오도록 수정해야 합니다. 이는 데이터의 정확성에 영향을 미칠 수 있는 중요한 부분입니다.

const clubs = data?.clubList || [];

if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-10 w-full text-Gray-4 text-sm font-medium">
불러오는 중...
</div>
);
}

if (isError) {
return (
<div className="flex flex-col items-center justify-center py-10 w-full text-red-500 text-sm font-medium">
독서 모임을 불러오는 데 실패했습니다.
</div>
);
}

if (clubs.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 w-full">
<p className="text-Gray-4 text-sm font-medium whitespace-pre-wrap text-center">
가입한 독서 모임이 없습니다.
</p>
</div>
);
}

return (
<div className="flex flex-col items-start gap-[8px] w-full max-w-[1048px] px-[18px] md:px-[40px] lg:px-0 mx-auto">
{clubs.map((club) => (
<MyMeetingCard key={club.clubId} club={club} />
))}
</div>
);
};

export default MyMeetingList;
17 changes: 17 additions & 0 deletions src/components/base-ui/Admin/users/NewsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// NewsList.tsx
"use client";
import NewsItem from "./items/AdminNewsItem";

export default function NewsList() {
const posts = [
{ id: 1, imageUrl: "/...", title: "...", content: "...", date: "2025.01.01" },
];
Comment on lines +6 to +8
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

posts 배열에 더미 데이터가 포함되어 있습니다. 이 부분은 실제 API를 통해 뉴스를 가져오도록 구현되어야 합니다.


return (
<div className="flex flex-col gap-[16px] w-full max-w-[1040px] mx-auto">
{posts.map(post => (
<NewsItem key={post.id} {...post} />
))}
</div>
);
}
Loading