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
189 changes: 189 additions & 0 deletions app/(admin)/admin/detail/[id]/account-history/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query';
import { ArrowRightIcon } from 'lucide-react';
import { getAccountHistoriesInServer } from '@/features/admin/api/account-history.server';
import { GetAccountHistoriesResponse } from '@/features/admin/api/types';
import { formatHHMM, formatYYYYMMDD } from '@/shared/lib/time';
import Badge from '@/shared/ui/badge';

export default async function AccountHistoryPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const queryClient = new QueryClient();
const { id: memberId } = await params;

// 서버 side에서 첫 페이지 데이터 미리 가져오기
await queryClient.prefetchQuery({
queryKey: ['accountHistory', memberId],
queryFn: () => getAccountHistoriesInServer({ memberId: Number(memberId) }),
});

const data: GetAccountHistoriesResponse = await queryClient.getQueryData([
'accountHistory',
memberId,
]);

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<div className="flex flex-col gap-300">
<div className="border-border-default rounded-100 flex w-full flex-col gap-200 border p-200">
<div className="flex items-center gap-150">
<span className="font-designer-16b text-text-default w-[73px]">
계정 생성일
</span>
<div className="font-designer-14r text-text-subtle">
{formatYYYYMMDD(data.joinedAt)}
</div>
</div>

<div className="flex items-center gap-150">
<span className="font-designer-16b text-text-default w-[73px]">
최근 로그인
</span>
<div className="font-designer-14r text-text-subtle">
{data.loginMostRecentlyAt
? formatYYYYMMDD(data.loginMostRecentlyAt)
: '기록 없음'}
</div>
</div>

<div className="flex items-center gap-150">
<span className="font-designer-16b text-text-default w-[73px]">
권한
</span>
<div className="font-designer-14r text-text-subtle">일반</div>
</div>

<div className="flex items-center gap-150">
<span className="font-designer-16b text-text-default w-[73px]">
계정 상태
</span>

<Badge color="green">활성</Badge>
</div>
</div>

<RecentLoginHistory loginHists={data.loginHists} />
<RecentRoleChangeHistory roleChangeHists={data.roleChangeHists} />
<RecentStatusChangeHistory
memberStatusChangeHists={data.memberStatusChangeHists}
/>
</div>
</HydrationBoundary>
);
}

function RecentLoginHistory({
loginHists,
}: Pick<GetAccountHistoriesResponse, 'loginHists'>) {
return (
<div className="border-border-default rounded-100 flex w-full flex-col gap-150 border p-200">
<h3 className="font-designer-16b text-text-default">최근 로그인 기록</h3>

<ul>
{loginHists.map((hist) => (
<li
key={hist}
className="font-designer-14r text-text-subtle border-b-border-subtle flex items-center gap-100 border-b py-100"
>
<span>{formatYYYYMMDD(hist)}</span>
<span>{formatHHMM(hist)}</span>
</li>
))}
</ul>
</div>
);
}

function RecentRoleChangeHistory({
roleChangeHists,
}: Pick<GetAccountHistoriesResponse, 'roleChangeHists'>) {
return (
<div className="border-border-default rounded-100 flex w-full flex-col gap-150 border p-200">
<h3 className="font-designer-16b text-text-default">권한 변경 이력</h3>

{roleChangeHists.length > 0 ? (
<ul>
{roleChangeHists.map((hist) => (
<li
key={hist.changedAt}
className="font-designer-14r text-text-subtle flex justify-between py-100"
>
<div className="flex items-center gap-100">
<span>{formatYYYYMMDD(hist.changedAt)}</span>
<span>{formatHHMM(hist.changedAt)}</span>
</div>

<div className="flex items-center gap-200">
<span>{hist.from}</span>

<ArrowRightIcon width={20} height={20} />

<span>{hist.to}</span>
</div>
</li>
))}
</ul>
) : (
<div className="font-designer-14r text-text-subtle flex justify-center">
변경 이력이 없습니다.
</div>
)}
</div>
);
}

function RecentStatusChangeHistory({
memberStatusChangeHists,
}: Pick<GetAccountHistoriesResponse, 'memberStatusChangeHists'>) {
return (
<div className="border-border-default rounded-100 flex w-full flex-col gap-150 border p-200">
<h3 className="font-designer-16b text-text-default">
계정 상태 변경 이력
</h3>

{memberStatusChangeHists.length > 0 ? (
<ul>
{memberStatusChangeHists.map((hist) => (
<li
key={hist.changedAt}
className="font-designer-14r text-text-subtle flex justify-between py-100"
>
<div className="flex items-center gap-100">
<span>{formatYYYYMMDD(hist.changedAt)}</span>
<span>{formatHHMM(hist.changedAt)}</span>
</div>

<div className="flex items-center gap-200">
<Badge
color={hist.from === '활성' ? 'green' : 'gray'}
shape="rectangle"
>
{hist.from}
</Badge>

<ArrowRightIcon width={20} height={20} />

<Badge
color={hist.to === '활성' ? 'green' : 'gray'}
shape="rectangle"
>
{hist.to}
</Badge>
</div>
</li>
))}
</ul>
) : (
<div className="font-designer-14r text-text-subtle flex justify-center">
변경 이력이 없습니다.
</div>
)}
</div>
);
}
30 changes: 30 additions & 0 deletions app/(admin)/admin/detail/[id]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ArrowLeftIcon } from 'lucide-react';
import Link from 'next/link';
import AdminDetailSideBar from '@/widgets/admin/ui/admin-detail-side-bar';

export default async function AdminDetailLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ id: string }>;
}) {
const { id: memberId } = await params;

return (
<div>
<header className="mb-300 flex items-center gap-200">
<Link href="/admin">
<ArrowLeftIcon />
</Link>
<h1 className="font-bold-h4">사용자 상세 정보</h1>
</header>

<div className="flex gap-200">
<AdminDetailSideBar memberId={memberId} />

<div className="flex-1">{children}</div>
</div>
</div>
);
}
5 changes: 5 additions & 0 deletions app/(admin)/admin/detail/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { notFound } from 'next/navigation';

export default function AdminDetailByIdPage() {
notFound(); // /admin/detail/[id] 경로 404 처리
}
140 changes: 140 additions & 0 deletions app/(admin)/admin/detail/[id]/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { QueryClient } from '@tanstack/react-query';
import { getUserProfileInServer } from '@/entities/user/api/get-user-profile.server';
import { GetUserProfileResponse } from '@/entities/user/api/types';
import ProfileInfoCard from '@/entities/user/ui/profile-info-card';
import CakeIcon from '@/features/my-page/ui/icon/cake.svg';
import GithubIcon from '@/features/my-page/ui/icon/github-logo.svg';
import GlobeIcon from '@/features/my-page/ui/icon/globe-simple.svg';
import PhoneIcon from '@/features/my-page/ui/icon/phone.svg';
import { getSincerityPresetByLevelName } from '@/shared/config/sincerity-temp-presets';
import UserAvatar from '@/shared/ui/avatar';
import Badge from '@/shared/ui/badge';

// todo: UserProfileModal과 거의 유사하여 나중에 리팩토링하기
export default async function ProfilePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const queryClient = new QueryClient();
const { id: memberId } = await params;

// 서버 side에서 첫 페이지 데이터 미리 가져오기
await queryClient.prefetchQuery({
queryKey: ['userProfile', memberId],
queryFn: () => getUserProfileInServer(Number(memberId)),
});

const profile: GetUserProfileResponse = await queryClient.getQueryData([
'userProfile',
memberId,
]);

const temperPreset = getSincerityPresetByLevelName(
profile.sincerityTemp.levelName,
);

return (
<div className="border-border-default rounded-100 flex flex-col gap-400 border p-400">
<div className="flex flex-row gap-300 px-200">
<UserAvatar
image={
profile.memberProfile.profileImage?.resizedImages[0].resizedImageUrl
}
size={80}
/>

<div>
<div className="flex flex-wrap gap-75 pb-75">
{profile.memberProfile.mbti && (
<Badge color="orange">{profile.memberProfile.mbti}</Badge>
)}
{profile.memberProfile.interests.slice(0, 4).map((interest) => (
<Badge key={interest.id} color="purple">
{interest.name}
</Badge>
))}
</div>

<div className="flex items-center justify-start">
<div className="font-designer-28b pb-50">
{profile.memberProfile.memberName}
</div>

<span
className="bg-border-default mx-150 block h-[12px] w-[1px]"
aria-hidden="true"
/>

<div className="flex items-center">
<temperPreset.Icon className="h-400 w-400" />
<span
className={`${temperPreset.textClass} font-designer-14b pl-[2px]`}
>
{profile.sincerityTemp.temperature.toFixed(1)} ℃
</span>
</div>
</div>

<div className="font-designer-15m pb-300">
{profile.memberProfile.simpleIntroduction}
</div>

<div className="grid grid-cols-2 gap-x-250 gap-y-100">
<Field
icon={<CakeIcon />}
value={profile.memberProfile.birthDate}
/>
<Field
icon={<GithubIcon />}
value={profile.memberProfile.githubLink?.url}
/>
<Field icon={<PhoneIcon />} value={profile.memberProfile.tel} />
<Field
icon={<GlobeIcon />}
value={profile.memberProfile.blogOrSnsLink?.url}
/>
</div>
</div>
</div>

<div className="flex flex-col gap-200">
<ProfileInfoCard
title="선호하는 스터디 주제"
content={profile.memberInfo.preferredStudySubject?.name}
/>
<ProfileInfoCard
title="기술 스택"
content={profile.memberInfo.techStacks
.map((t) => t.techStackName)
.join(', ')}
/>
<ProfileInfoCard
title="가능 시간대"
content={profile.memberInfo.availableStudyTimes
.map((t) => t.label)
.join(', ')}
/>
<ProfileInfoCard
title="자기소개"
content={profile.memberInfo.selfIntroduction}
/>
<ProfileInfoCard
title="공부 주제 및 계획"
content={profile.memberInfo.studyPlan}
/>
</div>
</div>
);
}

function Field({ icon, value }: { icon: React.ReactNode; value?: string }) {
return (
<div className="flex items-center gap-100">
{icon}
<span className="font-designer-14r text-text-subtle leading-none">
{value ?? ''}
</span>
</div>
);
}
3 changes: 3 additions & 0 deletions app/(admin)/admin/detail/[id]/sincerity-temp/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function SincerityTempPage() {
return <div>SincerityTempPage</div>;
}
3 changes: 3 additions & 0 deletions app/(admin)/admin/detail/[id]/study/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function StudyPage() {
return <div>StudyPage</div>;
}
5 changes: 5 additions & 0 deletions app/(admin)/admin/detail/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { notFound } from 'next/navigation';

export default function AdminDetailPage() {
notFound(); // /admin/detail 경로 404 처리
}
Loading