diff --git a/app/(admin)/admin/detail/[id]/account-history/page.tsx b/app/(admin)/admin/detail/[id]/account-history/page.tsx new file mode 100644 index 00000000..6ffff85d --- /dev/null +++ b/app/(admin)/admin/detail/[id]/account-history/page.tsx @@ -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 ( + +
+
+
+ + 계정 생성일 + +
+ {formatYYYYMMDD(data.joinedAt)} +
+
+ +
+ + 최근 로그인 + +
+ {data.loginMostRecentlyAt + ? formatYYYYMMDD(data.loginMostRecentlyAt) + : '기록 없음'} +
+
+ +
+ + 권한 + +
일반
+
+ +
+ + 계정 상태 + + + 활성 +
+
+ + + + +
+
+ ); +} + +function RecentLoginHistory({ + loginHists, +}: Pick) { + return ( +
+

최근 로그인 기록

+ + +
+ ); +} + +function RecentRoleChangeHistory({ + roleChangeHists, +}: Pick) { + return ( +
+

권한 변경 이력

+ + {roleChangeHists.length > 0 ? ( +
    + {roleChangeHists.map((hist) => ( +
  • +
    + {formatYYYYMMDD(hist.changedAt)} + {formatHHMM(hist.changedAt)} +
    + +
    + {hist.from} + + + + {hist.to} +
    +
  • + ))} +
+ ) : ( +
+ 변경 이력이 없습니다. +
+ )} +
+ ); +} + +function RecentStatusChangeHistory({ + memberStatusChangeHists, +}: Pick) { + return ( +
+

+ 계정 상태 변경 이력 +

+ + {memberStatusChangeHists.length > 0 ? ( +
    + {memberStatusChangeHists.map((hist) => ( +
  • +
    + {formatYYYYMMDD(hist.changedAt)} + {formatHHMM(hist.changedAt)} +
    + +
    + + {hist.from} + + + + + + {hist.to} + +
    +
  • + ))} +
+ ) : ( +
+ 변경 이력이 없습니다. +
+ )} +
+ ); +} diff --git a/app/(admin)/admin/detail/[id]/layout.tsx b/app/(admin)/admin/detail/[id]/layout.tsx new file mode 100644 index 00000000..66947cef --- /dev/null +++ b/app/(admin)/admin/detail/[id]/layout.tsx @@ -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 ( +
+
+ + + +

사용자 상세 정보

+
+ +
+ + +
{children}
+
+
+ ); +} diff --git a/app/(admin)/admin/detail/[id]/page.tsx b/app/(admin)/admin/detail/[id]/page.tsx new file mode 100644 index 00000000..3774cfa8 --- /dev/null +++ b/app/(admin)/admin/detail/[id]/page.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation'; + +export default function AdminDetailByIdPage() { + notFound(); // /admin/detail/[id] 경로 404 처리 +} diff --git a/app/(admin)/admin/detail/[id]/profile/page.tsx b/app/(admin)/admin/detail/[id]/profile/page.tsx new file mode 100644 index 00000000..c400e549 --- /dev/null +++ b/app/(admin)/admin/detail/[id]/profile/page.tsx @@ -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 ( +
+
+ + +
+
+ {profile.memberProfile.mbti && ( + {profile.memberProfile.mbti} + )} + {profile.memberProfile.interests.slice(0, 4).map((interest) => ( + + {interest.name} + + ))} +
+ +
+
+ {profile.memberProfile.memberName} +
+ +
+ +
+ {profile.memberProfile.simpleIntroduction} +
+ +
+ } + value={profile.memberProfile.birthDate} + /> + } + value={profile.memberProfile.githubLink?.url} + /> + } value={profile.memberProfile.tel} /> + } + value={profile.memberProfile.blogOrSnsLink?.url} + /> +
+
+
+ +
+ + t.techStackName) + .join(', ')} + /> + t.label) + .join(', ')} + /> + + +
+
+ ); +} + +function Field({ icon, value }: { icon: React.ReactNode; value?: string }) { + return ( +
+ {icon} + + {value ?? ''} + +
+ ); +} diff --git a/app/(admin)/admin/detail/[id]/sincerity-temp/page.tsx b/app/(admin)/admin/detail/[id]/sincerity-temp/page.tsx new file mode 100644 index 00000000..69f6bffb --- /dev/null +++ b/app/(admin)/admin/detail/[id]/sincerity-temp/page.tsx @@ -0,0 +1,71 @@ +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from '@tanstack/react-query'; +import { getSincerityTemperatureHistoryInServer } from '@/features/admin/api/sincerity-temperature-history.server'; +import { GetSincerityTemperatureHistoryResponse } from '@/features/admin/api/types'; +import SincerityTempTable from '@/features/admin/ui/sincerity-temp-table'; +import { getSincerityPresetByLevelName } from '@/shared/config/sincerity-temp-presets'; + +const LEVEL_NAME_MAP = { + FIRST: '1단계', + SECOND: '2단계', + THIRD: '3단계', + FOURTH: '4단계', +}; + +export default async function SincerityTempPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const queryClient = new QueryClient(); + const { id } = await params; + + const memberId = Number(id); + + // 서버 side에서 첫 페이지 데이터 미리 가져오기 + await queryClient.prefetchQuery({ + queryKey: ['sincerityTemperatureHistory', memberId, 1], // "sincerityTemperatureHistory", memberId, page + queryFn: () => + getSincerityTemperatureHistoryInServer({ + memberId, + page: 1, + }), + }); + + const data: GetSincerityTemperatureHistoryResponse = + await queryClient.getQueryData([ + 'sincerityTemperatureHistory', + memberId, + 1, + ]); + + const temperPreset = getSincerityPresetByLevelName( + LEVEL_NAME_MAP[data.sincerityTempLevel], + ); + + return ( + +
+
+ + 현재 성실 온도 + + +
+ + + {data.currentSincerityTemperature} ℃ + +
+
+ + +
+
+ ); +} diff --git a/app/(admin)/admin/detail/[id]/study/page.tsx b/app/(admin)/admin/detail/[id]/study/page.tsx new file mode 100644 index 00000000..9c4cb849 --- /dev/null +++ b/app/(admin)/admin/detail/[id]/study/page.tsx @@ -0,0 +1,3 @@ +export default function StudyPage() { + return
StudyPage
; +} diff --git a/app/(admin)/admin/detail/page.tsx b/app/(admin)/admin/detail/page.tsx new file mode 100644 index 00000000..9292f648 --- /dev/null +++ b/app/(admin)/admin/detail/page.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation'; + +export default function AdminDetailPage() { + notFound(); // /admin/detail 경로 404 처리 +} diff --git a/app/(admin)/admin/page.tsx b/app/(admin)/admin/page.tsx new file mode 100644 index 00000000..46e92df5 --- /dev/null +++ b/app/(admin)/admin/page.tsx @@ -0,0 +1,23 @@ +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from '@tanstack/react-query'; +import { getMemberListInServer } from '@/features/admin/api/member-list.server'; +import MemberListTable from '@/features/admin/ui/member-list-table'; + +export default async function AdminPage() { + const queryClient = new QueryClient(); + + // 서버 side에서 첫 페이지 데이터 미리 가져오기 + await queryClient.prefetchQuery({ + queryKey: ['memberList', null, null, '', 1], // memberList, roleId, memberStatus, searchKeyword, page + queryFn: () => getMemberListInServer({}), + }); + + return ( + + + + ); +} diff --git a/app/layout.tsx b/app/(admin)/layout.tsx similarity index 60% rename from app/layout.tsx rename to app/(admin)/layout.tsx index 47ab1f7f..6a18d503 100644 --- a/app/layout.tsx +++ b/app/(admin)/layout.tsx @@ -1,25 +1,29 @@ -import './global.css'; +import '../global.css'; import { GoogleTagManager } from '@next/third-parties/google'; +import { clsx } from 'clsx'; import type { Metadata } from 'next'; import localFont from 'next/font/local'; import MainProvider from '@/app/provider'; -import Header from '@/widgets/home/header'; +import AdminSideBar from '@/widgets/admin/ui/admin-side-bar'; export const metadata: Metadata = { title: 'ZERO-ONE', description: '매일 아침을 함께 시작하는 1:1 기상 스터디 플랫폼, ZERO-ONE', + icons: { + icon: '/favicon.ico', + }, }; const pretendard = localFont({ - src: '../public/fonts/PretendardVariable.woff2', + src: '../../public/fonts/PretendardVariable.woff2', variable: '--font-pretendard', display: 'swap', }); const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID; -export default function RootLayout({ +export default function AdminLayout({ children, }: Readonly<{ children: React.ReactNode; @@ -27,10 +31,13 @@ export default function RootLayout({ return ( {GTM_ID && } - + -
-
{children}
+
+ + +
{children}
+
diff --git a/app/not-found.tsx b/app/(admin)/not-found.tsx similarity index 100% rename from app/not-found.tsx rename to app/(admin)/not-found.tsx diff --git a/app/(my)/layout.tsx b/app/(service)/(my)/layout.tsx similarity index 72% rename from app/(my)/layout.tsx rename to app/(service)/(my)/layout.tsx index c4c054fd..2c5c2b20 100644 --- a/app/(my)/layout.tsx +++ b/app/(service)/(my)/layout.tsx @@ -12,10 +12,10 @@ export default function MyLayout({ children: React.ReactNode; }>) { return ( -
+
-
- {children} +
+
{children}
); diff --git a/app/(my)/my-page/page.tsx b/app/(service)/(my)/my-page/page.tsx similarity index 100% rename from app/(my)/my-page/page.tsx rename to app/(service)/(my)/my-page/page.tsx diff --git a/app/(my)/my-study-review/page.tsx b/app/(service)/(my)/my-study-review/page.tsx similarity index 98% rename from app/(my)/my-study-review/page.tsx rename to app/(service)/(my)/my-study-review/page.tsx index 21cffeda..23c2bf88 100644 --- a/app/(my)/my-study-review/page.tsx +++ b/app/(service)/(my)/my-study-review/page.tsx @@ -2,16 +2,16 @@ import Image from 'next/image'; import { useEffect, useRef, useState } from 'react'; +import { MyReviewItem } from '@/entities/review/api/review-types'; import KeywordReview from '@/entities/user/ui/keyword-review'; import MoreKeywordReviewModal from '@/entities/user/ui/more-keyword-review-modal'; -import { MyReviewItem } from '@/features/study/api/types'; +import { formatKoreaRelativeTime } from '@/shared/lib/time'; +import UserAvatar from '@/shared/ui/avatar'; import { useMyNegativeKeywordsQuery, useMyReviewsInfinityQuery, useUserPositiveKeywordsQuery, -} from '@/features/study/model/use-review-query'; -import { formatKoreaRelativeTime } from '@/shared/lib/time'; -import UserAvatar from '@/shared/ui/avatar'; +} from '@/entities/review/model/use-review-query'; export default function MyStudyReview() { const { data: positiveKeywordsData } = useUserPositiveKeywordsQuery({ diff --git a/app/(service)/(my)/my-study/entry-list/page.tsx b/app/(service)/(my)/my-study/entry-list/page.tsx new file mode 100644 index 00000000..9fde4dc7 --- /dev/null +++ b/app/(service)/(my)/my-study/entry-list/page.tsx @@ -0,0 +1,20 @@ +import EntryList from '@/features/my-page/ui/entry-list'; +import Image from 'next/image'; + +export default function EntryPage() { + return ( +
+
+ arrow-left +
새로운 신청자 확인하기
+
+ + +
+ ); +} diff --git a/app/(my)/my-study/page.tsx b/app/(service)/(my)/my-study/page.tsx similarity index 100% rename from app/(my)/my-study/page.tsx rename to app/(service)/(my)/my-study/page.tsx diff --git a/app/(service)/(nav)/study/[id]/page.tsx b/app/(service)/(nav)/study/[id]/page.tsx new file mode 100644 index 00000000..7addef93 --- /dev/null +++ b/app/(service)/(nav)/study/[id]/page.tsx @@ -0,0 +1,3 @@ +export default function StudyPage() { + return
Study Page
; +} diff --git a/app/(service)/(nav)/study/page.tsx b/app/(service)/(nav)/study/page.tsx new file mode 100644 index 00000000..ed9ab587 --- /dev/null +++ b/app/(service)/(nav)/study/page.tsx @@ -0,0 +1,28 @@ +import GroupStudyList from '@/features/study/group/ui/group-study-list'; +import IconPlus from '@/shared/icons/plus.svg'; +import Button from '@/shared/ui/button'; +import Sidebar from '@/widgets/home/sidebar'; + +export default function Study() { + return ( +
+
+
+ + 스터디 둘러보기 + + +
+ +
+ +
+ ); +} diff --git a/app/(service)/layout.tsx b/app/(service)/layout.tsx new file mode 100644 index 00000000..c78a2b57 --- /dev/null +++ b/app/(service)/layout.tsx @@ -0,0 +1,47 @@ +import '../global.css'; + +import { GoogleTagManager } from '@next/third-parties/google'; +import { clsx } from 'clsx'; +import type { Metadata } from 'next'; +import localFont from 'next/font/local'; +import MainProvider from '@/app/provider'; +import Header from '@/widgets/home/header'; + +export const metadata: Metadata = { + title: 'ZERO-ONE', + description: '매일 아침을 함께 시작하는 1:1 기상 스터디 플랫폼, ZERO-ONE', + icons: { + icon: '/favicon.ico', + }, +}; + +const pretendard = localFont({ + src: '../../public/fonts/PretendardVariable.woff2', + variable: '--font-pretendard', + display: 'swap', +}); + +const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID; + +export default function ServiceLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {GTM_ID && } + + +
+ {/** 1400 + 48*2 패딩 양옆 48로 임의적용 */} +
+
+
{children}
+
+
+
+ + + ); +} diff --git a/app/login/page.tsx b/app/(service)/login/page.tsx similarity index 100% rename from app/login/page.tsx rename to app/(service)/login/page.tsx diff --git a/app/(service)/not-found.tsx b/app/(service)/not-found.tsx new file mode 100644 index 00000000..48151d31 --- /dev/null +++ b/app/(service)/not-found.tsx @@ -0,0 +1,16 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import Button from '@/shared/ui/button'; + +export default function NotFound() { + return ( +
+ 404 에러 + + + +
+ ); +} diff --git a/app/page.tsx b/app/(service)/page.tsx similarity index 80% rename from app/page.tsx rename to app/(service)/page.tsx index 80303a27..d1eb17fa 100644 --- a/app/page.tsx +++ b/app/(service)/page.tsx @@ -1,5 +1,5 @@ import { Metadata } from 'next'; -import StudyCard from '@/features/study/ui/study-card'; +import StudyCard from '@/features/study/schedule/ui/study-card'; import Banner from '@/widgets/home/banner'; import Sidebar from '@/widgets/home/sidebar'; @@ -10,7 +10,7 @@ export const metadata: Metadata = { export default async function Home() { return ( -
+
diff --git a/app/redirection/page.tsx b/app/(service)/redirection/page.tsx similarity index 100% rename from app/redirection/page.tsx rename to app/(service)/redirection/page.tsx diff --git a/app/sign-up/page.tsx b/app/(service)/sign-up/page.tsx similarity index 100% rename from app/sign-up/page.tsx rename to app/(service)/sign-up/page.tsx diff --git a/app/global.css b/app/global.css index 1cb1b7fa..4f4957b0 100644 --- a/app/global.css +++ b/app/global.css @@ -714,6 +714,374 @@ https://velog.io/@oneook/tailwindcss-4.0-%EB%AC%B4%EC%97%87%EC%9D%B4-%EB%8B%AC%E } } +/* 자동완성이 안되는 문제가 있어 잠시 주석처리 합니다 */ +/* @layer utilities { + /* typography */ +/* .font-display-headings1 { + font-size: 72px; + font-weight: var(--font-weight-semibold); + line-height: 108px; + letter-spacing: 0; + } + + .font-display-headings2 { + font-size: 64px; + font-weight: var(--font-weight-bold); + line-height: 96px; + letter-spacing: 0; + } + + .font-display-headings3 { + font-size: 58px; + font-weight: var(--font-weight-bold); + line-height: 88px; + letter-spacing: 0; + } + + .font-display-headings4 { + font-size: 52px; + font-weight: var(--font-weight-bold); + line-height: 78px; + letter-spacing: 0; + } + + .font-display-headings5 { + font-size: 48px; + font-weight: var(--font-weight-bold); + line-height: 72px; + letter-spacing: 0; + } + + .font-display-headings6 { + font-size: 40px; + font-weight: var(--font-weight-bold); + line-height: 60px; + letter-spacing: 0; + } + + .font-bold-h1 { + font-size: 36px; + font-weight: var(--font-weight-bold); + line-height: 54px; + letter-spacing: 0; + } + + .font-bold-h2 { + font-size: 32px; + font-weight: var(--font-weight-bold); + line-height: 48px; + letter-spacing: 0; + } + + .font-bold-h3 { + font-size: 28px; + font-weight: var(--font-weight-bold); + line-height: 42px; + letter-spacing: 0; + } + + .font-bold-h4 { + font-size: 24px; + font-weight: var(--font-weight-bold); + line-height: 36px; + letter-spacing: 0; + } + + .font-bold-h5 { + font-size: 20px; + font-weight: var(--font-weight-bold); + line-height: 30px; + letter-spacing: 0; + } + + .font-bold-h6 { + font-size: 18px; + font-weight: var(--font-weight-bold); + line-height: 28px; + letter-spacing: 0; + } + + .font-designer-36r { + font-size: 36px; + font-weight: var(--font-weight-regular); + line-height: 54px; + letter-spacing: 0; + } + + .font-designer-36m { + font-size: 36px; + font-weight: var(--font-weight-medium); + line-height: 54px; + letter-spacing: 0; + } + + .font-designer-36b { + font-size: 36px; + font-weight: var(--font-weight-bold); + line-height: 54px; + letter-spacing: 0; + } + + .font-designer-32r { + font-size: 32px; + font-weight: var(--font-weight-regular); + line-height: 48px; + letter-spacing: 0; + } + + .font-designer-32m { + font-size: 32px; + font-weight: var(--font-weight-medium); + line-height: 48px; + letter-spacing: 0; + } + + .font-designer-32b { + font-size: 32px; + font-weight: var(--font-weight-bold); + line-height: 48px; + letter-spacing: 0; + } + + .font-designer-28r { + font-size: 28px; + font-weight: var(--font-weight-regular); + line-height: 42px; + letter-spacing: 0; + } + + .font-designer-28m { + font-size: 28px; + font-weight: var(--font-weight-medium); + line-height: 42px; + letter-spacing: 0; + } + + .font-designer-28b { + font-size: 28px; + font-weight: var(--font-weight-bold); + line-height: 42px; + letter-spacing: 0; + } + + .font-designer-24r { + font-size: 24px; + font-weight: var(--font-weight-regular); + line-height: 37px; + letter-spacing: 0; + } + + .font-designer-24m { + font-size: 24px; + font-weight: var(--font-weight-medium); + line-height: 37px; + letter-spacing: 0; + } + + .font-designer-24b { + font-size: 24px; + font-weight: var(--font-weight-bold); + line-height: 37px; + letter-spacing: 0; + } + + .font-designer-20r { + font-size: 20px; + font-weight: var(--font-weight-regular); + line-height: 30px; + letter-spacing: 0; + } + + .font-designer-20m { + font-size: 20px; + font-weight: var(--font-weight-medium); + line-height: 30px; + letter-spacing: 0; + } + + .font-designer-20b { + font-size: 20px; + font-weight: var(--font-weight-bold); + line-height: 30px; + letter-spacing: 0; + } + + .font-designer-18r { + font-size: 18px; + font-weight: var(--font-weight-regular); + line-height: 29px; + letter-spacing: 0; + } + + .font-designer-18m { + font-size: 18px; + font-weight: var(--font-weight-medium); + line-height: 29px; + letter-spacing: 0; + } + + .font-designer-18b { + font-size: 18px; + font-weight: var(--font-weight-bold); + line-height: 29px; + letter-spacing: 0; + } + + .font-designer-16r { + font-size: 16px; + font-weight: var(--font-weight-regular); + line-height: 25px; + letter-spacing: 0; + } + + .font-designer-16m { + font-size: 16px; + font-weight: var(--font-weight-medium); + line-height: 25px; + letter-spacing: 0; + } + + .font-designer-16b { + font-size: 16px; + font-weight: var(--font-weight-bold); + line-height: 25px; + letter-spacing: 0; + } + + .font-designer-15r { + font-size: 15px; + font-weight: var(--font-weight-regular); + line-height: 23px; + letter-spacing: 0; + } + + .font-designer-15m { + font-size: 15px; + font-weight: var(--font-weight-medium); + line-height: 23px; + letter-spacing: 0; + } + + .font-designer-15b { + font-size: 15px; + font-weight: var(--font-weight-bold); + line-height: 23px; + letter-spacing: 0; + } + + .font-designer-14r { + font-size: 14px; + font-weight: var(--font-weight-regular); + line-height: 22px; + letter-spacing: 0; + } + + .font-designer-14m { + font-size: 14px; + font-weight: var(--font-weight-medium); + line-height: 22px; + letter-spacing: 0; + } + + .font-designer-14b { + font-size: 14px; + font-weight: var(--font-weight-bold); + line-height: 22px; + letter-spacing: 0; + } + + .font-designer-13r { + font-size: 13px; + font-weight: var(--font-weight-regular); + line-height: 20px; + letter-spacing: 0; + } + + .font-designer-13m { + font-size: 13px; + font-weight: var(--font-weight-medium); + line-height: 20px; + letter-spacing: 0; + } + + .font-designer-13b { + font-size: 13px; + font-weight: var(--font-weight-bold); + line-height: 20px; + letter-spacing: 0; + } + + .font-designer-12r { + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: 19px; + letter-spacing: 0; + } + + .font-designer-12m { + font-size: 12px; + font-weight: var(--font-weight-medium); + line-height: 19px; + letter-spacing: 0; + } + + .font-designer-12b { + font-size: 12px; + font-weight: var(--font-weight-bold); + line-height: 19px; + letter-spacing: 0; + } + + .font-designer-11r { + font-size: 11px; + font-weight: var(--font-weight-regular); + line-height: 18px; + letter-spacing: 0; + } + + .font-designer-11m { + font-size: 11px; + font-weight: var(--font-weight-medium); + line-height: 18px; + letter-spacing: 0; + } + + .font-designer-11b { + font-size: 11px; + font-weight: var(--font-weight-bold); + line-height: 18px; + letter-spacing: 0; + } + + .font-designer-10r { + font-size: 10px; + font-weight: var(--font-weight-regular); + line-height: 17px; + letter-spacing: 0; + } + + .font-designer-10m { + font-size: 10px; + font-weight: var(--font-weight-medium); + line-height: 17px; + letter-spacing: 0; + } + + .font-designer-10b { + font-size: 10px; + font-weight: var(--font-weight-bold); + line-height: 17px; + letter-spacing: 0; + } + + .scrollbar-hide { + &::-webkit-scrollbar { + display: none; + } + scrollbar-width: none; + } +} */ + /* shadcn 색상 테마 */ :root { diff --git a/middleware.ts b/middleware.ts index 2e47f1f0..8ba30306 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; +import { decodeJwt } from '@/shared/lib/jwt'; import { getServerCookie } from '@/shared/lib/server-cookie'; import { isNumeric } from '@/shared/lib/validation'; import { isApiError } from '@/shared/tanstack-query/api-error'; @@ -105,10 +106,27 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(mainUrl); } + if (request.nextUrl.pathname.startsWith('/admin')) { + const decodedJwt = decodeJwt(accessToken); + + if (!decodedJwt || !decodedJwt.roleIds.includes('ROLE_ADMIN')) { + const homeUrl = new URL('/', request.url); + + return NextResponse.redirect(homeUrl); + } + } + return response; } // middleware가 적용될 경로 설정 export const config = { - matcher: ['/', '/my-page', '/my-study', '/my-study-review', '/sign-up'], + matcher: [ + '/', + '/my-page', + '/my-study', + '/my-study-review', + '/sign-up', + '/admin/:path*', + ], }; diff --git a/next.config.ts b/next.config.ts index 0ba97d79..b8d962c9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -21,6 +21,11 @@ const nextConfig: NextConfig = { hostname: 'test-api.zeroone.it.kr', pathname: '/**', }, + { + protocol: 'https', + hostname: 'api.zeroone.it.kr', + pathname: '/profile-image/**', + }, { protocol: 'https', hostname: 'www.zeroone.it.kr', diff --git a/package.json b/package.json index d0ae9f0f..2178740b 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-dialog": "^1.1.10", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-toggle": "^1.1.9", @@ -33,6 +34,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "dayjs": "^1.11.18", "embla-carousel-react": "^8.6.0", "lucide-react": "^0.475.0", "next": "15.1.7", diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 00000000..20bbeb24 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/icons/arrow-left-line.svg b/public/icons/arrow-left-line.svg new file mode 100644 index 00000000..56b8b508 --- /dev/null +++ b/public/icons/arrow-left-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/book.svg b/public/icons/book.svg new file mode 100644 index 00000000..c1e78a4d --- /dev/null +++ b/public/icons/book.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/camera.svg b/public/icons/camera.svg new file mode 100644 index 00000000..cc6a70ac --- /dev/null +++ b/public/icons/camera.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/empty-study-case.svg b/public/icons/empty-study-case.svg new file mode 100644 index 00000000..f49d60aa --- /dev/null +++ b/public/icons/empty-study-case.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/icons/feedback.svg b/public/icons/feedback.svg new file mode 100644 index 00000000..e985f7ee --- /dev/null +++ b/public/icons/feedback.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/icons/filled-x.svg b/public/icons/filled-x.svg new file mode 100644 index 00000000..dcad8aa1 --- /dev/null +++ b/public/icons/filled-x.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/group.svg b/public/icons/group.svg new file mode 100644 index 00000000..14b74cce --- /dev/null +++ b/public/icons/group.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/keyboard-arrow-left.svg b/public/icons/keyboard-arrow-left.svg new file mode 100644 index 00000000..7cae4f94 --- /dev/null +++ b/public/icons/keyboard-arrow-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/keyboard-arrow-right.svg b/public/icons/keyboard-arrow-right.svg new file mode 100644 index 00000000..63932aaf --- /dev/null +++ b/public/icons/keyboard-arrow-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/logo.svg b/public/icons/logo.svg new file mode 100644 index 00000000..8ffddc1c --- /dev/null +++ b/public/icons/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/logo_title.svg b/public/icons/logo_title.svg new file mode 100644 index 00000000..c39766b3 --- /dev/null +++ b/public/icons/logo_title.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/icons/more.svg b/public/icons/more.svg new file mode 100644 index 00000000..b467ef1c --- /dev/null +++ b/public/icons/more.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/out.svg b/public/icons/out.svg new file mode 100644 index 00000000..48f3e4d6 --- /dev/null +++ b/public/icons/out.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/phone.svg b/public/icons/phone.svg new file mode 100644 index 00000000..2800649d --- /dev/null +++ b/public/icons/phone.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/seal-check.svg b/public/icons/seal-check.svg new file mode 100644 index 00000000..57512572 --- /dev/null +++ b/public/icons/seal-check.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/icons/search.svg b/public/icons/search.svg new file mode 100644 index 00000000..1fe51ca0 --- /dev/null +++ b/public/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/trending-down.svg b/public/icons/trending-down.svg new file mode 100644 index 00000000..7a615a60 --- /dev/null +++ b/public/icons/trending-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/trending-up.svg b/public/icons/trending-up.svg new file mode 100644 index 00000000..1ea42c7d --- /dev/null +++ b/public/icons/trending-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/features/study/api/get-review.ts b/src/entities/review/api/get-review.ts similarity index 97% rename from src/features/study/api/get-review.ts rename to src/entities/review/api/get-review.ts index 58b3a4cb..a53c290e 100644 --- a/src/features/study/api/get-review.ts +++ b/src/entities/review/api/get-review.ts @@ -1,4 +1,3 @@ -import { axiosInstance } from '@/shared/tanstack-query/axios'; import type { AddStudyReviewRequest, UserPositiveKeywordsResponse, @@ -9,7 +8,8 @@ import type { MyReviewsResponse, MyReviewsRequest, ShouldReviewPartnerResponse, -} from './types'; +} from '@/entities/review/api/review-types'; +import { axiosInstance } from '@/shared/tanstack-query/axios'; export const getPartnerStudyReview = async (): Promise => { diff --git a/src/features/study/api/types.ts b/src/entities/review/api/review-types.ts similarity index 51% rename from src/features/study/api/types.ts rename to src/entities/review/api/review-types.ts index 70fd16c9..22123814 100644 --- a/src/features/study/api/types.ts +++ b/src/entities/review/api/review-types.ts @@ -1,100 +1,3 @@ -export type StudyProgressStatus = - | 'PENDING' - | 'IN_PROGRESS' - | 'COMPLETE' - | 'ABSENT'; - -export interface DailyStudy { - interviewer: string; - interviewerImage: string; - interviewee: string; - intervieweeImage: string; - dailyStudyId: number; - subject: string; - description: string; - link: string; - progressStatus: StudyProgressStatus; - studyDate: string; - feedback: string | undefined; -} - -export interface DailyStudyDetail { - dailyStudyId: number; - interviewerId: number; - interviewerName: string; - interviewerImage: string; - intervieweeId: number; - intervieweeName: string; - intervieweeImage: string; - studySpaceId: number; - progressStatus: StudyProgressStatus; - subject: string; - description: string; - link: string; - feedback: string; -} - -export interface GetDailyStudiesParams { - cursor?: number; - pageSize?: number; - studyDate?: string; -} - -export interface GetDailyStudiesResponse { - items: DailyStudy[]; - nextCursor: number; - hasNext: boolean; -} - -export interface GetMonthlyCalendarParams { - year: number; - month: number; -} - -export interface StudyCalendarDay { - day: number; - hasStudy: boolean; - status: StudyProgressStatus | undefined; -} - -export interface MonthlyCalendarResponse { - calendar: StudyCalendarDay[]; - monthlyCompletedCount?: number; - totalCompletedCount?: number; -} - -export interface PostDailyRetrospectRequest { - description: string; - parentId: number; -} - -export interface PrepareStudyRequest { - subject: string; - link: string; -} - -export interface JoinStudyRequest { - memberId: number; - selfIntroduction?: string; - studyPlan?: string; - preferredStudySubjectId?: string; - availableStudyTimeIds?: number[]; - techStackIds?: number[]; - tel?: string; - githubLink?: string; - blogOrSnsLink?: string; -} - -export interface WeeklyParticipationResponse { - memberId: number; - isParticipate: boolean; -} - -export interface CompleteStudyRequest { - feedback: string; - progressStatus: StudyProgressStatus; -} - export interface EvalKeyword { id: number; keyword: string; diff --git a/src/features/study/lib/use-reminder-review.tsx b/src/entities/review/lib/use-reminder-review.tsx similarity index 100% rename from src/features/study/lib/use-reminder-review.tsx rename to src/entities/review/lib/use-reminder-review.tsx diff --git a/src/features/study/model/use-review-query.ts b/src/entities/review/model/use-review-query.ts similarity index 98% rename from src/features/study/model/use-review-query.ts rename to src/entities/review/model/use-review-query.ts index fe2fbfc7..26d994a0 100644 --- a/src/features/study/model/use-review-query.ts +++ b/src/entities/review/model/use-review-query.ts @@ -4,6 +4,10 @@ import { useQuery, useSuspenseQuery, } from '@tanstack/react-query'; +import { + MyNegativeKeywordsRequest, + UserPositiveKeywordsRequest, +} from '@/entities/review/api/review-types'; import { getKoreaDate } from '@/shared/lib/time'; import { addStudyReview, @@ -13,10 +17,6 @@ import { getMyReviews, getShouldReviewPartner, } from '../api/get-review'; -import { - MyNegativeKeywordsRequest, - UserPositiveKeywordsRequest, -} from '../api/types'; export const usePartnerStudyReviewQuery = () => { return useSuspenseQuery({ diff --git a/src/features/study/ui/study-review-modal.tsx b/src/entities/review/ui/study-review-modal.tsx similarity index 98% rename from src/features/study/ui/study-review-modal.tsx rename to src/entities/review/ui/study-review-modal.tsx index 4bfd7ace..e5f7a0f8 100644 --- a/src/features/study/ui/study-review-modal.tsx +++ b/src/entities/review/ui/study-review-modal.tsx @@ -3,17 +3,20 @@ import { XIcon } from 'lucide-react'; import Image from 'next/image'; import { useState } from 'react'; +import { + EvalKeyword, + StudyEvaluationResponse, +} from '@/entities/review/api/review-types'; import UserAvatar from '@/shared/ui/avatar'; import Button from '@/shared/ui/button'; import Checkbox from '@/shared/ui/checkbox'; import { TextAreaInput } from '@/shared/ui/input'; import ListItem from '@/shared/ui/list-item'; import { Modal } from '@/shared/ui/modal'; -import { EvalKeyword, StudyEvaluationResponse } from '../api/types'; import { useAddStudyReviewMutation, usePartnerStudyReviewQuery, -} from '../model/use-review-query'; +} from '@/entities/review/model/use-review-query'; interface FormState { studySpaceId: number; diff --git a/src/entities/user/model/use-user-profile-query.ts b/src/entities/user/model/use-user-profile-query.ts index d31a5f19..38d00741 100644 --- a/src/entities/user/model/use-user-profile-query.ts +++ b/src/entities/user/model/use-user-profile-query.ts @@ -42,7 +42,7 @@ export const usePatchAutoMatchingMutation = () => { }); } - return { prev }; + return { prev }; }, onError: (_err, { memberId }, ctx) => { diff --git a/src/entities/user/ui/my-profile-card.tsx b/src/entities/user/ui/my-profile-card.tsx index cd2eac19..606920eb 100644 --- a/src/entities/user/ui/my-profile-card.tsx +++ b/src/entities/user/ui/my-profile-card.tsx @@ -2,9 +2,9 @@ import Link from 'next/link'; import React, { useState } from 'react'; +import { useReviewReminder } from '@/entities/review/lib/use-reminder-review'; +import StudyReviewModal from '@/entities/review/ui/study-review-modal'; import { usePatchAutoMatchingMutation } from '@/entities/user/model/use-user-profile-query'; -import { useReviewReminder } from '@/features/study/lib/use-reminder-review'; -import StudyReviewModal from '@/features/study/ui/study-review-modal'; import { getSincerityPresetByLevelName } from '@/shared/config/sincerity-temp-presets'; import { cn } from '@/shared/shadcn/lib/utils'; import UserAvatar from '@/shared/ui/avatar'; diff --git a/src/entities/user/ui/user-phone-number-copy-modal.tsx b/src/entities/user/ui/user-phone-number-copy-modal.tsx new file mode 100644 index 00000000..6184bbec --- /dev/null +++ b/src/entities/user/ui/user-phone-number-copy-modal.tsx @@ -0,0 +1,56 @@ +import { XIcon } from 'lucide-react'; +import Button from '@/shared/ui/button'; +import { Modal } from '@/shared/ui/modal'; + +export default function UserPhoneNumberCopyModal({ + trigger, + phoneNumber, +}: { + trigger: React.ReactNode; + phoneNumber: string; +}) { + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(phoneNumber); + alert('전화번호가 복사되었습니다.'); + } catch (e) { + alert('전화번호 복사에 실패하였습니다'); + } + }; + + return ( + + {trigger} + + + + + + + 전화하기 + + + + + + + +
+ + {phoneNumber} + + + +
+ + + 개인정보 보호를 위해 통화 시 주의해 주세요. + +
+
+
+
+ ); +} diff --git a/src/entities/user/ui/user-profile-modal.tsx b/src/entities/user/ui/user-profile-modal.tsx index 041aaa84..9385fbe2 100644 --- a/src/entities/user/ui/user-profile-modal.tsx +++ b/src/entities/user/ui/user-profile-modal.tsx @@ -9,11 +9,11 @@ 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 { useUserPositiveKeywordsQuery } from '@/features/study/model/use-review-query'; import { getSincerityPresetByLevelName } from '@/shared/config/sincerity-temp-presets'; import UserAvatar from '@/shared/ui/avatar'; import Badge from '@/shared/ui/badge'; import { Modal } from '@/shared/ui/modal'; +import { useUserPositiveKeywordsQuery } from '@/entities/review/model/use-review-query'; interface UserProfileModalProps { memberId: number; diff --git a/src/features/admin/api/account-history.server.ts b/src/features/admin/api/account-history.server.ts new file mode 100644 index 00000000..48e48658 --- /dev/null +++ b/src/features/admin/api/account-history.server.ts @@ -0,0 +1,16 @@ +import { axiosServerInstance } from '@/shared/tanstack-query/axios.server'; +import { + GetAccountHistoriesRequest, + GetAccountHistoriesResponse, +} from './types'; + +// 회원 계정 이력 조회 +export const getAccountHistoriesInServer = async ({ + memberId, +}: GetAccountHistoriesRequest): Promise => { + const res = await axiosServerInstance.get( + `/admin/members/${memberId}/account-histories`, + ); + + return res.data.content; +}; diff --git a/src/features/admin/api/member-list.server.ts b/src/features/admin/api/member-list.server.ts new file mode 100644 index 00000000..3ac9c020 --- /dev/null +++ b/src/features/admin/api/member-list.server.ts @@ -0,0 +1,26 @@ +import { axiosServerInstance } from '@/shared/tanstack-query/axios.server'; +import { GetMemberListRequest, GetMemberListResponse } from './types'; + +export const getMemberListInServer = async ({ + roleId, + memberStatus, + searchKeyword, + page = 1, +}: GetMemberListRequest): Promise => { + // size는 10으로 고정 + let queryString = `page=${page}&size=10`; + + if (roleId) { + queryString += `&role-id=${roleId}`; + } + if (memberStatus) { + queryString += `&member-status=${memberStatus}`; + } + if (searchKeyword) { + queryString += `&search-keyword=${searchKeyword}`; + } + + const res = await axiosServerInstance.get(`/admin/members?${queryString}`); + + return res.data.content; +}; diff --git a/src/features/admin/api/member-list.ts b/src/features/admin/api/member-list.ts new file mode 100644 index 00000000..6c5251ac --- /dev/null +++ b/src/features/admin/api/member-list.ts @@ -0,0 +1,56 @@ +import { axiosInstance } from '@/shared/tanstack-query/axios'; +import { + ChangeMemberRoleRequest, + ChangeMemberStatusRequest, + GetMemberListRequest, + GetMemberListResponse, +} from './types'; + +// 사용자 목록 조회 +export const getMemberList = async ({ + roleId, + memberStatus, + searchKeyword, + page = 1, +}: GetMemberListRequest): Promise => { + // size는 10으로 고정 + let queryString = `page=${page}&size=10`; + + if (roleId) { + queryString += `&role-id=${roleId}`; + } + if (memberStatus) { + queryString += `&member-status=${memberStatus}`; + } + if (searchKeyword) { + queryString += `&search-keyword=${searchKeyword}`; + } + + const res = await axiosInstance.get(`/admin/members?${queryString}`); + + return res.data.content; +}; + +// 사용자 계정 상태 변경 +export const changeMemberStatus = async ({ + memberId, + to, +}: ChangeMemberStatusRequest) => { + const res = await axiosInstance.patch( + `/admin/members/${memberId}/status?to=${to}`, + ); + + return res.data; +}; + +// 사용자 권한 변경 +export const changeMemberRole = async ({ + memberId, + roleId, +}: ChangeMemberRoleRequest) => { + const res = await axiosInstance.patch( + `/admin/members/${memberId}/role?role-id=${roleId}`, + ); + + return res.data; +}; diff --git a/src/features/admin/api/sincerity-temperature-history.server.ts b/src/features/admin/api/sincerity-temperature-history.server.ts new file mode 100644 index 00000000..e8ff25be --- /dev/null +++ b/src/features/admin/api/sincerity-temperature-history.server.ts @@ -0,0 +1,19 @@ +import { axiosServerInstance } from '@/shared/tanstack-query/axios.server'; +import { + GetSincerityTemperatureHistoryRequest, + GetSincerityTemperatureHistoryResponse, +} from './types'; + +// 성실 온도 이력 조회 API 요청 함수 +export const getSincerityTemperatureHistoryInServer = async ({ + memberId, + page = 1, +}: GetSincerityTemperatureHistoryRequest): Promise => { + const queryString = `page=${page}&page-size=10`; + + const res = await axiosServerInstance.get( + `/admin/members/${memberId}/sincerity-temperature-histories?${queryString}`, + ); + + return res.data.content; +}; diff --git a/src/features/admin/api/sincerity-temperature-history.ts b/src/features/admin/api/sincerity-temperature-history.ts new file mode 100644 index 00000000..188f202f --- /dev/null +++ b/src/features/admin/api/sincerity-temperature-history.ts @@ -0,0 +1,19 @@ +import { axiosInstance } from '@/shared/tanstack-query/axios'; +import { + GetSincerityTemperatureHistoryRequest, + GetSincerityTemperatureHistoryResponse, +} from './types'; + +// 성실 온도 이력 조회 API 요청 함수 +export const getSincerityTemperatureHistory = async ({ + memberId, + page = 1, +}: GetSincerityTemperatureHistoryRequest): Promise => { + const queryString = `page=${page}&page-size=10`; + + const res = await axiosInstance.get( + `/admin/members/${memberId}/sincerity-temperature-histories?${queryString}`, + ); + + return res.data.content; +}; diff --git a/src/features/admin/api/types.ts b/src/features/admin/api/types.ts new file mode 100644 index 00000000..494cde1b --- /dev/null +++ b/src/features/admin/api/types.ts @@ -0,0 +1,92 @@ +export type RoleId = 'ROLE_MEMBER' | 'ROLE_ADMIN' | 'ROLE_MENTOR'; +type RoleName = '일반' | '관리자' | '멘토'; +export type MemberStatus = 'ACTIVE' | 'PAUSED' | 'PERM_BAN' | 'DORMANT'; + +// 사용자 리스트 조회 API 요청 타입 +export interface GetMemberListRequest { + roleId?: RoleId; + memberStatus?: MemberStatus; + searchKeyword?: string; + page?: number; +} + +// 사용자 리스트 조회 API 응답 타입 +export interface GetMemberListResponse { + page: number; + size: number; + totalElements: number; + totalPages: number; + hasNext: boolean; + hasPrevious: boolean; + content: { + memberId: number; + memberStatus: MemberStatus; + memberName: string; + joinedAt: string; + loginMostRecentlyAt: string; + role: { + roleId: RoleId; + roleName: RoleName; + }; + }[]; +} + +// 사용자 계정 이력 조회 API 요청 타입 +export interface GetAccountHistoriesRequest { + memberId: number; +} + +// 사용자 계정 이력 조회 API 응답 타입 +export interface GetAccountHistoriesResponse { + memberId: number; + joinedAt: string; + loginMostRecentlyAt: string; + loginHists: string[]; + roleChangeHists: { + changedAt: string; + from: string; + to: string; + }[]; + memberStatusChangeHists: { + changedAt: string; + from: string; + to: string; + }[]; +} + +// 사용자 상태 변경 API 요청 타입 +export interface ChangeMemberStatusRequest { + memberId: number; + to: MemberStatus; +} + +// 사용자 권한 변경 API 요청 타입 +export interface ChangeMemberRoleRequest { + memberId: number; + roleId: RoleId; +} + +// 성실 온도 이력 조회 API 요청 타입 +export interface GetSincerityTemperatureHistoryRequest { + memberId: number; + page: number; +} + +// 성실 온도 이력 조회 API 응답 타입 +export interface GetSincerityTemperatureHistoryResponse { + currentSincerityTemperature: number; + sincerityTempLevel: 'FIRST' | 'SECOND' | 'THIRD' | 'FOURTH'; + sincerityTemperatureHistory: { + content: { + reasonType: 'STUDY_REVIEW'; + increment: number; + recordedAt: string; + }[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + hasNext: boolean; + hasPrevious: boolean; + }; +} diff --git a/src/features/admin/const/member.ts b/src/features/admin/const/member.ts new file mode 100644 index 00000000..9c269162 --- /dev/null +++ b/src/features/admin/const/member.ts @@ -0,0 +1,24 @@ +export const ROLE_MAP = { + ROLE_MEMBER: '일반', + ROLE_MENTOR: '멘토', + ROLE_ADMIN: '관리자', +}; + +export const ROLE_OPTIONS = Object.entries(ROLE_MAP).map(([key, label]) => ({ + value: key, + label, +})); + +export const MEMBER_STATUS_MAP = { + ACTIVE: '활성', + PERM_BAN: '일시정지', + PAUSED: '영구정지', + DORMANT: '휴면', +}; + +export const MEMBER_STATUS_OPTIONS = Object.entries(MEMBER_STATUS_MAP).map( + ([key, label]) => ({ + value: key, + label, + }), +); diff --git a/src/features/admin/model/use-member-list-query.ts b/src/features/admin/model/use-member-list-query.ts new file mode 100644 index 00000000..38422eb3 --- /dev/null +++ b/src/features/admin/model/use-member-list-query.ts @@ -0,0 +1,127 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + changeMemberRole, + changeMemberStatus, + getMemberList, +} from '../api/member-list'; +import { + ChangeMemberRoleRequest, + ChangeMemberStatusRequest, + GetMemberListRequest, + GetMemberListResponse, +} from '../api/types'; + +// 사용자 목록 조회 +export const useGetMemberListQuery = ({ + roleId, + memberStatus, + searchKeyword, + page = 1, +}: GetMemberListRequest) => { + return useQuery({ + queryKey: ['memberList', roleId, memberStatus, searchKeyword, page], + queryFn: () => + getMemberList({ + roleId, + memberStatus, + searchKeyword, + page, + }), + }); +}; + +// 사용자 계정 상태 변경 +export const useChangeMemberStatusMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: { + members: GetMemberListResponse['content']; + to: ChangeMemberStatusRequest['to']; + }) => { + // 병렬로 선택한 모든 사용자의 상태 변경 요청을 보냄 + // 성공: {status: "fulfilled", value: {memberId, success: true}} + // 실패: {status: "fulfilled", value: {memberId, success: false}} (개별 요청 실패는 catch에서 처리했기 때문에 status가 rejected가 되지 않음) + const results: PromiseSettledResult<{ + memberId: number; + success: boolean; + }>[] = await Promise.allSettled( + data.members.map((member) => + changeMemberStatus({ memberId: member.memberId, to: data.to }) + .then(() => ({ memberId: member.memberId, success: true })) + .catch(() => ({ memberId: member.memberId, success: false })), + ), + ); + + const failedMemberIds = results + .filter( + (result) => result.status === 'fulfilled' && !result.value.success, + ) + .map((result) => + result.status === 'fulfilled' ? result.value.memberId : null, + ) + .filter(Boolean); + + if (failedMemberIds.length > 0) { + const failedMemberNames = data.members + .filter((member) => failedMemberIds.includes(member.memberId)) + .map((member) => member.memberName); + + alert( + `다음 회원들의 상태 변경에 실패했습니다: ${failedMemberNames.join(', ')}`, + ); + } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['memberList'] }); + }, + }); +}; + +// 사용자 권한 변경 +export const useChangeMemberRoleMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: { + members: GetMemberListResponse['content']; + roleId: ChangeMemberRoleRequest['roleId']; + }) => { + // 병렬로 선택한 모든 사용자의 상태 변경 요청을 보냄 + // 성공: {status: "fulfilled", value: {memberId, success: true}} + // 실패: {status: "fulfilled", value: {memberId, success: false}} (개별 요청 실패는 catch에서 처리했기 때문에 status가 rejected가 되지 않음) + const results: PromiseSettledResult<{ + memberId: number; + success: boolean; + }>[] = await Promise.allSettled( + data.members.map((member) => + changeMemberRole({ memberId: member.memberId, roleId: data.roleId }) + .then(() => ({ memberId: member.memberId, success: true })) + .catch(() => ({ memberId: member.memberId, success: false })), + ), + ); + + const failedMemberIds = results + .filter( + (result) => result.status === 'fulfilled' && !result.value.success, + ) + .map((result) => + result.status === 'fulfilled' ? result.value.memberId : null, + ) + .filter(Boolean); + + if (failedMemberIds.length > 0) { + const failedMemberNames = data.members + .filter((member) => failedMemberIds.includes(member.memberId)) + .map((member) => member.memberName); + + alert( + `다음 회원들의 권한 변경에 실패했습니다: ${failedMemberNames.join(', ')}`, + ); + } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['memberList'] }); + }, + }); +}; diff --git a/src/features/admin/model/use-sincerity-temperature-history-query.ts b/src/features/admin/model/use-sincerity-temperature-history-query.ts new file mode 100644 index 00000000..a183962c --- /dev/null +++ b/src/features/admin/model/use-sincerity-temperature-history-query.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import { getSincerityTemperatureHistory } from '../api/sincerity-temperature-history'; +import { GetSincerityTemperatureHistoryRequest } from '../api/types'; + +export const useGetSincerityTemperatureHistoryQuery = ({ + memberId, + page, +}: GetSincerityTemperatureHistoryRequest) => { + return useQuery({ + queryKey: ['sincerityTemperatureHistory', memberId, page], + queryFn: () => + getSincerityTemperatureHistory({ + memberId, + page, + }), + }); +}; diff --git a/src/features/admin/ui/chage-status-modal.tsx b/src/features/admin/ui/chage-status-modal.tsx new file mode 100644 index 00000000..18d2cb13 --- /dev/null +++ b/src/features/admin/ui/chage-status-modal.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { XIcon } from 'lucide-react'; +import { useState } from 'react'; +import Button from '@/shared/ui/button'; +import { Modal } from '@/shared/ui/modal'; +import { RadioGroup, RadioGroupItem } from '@/shared/ui/radio'; +import { GetMemberListResponse, MemberStatus } from '../api/types'; +import { MEMBER_STATUS_OPTIONS } from '../const/member'; +import { useChangeMemberStatusMutation } from '../model/use-member-list-query'; + +interface ChangeStatusModalProps { + members: GetMemberListResponse['content']; +} + +export default function ChangeStatusModal({ members }: ChangeStatusModalProps) { + const [open, setOpen] = useState(false); + + return ( + + + + + + + + + + + 계정 상태 변경 + + setOpen(false)}> + + + + + setOpen(false)} /> + + + + ); +} + +function ChangeStatusForm({ + members, + onClose, +}: { + members: GetMemberListResponse['content']; + onClose: () => void; +}) { + const INIT_STATUS: MemberStatus = 'ACTIVE'; + const [status, setStatus] = useState(INIT_STATUS); + + const { mutate: changeStatus } = useChangeMemberStatusMutation(); + + const handleChangeStatus = () => { + changeStatus( + { members, to: status }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + }; + + return ( + <> + + setStatus(status)} + > + {MEMBER_STATUS_OPTIONS.map(({ value, label }) => ( +
+ + +
+ ))} +
+
+ + + + + + + + + ); +} diff --git a/src/features/admin/ui/change-role-modal.tsx b/src/features/admin/ui/change-role-modal.tsx new file mode 100644 index 00000000..b7751d8a --- /dev/null +++ b/src/features/admin/ui/change-role-modal.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { XIcon } from 'lucide-react'; +import { useState } from 'react'; +import Button from '@/shared/ui/button'; +import { Modal } from '@/shared/ui/modal'; +import { RadioGroup, RadioGroupItem } from '@/shared/ui/radio'; +import { GetMemberListResponse, RoleId } from '../api/types'; +import { ROLE_OPTIONS } from '../const/member'; +import { useChangeMemberRoleMutation } from '../model/use-member-list-query'; + +interface ChangeRoleModalProps { + members: GetMemberListResponse['content']; +} + +export default function ChangeRoleModal({ members }: ChangeRoleModalProps) { + const [open, setOpen] = useState(false); + + return ( + + + + + + + + + + + 권한 변경 + + setOpen(false)}> + + + + + setOpen(false)} /> + + + + ); +} + +function ChangeRoleForm({ + members, + onClose, +}: { + members: GetMemberListResponse['content']; + onClose: () => void; +}) { + const INIT_ROLE: RoleId = 'ROLE_MEMBER'; + const [role, setRole] = useState(INIT_ROLE); + + const { mutate: changeStatus } = useChangeMemberRoleMutation(); + + const handleChangeStatus = () => { + changeStatus( + { members, roleId: role }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + }; + + return ( + <> + + setRole(roleId)} + > + {ROLE_OPTIONS.map(({ value, label }) => ( +
+ + +
+ ))} +
+
+ + + + + + + + + ); +} diff --git a/src/features/admin/ui/member-list-table.tsx b/src/features/admin/ui/member-list-table.tsx new file mode 100644 index 00000000..2cf2a30a --- /dev/null +++ b/src/features/admin/ui/member-list-table.tsx @@ -0,0 +1,299 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; +import { formatYYYYMMDD } from '@/shared/lib/time'; +import Badge from '@/shared/ui/badge'; +import Button from '@/shared/ui/button'; +import Checkbox from '@/shared/ui/checkbox'; +import { SingleDropdown } from '@/shared/ui/dropdown'; +import Pagination from '@/shared/ui/pagination'; +import FilledX from 'public/icons/filled-x.svg'; +import SealCheckIcon from 'public/icons/seal-check.svg'; +import SearchIcon from 'public/icons/search.svg'; +import ChangeStatusModal from './chage-status-modal'; +import ChangeRoleModal from './change-role-modal'; +import { MemberStatus, RoleId } from '../api/types'; +import { + MEMBER_STATUS_MAP, + MEMBER_STATUS_OPTIONS, + ROLE_MAP, + ROLE_OPTIONS, +} from '../const/member'; +import { useGetMemberListQuery } from '../model/use-member-list-query'; + +export default function MemberListTable() { + const [roleId, setRoleId] = useState(null); + const [memberStatus, setMemberStatus] = useState(null); + const [searchKeyword, setSearchKeyword] = useState(''); + const [page, setPage] = useState(1); + + const { data } = useGetMemberListQuery({ + roleId, + memberStatus, + searchKeyword, + page, + }); + + const memberList = data?.content || []; + + const [selectedIds, setSelectedIds] = useState>(() => new Set()); + const headerCheckboxRef = useRef(null); + + const allSelected = + memberList.length > 0 && selectedIds.size === memberList.length; // 모든 행을 선택했는지 + const someSelected = + selectedIds.size > 0 && selectedIds.size < memberList.length; // 한개 이상 행을 선택했는지 + + useEffect(() => { + if (headerCheckboxRef.current) { + headerCheckboxRef.current.indeterminate = someSelected; + } + }, [someSelected]); + + const toggleAll = () => { + if (allSelected) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(memberList.map((u) => u.memberId))); + } + }; + + const toggleRow = (id: number) => { + setSelectedIds((prev) => { + const next = new Set(prev); + + if (next.has(id)) next.delete(id); + else next.add(id); + + return next; + }); + }; + + const router = useRouter(); + + return ( + <> +
+
+

사용자 관리

+ + + {data?.totalElements} + + + 명의 사용자 + +
+ + +
+ +
+
+ {someSelected && ( +
+

+ + {selectedIds.size} + + 명 선택 +

+ +
+ + selectedIds.has(member.memberId), + )} + /> + + selectedIds.has(member.memberId), + )} + /> +
+
+ )} +
+ + +
+ +
+
+ + + + + + + + + + + + + {memberList.map((user, idx) => ( + { + router.push(`/admin/detail/${user.memberId}/profile`); + }} + > + + + + + + + + ))} + +
+ + + 이름 + + 가입일 + + 최근 로그인 + + 권한 + + 상태 +
{ + e.stopPropagation(); + }} + > + toggleRow(user.memberId)} + checked={selectedIds.has(user.memberId)} + /> + + {user.memberName} + + {formatYYYYMMDD(user.joinedAt)} + + {user.loginMostRecentlyAt + ? formatYYYYMMDD(user.loginMostRecentlyAt) + : '-'} + + {user.role.roleId === 'ROLE_MENTOR' && } + {ROLE_MAP[user.role.roleId]} + + + {MEMBER_STATUS_MAP[user.memberStatus]} + +
+
+ + +
+ + ); +} + +function MemberListFilter({ + roleId, + memberStatus, + onSelectRoleId, + onSelectMemberStatus, +}: { + roleId: RoleId | null; + memberStatus: MemberStatus | null; + onSelectRoleId: (roleId: RoleId | null) => void; + onSelectMemberStatus: (memberStatus: MemberStatus | null) => void; +}) { + return ( +
+ {(roleId || memberStatus) && ( + + )} +
+ + +
+
+ ); +} + +function MemberListSearchInput({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + return ( +
+
+ + onChange(e.target.value)} + className="outline-0" + placeholder="이름으로 검색" + /> +
+ + {value.length > 0 && ( + + )} +
+ ); +} diff --git a/src/features/admin/ui/sincerity-temp-table.tsx b/src/features/admin/ui/sincerity-temp-table.tsx new file mode 100644 index 00000000..5490dca3 --- /dev/null +++ b/src/features/admin/ui/sincerity-temp-table.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useState } from 'react'; +import { formatYYYYMMDD } from '@/shared/lib/time'; +import Pagination from '@/shared/ui/pagination'; +import TrendingDown from 'public/icons/trending-down.svg'; +import TrendingUp from 'public/icons/trending-up.svg'; +import { useGetSincerityTemperatureHistoryQuery } from '../model/use-sincerity-temperature-history-query'; + +interface SincerityTempTableProps { + memberId: number; +} + +export default function SincerityTempTable({ + memberId, +}: SincerityTempTableProps) { + const [page, setPage] = useState(1); + + const { data } = useGetSincerityTemperatureHistoryQuery({ + memberId, + page, + }); + + const sincerityTemperatureHistory = data?.sincerityTemperatureHistory.content; + + return ( + <> +
+ + 성실온도 내역 + + +
+ {sincerityTemperatureHistory?.length > 0 ? ( + sincerityTemperatureHistory.map((history, idx) => ( +
+
+ + {history.reasonType === 'STUDY_REVIEW' ? '스터디 리뷰' : ''} + + + {formatYYYYMMDD(history.recordedAt)} + +
+ +
+ {history.increment > 0 ? ( + <> + + +{history.increment}℃ + + + + ) : ( + <> + + {history.increment}℃ + + + + )} +
+
+ )) + ) : ( +
+ 성실 온도 내역이 없습니다. +
+ )} +
+
+ + + + ); +} diff --git a/src/features/auth/ui/header-user-dropdown.tsx b/src/features/auth/ui/header-user-dropdown.tsx index 9ccd7be5..fd7028ae 100644 --- a/src/features/auth/ui/header-user-dropdown.tsx +++ b/src/features/auth/ui/header-user-dropdown.tsx @@ -1,23 +1,44 @@ 'use client'; import { useRouter } from 'next/navigation'; +import { decodeJwt } from '@/shared/lib/jwt'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/shared/shadcn/ui/dropdown-menu'; +import { getCookie } from '@/shared/tanstack-query/cookie'; import UserAvatar from '@/shared/ui/avatar'; import { useLogoutMutation } from '../model/use-auth-mutation'; export default function HeaderUserDropdown({ userImg }: { userImg: string }) { const { mutateAsync: logout } = useLogoutMutation(); + + const jwt = getCookie('accessToken'); + const decodedJwt = decodeJwt(jwt); + + const hasAdminRole = decodedJwt && decodedJwt?.roleIds.includes('ROLE_ADMIN'); + const router = useRouter(); const handleLogout = async () => { await logout(); }; + const baseOptions = [ + { + label: '내 정보 수정', + value: '/my-page', + onMenuClick: () => router.push('/my-page'), + }, + { + label: '로그아웃', + value: 'logout', + onMenuClick: handleLogout, + }, + ]; + return ( @@ -27,18 +48,17 @@ export default function HeaderUserDropdown({ userImg }: { userImg: string }) { - {[ - { - label: '내 정보 수정', - value: '/my-page', - onMenuClick: () => router.push('/my-page'), - }, - { - label: '로그아웃', - value: 'logout', - onMenuClick: handleLogout, - }, - ].map((option) => ( + {(hasAdminRole + ? [ + ...baseOptions, + { + label: '서비스 관리', + value: '/admin', + onMenuClick: () => router.push('/admin'), + }, + ] + : baseOptions + ).map((option) => ( (false); - const [image, setImage] = useState( - getCookie('socialImageURL') || 'profile-default.svg', - ); + const [image, setImage] = useState(getCookie('socialImageURL') || undefined); const fileInputRef = useRef(null); const signUp = useSignUpMutation(); diff --git a/src/features/my-page/api/get-entry-list.ts b/src/features/my-page/api/get-entry-list.ts new file mode 100644 index 00000000..070b71dd --- /dev/null +++ b/src/features/my-page/api/get-entry-list.ts @@ -0,0 +1,25 @@ +import { axiosInstance } from '@/shared/tanstack-query/axios'; +import { EntryListRequest, GroupStudyApplyListResponse } from './types'; + +// 그룹 스터디 리스트 조회 +export const getEntryList = async ( + params: EntryListRequest, +): Promise => { + const { page, size, status, groupStudyId } = params; + + const { data } = await axiosInstance.get( + `/group-studies/${groupStudyId}/applies?applyStatus=${status}`, + { + params: { + page, + size, + }, + }, + ); + + if (data.statusCode !== 200) { + throw new Error('Failed to fetch entry list'); + } + + return data.content; +}; diff --git a/src/features/my-page/api/types.ts b/src/features/my-page/api/types.ts index 63315e17..61b69ba4 100644 --- a/src/features/my-page/api/types.ts +++ b/src/features/my-page/api/types.ts @@ -80,3 +80,80 @@ export interface StudyDashboardResponse { studyActivity: StudyActivity; growthMetric: GrowthMetric; } + +export interface EntryListRequest { + groupStudyId: number; + page: number; + size: number; + status?: 'PENDING'; +} + +export interface EntryStatusRequest { + groupStudyId: number; + applyId: number; + status: 'APPROVED' | 'REJECTED'; +} + +export interface ImageSizeType { + imageTypeName: 'ORIGINAL' | 'SMALL' | 'MEDIUM' | 'LARGE'; + width: number | null; + height: number | null; +} + +export interface ResizedImage { + resizedImageId: number; + resizedImageUrl: string; + imageSizeType: ImageSizeType; +} + +export interface ProfileImage { + imageId: number; + resizedImages: ResizedImage[]; +} + +export interface SincerityTemp { + temperature: number; + levelId: number; + levelName: '1단계' | '2단계' | '3단계' | '4단계'; // 추후 서버 기준에 맞게 확장 가능 +} + +export interface Applicant { + memberId: number; + memberName: string; + profileImage: ProfileImage; + sincerityTemp: SincerityTemp; +} + +export interface GroupStudy { + groupStudyId: number; + title: string; + description: string; +} + +export type ApplyRole = 'LEADER' | 'PARTICIPANT'; +export type ApplyStatus = 'PENDING' | 'APPROVED' | 'REJECTED'; + +export interface GroupStudyApply { + applyId: number; + applicantInfo: Applicant; + groupStudy: GroupStudy; + progressScore: number; + role: ApplyRole; + lastAccessed: string; + answer: string[]; + status: ApplyStatus; + processedAt: string | null; + reason: string | null; + createdAt: string; + updatedAt: string; +} + +export interface GroupStudyApplyListResponse { + content: GroupStudyApply[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + hasNext: boolean; + hasPrevious: boolean; +} diff --git a/src/features/my-page/api/update-entry-status.ts b/src/features/my-page/api/update-entry-status.ts new file mode 100644 index 00000000..4ce20b5f --- /dev/null +++ b/src/features/my-page/api/update-entry-status.ts @@ -0,0 +1,22 @@ +import { axiosInstance } from '@/shared/tanstack-query/axios'; +import { EntryStatusRequest } from './types'; + +// 그룹 스터디 리스트 조회 +export const updateEntryStatus = async (params: EntryStatusRequest) => { + const { status, groupStudyId, applyId } = params; + + const { data } = await axiosInstance.patch( + `/group-studies/${groupStudyId}/apply/${applyId}/process`, + { + params: { + status, + }, + }, + ); + + if (data.statusCode !== 200) { + throw new Error('Failed to fetch entry list'); + } + + return data.content; +}; diff --git a/src/features/my-page/consts/my-page-const.ts b/src/features/my-page/consts/my-page-const.ts index 5f909266..15b20a72 100644 --- a/src/features/my-page/consts/my-page-const.ts +++ b/src/features/my-page/consts/my-page-const.ts @@ -29,5 +29,3 @@ export const MBTI_OPTIONS = [ { label: 'ENFJ', value: 'ENFJ' }, { label: 'ENTJ', value: 'ENTJ' }, ]; - -export const DEFAULT_PROFILE_IMAGE_URL = '/profile-default.svg'; diff --git a/src/features/my-page/model/profile-form.schema.ts b/src/features/my-page/model/profile-form.schema.ts index 51eee97d..5323a581 100644 --- a/src/features/my-page/model/profile-form.schema.ts +++ b/src/features/my-page/model/profile-form.schema.ts @@ -1,4 +1,3 @@ -import { FieldNamesMarkedBoolean } from 'react-hook-form'; import { z } from 'zod'; import type { MemberProfile } from '@/entities/user/api/types'; import { UrlSchema } from '@/shared/util/zod-schema'; diff --git a/src/features/my-page/ui/entry-card.tsx b/src/features/my-page/ui/entry-card.tsx new file mode 100644 index 00000000..eee4980a --- /dev/null +++ b/src/features/my-page/ui/entry-card.tsx @@ -0,0 +1,89 @@ +import dayjs from 'dayjs'; +import Image from 'next/image'; +import React from 'react'; +import { getSincerityPresetByLevelName } from '@/shared/config/sincerity-temp-presets'; +import { cn } from '@/shared/shadcn/lib/utils'; +import Button from '@/shared/ui/button'; +import { GroupStudyApply } from '../api/types'; + +export default function EntryCard(props: { data: GroupStudyApply }) { + const { data: applicant } = props; + const temperPreset = getSincerityPresetByLevelName( + applicant.applicantInfo.sincerityTemp.levelName as string, + ); + + const timeAgo = (date: string | Date): string => { + const now = dayjs(); + const target = dayjs(date); + + if (!target.isValid()) return ''; + + const diffMin = now.diff(target, 'minute'); + if (diffMin < 1) return '방금 전'; + if (diffMin < 60) return `${diffMin}분 전`; + + const diffHour = now.diff(target, 'hour'); + if (diffHour < 24) return `${diffHour}시간 전`; + + const diffDay = now.diff(target, 'day'); + if (diffDay < 7) return `${diffDay}일 전`; + + const diffWeek = Math.floor(diffDay / 7); + + return `${diffWeek}주 전`; + }; + + const ApplicantStatus = () => {}; + + return ( +
+
+ profile +
+
+ + {applicant.applicantInfo.memberName} + + + {`${applicant.applicantInfo.sincerityTemp.temperature}`} ℃ + +
+ +

+ {timeAgo(applicant.createdAt)} +

+
+
+

{applicant.answer}

+
+ + +
+
+ ); +} diff --git a/src/features/my-page/ui/entry-list.tsx b/src/features/my-page/ui/entry-list.tsx new file mode 100644 index 00000000..8e0a743c --- /dev/null +++ b/src/features/my-page/ui/entry-list.tsx @@ -0,0 +1,44 @@ +'use client'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import React from 'react'; +import EntryCard from './entry-card'; +import { getEntryList } from '../api/get-entry-list'; + +export default function EntryList() { + const { data, fetchNextPage } = useInfiniteQuery({ + queryKey: ['entryList'], + queryFn: async ({ pageParam }) => { + const response = await getEntryList({ + groupStudyId: 1, + page: pageParam, + size: 20, + status: 'PENDING', + }); + + return response; + }, + getNextPageParam: (lastPage) => { + if (lastPage.hasNext) { + return lastPage.page + 1; + } + + return null; + }, + initialPageParam: 0, + maxPages: 3, + }); + + console.log('data', data); + + return ( +
+ {data?.pages.map((page, pageIndex) => ( + + {page.content.map((applicant) => ( + + ))} + + ))} +
+ ); +} diff --git a/src/features/my-page/ui/profile-edit-modal.tsx b/src/features/my-page/ui/profile-edit-modal.tsx index d6126b95..b9f2b7d9 100644 --- a/src/features/my-page/ui/profile-edit-modal.tsx +++ b/src/features/my-page/ui/profile-edit-modal.tsx @@ -16,11 +16,7 @@ import MultiItemSelector from '@/shared/ui/form/multi-item-selector'; import { BaseInput, TextAreaInput } from '@/shared/ui/input'; import { Modal } from '@/shared/ui/modal'; -import { - DEFAULT_OPTIONS, - DEFAULT_PROFILE_IMAGE_URL, - MBTI_OPTIONS, -} from '../consts/my-page-const'; +import { DEFAULT_OPTIONS, MBTI_OPTIONS } from '../consts/my-page-const'; import { ProfileFormSchema, type ProfileFormInput, @@ -83,7 +79,7 @@ function ProfileEditForm({ const [image, setImage] = useState( memberProfile.profileImage?.resizedImages?.[0]?.resizedImageUrl ?? - DEFAULT_PROFILE_IMAGE_URL, + undefined, ); const methods = useForm({ @@ -94,13 +90,13 @@ function ProfileEditForm({ const { handleSubmit, - formState: { isValid, isSubmitting, dirtyFields }, + formState: { isValid, isSubmitting }, } = methods; const onValidSubmit = async (values: ProfileFormValues) => { const file = fileInputRef.current?.files?.[0]; const profileImageExtension = - image === DEFAULT_PROFILE_IMAGE_URL ? 'jpg' : file?.name.split('.').pop(); + image === undefined ? 'jpg' : file?.name.split('.').pop(); const payload = toUpdateProfilePayload(values, { profileImageExtension }); const updatedProfile = await updateProfile(payload); @@ -111,7 +107,7 @@ function ProfileEditForm({ if (file) { imageFormData.append('file', file); - } else if (image === DEFAULT_PROFILE_IMAGE_URL) { + } else if (image === undefined) { const defaultProfileImage = 'profile-default.jpg'; const response = await fetch(defaultProfileImage); const blob = await response.blob(); diff --git a/src/features/my-page/ui/profile-info-edit-modal.tsx b/src/features/my-page/ui/profile-info-edit-modal.tsx index e3e856b5..254052fa 100644 --- a/src/features/my-page/ui/profile-info-edit-modal.tsx +++ b/src/features/my-page/ui/profile-info-edit-modal.tsx @@ -13,7 +13,7 @@ import { MultiDropdown, SingleDropdown } from '@/shared/ui/dropdown'; import FormField from '@/shared/ui/form/form-field'; import { TextAreaInput } from '@/shared/ui/input'; import { Modal } from '@/shared/ui/modal'; -import { ToggleGroup } from '@/shared/ui/toggle'; +import { GroupItems } from '@/shared/ui/toggle'; import { ProfileInfoFormSchema, @@ -210,7 +210,7 @@ function ProfileInfoEditForm({ direction="vertical" required > - + diff --git a/src/features/study/api/get-study-data.ts b/src/features/study/api/get-study-data.ts deleted file mode 100644 index 89f1c674..00000000 --- a/src/features/study/api/get-study-data.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { - CompleteStudyRequest, - DailyStudyDetail, - GetDailyStudiesParams, - GetDailyStudiesResponse, - GetMonthlyCalendarParams, - JoinStudyRequest, - MonthlyCalendarResponse, - PostDailyRetrospectRequest, - PrepareStudyRequest, - WeeklyParticipationResponse, -} from '@/features/study/api/types'; -import { axiosInstance } from '@/shared/tanstack-query/axios'; - -// 스터디 상세 조회 -export const getDailyStudyDetail = async ( - params: string, -): Promise => { - const res = await axiosInstance.get(`/study/daily/mine/${params}`); - - return res.data.content; -}; - -// 스터디 전체 조회 -export const getDailyStudies = async ( - params?: GetDailyStudiesParams, -): Promise => { - const res = await axiosInstance.get('/study/daily', { params }); - - return res.data.content; -}; - -// 월 별 스터디 캘린더 조회 -export const getMonthlyStudyCalendar = async ( - params: GetMonthlyCalendarParams, -): Promise => { - const res = await axiosInstance.get('/study/daily/month', { params }); - - return res.data.content; -}; - -export const postDailyRetrospect = async (body: PostDailyRetrospectRequest) => { - const res = await axiosInstance.post('/study/daily/retrospect', body); - - return res.data; -}; - -// 면접 준비 시작 -export const putStudyDaily = async ( - dailyId: number, - body: PrepareStudyRequest, -) => { - const res = await axiosInstance.put(`/study/daily/${dailyId}/prepare`, body); - - return res.data; -}; - -// 면접 완료 및 회고 작성 -export const completeStudy = async ( - dailyStudyId: number, - body: CompleteStudyRequest, -) => { - const res = await axiosInstance.post( - `/study/daily/${dailyStudyId}/complete`, - body, - ); - - return res.data; -}; - -// CS 스터디 매칭 신청 -export const postJoinStudy = async (payload: JoinStudyRequest) => { - const cleanPayload = Object.fromEntries( - Object.entries(payload).filter( - ([_, value]) => - value !== undefined && - value !== '' && - !(Array.isArray(value) && value.length === 0), - ), - ); - - const res = await axiosInstance.post('/matching/apply', cleanPayload); - - return res.data; -}; - -// 스터디 참여 유무 확인 -export const getWeeklyParticipation = async ( - studyDate: string, -): Promise => { - const res = await axiosInstance.get('/study/week/participation', { - params: { studyDate }, - }); - - return res.data.content; -}; diff --git a/src/features/study/group/api/apply-group-study.ts b/src/features/study/group/api/apply-group-study.ts new file mode 100644 index 00000000..372dfed8 --- /dev/null +++ b/src/features/study/group/api/apply-group-study.ts @@ -0,0 +1,17 @@ +import { axiosInstance } from '@/shared/tanstack-query/axios'; +import { + ApplyGroupStudyRequest, + ApplyGroupStudyResponse, +} from './group-study-types'; + +// 그룹 스터디 신청 요청 +export const applyGroupStudy = async ({ + groupStudyId, + answer, +}: ApplyGroupStudyRequest): Promise => { + const res = await axiosInstance.post(`/group-studies/${groupStudyId}/apply`, { + answer, + }); + + return res.data; +}; diff --git a/src/features/study/group/api/creat-group-study.ts b/src/features/study/group/api/creat-group-study.ts new file mode 100644 index 00000000..0f4d4a65 --- /dev/null +++ b/src/features/study/group/api/creat-group-study.ts @@ -0,0 +1,9 @@ +import { axiosInstance } from '@/shared/tanstack-query/axios'; +import { OpenGroupStudyRequest } from './group-study-types'; + +// CS 스터디 매칭 신청 +export const createGroupStudy = async (payload: OpenGroupStudyRequest) => { + const res = await axiosInstance.post('/group-studies', payload); + + return res.data; +}; diff --git a/src/features/study/group/api/get-group-study-list.ts b/src/features/study/group/api/get-group-study-list.ts new file mode 100644 index 00000000..279a9650 --- /dev/null +++ b/src/features/study/group/api/get-group-study-list.ts @@ -0,0 +1,28 @@ +import { axiosInstance } from '@/shared/tanstack-query/axios'; +import { + GroupStudyListRequest, + GroupStudyListResponse, +} from './group-study-types'; + +// 그룹 스터디 리스트 조회 +export const getGroupStudyList = async ( + params: GroupStudyListRequest, +): Promise => { + const { page, size, status } = params; + + try { + const { data } = await axiosInstance.get('/group-studies', { + params: { + page, + size, + status, + }, + }); + + if (data.statusCode !== 200) { + throw new Error('Failed to fetch group study list'); + } + + return data.content; + } catch (err) {} +}; diff --git a/src/features/study/group/api/group-study-types.ts b/src/features/study/group/api/group-study-types.ts new file mode 100644 index 00000000..219f78be --- /dev/null +++ b/src/features/study/group/api/group-study-types.ts @@ -0,0 +1,148 @@ +import { + EXPERIENCE_LEVEL_OPTIONS, + REGULAR_MEETINGS, + STUDY_METHODS, + STUDY_TYPES, + TARGET_ROLE_OPTIONS, + THUMBNAIL_EXTENSION, +} from '../const/group-study-const'; + +// 그룹 스터디 신청 상태 +type ApplicationStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'KICKED'; + +// 그룹 스터디 신청 Request 타입 +export interface ApplyGroupStudyRequest { + groupStudyId: number; + answer: string[]; +} + +// 그룹 스터디 신청 Response 타입 +export interface ApplyGroupStudyResponse { + applyId: number; + applicantId: number; + groupStudyId: number; + status: ApplicationStatus; + createdAt: string; +} + +export type StudyType = (typeof STUDY_TYPES)[number]; +export type TargetRole = (typeof TARGET_ROLE_OPTIONS)[number]; +export type ExperienceLevel = (typeof EXPERIENCE_LEVEL_OPTIONS)[number]; +export type StudyMethod = (typeof STUDY_METHODS)[number]; +export type RegularMeeting = (typeof REGULAR_MEETINGS)[number]; +export type ThumbnailExtension = (typeof THUMBNAIL_EXTENSION)[number]; +export const EXTENSION_TO_MIME: Record< + Uppercase<(typeof THUMBNAIL_EXTENSION)[number]>, + string +> = { + DEFAULT: 'image/jpeg', + JPG: 'image/jpeg', + JPEG: 'image/jpeg', + PNG: 'image/png', + GIF: 'image/gif', + WEBP: 'image/webp', + SVG: 'image/svg+xml', +}; + +export interface BasicInfo { + type: StudyType; + targetRoles: TargetRole[]; + maxMembersCount: number; + experienceLevels: ExperienceLevel[]; + method: StudyMethod; + regularMeeting: RegularMeeting; + location: string; + startDate: string; + endDate: string; + price: number; + createdAt: string; + updatedAt: string; +} + +export interface DetailInfo { + title: string; + description: string; + summary: string; + thumbnailExtension: ThumbnailExtension; +} + +export interface InterviewPost { + interviewPost: string[]; +} + +export interface OpenGroupStudyRequest { + basicInfo: BasicInfo; + detailInfo: DetailInfo; + interviewPost: InterviewPost; + thumbnailExtension: ThumbnailExtension; +} + +// 그룹 리스트 타입 +export interface GroupStudyListRequest { + page: number; + size: number; + status: GroupStudyStatus; +} + +export interface ImageSizeType { + imageTypeName: 'ORIGINAL' | 'SMALL' | 'MEDIUM' | 'LARGE'; + width: number | null; + height: number | null; +} + +export interface ResizedImage { + resizedImageId: number; + resizedImageUrl: string; + imageSizeType: ImageSizeType; +} + +export interface Thumbnail { + imageId: number; + resizedImages: ResizedImage[]; +} + +export interface SimpleDetailInfo { + thumbnail: Thumbnail; + title: string; + summary: string; +} + +export type GroupStudyStatus = 'RECRUITING' | 'IN_PROGRESS' | 'COMPLETED'; +export type GroupStudyType = 'PROJECT' | 'STUDY'; +export type HostType = 'ZEROONE' | 'GENERAL' | 'METOR'; +export type Method = 'ONLINE' | 'OFFLINE'; + +export interface DetailBasicInfo { + groupStudyId: number; + type: GroupStudyType; + hostType: HostType; + targetRoles: TargetRole[]; + maxMembersCount: number; + experienceLevels: ExperienceLevel[]; + method: Method; + regularMeeting: RegularMeeting; + location: string; + startDate: string; + endDate: string; + price: number; + status: GroupStudyStatus; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface GroupStudyData { + basicInfo: DetailBasicInfo; + simpleDetailInfo: SimpleDetailInfo; + currentParticipantCount: number; +} + +export interface GroupStudyListResponse { + content: GroupStudyData[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + hasNext: boolean; + hasPrevious: boolean; +} diff --git a/src/features/study/group/const/group-study-const.ts b/src/features/study/group/const/group-study-const.ts new file mode 100644 index 00000000..3d1b7b89 --- /dev/null +++ b/src/features/study/group/const/group-study-const.ts @@ -0,0 +1,80 @@ +export const STUDY_TYPES = [ + 'PROJECT', + 'MENTORING', + 'SEMINAR', + 'CHALLENGE', + 'BOOK_STUDY', + 'LECTURE_STUDY', +] as const; + +export const TARGET_ROLE_OPTIONS = [ + 'BACKEND', + 'FRONTEND', + 'PLANNER', + 'DESIGNER', +] as const; + +export const EXPERIENCE_LEVEL_OPTIONS = [ + 'BEGINNER', + 'JOB_SEEKER', + 'JUNIOR', + 'MIDDLE', + 'SENIOR', +] as const; + +export const STUDY_METHODS = ['ONLINE', 'OFFLINE', 'HYBRID'] as const; + +export const REGULAR_MEETINGS = [ + 'NONE', + 'WEEKLY', + 'BIWEEKLY', + 'TRIPLE_WEEKLY_OR_MORE', +] as const; + +export const THUMBNAIL_EXTENSION = [ + 'DEFAULT', + 'JPG', + 'PNG', + 'GIF', + 'WEBP', + 'SVG', + 'JPEG', +] as const; + +// UI 매칭을 위한 라벨 const +export const STUDY_TYPE_LABELS = { + PROJECT: '프로젝트', + MENTORING: '멘토링', + SEMINAR: '세미나', + CHALLENGE: '챌린지', + BOOK_STUDY: '책 스터디', + LECTURE_STUDY: '강의 스터디', +} as const; + +export const ROLE_OPTIONS_UI = [ + { label: '백엔드', value: 'BACKEND' }, + { label: '프론트엔드', value: 'FRONTEND' }, + { label: '기획', value: 'PLANNER' }, + { label: '디자이너', value: 'DESIGNER' }, +]; + +export const EXPERIENCE_LEVEL_OPTIONS_UI = [ + { label: '입문자', value: 'BEGINNER' }, + { label: '취준생', value: 'JOB_SEEKER' }, + { label: '주니어', value: 'JUNIOR' }, + { label: '미들', value: 'MIDDLE' }, + { label: '시니어', value: 'SENIOR' }, +]; + +export const STUDY_METHOD_LABELS = { + ONLINE: '온라인', + OFFLINE: '오프라인', + HYBRID: '온오프라인', +} as const; + +export const REGULAR_MEETING_LABELS = { + NONE: '없음', + WEEKLY: '주 1회', + BIWEEKLY: '주 2회', + TRIPLE_WEEKLY_OR_MORE: '주 3회 이상', +} as const; diff --git a/src/features/study/group/const/use-group-study-mutation.ts b/src/features/study/group/const/use-group-study-mutation.ts new file mode 100644 index 00000000..bee72b30 --- /dev/null +++ b/src/features/study/group/const/use-group-study-mutation.ts @@ -0,0 +1,10 @@ +import { useMutation } from '@tanstack/react-query'; +import { createGroupStudy } from '../api/creat-group-study'; +import { OpenGroupStudyRequest } from '../api/group-study-types'; + +// 그룹 스터디 개설 mutation +export const useCreateGroupStudyMutation = () => { + return useMutation({ + mutationFn: (payload: OpenGroupStudyRequest) => createGroupStudy(payload), + }); +}; diff --git a/src/features/study/group/model/open-group-form.schema.ts b/src/features/study/group/model/open-group-form.schema.ts new file mode 100644 index 00000000..96253ad4 --- /dev/null +++ b/src/features/study/group/model/open-group-form.schema.ts @@ -0,0 +1,109 @@ +import { z } from 'zod'; +import { OpenGroupStudyRequest } from '../api/group-study-types'; +import { + STUDY_TYPES, + TARGET_ROLE_OPTIONS, + EXPERIENCE_LEVEL_OPTIONS, + REGULAR_MEETINGS, + THUMBNAIL_EXTENSION, + STUDY_METHODS, +} from '../const/group-study-const'; + +const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; + +export const OpenGroupFormSchema = z.object({ + type: z.enum(STUDY_TYPES), + targetRoles: z + .array(z.enum(TARGET_ROLE_OPTIONS)) + .min(1, '역할을 1개 이상 선택해 주세요.'), + maxMembersCount: z + .string() + .trim() + .regex(/^[1-9]\d*$/, '최소 1명 이상을 선택해주세요.'), + experienceLevels: z + .array(z.enum(EXPERIENCE_LEVEL_OPTIONS)) + .min(1, '경력을 1개 이상 선택해 주세요.'), + method: z.enum(STUDY_METHODS), + location: z.string().trim(), + regularMeeting: z.enum(REGULAR_MEETINGS), + startDate: z + .string() + .trim() + .regex(ISO_DATE_REGEX, 'YYYY-MM-DD 형식의 시작일을 입력해 주세요.'), + endDate: z + .string() + .trim() + .regex(ISO_DATE_REGEX, 'YYYY-MM-DD 형식의 종료일을 입력해 주세요.'), + price: z.string().trim().optional(), + title: z.string().trim().min(1, '스터디 제목을 입력해주세요.'), + summary: z.string().trim().min(1, '한 줄 소개를 입력해주세요.'), + description: z.string().trim().min(1, '스터디 소개를 입력해주세요.'), + interviewPost: z + .array(z.string()) + .refine((arr) => arr.length > 0 && arr.every((v) => v.trim() !== ''), { + message: '모든 질문을 입력해야 합니다.', + }) + .refine((arr) => arr.length <= 10, { + message: '질문은 최대 10개까지만 입력할 수 있습니다.', + }), + thumbnailExtension: z + .enum(THUMBNAIL_EXTENSION) + .refine((val) => val !== 'DEFAULT', '썸네일 이미지를 선택해주세요.'), +}); + +// 사진 상태 저장을 위한 로컬용 state +export type OpenGroupFormValues = z.input & { + thumbnailFile?: File | undefined; +}; +export type OpenGroupParsedValues = z.output; + +export function buildOpenGroupDefaultValues(): OpenGroupFormValues { + return { + type: 'PROJECT', + targetRoles: [], + maxMembersCount: '', + experienceLevels: [], + method: 'ONLINE', + location: '', + regularMeeting: 'NONE', + startDate: '', + endDate: '', + price: '', + title: '', + description: '', + summary: '', + interviewPost: [''], + thumbnailExtension: 'DEFAULT', + }; +} + +export function toOpenGroupRequest( + v: OpenGroupParsedValues, +): OpenGroupStudyRequest { + return { + basicInfo: { + type: v.type, + targetRoles: v.targetRoles, + maxMembersCount: Number(v.maxMembersCount), + experienceLevels: v.experienceLevels ?? [], + method: v.method, + regularMeeting: v.regularMeeting, + location: v.location.trim(), + startDate: v.startDate.trim(), + endDate: v.endDate.trim(), + price: Number(v.price), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + detailInfo: { + thumbnailExtension: v.thumbnailExtension, + title: v.title, + description: v.description, + summary: v.summary, + }, + interviewPost: { + interviewPost: v.interviewPost ?? [], + }, + thumbnailExtension: v.thumbnailExtension, + }; +} diff --git a/src/features/study/group/model/use-apply-group-study.tsx b/src/features/study/group/model/use-apply-group-study.tsx new file mode 100644 index 00000000..f015af51 --- /dev/null +++ b/src/features/study/group/model/use-apply-group-study.tsx @@ -0,0 +1,9 @@ +import { useMutation } from '@tanstack/react-query'; +import { applyGroupStudy } from '../api/apply-group-study'; + +// 그룹 스터디 신청 훅 +export const useApplyGroupStudyMutation = () => { + return useMutation({ + mutationFn: applyGroupStudy, + }); +}; diff --git a/src/features/study/group/ui/apply-group-study-modal.tsx b/src/features/study/group/ui/apply-group-study-modal.tsx new file mode 100644 index 00000000..922bd197 --- /dev/null +++ b/src/features/study/group/ui/apply-group-study-modal.tsx @@ -0,0 +1,226 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { XIcon } from 'lucide-react'; +import { useState } from 'react'; + +import { useController, useForm } from 'react-hook-form'; +import { z } from 'zod'; +import Button from '@/shared/ui/button'; +import Checkbox from '@/shared/ui/checkbox'; +import { Modal } from '@/shared/ui/modal'; +import { useApplyGroupStudyMutation } from '../model/use-apply-group-study'; + +interface ApplyGroupStudyModalProps { + groupStudyId: number; + title: string; + questions: string[]; +} + +export default function ApplyGroupStudyModal({ + groupStudyId, + title, + questions, +}: ApplyGroupStudyModalProps) { + const [open, setOpen] = useState(false); + + return ( + + + + + + + + + + + 스터디 신청서 작성하기 + + setOpen(false)}> + + + + + setOpen(false)} + /> + + + + ); +} + +const ApplyGroupStudyFormSchema = z.object({ + answer: z.array( + z.string().min(1, '답변을 작성해주세요.'), // 각 항목에 최소 1글자 이상 + ), + agree: z + .boolean() + .refine((val) => val === true, { message: '참여 규칙에 동의해야 합니다.' }), +}); + +type ApplyGroupStudyFormData = z.infer; + +function ApplyGroupStudyForm({ + groupStudyId, + title, + questions, + onClose, +}: { + groupStudyId: number; + title: string; + questions: string[]; + onClose: () => void; +}) { + const { + register, + handleSubmit, + control, + formState: { errors }, + } = useForm({ + resolver: zodResolver(ApplyGroupStudyFormSchema), + defaultValues: { + answer: Array(questions.length).fill(''), + }, + }); + + // ✅ checkbox를 controller로 제어 + const { + field: { value: checked, onChange: onToggle }, + } = useController({ + name: 'agree', + control, + }); + + const { mutate: applyGroupStudy } = useApplyGroupStudyMutation(); + + const onSubmit = (data: ApplyGroupStudyFormData) => { + const { answer } = data; + + applyGroupStudy( + { answer, groupStudyId }, + { + onSuccess: () => { + alert('스터디 신청이 완료되었습니다.'); + onClose(); + }, + }, + ); + }; + + return ( + <> + +

{title}

+ +
+ + 리더의 질문 + + +
+ {questions.map((question, index) => ( +
+ + +
+