From ef0e56dbe7fe48c40cc22dea046c0d6723477651 Mon Sep 17 00:00:00 2001 From: cindy-chaewon Date: Tue, 28 Apr 2026 18:20:28 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=ED=99=88=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20SSR=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20?= =?UTF-8?q?=EB=B0=8F=20API=20=EC=97=B0=EB=8F=99=20(#205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - page.tsx를 role 기반 redirect gate로 변경 (ADMIN/USER 분기) - dashboard/admin, dashboard/user SSR 페이지 신규 생성 - 각 query 파일에 서버/클라이언트 공용 options 함수 추가 - useMyInterviewForHomeQuery, useRemindEvaluatorsMutation 신규 생성 - routes.ts에 DASHBOARD 경로 추가, middleware.ts 보호 경로 추가 - login API body 전송 방식 수정 (body → json) - 불필요한 console.log 제거 --- apps/web/src/api/login.ts | 2 +- .../src/app/(main)/dashboard/admin/page.tsx | 44 +++++++++++ .../src/app/(main)/dashboard/user/page.tsx | 60 +++++++++++++++ apps/web/src/app/(main)/page.tsx | 76 +++---------------- apps/web/src/middleware.ts | 1 + apps/web/src/routes.ts | 5 ++ .../mutation/useRemindEvaluatorsMutation.ts | 9 +++ .../useCurrentRecruitmentSummaryByOrgQuery.ts | 26 +++++-- .../useCurrentRecruitmentSummaryQuery.ts | 19 +++-- .../store/query/useInterviewScheduleQuery.ts | 7 +- .../query/useMyDocumentEvaluationsQuery.ts | 25 ++++-- .../store/query/useMyInterviewForHomeQuery.ts | 59 ++++++++++++++ .../query/useOrganizationInterviewsQuery.ts | 4 +- .../store/query/usePendingEvaluatorsQuery.ts | 26 +++++-- .../query/useRecruitmentProgressQuery.ts | 28 +++++-- 15 files changed, 284 insertions(+), 107 deletions(-) create mode 100644 apps/web/src/app/(main)/dashboard/admin/page.tsx create mode 100644 apps/web/src/app/(main)/dashboard/user/page.tsx create mode 100644 apps/web/src/store/mutation/useRemindEvaluatorsMutation.ts create mode 100644 apps/web/src/store/query/useMyInterviewForHomeQuery.ts diff --git a/apps/web/src/api/login.ts b/apps/web/src/api/login.ts index f29de190..805d8331 100644 --- a/apps/web/src/api/login.ts +++ b/apps/web/src/api/login.ts @@ -5,7 +5,7 @@ import { cookieOptions } from './authCookies'; export async function login(data: LoginRequest): Promise { const response = await api.post('api/v1/auth/login', { - body: JSON.stringify(data), + json: data, }); // 토큰 세팅 diff --git a/apps/web/src/app/(main)/dashboard/admin/page.tsx b/apps/web/src/app/(main)/dashboard/admin/page.tsx new file mode 100644 index 00000000..c1525932 --- /dev/null +++ b/apps/web/src/app/(main)/dashboard/admin/page.tsx @@ -0,0 +1,44 @@ +import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; +import { getQueryClient } from '@web/store/query/getQueryClient'; +import { getServerSideTokens } from '@web/api/serverSideTokens'; +import { getCurrentRecruitmentSummaryQueryOptions } from '@web/store/query/useCurrentRecruitmentSummaryQuery'; +import { getRecruitmentProgressQueryOptions } from '@web/store/query/useRecruitmentProgressQuery'; +import { getPendingEvaluatorsQueryOptions } from '@web/store/query/usePendingEvaluatorsQuery'; +import { AdminHomeDashboardScreen } from '@web/app/(main)/_components/home/Admin/AdminHomeDashboardScreen'; +import { AdminHomeEmptyScreen } from '@web/app/(main)/_components/home/Admin/AdminHomeEmptyScreen'; + +export default async function AdminDashboardPage() { + const tokens = await getServerSideTokens(); + const queryClient = getQueryClient(); + + const summaryOptions = getCurrentRecruitmentSummaryQueryOptions(tokens); + let summaries: Awaited> = []; + + try { + summaries = await queryClient.fetchQuery(summaryOptions); + } catch { + return ; + } + + const firstSummary = summaries[0]; + if (!firstSummary) { + return ; + } + + const recruitmentId = firstSummary.recruitmentId; + const docProgressOptions = getRecruitmentProgressQueryOptions(recruitmentId, 'DOCUMENT', tokens); + const interviewProgressOptions = getRecruitmentProgressQueryOptions(recruitmentId, 'INTERVIEW', tokens); + const pendingOptions = getPendingEvaluatorsQueryOptions(recruitmentId, tokens); + + await Promise.all([ + queryClient.fetchQuery(docProgressOptions), + queryClient.fetchQuery(interviewProgressOptions), + queryClient.fetchQuery(pendingOptions), + ]).catch(() => {}); + + return ( + + + + ); +} diff --git a/apps/web/src/app/(main)/dashboard/user/page.tsx b/apps/web/src/app/(main)/dashboard/user/page.tsx new file mode 100644 index 00000000..231e5b82 --- /dev/null +++ b/apps/web/src/app/(main)/dashboard/user/page.tsx @@ -0,0 +1,60 @@ +import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; +import { getQueryClient } from '@web/store/query/getQueryClient'; +import { getServerSideTokens } from '@web/api/serverSideTokens'; +import { getCurrentRecruitmentSummaryByOrgQueryOptions } from '@web/store/query/useCurrentRecruitmentSummaryByOrgQuery'; +import { getMyDocumentEvaluationsQueryOptions } from '@web/store/query/useMyDocumentEvaluationsQuery'; +import { + getOrgInterviewsForHomeQueryOptions, + getMyTimeSlotsForHomeQueryOptions, +} from '@web/store/query/useMyInterviewForHomeQuery'; +import { UserHomeDashboardScreen } from '@web/app/(main)/_components/home/User/UserHomeDashboardScreen'; +import { UserHomeEmptyScreen } from '@web/app/(main)/_components/home/User/UserHomeEmptyScreen'; + +export default async function UserDashboardPage() { + const tokens = await getServerSideTokens(); + const orgId = tokens.organizationId; + const queryClient = getQueryClient(); + + if (!orgId) { + return ; + } + + const summaryOptions = getCurrentRecruitmentSummaryByOrgQueryOptions(orgId, tokens); + let summaries: Awaited> = []; + + try { + summaries = await queryClient.fetchQuery(summaryOptions); + } catch { + return ; + } + + const firstSummary = summaries[0]; + if (!firstSummary) { + return ; + } + + const recruitmentId = firstSummary.recruitmentId; + + const docEvalOptions = getMyDocumentEvaluationsQueryOptions(recruitmentId, tokens); + const orgInterviewsOptions = getOrgInterviewsForHomeQueryOptions(orgId, tokens); + + await Promise.all([ + queryClient.fetchQuery(docEvalOptions), + queryClient.fetchQuery(orgInterviewsOptions).then((orgInterviews) => { + const interviewId = orgInterviews.find( + (iv) => iv.recruitmentId === recruitmentId + )?.interviewId; + if (interviewId) { + return queryClient.fetchQuery( + getMyTimeSlotsForHomeQueryOptions(interviewId, tokens) + ); + } + }), + ]).catch(() => {}); + + return ( + + + + ); +} diff --git a/apps/web/src/app/(main)/page.tsx b/apps/web/src/app/(main)/page.tsx index 825546bc..7a61d635 100644 --- a/apps/web/src/app/(main)/page.tsx +++ b/apps/web/src/app/(main)/page.tsx @@ -1,66 +1,14 @@ -'use client'; - -import { useMemo } from 'react'; -import { getCookie } from 'cookies-next'; -import { AdminHomeDashboardScreen } from '@web/app/(main)/_components/home/Admin/AdminHomeDashboardScreen'; -import { AdminHomeEmptyScreen } from '@web/app/(main)/_components/home/Admin/AdminHomeEmptyScreen'; -import { UserHomeDashboardScreen } from '@web/app/(main)/_components/home/User/UserHomeDashboardScreen'; -import { UserHomeEmptyScreen } from '@web/app/(main)/_components/home/User/UserHomeEmptyScreen'; -import { useCurrentRecruitmentSummaryQuery } from '@web/store/query/useCurrentRecruitmentSummaryQuery'; -import { Spinner } from '@repo/ui/Spinner'; -import { useCurrentRecruitmentSummaryByOrgQuery } from '@web/store/query/useCurrentRecruitmentSummaryByOrgQuery'; -import { getClientSideTokens } from '@web/utils/getClientSideTokens'; - -export default function HomePage() { - const role = useMemo<'admin' | 'user'>(() => { - const raw = getCookie('role'); - return raw === 'ADMIN' ? 'admin' : 'user'; - }, []); - - const { organizationId } = getClientSideTokens(); - const orgId = Number(organizationId); - - const { data: adminSummaryData, isLoading: isAdminLoading } = - useCurrentRecruitmentSummaryQuery(); - - const { data: userSummaryData, isLoading: isUserLoading } = - useCurrentRecruitmentSummaryByOrgQuery(orgId); - - const LoadingIndicator = ( -
- -
- ); - - if (role === 'admin') { - if (isAdminLoading) { - return LoadingIndicator; - } - - const adminHasData = adminSummaryData && adminSummaryData.length > 0; - - return adminHasData ? ( - - ) : ( - - ); - } - - if (isUserLoading) { - return LoadingIndicator; +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { ROUTES } from '@web/routes'; + +export default async function RootPage() { + const cookieStore = await cookies(); + const role = cookieStore.get('role')?.value; + + if (role === 'ADMIN') { + redirect(ROUTES.DASHBOARD.ADMIN); + } else { + redirect(ROUTES.DASHBOARD.USER); } - - const userHasData = userSummaryData && userSummaryData.length > 0; - - return userHasData ? : ; } diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index e26687da..a89b1d97 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -14,6 +14,7 @@ const publicPaths: string[] = [ const RESERVED_PREFIXES = [ 'api', '_next', + 'dashboard', 'application-list', 'apply-management', 'docs-evaluation', diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts index 15606ef6..a43e7751 100644 --- a/apps/web/src/routes.ts +++ b/apps/web/src/routes.ts @@ -21,4 +21,9 @@ export const ROUTES = { }, ORGANIZATION: '/organization', + + DASHBOARD: { + ADMIN: '/dashboard/admin', + USER: '/dashboard/user', + }, } as const; diff --git a/apps/web/src/store/mutation/useRemindEvaluatorsMutation.ts b/apps/web/src/store/mutation/useRemindEvaluatorsMutation.ts new file mode 100644 index 00000000..e60bb511 --- /dev/null +++ b/apps/web/src/store/mutation/useRemindEvaluatorsMutation.ts @@ -0,0 +1,9 @@ +import { useMutation } from '@tanstack/react-query'; +import { POST } from '@web/api/fetch'; + +export function useRemindEvaluatorsMutation() { + return useMutation({ + mutationFn: (recruitmentId: number) => + POST(`api/v1/evaluations/remind?recruitmentId=${recruitmentId}`), + }); +} diff --git a/apps/web/src/store/query/useCurrentRecruitmentSummaryByOrgQuery.ts b/apps/web/src/store/query/useCurrentRecruitmentSummaryByOrgQuery.ts index ca53a244..fdf9b7bd 100644 --- a/apps/web/src/store/query/useCurrentRecruitmentSummaryByOrgQuery.ts +++ b/apps/web/src/store/query/useCurrentRecruitmentSummaryByOrgQuery.ts @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { GET } from '@web/api/fetch'; +import type { Tokens } from '@web/api/types'; // 사용자용 @@ -30,16 +31,27 @@ export interface OrganizationRecruitmentSummaryResponse { result: OrganizationRecruitmentSummary[]; success: boolean; } -export function useCurrentRecruitmentSummaryByOrgQuery(organizationId: number) { - return useQuery({ - queryKey: ['recruitments', organizationId, 'current', 'summary'], + +export function getCurrentRecruitmentSummaryByOrgQueryOptions( + organizationId: number, + tokens?: Tokens +) { + return { + queryKey: ['recruitments', organizationId, 'current', 'summary'] as const, queryFn: async () => { const res = await GET( - `api/v1/recruitments/${organizationId}/current/summary` + `api/v1/recruitments/${organizationId}/current/summary`, + undefined, + tokens ); - console.log("홈", res.result) return res.result; }, - enabled: !!organizationId, - }); + enabled: !!organizationId, + }; +} + +export function useCurrentRecruitmentSummaryByOrgQuery(organizationId: number) { + return useQuery( + getCurrentRecruitmentSummaryByOrgQueryOptions(organizationId) + ); } \ No newline at end of file diff --git a/apps/web/src/store/query/useCurrentRecruitmentSummaryQuery.ts b/apps/web/src/store/query/useCurrentRecruitmentSummaryQuery.ts index 8933a52a..e94caf5a 100644 --- a/apps/web/src/store/query/useCurrentRecruitmentSummaryQuery.ts +++ b/apps/web/src/store/query/useCurrentRecruitmentSummaryQuery.ts @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { GET } from '@web/api/fetch'; +import type { Tokens } from '@web/api/types'; //관리자용 export interface DDay { @@ -30,14 +31,22 @@ export interface CurrentRecruitmentSummaryResponse { success: boolean; } -export function useCurrentRecruitmentSummaryQuery() { - return useQuery({ - queryKey: ['recruitment', 'current', 'summary'], +export function getCurrentRecruitmentSummaryQueryOptions(tokens?: Tokens) { + return { + queryKey: ['recruitment', 'current', 'summary'] as const, queryFn: async () => { const res = await GET( - '/api/v1/admin/recruitments/current/summary' + 'api/v1/admin/recruitments/current/summary', + undefined, + tokens ); return res.result; }, - }); + }; +} + +export function useCurrentRecruitmentSummaryQuery() { + return useQuery( + getCurrentRecruitmentSummaryQueryOptions() + ); } \ No newline at end of file diff --git a/apps/web/src/store/query/useInterviewScheduleQuery.ts b/apps/web/src/store/query/useInterviewScheduleQuery.ts index 54cfc5dd..47b41d6f 100644 --- a/apps/web/src/store/query/useInterviewScheduleQuery.ts +++ b/apps/web/src/store/query/useInterviewScheduleQuery.ts @@ -1,8 +1,6 @@ // src/store/query/useInterviewSchedule.ts import { useSuspenseQuery, - type FetchQueryOptions, - UseSuspenseQueryResult, queryOptions, UseSuspenseQueryOptions, } from '@tanstack/react-query'; @@ -75,10 +73,7 @@ export function getInterviewScheduleQueryOptions({ `api/v1/interviews/${interviewId}/schedule`, undefined, tokens - ).then((res) => { - console.log('[useInterviewSchedule] raw response:', res); - return res.result; - }), + ).then((res) => res.result), staleTime: 1000 * 60, enabled: interviewId > 0, }); diff --git a/apps/web/src/store/query/useMyDocumentEvaluationsQuery.ts b/apps/web/src/store/query/useMyDocumentEvaluationsQuery.ts index c4ef0144..0a607d1f 100644 --- a/apps/web/src/store/query/useMyDocumentEvaluationsQuery.ts +++ b/apps/web/src/store/query/useMyDocumentEvaluationsQuery.ts @@ -1,13 +1,14 @@ import { useQuery } from '@tanstack/react-query'; import { GET } from '@web/api/fetch'; import { queryKeys } from '../constants/queryKeys'; +import type { Tokens } from '@web/api/types'; export interface MyEvaluationItem { - id: number; + id: number; name: string; email: string; positionName: string; - status: string; + status: string; } export interface MyDocumentEvaluationsResult { @@ -22,16 +23,26 @@ export interface MyDocumentEvaluationsResponse { success: boolean; } -export function useMyDocumentEvaluationsQuery(recruitmentId: number) { - return useQuery({ +export function getMyDocumentEvaluationsQueryOptions( + recruitmentId: number, + tokens?: Tokens +) { + return { queryKey: queryKeys.recruitments.myDocumentEvaluations(recruitmentId), queryFn: async () => { const res = await GET( - `api/v1/recruitments/${recruitmentId}/my/evaluations/documents` + `api/v1/recruitments/${recruitmentId}/my/evaluations/documents`, + undefined, + tokens ); - console.log("서류평가리스트", res.result) return res.result; }, enabled: !!recruitmentId, - }); + }; +} + +export function useMyDocumentEvaluationsQuery(recruitmentId: number) { + return useQuery( + getMyDocumentEvaluationsQueryOptions(recruitmentId) + ); } \ No newline at end of file diff --git a/apps/web/src/store/query/useMyInterviewForHomeQuery.ts b/apps/web/src/store/query/useMyInterviewForHomeQuery.ts new file mode 100644 index 00000000..a33387fe --- /dev/null +++ b/apps/web/src/store/query/useMyInterviewForHomeQuery.ts @@ -0,0 +1,59 @@ +import { useQuery } from '@tanstack/react-query'; +import { GET } from '@web/api/fetch'; +import { queryKeys } from '../constants'; +import type { Tokens } from '@web/api/types'; +import type { OrgInterviewInfo } from './useOrganizationInterviewsQuery'; +import type { InterviewSchedule } from './useInterviewScheduleQuery'; + +export function getOrgInterviewsForHomeQueryOptions( + organizationId: number, + tokens?: Tokens +) { + return { + queryKey: queryKeys.interview.orgList(), + queryFn: async () => { + const res = await GET( + `api/v1/interviews/organizations/${organizationId}`, + undefined, + tokens + ); + return res.result; + }, + enabled: !!organizationId, + staleTime: 1000 * 60, + }; +} + +export function getMyTimeSlotsForHomeQueryOptions( + interviewId: number | undefined, + tokens?: Tokens +) { + return { + queryKey: queryKeys.interview.myTimeSlots(interviewId ?? 0), + queryFn: async () => { + const res = await GET( + `api/v1/interviews/${interviewId}/my-time-slots`, + undefined, + tokens + ); + return res.result.map((sch) => ({ + ...sch, + date: sch.date.replace(/-/g, '.'), + })); + }, + enabled: !!interviewId, + staleTime: 1000 * 60, + }; +} + +export function useOrgInterviewsForHomeQuery(organizationId: number) { + return useQuery( + getOrgInterviewsForHomeQueryOptions(organizationId) + ); +} + +export function useMyTimeSlotsForHomeQuery(interviewId: number | undefined) { + return useQuery( + getMyTimeSlotsForHomeQueryOptions(interviewId) + ); +} diff --git a/apps/web/src/store/query/useOrganizationInterviewsQuery.ts b/apps/web/src/store/query/useOrganizationInterviewsQuery.ts index 84e8712c..e0618ed1 100644 --- a/apps/web/src/store/query/useOrganizationInterviewsQuery.ts +++ b/apps/web/src/store/query/useOrganizationInterviewsQuery.ts @@ -6,7 +6,7 @@ import { } from '@tanstack/react-query'; import { GET } from '@web/api/fetch'; import { queryKeys } from '../constants'; -import type { ApiResponse, Tokens } from '@web/api/types'; +import type { Tokens } from '@web/api/types'; // — 요청/응답 타입 — @@ -44,7 +44,6 @@ export function getOrgInterviewsOptions( undefined, tokens ); - console.log(res); return res.result; }, staleTime: 1000 * 60 * 1, @@ -63,7 +62,6 @@ export function useOrganizationInterviewsQuery( undefined, tokens ); - console.log(res); return res.result; }, staleTime: 1000 * 60 * 1, diff --git a/apps/web/src/store/query/usePendingEvaluatorsQuery.ts b/apps/web/src/store/query/usePendingEvaluatorsQuery.ts index 977a6dec..c575ac23 100644 --- a/apps/web/src/store/query/usePendingEvaluatorsQuery.ts +++ b/apps/web/src/store/query/usePendingEvaluatorsQuery.ts @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { GET } from '@web/api/fetch'; +import type { Tokens } from '@web/api/types'; export interface PendingEvaluator { userId: number; @@ -8,7 +9,7 @@ export interface PendingEvaluator { } export interface PendingEvaluatorsResult { - stage: string; + stage: string; deadline: string; daysToDeadline: number; hoursToDeadline: number; @@ -23,15 +24,26 @@ export interface PendingEvaluatorsResponse { success: boolean; } -export function usePendingEvaluatorsQuery(recruitmentId: number) { - return useQuery({ - queryKey: ['admin', 'recruitments', recruitmentId, 'pending-evaluators'], +export function getPendingEvaluatorsQueryOptions( + recruitmentId: number, + tokens?: Tokens +) { + return { + queryKey: ['admin', 'recruitments', recruitmentId, 'pending-evaluators'] as const, queryFn: async () => { const res = await GET( - `api/v1/admin/recruitments/${recruitmentId}/pending-evaluators` + `api/v1/admin/recruitments/${recruitmentId}/pending-evaluators`, + undefined, + tokens ); return res.result; }, - enabled: !!recruitmentId, - }); + enabled: !!recruitmentId, + }; +} + +export function usePendingEvaluatorsQuery(recruitmentId: number) { + return useQuery( + getPendingEvaluatorsQueryOptions(recruitmentId) + ); } \ No newline at end of file diff --git a/apps/web/src/store/query/useRecruitmentProgressQuery.ts b/apps/web/src/store/query/useRecruitmentProgressQuery.ts index 53bd757f..c594efef 100644 --- a/apps/web/src/store/query/useRecruitmentProgressQuery.ts +++ b/apps/web/src/store/query/useRecruitmentProgressQuery.ts @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { GET } from '@web/api/fetch'; +import type { Tokens } from '@web/api/types'; export type EvaluationStage = 'DOCUMENT' | 'INTERVIEW'; @@ -18,18 +19,31 @@ export interface RecruitmentProgressResponse { result: RecruitmentProgressItem[]; success: boolean; } -export function useRecruitmentProgressQuery( + +export function getRecruitmentProgressQueryOptions( recruitmentId: number, - stage: EvaluationStage = 'DOCUMENT' + stage: EvaluationStage = 'DOCUMENT', + tokens?: Tokens ) { - return useQuery({ - queryKey: ['admin', 'recruitments', recruitmentId, 'progress', stage], + return { + queryKey: ['admin', 'recruitments', recruitmentId, 'progress', stage] as const, queryFn: async () => { const res = await GET( - `api/v1/admin/recruitments/${recruitmentId}/progress?stage=${stage}` + `api/v1/admin/recruitments/${recruitmentId}/progress?stage=${stage}`, + undefined, + tokens ); return res.result; }, - enabled: !!recruitmentId, - }); + enabled: !!recruitmentId, + }; +} + +export function useRecruitmentProgressQuery( + recruitmentId: number, + stage: EvaluationStage = 'DOCUMENT' +) { + return useQuery( + getRecruitmentProgressQueryOptions(recruitmentId, stage) + ); } \ No newline at end of file From e0c561dccf928bfb3a450187a628544c6ead9907 Mon Sep 17 00:00:00 2001 From: cindy-chaewon Date: Tue, 28 Apr 2026 18:20:56 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=ED=99=88=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20API?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserHomeDashboardScreen: 면접 배정 실데이터 연동 (mock 제거) - UserInterviewReview: schedules prop 기반 날짜 네비게이션 구현 - UserDocReviewList: 서류평가 페이지 라우터 연결 - AdminHomeDashboardScreen: 상세보기 라우터 + 리마인드 mutation 연동 - PendingUsers: isReminding 로딩 상태 추가 - OverallProgress: @repo/utils 경로 수정 --- .../admin/OverallProgress/OverallProgress.tsx | 2 +- .../admin/PendingUsers/PendingUsers.tsx | 5 +- .../home/Admin/AdminHomeDashboardScreen.tsx | 21 ++- .../home/User/UserHomeDashboardScreen.tsx | 89 ++++++----- .../UserDocReviewList/UserDocReviewList.tsx | 6 +- .../UserInterviewReview.tsx | 140 ++++++++++-------- 6 files changed, 151 insertions(+), 112 deletions(-) diff --git a/apps/web/src/app/(main)/_components/admin/OverallProgress/OverallProgress.tsx b/apps/web/src/app/(main)/_components/admin/OverallProgress/OverallProgress.tsx index 9cbcb6d2..666166b6 100644 --- a/apps/web/src/app/(main)/_components/admin/OverallProgress/OverallProgress.tsx +++ b/apps/web/src/app/(main)/_components/admin/OverallProgress/OverallProgress.tsx @@ -9,7 +9,7 @@ import { IcHomeInterviewColored, } from '@repo/ui/icons/colored'; import { RecruitmentProgressItem } from '@web/store/query/useRecruitmentProgressQuery'; -import { getPositionTagColor } from 'node_modules/@repo/utils/src/util/tag'; +import { getPositionTagColor } from '@repo/utils/util/tag'; export interface OverallProgressProps { docData?: RecruitmentProgressItem[]; diff --git a/apps/web/src/app/(main)/_components/admin/PendingUsers/PendingUsers.tsx b/apps/web/src/app/(main)/_components/admin/PendingUsers/PendingUsers.tsx index 42c681b2..6e48c096 100644 --- a/apps/web/src/app/(main)/_components/admin/PendingUsers/PendingUsers.tsx +++ b/apps/web/src/app/(main)/_components/admin/PendingUsers/PendingUsers.tsx @@ -12,9 +12,10 @@ import { PendingEvaluatorsResult } from '@web/store/query/usePendingEvaluatorsQu export interface PendingUsersProps { data: PendingEvaluatorsResult; onRemind: () => void; + isReminding?: boolean; } -export const PendingUsers = ({ data, onRemind }: PendingUsersProps) => { +export const PendingUsers = ({ data, onRemind, isReminding }: PendingUsersProps) => { const deadline = new Date(data.deadline); return ( @@ -35,6 +36,8 @@ export const PendingUsers = ({ data, onRemind }: PendingUsersProps) => { /> } onClick={onRemind} + isLoading={isReminding} + disabled={isReminding} > 리마인드 알림 diff --git a/apps/web/src/app/(main)/_components/home/Admin/AdminHomeDashboardScreen.tsx b/apps/web/src/app/(main)/_components/home/Admin/AdminHomeDashboardScreen.tsx index 416ccc39..49bb03e6 100644 --- a/apps/web/src/app/(main)/_components/home/Admin/AdminHomeDashboardScreen.tsx +++ b/apps/web/src/app/(main)/_components/home/Admin/AdminHomeDashboardScreen.tsx @@ -1,6 +1,7 @@ 'use client'; import React from 'react'; +import { useRouter } from 'next/navigation'; import { Flex } from '@repo/ui/Flex'; import { AdminHomeHeader } from '@web/app/(main)/_components/admin/AdminHomeHeader/AdminHomeHeader'; import { AnnounceCard } from '@web/app/(main)/_components/admin/AnnouncedCard/AnnouncedCard'; @@ -10,23 +11,20 @@ import { PendingUsers } from '@web/app/(main)/_components/admin/PendingUsers/Pen import { useCurrentRecruitmentSummaryQuery } from '@web/store/query/useCurrentRecruitmentSummaryQuery'; import { useRecruitmentProgressQuery } from '@web/store/query/useRecruitmentProgressQuery'; import { usePendingEvaluatorsQuery } from '@web/store/query/usePendingEvaluatorsQuery'; +import { useRemindEvaluatorsMutation } from '@web/store/mutation/useRemindEvaluatorsMutation'; export const AdminHomeDashboardScreen = () => { + const router = useRouter(); const { data: summaryData } = useCurrentRecruitmentSummaryQuery(); const currentRecruitment = summaryData?.[0]; const recruitmentId = currentRecruitment?.recruitmentId; - const { data: docProgress } = useRecruitmentProgressQuery( - recruitmentId!, - 'DOCUMENT' - ); - const { data: interviewProgress } = useRecruitmentProgressQuery( - recruitmentId!, - 'INTERVIEW' - ); - + const { data: docProgress } = useRecruitmentProgressQuery(recruitmentId!, 'DOCUMENT'); + const { data: interviewProgress } = useRecruitmentProgressQuery(recruitmentId!, 'INTERVIEW'); const { data: pendingData } = usePendingEvaluatorsQuery(recruitmentId!); + const { mutate: remind, isPending: isReminding } = useRemindEvaluatorsMutation(); + if (!currentRecruitment) { return Loading...; } @@ -46,7 +44,7 @@ export const AdminHomeDashboardScreen = () => { console.log('지원 현황 이동')} + onViewDetail={() => router.push('/apply-management')} /> @@ -60,7 +58,8 @@ export const AdminHomeDashboardScreen = () => { {pendingData && ( console.log('리마인드 알림')} + onRemind={() => remind(recruitmentId!)} + isReminding={isReminding} /> )} diff --git a/apps/web/src/app/(main)/_components/home/User/UserHomeDashboardScreen.tsx b/apps/web/src/app/(main)/_components/home/User/UserHomeDashboardScreen.tsx index 8b35a70f..8220da60 100644 --- a/apps/web/src/app/(main)/_components/home/User/UserHomeDashboardScreen.tsx +++ b/apps/web/src/app/(main)/_components/home/User/UserHomeDashboardScreen.tsx @@ -13,18 +13,22 @@ import { AnnouncementEvent, } from '@web/app/(main)/_components/user/UserAnnouncementProgress/UserAnnouncementProgress'; import { - InterviewSlot, - ReviewerRole, + InterviewScheduleItem, UserInterviewReview, } from '@web/app/(main)/_components/user/UserInterviewReview/UserInterviewReview'; import { useCurrentRecruitmentSummaryByOrgQuery } from '@web/store/query/useCurrentRecruitmentSummaryByOrgQuery'; import { useMyDocumentEvaluationsQuery } from '@web/store/query/useMyDocumentEvaluationsQuery'; +import { + useOrgInterviewsForHomeQuery, + useMyTimeSlotsForHomeQuery, +} from '@web/store/query/useMyInterviewForHomeQuery'; import { getClientSideTokens } from '@web/utils/getClientSideTokens'; export const UserHomeDashboardScreen = () => { - const { organizationId } = getClientSideTokens(); + const { organizationId, userId } = getClientSideTokens(); const orgId = Number(organizationId); + const uid = Number(userId); const { data: summaryData, isLoading: isSummaryLoading } = useCurrentRecruitmentSummaryByOrgQuery(orgId); @@ -32,8 +36,15 @@ export const UserHomeDashboardScreen = () => { const currentRecruitment = summaryData?.[0]; const recruitmentId = currentRecruitment?.recruitmentId; - const { data: myEvalData, isLoading: isEvalLoading } = - useMyDocumentEvaluationsQuery(recruitmentId!); + const { data: myEvalData } = useMyDocumentEvaluationsQuery(recruitmentId!); + + const { data: orgInterviews } = useOrgInterviewsForHomeQuery(orgId); + const currentInterview = orgInterviews?.find( + (iv) => iv.recruitmentId === recruitmentId + ); + const { data: mySchedules } = useMyTimeSlotsForHomeQuery( + currentInterview?.interviewId + ); const announcementProps = useMemo(() => { if (!currentRecruitment) return null; @@ -45,10 +56,7 @@ export const UserHomeDashboardScreen = () => { }) ); - return { - title: currentRecruitment.title, - events: sortedEvents, - }; + return { title: currentRecruitment.title, events: sortedEvents }; }, [currentRecruitment]); const docReviewProps = useMemo(() => { @@ -69,31 +77,39 @@ export const UserHomeDashboardScreen = () => { return { itemsBefore, itemsAfter }; }, [myEvalData]); - const initialDate = new Date(2025, 4, 12); - const slotsByRole: Record = { - interviewer: [ - { - start: '13:00', - end: '13:30', - applicants: ['김현호', '윤지원'], - interviewers: [ - { - id: 'i1', - avatarUrl: 'https://randomuser.me/api/portraits/women/68.jpg', - }, - { - id: 'i2', - avatarUrl: 'https://randomuser.me/api/portraits/women/32.jpg', - }, - { - id: 'i3', - avatarUrl: 'https://randomuser.me/api/portraits/women/44.jpg', - }, - ], - }, - ], - guide: [], - }; + const schedules = useMemo(() => { + if (!mySchedules?.length) return []; + + return mySchedules.map((schedule) => { + const parts = schedule.date.split('.').map(Number); + const date = new Date(parts[0]!, parts[1]! - 1, parts[2]!); + + const toSlot = ( + ts: (typeof schedule.timeSlots)[number], + roleUsers: { userId: number; name: string; role: string; profileUrl: string }[] + ) => ({ + start: ts.startTime, + end: ts.endTime, + applicants: ts.applicants.map((a) => a.name), + interviewers: roleUsers.map((u) => ({ + id: String(u.userId), + avatarUrl: u.profileUrl, + })), + }); + + return { + date, + slotsByRole: { + interviewer: schedule.timeSlots + .filter((ts) => ts.interviewers.some((iv) => iv.userId === uid)) + .map((ts) => toSlot(ts, ts.interviewers)), + guide: schedule.timeSlots + .filter((ts) => ts.assistants.some((a) => a.userId === uid)) + .map((ts) => toSlot(ts, ts.assistants)), + }, + }; + }); + }, [mySchedules, uid]); if (isSummaryLoading) { return ( @@ -142,10 +158,7 @@ export const UserHomeDashboardScreen = () => { itemsAfter={docReviewProps.itemsAfter} /> - + diff --git a/apps/web/src/app/(main)/_components/user/UserDocReviewList/UserDocReviewList.tsx b/apps/web/src/app/(main)/_components/user/UserDocReviewList/UserDocReviewList.tsx index f34a11cb..4e51abf6 100644 --- a/apps/web/src/app/(main)/_components/user/UserDocReviewList/UserDocReviewList.tsx +++ b/apps/web/src/app/(main)/_components/user/UserDocReviewList/UserDocReviewList.tsx @@ -1,12 +1,13 @@ 'use client'; import React, { useState, useMemo } from 'react'; +import { useRouter } from 'next/navigation'; import * as styles from './UserDocReviewList.css'; import { OptionsList, TextToggleSwitch } from '@repo/ui/TextToggleSwitch'; import { Text } from '@repo/ui/Text'; import { Tag } from '@repo/ui/Tag'; import { Button } from '@repo/ui/Button'; import { IcArrowRight } from '@repo/ui/icons/mono'; -import { getPositionTagColor } from 'node_modules/@repo/utils/src/util/tag'; +import { getPositionTagColor } from '@repo/utils/util/tag'; export interface ReviewItem { id: string; @@ -25,6 +26,7 @@ export const UserDocReviewList: React.FC = ({ itemsBefore, itemsAfter, }) => { + const router = useRouter(); const [tab, setTab] = useState('before'); const items = useMemo( @@ -47,7 +49,7 @@ export const UserDocReviewList: React.FC = ({ variant="sub" size="32" width="15.2rem" - onClick={() => console.log('서류 평가 페이지 이동')} + onClick={() => router.push('/docs-evaluation')} rightIcon={} > 서류 평가 바로가기 diff --git a/apps/web/src/app/(main)/_components/user/UserInterviewReview/UserInterviewReview.tsx b/apps/web/src/app/(main)/_components/user/UserInterviewReview/UserInterviewReview.tsx index 408a82a8..5e8f1d63 100644 --- a/apps/web/src/app/(main)/_components/user/UserInterviewReview/UserInterviewReview.tsx +++ b/apps/web/src/app/(main)/_components/user/UserInterviewReview/UserInterviewReview.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; import * as styles from './UserInterviewReview.css'; import { Text } from '@repo/ui/Text'; import { Button } from '@repo/ui/Button'; @@ -19,28 +20,33 @@ export interface InterviewSlot { interviewers: { id: string; avatarUrl: string }[]; } -export interface UserInterviewReviewProps { - initialDate: Date; +export interface InterviewScheduleItem { + date: Date; slotsByRole: Record; } +export interface UserInterviewReviewProps { + schedules: InterviewScheduleItem[]; +} + export const UserInterviewReview: React.FC = ({ - initialDate, - slotsByRole, + schedules, }) => { + const router = useRouter(); const [role, setRole] = useState('interviewer'); - const [currentDate] = useState(initialDate); + const [dateIndex, setDateIndex] = useState(0); const toggleOptions: OptionsList = [ { value: 'interviewer', label: '면접관' }, { value: 'guide', label: '안내자' }, ]; - const dateLabel = `${currentDate.getFullYear()}년 ${ - currentDate.getMonth() + 1 - }월 ${currentDate.getDate()}일`; + const current = schedules[dateIndex]; + const slots = current?.slotsByRole[role] ?? []; - const slots = slotsByRole[role]; + const dateLabel = current + ? `${current.date.getFullYear()}년 ${current.date.getMonth() + 1}월 ${current.date.getDate()}일` + : '면접 일정 없음'; return (
@@ -52,7 +58,7 @@ export const UserInterviewReview: React.FC = ({ variant="sub" size="32" width="15.2rem" - onClick={() => {}} + onClick={() => router.push('/interview-evaluation')} rightIcon={} > 면접 평가 바로가기 @@ -74,66 +80,82 @@ export const UserInterviewReview: React.FC = ({ {dateLabel} - - -
    - {slots.map((slot) => ( -
  • - - - - {slot.start} ~ {slot.end} - + {slots.length === 0 ? ( + + + 배정된 면접이 없습니다. + + + ) : ( +
      + {slots.map((slot) => ( +
    • + + + + {slot.start} ~ {slot.end} + -
      - - - 지원자 - - {slot.applicants.map((name) => ( - - {name} - - ))} - - - - - {role === 'interviewer' ? '면접관' : '안내자'} - - - {slot.interviewers.map((iv) => ( - +
      + + + 지원자 + + {slot.applicants.map((name) => ( + + {name} + ))} - -
      -
      -
    • - ))} -
    + + + + {role === 'interviewer' ? '면접관' : '안내자'} + + + {slot.interviewers.map((iv) => ( + + ))} + + + +
    +
  • + ))} +
+ )}
); From 054d4c15a4cd69e0eb9c0d123ad90156fd64ff68 Mon Sep 17 00:00:00 2001 From: cindy-chaewon Date: Tue, 28 Apr 2026 18:21:17 +0900 Subject: [PATCH 3/4] =?UTF-8?q?chore:=20tsconfig=20baseUrl=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=ED=83=80=EC=9E=85=20=EC=84=A0=EC=96=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apps/web: baseUrl 제거, paths를 상대 경로로 수정, @repo/utils 경로 추가 - packages/theme: baseUrl 제거, rootDir 명시 - CSS 및 패키지 side-effect import 타입 선언 파일 추가 - packages/ui/.storybook 전용 tsconfig 추가 - vscode typescript.tsdk 설정 추가 --- .vscode/settings.json | 3 ++- apps/web/src/types/modules.d.ts | 3 +++ apps/web/tsconfig.json | 11 +++++------ packages/theme/tsconfig.json | 2 +- packages/ui/.storybook/modules.d.ts | 1 + packages/ui/.storybook/tsconfig.json | 11 +++++++++++ 6 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/types/modules.d.ts create mode 100644 packages/ui/.storybook/modules.d.ts create mode 100644 packages/ui/.storybook/tsconfig.json diff --git a/.vscode/settings.json b/.vscode/settings.json index 44a73ec3..4191defe 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,6 @@ { "mode": "auto" } - ] + ], + "typescript.tsdk": "apps/web/node_modules/typescript/lib" } diff --git a/apps/web/src/types/modules.d.ts b/apps/web/src/types/modules.d.ts new file mode 100644 index 00000000..c7ffca3d --- /dev/null +++ b/apps/web/src/types/modules.d.ts @@ -0,0 +1,3 @@ +declare module '*.css'; +declare module '@repo/ui/styles'; +declare module '@repo/theme/themes'; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 1670ad66..e4c1d10c 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -3,15 +3,14 @@ "compilerOptions": { "paths": { "@web/*": ["./src/*"], - "@repo/ui/*": ["packages/ui/src/*"], - "@repo/theme/*": ["packages/theme/src/*"], - "./types": ["node_modules/@tanstack/react-query/build/modern/types.d.ts"], + "@repo/ui/*": ["../../packages/ui/src/*"], + "@repo/theme/*": ["../../packages/theme/src/*"], + "@repo/utils/*": ["../../packages/utils/src/*"], + "./types": ["./node_modules/@tanstack/react-query/build/modern/types.d.ts"], "@tanstack/query-core/build/modern/*": [ - "node_modules/@tanstack/query-core/build/modern/*" + "./node_modules/@tanstack/query-core/build/modern/*" ] }, - "baseUrl": "./", - "allowJs": true, "skipLibCheck": true, "skipDefaultLibCheck": true diff --git a/packages/theme/tsconfig.json b/packages/theme/tsconfig.json index ae8864d8..ac3acff0 100644 --- a/packages/theme/tsconfig.json +++ b/packages/theme/tsconfig.json @@ -6,11 +6,11 @@ "moduleResolution": "bundler", "target": "ESNext", "allowImportingTsExtensions": false, - "baseUrl": ".", "paths": { "@/*": ["./src/*"] }, "outDir": "./dist", + "rootDir": "./src", "declaration": true }, "include": ["src/**/*", "**/*.css.ts"], diff --git a/packages/ui/.storybook/modules.d.ts b/packages/ui/.storybook/modules.d.ts new file mode 100644 index 00000000..35306c6f --- /dev/null +++ b/packages/ui/.storybook/modules.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/packages/ui/.storybook/tsconfig.json b/packages/ui/.storybook/tsconfig.json new file mode 100644 index 00000000..651faa6b --- /dev/null +++ b/packages/ui/.storybook/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true + }, + "include": [ + "../src/**/*", + "./**/*" + ] +} From ddb51d6c8691106174cf34289909a2c13d40350f Mon Sep 17 00:00:00 2001 From: cindy-chaewon Date: Tue, 28 Apr 2026 18:21:40 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=EA=B0=9C=EB=B0=9C=20=EA=B0=80?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md: 프로젝트 개요, 구조, 컨벤션 정리 - docs/api-ssr-pattern.md: API 연동 + SSR prefetch 패턴 - docs/modal-pattern.md: Parallel/Intercepting Route 모달 패턴 - docs/ui-design-system.md: UI 컴포넌트 + 토큰 + Toast 사용 가이드 - docs/form-pattern.md: react-hook-form + TextField 폼 패턴 --- CLAUDE.md | 173 ++++++++++++++++ docs/api-ssr-pattern.md | 315 +++++++++++++++++++++++++++++ docs/form-pattern.md | 301 +++++++++++++++++++++++++++ docs/modal-pattern.md | 239 ++++++++++++++++++++++ docs/ui-design-system.md | 424 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 1452 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/api-ssr-pattern.md create mode 100644 docs/form-pattern.md create mode 100644 docs/modal-pattern.md create mode 100644 docs/ui-design-system.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..9bc1820a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,173 @@ +# WITHUS 프론트엔드 — Claude Code 컨텍스트 + +## 프로젝트 개요 + +**위더스(WITHUS)** — 리크루팅 프로세스 자동화 통합 솔루션 +공고 → 지원서 취합 → 서류/면접 평가 → 합불 발표까지 모든 과정을 한 곳에서 관리하는 B2B SaaS 서비스. + +--- + +## 모노레포 구조 + +```text +WITHUS-FE/ +├── apps/ +│ └── web/ # Next.js 16 앱 (App Router) — 메인 작업 공간 +├── packages/ +│ ├── ui/ # 공통 UI 컴포넌트 (@repo/ui) +│ ├── theme/ # 디자인 토큰 (@repo/theme) +│ ├── utils/ # 공통 유틸 함수 (@repo/utils) +│ ├── typescript-config/ # 공통 TS 설정 +│ └── eslint-config/ # 공통 ESLint 설정 +└── docs/ + ├── api-ssr-pattern.md # API 연동 + SSR 패턴 가이드 (필독) + ├── modal-pattern.md # 모달 구현 패턴 가이드 (필독) + ├── ui-design-system.md # UI 마크업 + 디자인 시스템 + Toast 가이드 (필독) + └── form-pattern.md # react-hook-form + TextField 폼 패턴 가이드 (필독) +``` + +패키지 매니저: **pnpm** / 모노레포 빌드: **Turborepo** + +--- + +## 핵심 기술 스택 (apps/web) + +| 역할 | 기술 | +| --- | --- | +| 프레임워크 | Next.js 16 (App Router) | +| 언어 | TypeScript (strict + noUncheckedIndexedAccess) | +| 스타일 | vanilla-extract (`.css.ts` 파일) | +| 서버 상태 | TanStack Query v5 | +| HTTP 클라이언트 | ky | +| 폼 | react-hook-form | + +--- + +## apps/web 주요 폴더 구조 + +```text +src/ +├── app/ +│ ├── (main)/ # 인증 필요 페이지 (레이아웃에 헤더/사이드바 있음) +│ │ ├── dashboard/ +│ │ │ ├── admin/page.tsx # Admin 홈 (SSR) +│ │ │ └── user/page.tsx # User 홈 (SSR) +│ │ ├── _components/ # (main) 라우트 전용 컴포넌트 +│ │ └── page.tsx # role 기반 redirect gate +│ └── login/ # 인증 없이 접근 가능 +├── api/ +│ ├── api.ts # ky 인스턴스 (prefixUrl: NEXT_PUBLIC_API_BASE_URL) +│ ├── fetch.ts # GET/POST/PUT/DELETE/PATCH 래퍼 (토큰 자동 주입 + 401 처리) +│ └── serverSideTokens.ts # 서버 컴포넌트에서 쿠키 토큰 추출 +├── store/ +│ ├── constants/ +│ │ └── queryKeys.ts # 모든 TanStack Query 키 중앙 관리 +│ ├── query/ +│ │ └── useXxxQuery.ts # 도메인별 query 파일 +│ └── mutation/ +│ └── useXxxMutation.ts # 도메인별 mutation 파일 +├── middleware.ts # 인증 미들웨어 + 라우트 보호 +└── routes.ts # 앱 라우트 상수 (ROUTES.*) +``` + +--- + +## 핵심 패턴 문서 + +새 기능 작업 전 반드시 읽을 것: + +- **`docs/api-ssr-pattern.md`** — API 연동 + SSR prefetch 패턴 +- **`docs/modal-pattern.md`** — 모달 구현 패턴 (Parallel Route + Intercepting Route) +- **`docs/form-pattern.md`** — react-hook-form + Controller + TextField 연동 패턴 +- **`docs/ui-design-system.md`** — UI 컴포넌트 + 디자인 토큰 + Toast 사용 가이드 + +## API 연동 패턴 + +**반드시 `docs/api-ssr-pattern.md` 를 먼저 읽고 작업할 것.** + +요약: + +1. `store/query/useXxxQuery.ts`에 `getXxxQueryOptions(param, tokens?)` + `useXxxQuery(param)` 작성 +2. `store/constants/queryKeys.ts`에 queryKey 등록 +3. `page.tsx`에서 `queryClient.fetchQuery(getXxxQueryOptions(param, tokens))` 로 SSR prefetch +4. `HydrationBoundary`로 감싼 뒤 클라이언트 컴포넌트에서 `useXxxQuery()` 사용 + +--- + +## 인증 흐름 + +- 로그인 시 `accessToken`, `refreshToken`, `userId`, `role`, `organizationId`, `name` 등을 쿠키에 저장 +- `role`: `ADMIN` 또는 `USER` +- 서버 컴포넌트: `getServerSideTokens()` → `Tokens` 객체 반환 +- 클라이언트 컴포넌트: `getClientSideTokens()` → 쿠키에서 직접 읽음 +- 401 응답 시 `fetch.ts`가 자동으로 토큰 재발급 시도 → 실패 시 로그인 페이지로 리다이렉트 + +--- + +## 개발 명령어 + +```bash +# 루트에서 실행 +pnpm dev # 개발 서버 시작 +pnpm build # 전체 빌드 +pnpm --filter web dev # web 앱만 개발 서버 +pnpm --filter web build # web 앱만 빌드 +``` + +--- + +## 커밋 컨벤션 + +```text +[타입]: [메시지] ([이슈번호]) +예: feat: 홈 대시보드 API 연동 (#42) +``` + +| 타입 | 설명 | +| --- | --- | +| `feat` | 새로운 기능 | +| `fix` | 버그 수정 | +| `refactor` | 리팩토링 | +| `chore` | 기타 잡다한 수정 | +| `docs` | 문서 수정 | +| `style` | 코드 스타일/포맷 | +| `build` | 빌드 관련 | + +브랜치: `[타입]/[이슈번호-작업내용]` (예: `feat/#42-home-api`) +기본 브랜치: `develop` + +--- + +## 코드 컨벤션 + +- 들여쓰기: 2 spaces +- 변수/함수/폴더: `camelCase` +- 컴포넌트/클래스: `PascalCase` +- 상수: `BIG_SNAKE_CASE` +- 주석은 WHY가 불명확할 때만, 한 줄로 +- `console.log` 커밋 금지 + +--- + +## TypeScript 주의사항 + +- `noUncheckedIndexedAccess: true` — 배열 인덱스 접근 시 `T | undefined` 반환 + - `arr[0]` 사용 후 반드시 `if (!arr[0]) return` 가드 처리 +- strict mode 전체 활성화 +- path alias: `@web/*` → `apps/web/src/*`, `@repo/ui/*`, `@repo/theme/*`, `@repo/utils/*` + +--- + +## 스타일 작성 + +컴포넌트와 같은 폴더에 `ComponentName.css.ts` 파일로 vanilla-extract 스타일 작성. + +```typescript +// Button.css.ts +import { style } from '@vanilla-extract/css'; +import { vars } from '@repo/theme/vars.css'; + +export const buttonStyle = style({ + backgroundColor: vars.color.primary, +}); +``` diff --git a/docs/api-ssr-pattern.md b/docs/api-ssr-pattern.md new file mode 100644 index 00000000..a4f85296 --- /dev/null +++ b/docs/api-ssr-pattern.md @@ -0,0 +1,315 @@ +# API 연동 + SSR 패턴 가이드 + +이 프로젝트의 표준 API 연동 방식이다. 새 페이지나 기능에 API를 붙일 때 이 패턴을 따른다. + +--- + +## 전체 구조 요약 + +``` +page.tsx (async 서버 컴포넌트) + └─ getServerSideTokens() // 쿠키에서 토큰 추출 + └─ getQueryClient() // 요청별 새 QueryClient + └─ queryClient.fetchQuery(...) // 서버에서 API 호출 → 캐시에 저장 + └─ HydrationBoundary // 캐시 상태를 클라이언트로 직렬화 전달 + └─ XxxScreen (Client Component) + └─ useXxxQuery() // 캐시에서 즉시 읽음 (네트워크 요청 없음) +``` + +- 서버에서 prefetch한 데이터는 `HydrationBoundary`를 통해 클라이언트로 전달된다. +- 클라이언트 hook은 이미 캐시에 있으므로 추가 네트워크 요청이 발생하지 않는다. + +--- + +## 1. Query 파일 작성 (`store/query/useXxxQuery.ts`) + +모든 query 파일은 **options 함수**와 **client hook** 두 가지를 export한다. + +```typescript +import { useQuery } from '@tanstack/react-query'; +import { GET } from '@web/api/fetch'; +import { queryKeys } from '@web/store/constants/queryKeys'; +import type { Tokens } from '@web/api/types'; + +// --- 타입 정의 --- +export interface MyData { + id: number; + name: string; +} + +// --- Options 함수 (서버 + 클라이언트 공용) --- +// tokens 파라미터: 서버에서 호출 시 주입, 클라이언트에서는 생략 +export function getMyDataQueryOptions(id: number, tokens?: Tokens) { + return { + queryKey: queryKeys.xxx.detail(id), + queryFn: async () => { + const res = await GET(`api/v1/something/${id}`, undefined, tokens); + return res.result; + }, + enabled: !!id, + staleTime: 1000 * 60, // 1분 + }; +} + +// --- 클라이언트 Hook --- +export function useMyDataQuery(id: number) { + return useQuery(getMyDataQueryOptions(id)); +} +``` + +### 규칙 +- `tokens?` 파라미터는 항상 마지막에 optional로 둔다. +- `queryFn`은 `res.result`만 반환한다 (ApiResponse wrapping 제거). +- `enabled: !!param`으로 파라미터가 falsy일 때 자동 비활성화. +- `console.log` 절대 남기지 않는다. + +--- + +## 2. queryKeys 등록 (`store/constants/queryKeys.ts`) + +새 엔드포인트 추가 시 `queryKeys`에도 키를 등록한다. + +```typescript +export const queryKeys = { + // 기존 키들 ... + myFeature: { + detail: (id: number) => ['myFeature', 'detail', id] as const, + list: () => ['myFeature', 'list'] as const, + }, +}; +``` + +--- + +## 3. Page.tsx — SSR prefetch 패턴 + +### 기본 패턴 (단순 prefetch) + +```typescript +// app/(main)/some-page/page.tsx +import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; +import { getQueryClient } from '@web/store/query/getQueryClient'; +import { getServerSideTokens } from '@web/api/serverSideTokens'; +import { getMyDataQueryOptions } from '@web/store/query/useMyDataQuery'; +import { SomePageScreen } from './_components/SomePageScreen'; +import { SomeEmptyScreen } from './_components/SomeEmptyScreen'; + +export default async function SomePage() { + const tokens = await getServerSideTokens(); + const queryClient = getQueryClient(); + + await queryClient.fetchQuery(getMyDataQueryOptions(someId, tokens)) + .catch(() => {}); // 실패해도 빈 화면 대신 클라이언트에서 재시도 + + return ( + + + + ); +} +``` + +### 체이닝 패턴 (순서가 있는 의존적 prefetch) + +앞 API의 결과로 다음 API 파라미터를 결정해야 할 때. + +```typescript +export default async function SomePage() { + const tokens = await getServerSideTokens(); + const queryClient = getQueryClient(); + + // 1단계: 첫 번째 API (이후 단계에 필요한 ID 획득) + let firstData; + try { + firstData = await queryClient.fetchQuery(getFirstQueryOptions(tokens)); + } catch { + return ; // 핵심 데이터 실패 → 빈 화면 + } + + const id = firstData[0]?.someId; + if (!id) return ; + + // 2단계: 독립적인 API는 병렬로 처리 + await Promise.all([ + queryClient.fetchQuery(getSecondQueryOptions(id, tokens)), + queryClient.fetchQuery(getThirdQueryOptions(id, tokens)), + // 3단계가 2단계 결과에 의존하면 .then()으로 체이닝 + queryClient.fetchQuery(getFourthQueryOptions(tokens)).then((result) => { + const dependentId = result.find((r) => r.someId === id)?.otherId; + if (dependentId) { + return queryClient.fetchQuery(getFifthQueryOptions(dependentId, tokens)); + } + }), + ]).catch(() => {}); // 보조 데이터 실패는 무시 (클라이언트에서 재시도) + + return ( + + + + ); +} +``` + +### 주의: queryClient.fetchQuery() vs queryFn() 직접 호출 + +```typescript +// 올바름: queryClient.fetchQuery() — 캐시에 저장됨 → HydrationBoundary로 전달됨 +await queryClient.fetchQuery(getMyQueryOptions(id, tokens)); + +// 잘못됨: queryFn 직접 호출 — 캐시에 저장 안 됨 → 클라이언트에서 다시 요청함 +await getMyQueryOptions(id, tokens).queryFn!(); +``` + +--- + +## 4. Client Component Hook 사용 + +```typescript +'use client'; + +import { useMyDataQuery } from '@web/store/query/useMyDataQuery'; + +export const SomePageScreen = () => { + // SSR에서 prefetch된 경우 → 캐시에서 즉시 반환 (isLoading=false) + // SSR에서 prefetch 안 된 경우 → 클라이언트에서 자동으로 fetch + const { data, isLoading } = useMyDataQuery(id); + + if (isLoading) return ; + return
{data?.name}
; +}; +``` + +### useSuspenseQuery vs useQuery + +| | `useSuspenseQuery` | `useQuery` | +|---|---|---| +| 로딩 중 | Suspense fallback으로 올라감 | `isLoading: true` 반환 | +| 에러 시 | ErrorBoundary로 올라감 | `isError: true` 반환 | +| 사용 위치 | Suspense 경계 안쪽 | 어디서든 | +| **홈 대시보드** | 사용하지 않음 | **사용** | + +홈 대시보드처럼 SSR prefetch + 클라이언트 조건부 렌더링이 필요한 곳은 `useQuery`를 쓴다. + +--- + +## 5. 실제 예시: Admin 홈 대시보드 + +### `store/query/useRecruitmentProgressQuery.ts` +```typescript +export function getRecruitmentProgressQueryOptions( + recruitmentId: number, + stage: 'DOCUMENT' | 'INTERVIEW', + tokens?: Tokens +) { + return { + queryKey: ['recruitment', 'progress', recruitmentId, stage], + queryFn: async () => { + const res = await GET( + `api/v1/recruitments/${recruitmentId}/progress?stage=${stage}`, + undefined, + tokens + ); + return res.result; + }, + enabled: !!recruitmentId, + }; +} + +export function useRecruitmentProgressQuery( + recruitmentId: number, + stage: 'DOCUMENT' | 'INTERVIEW' +) { + return useQuery(getRecruitmentProgressQueryOptions(recruitmentId, stage)); +} +``` + +### `app/(main)/dashboard/admin/page.tsx` +```typescript +export default async function AdminDashboardPage() { + const tokens = await getServerSideTokens(); + const queryClient = getQueryClient(); + + // 1. 현재 공고 요약 (recruitmentId 획득) + const summaryOptions = getCurrentRecruitmentSummaryQueryOptions(tokens); + let summaries: Awaited> = []; + try { + summaries = await queryClient.fetchQuery(summaryOptions); + } catch { + return ; + } + + const firstSummary = summaries[0]; + if (!firstSummary) return ; + + // 2. 나머지 데이터 병렬 prefetch + await Promise.all([ + queryClient.fetchQuery(getRecruitmentProgressQueryOptions(firstSummary.recruitmentId, 'DOCUMENT', tokens)), + queryClient.fetchQuery(getRecruitmentProgressQueryOptions(firstSummary.recruitmentId, 'INTERVIEW', tokens)), + queryClient.fetchQuery(getPendingEvaluatorsQueryOptions(firstSummary.recruitmentId, tokens)), + ]).catch(() => {}); + + return ( + + + + ); +} +``` + +--- + +## 6. Mutation 파일 작성 + +```typescript +// store/mutation/useXxxMutation.ts +import { useMutation } from '@tanstack/react-query'; +import { POST } from '@web/api/fetch'; + +export function useXxxMutation() { + return useMutation({ + mutationFn: (param: number) => + POST(`api/v1/something/${param}`), + }); +} +``` + +```typescript +// 사용 (Client Component) +const { mutate: doSomething, isPending } = useXxxMutation(); + +``` + +--- + +## 7. 폴더 구조 + +``` +store/ + constants/ + queryKeys.ts # 모든 queryKey 중앙 관리 + query/ + getQueryClient.ts # 서버용 QueryClient 팩토리 + useXxxQuery.ts # 각 도메인별 query 파일 + mutation/ + useXxxMutation.ts # 각 도메인별 mutation 파일 + +app/(main)/ + dashboard/ + admin/page.tsx # Admin SSR page + user/page.tsx # User SSR page + _components/ + home/ + Admin/AdminHomeDashboardScreen.tsx # 'use client' + User/UserHomeDashboardScreen.tsx # 'use client' +``` + +--- + +## 8. 체크리스트 (새 API 연동 시) + +- [ ] `queryKeys`에 새 키 등록 +- [ ] `useXxxQuery.ts` 파일 생성: options 함수 + client hook +- [ ] `page.tsx`에서 `queryClient.fetchQuery(getXxxQueryOptions(..., tokens))` 호출 +- [ ] `HydrationBoundary`로 감싸기 +- [ ] Client Component에서 `useXxxQuery()` hook 사용 +- [ ] `console.log` 없는지 확인 diff --git a/docs/form-pattern.md b/docs/form-pattern.md new file mode 100644 index 00000000..d867aaad --- /dev/null +++ b/docs/form-pattern.md @@ -0,0 +1,301 @@ +# Form 패턴 가이드 — react-hook-form + TextField + +이 프로젝트의 폼은 **react-hook-form** + `Controller` + `@repo/ui/TextField` 조합으로 구현한다. + +--- + +## 기본 구조 + +```typescript +'use client'; + +import { useForm, Controller } from 'react-hook-form'; +import { TextField } from '@repo/ui/TextField'; +import { Button } from '@repo/ui/Button'; +import { Flex } from '@repo/ui/Flex'; + +interface MyFormValues { + email: string; + password: string; +} + +export function MyForm() { + const { + control, + handleSubmit, + formState: { errors, isValid }, + } = useForm({ + mode: 'onTouched', // blur 시점에 검증 + defaultValues: { email: '', password: '' }, + }); + + const onSubmit = (data: MyFormValues) => { + // mutation 호출 등 + }; + + return ( +
+ + ( + + )} + /> + + + +
+ ); +} +``` + +--- + +## useForm 옵션 + +```typescript +const { control, handleSubmit, formState, setError, watch, setValue, reset } = + useForm({ + mode: 'onTouched', // 언제 검증할지: 'onTouched' | 'onChange' | 'onBlur' | 'onSubmit' + defaultValues: { // 초기값 (항상 지정할 것) + email: '', + name: '', + }, + }); +``` + +| mode | 검증 시점 | +| --- | --- | +| `onTouched` | 첫 blur 이후부터 변경마다 (권장) | +| `onChange` | 입력할 때마다 | +| `onBlur` | blur할 때마다 | +| `onSubmit` | submit할 때만 | + +--- + +## Controller + TextField 연동 + +`TextField`는 react-hook-form과 직접 통합되지 않으므로 반드시 `Controller`로 감싼다. + +```typescript + ( + + )} +/> +``` + +--- + +## rules — 유효성 검사 + +```typescript +rules={{ + required: '필수 입력 항목입니다', + + minLength: { + value: 8, + message: '8자 이상 입력해주세요', + }, + + maxLength: { + value: 50, + message: '50자 이하로 입력해주세요', + }, + + pattern: { + value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, + message: '올바른 이메일 형식을 입력해주세요', + }, + + validate: (value) => { + if (value.trim() === '') return '공백만 입력할 수 없습니다'; + return true; // true 반환 시 통과 + }, +}} +``` + +--- + +## 서버 에러 처리 — setError + +mutation 에러 응답의 에러 코드로 특정 필드에 에러를 수동 설정. + +```typescript +const { mutate, isPending } = useSomeMutation(); + +const onSubmit = (data: FormValues) => { + mutate(data, { + onError: async (error) => { + const errData = await error.response.json() as { code: string }; + + if (errData.code === 'USER404') { + setError('email', { + type: 'manual', + message: '가입된 이메일이 존재하지 않습니다.', + }); + } else if (errData.code === 'USER401') { + setError('password', { + type: 'manual', + message: '비밀번호가 일치하지 않습니다.', + }); + } + }, + }); +}; +``` + +--- + +## formState — 폼 상태 + +```typescript +const { formState: { errors, isValid, isSubmitting, isDirty } } = useForm(); + +// 제출 버튼 제어 + +``` + +| 상태 | 설명 | +| --- | --- | +| `errors` | 각 필드의 에러 객체 (`errors.email?.message`) | +| `isValid` | 모든 필드가 유효하면 true | +| `isSubmitting` | handleSubmit 실행 중 true | +| `isDirty` | defaultValues와 다른 값이 있으면 true | + +--- + +## 전체 예시 — 로그인 폼 + +```typescript +'use client'; + +import { useForm, Controller } from 'react-hook-form'; +import { TextField } from '@repo/ui/TextField'; +import { Button } from '@repo/ui/Button'; +import { Flex } from '@repo/ui/Flex'; +import { useLoginMutation } from '@web/store/mutation/useLoginMutation'; + +interface LoginRequest { + email: string; + password: string; +} + +export default function LoginForm() { + const { + control, + handleSubmit, + setError, + formState: { errors, isValid }, + } = useForm({ + mode: 'onTouched', + defaultValues: { email: '', password: '' }, + }); + + const { mutate: login, isPending } = useLoginMutation(); + + const onSubmit = (data: LoginRequest) => { + login(data, { + onError: async (error) => { + const { code } = await error.response.json() as { code: string }; + if (code === 'USER404') { + setError('email', { type: 'manual', message: '가입된 이메일이 없습니다.' }); + } else if (code === 'USER401') { + setError('password', { type: 'manual', message: '비밀번호가 틀렸습니다.' }); + } + }, + }); + }; + + return ( +
+ + ( + + )} + /> + + ( + + )} + /> + + + +
+ ); +} +``` + +--- + +## 체크리스트 + +- [ ] `useForm`에 `mode: 'onTouched'`와 `defaultValues` 반드시 지정 +- [ ] `TextField`는 반드시 `Controller`로 감싸기 +- [ ] `render`의 `field`를 `inputProps`에 스프레드 (`...field`) +- [ ] `errorMessage={errors.fieldName?.message}` 연결 +- [ ] 제출 버튼 `disabled={!isValid}` + `isLoading={isPending}` 설정 +- [ ] 서버 에러는 `setError()`로 해당 필드에 표시 diff --git a/docs/modal-pattern.md b/docs/modal-pattern.md new file mode 100644 index 00000000..ac767576 --- /dev/null +++ b/docs/modal-pattern.md @@ -0,0 +1,239 @@ +# 모달 구현 패턴 가이드 + +이 프로젝트의 모달은 **Next.js Parallel Route + Intercepting Route** 조합으로 구현한다. +모달을 열면 URL이 바뀌고, 브라우저 뒤로가기로 닫힌다. + +--- + +## 전체 구조 + +``` +app/(main)/ +├── layout.tsx # modal prop 받아서 렌더링 +├── @modal/ +│ ├── default.tsx # 모달 없을 때 null 반환 (필수) +│ └── (.)some-modal/ +│ └── page.tsx # 실제 모달 컴포넌트 +└── some-page/ + └── page.tsx # 모달을 여는 버튼이 있는 페이지 +``` + +- `@modal`: Parallel Route — 레이아웃이 `modal` prop으로 받음 +- `(.)`: Intercepting Route — 같은 레벨의 경로를 가로채서 모달로 표시 +- `default.tsx`: 모달이 없을 때 `null` 반환 (없으면 에러) + +--- + +## 1. 폴더/파일 세팅 + +### `@modal/default.tsx` — 항상 필요 +```typescript +export default function ModalDefault() { + return null; +} +``` + +### `layout.tsx` — modal prop 추가 +```typescript +export default function SomeLayout({ + children, + modal, +}: { + children: React.ReactNode; + modal: React.ReactNode; +}) { + return ( + <> + {children} + {modal} + + ); +} +``` + +--- + +## 2. 모달 페이지 작성 (`@modal/(.)modal-name/page.tsx`) + +```typescript +'use client'; + +import { Modal } from '@repo/ui/Modal'; +import { useRouter } from 'next/navigation'; + +export default function SomeModal() { + const router = useRouter(); + const close = () => router.back(); // 뒤로가기 = 모달 닫기 + + return ( + + + + + {/* 모달 내용 */} + + + + + + + ); +} +``` + +--- + +## 3. 모달 열기 — 버튼에서 router.push + +```typescript +'use client'; +import { useRouter } from 'next/navigation'; + +export function SomePage() { + const router = useRouter(); + + return ( + + ); +} +``` + +URL이 `/some-modal`로 바뀌면서 `@modal/(.)some-modal/page.tsx`가 intercept해서 모달로 렌더링됨. + +--- + +## 4. Modal 컴포넌트 API (`@repo/ui/Modal`) + +### 조합 구조 +```typescript + // 배경 오버레이 + // 모달 컨테이너 (기본 42rem) + // 헤더 (text or children) + // 내용 영역 + {/* 자유롭게 */} + + // 하단 버튼 영역 + // 또는 + + + +``` + +### Modal.Overlay +| prop | 타입 | 설명 | +|------|------|------| +| `open` | `boolean` | 표시 여부 (모달 내에선 항상 `true`) | +| `onClose` | `() => void` | 오버레이 클릭 시 닫기 콜백 | + +### Modal.Layout +| prop | 타입 | 기본값 | +|------|------|------| +| `width` | `string` | `'42rem'` | + +### Modal.Header +| prop | 타입 | 설명 | +|------|------|------| +| `text` | `string` | 헤더 텍스트 | +| `children` | `ReactNode` | 커스텀 헤더 (text 대신 사용) | + +### Modal.Footer +| prop | 타입 | 기본값 | +|------|------|------| +| `hasTopBorder` | `boolean` | `false` | + +### Modal.DoubleCTA — 취소/확인 버튼 쌍 +```typescript + +``` + +### Modal.CTA — 단일 버튼 +```typescript + +``` + +### Modal.ModalTextContent — 아이콘 + 텍스트 확인/경고 모달 +```typescript + + } // IcModalCheck | IcModalWarning | IcLogout + title="정말 삭제하시겠습니까?" + description="삭제 후 복구할 수 없습니다." + /> + +``` + +--- + +## 5. useModal — 간단한 확인 모달 + +URL 변경 없이 인라인으로 확인/경고 모달을 띄울 때 사용. + +```typescript +import { useModal } from '@repo/ui/hooks'; + +const { confirm } = useModal(); + +// 기본 사용 +confirm({ + type: 'warning', // 'info' | 'warning' | 'logout' + title: '정말 삭제하시겠어요?', + description: '삭제 후 복구할 수 없습니다.', + cancelText: '취소', + confirmText: '삭제', + onConfirm: () => handleDelete(), +}); + +// 확인 버튼만 (취소 없음) +confirm({ + type: 'info', + title: '저장 완료', + description: '성공적으로 저장되었습니다.', + hideCancel: true, + onConfirm: () => router.push('/'), +}); +``` + +--- + +## 6. 어떤 방식을 선택할까? + +| 상황 | 방식 | +|------|------| +| 폼 입력, 검색, 복잡한 UI | Parallel + Intercepting Route | +| 확인/경고 다이얼로그 (간단) | `useModal()` | +| URL에 모달 상태가 반영돼야 함 | Parallel + Intercepting Route | +| URL 변경 없이 빠르게 | `useModal()` | + +--- + +## 7. 실제 구현 예시 — 파일 위치 참고 + +| 모달 | 경로 | +|------|------| +| 소속 추가 | `app/(main)/@modal/(.)affiliation-add/page.tsx` | +| 멤버 초대 | `app/(main)/organization/@modal/(.)invite/page.tsx` | +| 파트 배정 | `app/(main)/organization/@modal/(.)part/page.tsx` | +| 담당자 배정 | `app/(main)/apply-management/[tab]/@modal/(.)assign-manager/page.tsx` | + +--- + +## 8. 체크리스트 (새 모달 추가 시) + +- [ ] 해당 레벨 레이아웃에 `modal` prop 추가 + `{modal}` 렌더링 +- [ ] `@modal/default.tsx` 생성 (null 반환) +- [ ] `@modal/(.)modal-name/page.tsx` 생성 +- [ ] 모달 page 최상단에 `'use client'` 추가 +- [ ] `close = () => router.back()` 로 닫기 구현 +- [ ] `Modal.Overlay`의 `onClose`에 `close` 연결 diff --git a/docs/ui-design-system.md b/docs/ui-design-system.md new file mode 100644 index 00000000..3d97f460 --- /dev/null +++ b/docs/ui-design-system.md @@ -0,0 +1,424 @@ +# UI 마크업 가이드 — 디자인 시스템 활용 + +마크업 작업 시 raw HTML/CSS 대신 `@repo/ui` 컴포넌트와 `@repo/theme` 토큰을 우선 사용한다. + +--- + +## 핵심 원칙 + +1. **컴포넌트 먼저**: `div + style` 대신 `Flex`, `Text` 등 기존 컴포넌트 사용 +2. **토큰 기반 스타일**: hardcode 금지 — 색상은 `vars.colors.*`, 폰트는 `vars.typography.*` +3. **rem 단위**: 치수는 항상 `rem` (1rem = 10px 기준, 예: `1.6rem` = 16px) +4. **커스텀 CSS는 `.css.ts`**: 인라인 style에 레이아웃 외 스타일 작성 금지 + +--- + +## 컴포넌트 Import 경로 + +```typescript +import { Button } from '@repo/ui/Button'; +import { Text } from '@repo/ui/Text'; +import { Flex } from '@repo/ui/Flex'; +import { TextField } from '@repo/ui/TextField'; +import { CheckBox } from '@repo/ui/CheckBox'; +import { Radio } from '@repo/ui/Radio'; +import { Option } from '@repo/ui/Option'; +import { Divider } from '@repo/ui/Divider'; +import { Spinner } from '@repo/ui/Spinner'; +import { Modal } from '@repo/ui/Modal'; +import { Tag } from '@repo/ui/Tag'; +import { Chip } from '@repo/ui/Chips/Chip'; +import TabBar from '@repo/ui/TabBar'; +import Dropdown from '@repo/ui/DropDown'; +import { Stepper } from '@repo/ui/Stepper'; +import { useModal } from '@repo/ui/hooks'; +import { useToast } from '@repo/ui/hooks'; +``` + +--- + +## 1. Flex — 레이아웃 + +`div` + flexbox CSS 대신 `Flex` 컴포넌트를 쓴다. + +```typescript + + ... + +``` + +--- + +## 2. Text — 타이포그래피 + +텍스트는 `

`, `` 대신 `Text`를 쓴다. `variant`로 사이즈+굵기를 동시에 지정. + +```typescript + + 라벨 텍스트 + +``` + +### variant 목록 (사이즈_타입_굵기) + +| variant | 크기 | 굵기 | +|---------|------|------| +| `xxl_title_bold` | 28px | 700 | +| `xxl_title_semibold` | 28px | 600 | +| `xl_title_bold` | 24px | 700 | +| `xl_title_semibold` | 24px | 600 | +| `lg_subtitle_bold` | 20px | 700 | +| `lg_subtitle_semibold` | 20px | 600 | +| `lg_subtitle_medium` | 20px | 500 | +| `md1_text_bold` | 18px | 700 | +| `md1_text_semibold` | 18px | 600 | +| `md1_text_medium` | 18px | 500 | +| `md1_text_regular` | 18px | 400 | +| `md2_text_bold` | 16px | 700 | +| `md2_text_semibold` | 16px | 600 | +| `md2_text_medium` | 16px | 500 | +| `md2_text_regular` | 16px | 400 | +| `sm_caption_semibold` | 14px | 600 | +| `sm_caption_medium` | 14px | 500 | +| `sm_caption_regular` | 14px | 400 | +| `xs_caption_semibold` | 12px | 600 | +| `xs_caption_medium` | 12px | 500 | +| `xs_caption_regular` | 12px | 400 | + +### color 목록 (주요) + +| color 값 | 설명 | +|----------|------| +| `grayscale90` | 가장 진한 회색 (거의 검정) | +| `grayscale70` | 진한 회색 | +| `grayscale60` | 중간 회색 | +| `grayscale50` | 보통 회색 | +| `grayscale30` | 연한 회색 | +| `primary5` ~ `primary90` | 브랜드 블루 계열 | +| `mint5` ~ `mint90` | 민트 계열 | +| `violet5` ~ `violet90` | 바이올렛 계열 | +| `pink5` ~ `pink90` | 핑크 계열 | +| `success` | 초록 (#22D363) | +| `error` | 빨강 (#FF3232) | +| `white` / `black` | 흰색/검정 | +| `inherit` | 부모 색상 상속 | + +--- + +## 3. Button + +```typescript + +``` + +| variant | 용도 | +|---------|------| +| `main` | 주요 CTA (파란색) | +| `sub` | 보조 액션 (옅은 파란색) | +| `basic` | 일반 액션 (회색) | +| `stroke` | 테두리만 | +| `white` | 흰색 배경, 테두리 있음 | + +--- + +## 4. Input + +### TextField — 라벨 + 에러 메시지 포함 + +```typescript + +``` + +### BaseInput — 라벨 없는 단순 입력 + +```typescript +} + showClear={!!value} + onClear={() => setValue('')} + size="auth" // 'search' | 'club' | 'auth'(기본) + inputProps={{ placeholder: '검색', value, onChange }} +/> +``` + +--- + +## 5. CheckBox / Radio / Option + +단독 체크박스/라디오: +```typescript + setChecked(!checked)} size="2.4rem" /> + setSelected(true)} size="1.8rem" /> +``` + +라벨과 함께 (권장): +```typescript +// checkbox with label +

+

제목

+
+ ); +} +``` + +### vars 구조 + +```typescript +vars.colors.primary50 // 색상 +vars.colors.grayscale70 +vars.colors.error +vars.borderRadius[8] // '0.8rem' +vars.borderRadius[16] +vars.typography.fontSize[16] // '1.6rem' +vars.typography.fontSize[20] +vars.typography.fontWeight.bold // '700' +vars.typography.fontWeight.semibold // '600' +vars.typography.fontWeight.medium // '500' +vars.typography.fontWeight.regular // '400' +``` + +--- + +## 11. 자주 쓰는 패턴 + +### 섹션 카드 +```typescript + + 섹션 제목 + {/* 내용 */} + +``` + +### 라벨 + 값 +```typescript + + 이름 + 이채원 + +``` + +### 버튼 그룹 +```typescript + + + + +``` + +### 빈 상태 (Empty State) +```typescript + + 데이터가 없습니다. + +``` + +--- + +## 12. 금지 사항 + +```typescript +// ❌ hardcode 색상 +
+

+ +// ✅ 토큰 사용 + + +// ❌ raw div + flex +

+ +// ✅ Flex 컴포넌트 + + +// ❌ px 단위 +gap: '16px', padding: '24px' + +// ✅ rem 단위 (10px = 1rem) +gap: '1.6rem', padding: '2.4rem' + +// ❌ 인라인 style에 복잡한 스타일 +
+ +// ✅ .css.ts 파일로 분리 +
+```