-
+
{studyDetail?.detailInfo.title}
-
+
+
{studyDetail?.detailInfo.summary}
diff --git a/src/features/study/group/api/create-inquiry.ts b/src/features/study/group/api/create-inquiry.ts
new file mode 100644
index 00000000..0419c458
--- /dev/null
+++ b/src/features/study/group/api/create-inquiry.ts
@@ -0,0 +1,34 @@
+import { axiosInstance } from '@/api/client/axios';
+
+export interface CreateInquiryRequest {
+ title: string;
+ content: string;
+ category?: 'CURRICULUM' | 'DIFFICULTY' | 'HW_AMOUNT' | 'SCHEDULE' | 'ETC';
+}
+
+export interface CreateInquiryResponse {
+ statusCode: number;
+ timestamp: string;
+ content: {
+ generatedQuestionId: number;
+ };
+ message: string;
+}
+
+// 문의 작성
+export const createInquiry = async (
+ groupStudyId: number,
+ request: CreateInquiryRequest,
+) => {
+ try {
+ const { data } = await axiosInstance.post
(
+ `/group-studies/${groupStudyId}/questions`,
+ request,
+ );
+
+ return data;
+ } catch (error) {
+ console.error('문의 작성 실패:', error);
+ throw error;
+ }
+};
diff --git a/src/features/study/group/model/inquiry.schema.ts b/src/features/study/group/model/inquiry.schema.ts
new file mode 100644
index 00000000..e34d8f05
--- /dev/null
+++ b/src/features/study/group/model/inquiry.schema.ts
@@ -0,0 +1,30 @@
+import { z } from 'zod';
+
+export enum InquiryCategory {
+ CURRICULUM = 'CURRICULUM',
+ DIFFICULTY = 'DIFFICULTY',
+ HW_AMOUNT = 'HW_AMOUNT',
+ ETC = 'ETC',
+}
+
+export const INQUIRY_TITLE_MAX_LENGTH=50;
+export const INQUIRY_CONTENT_MAX_LENGTH = 500;
+
+
+export const inquirySchema = z.object({
+ title: z.string().min(1, '제목을 입력해주세요.').min(1).max(
+ INQUIRY_TITLE_MAX_LENGTH,`제목은 ${INQUIRY_TITLE_MAX_LENGTH}자 이하로 입력해주세요.`
+ ),
+ content: z
+ .string()
+ .min(1, '내용을 입력해주세요.')
+ .max(
+ INQUIRY_CONTENT_MAX_LENGTH,
+ `내용은 ${INQUIRY_CONTENT_MAX_LENGTH}자 이하로 입력해주세요.`,
+ ),
+ category: z.nativeEnum(InquiryCategory, {
+ message: '카테고리를 선택해주세요.',
+ }),
+});
+
+export type InquiryFormValues = z.infer;
diff --git a/src/hooks/queries/inquiry-api.ts b/src/hooks/queries/inquiry-api.ts
new file mode 100644
index 00000000..2b781bbc
--- /dev/null
+++ b/src/hooks/queries/inquiry-api.ts
@@ -0,0 +1,21 @@
+import { useMutation } from '@tanstack/react-query';
+import {
+ createInquiry,
+ CreateInquiryRequest,
+} from '@/features/study/group/api/create-inquiry';
+
+export const useCreateInquiry = () => {
+ return useMutation({
+ mutationFn: async ({
+ groupStudyId,
+ request,
+ }: {
+ groupStudyId: number;
+ request: CreateInquiryRequest;
+ }) => {
+ const data = await createInquiry(groupStudyId, request);
+
+ return data.content;
+ },
+ });
+};
From 755ff53d99d46af546b476139e8b7d9dda876bcc Mon Sep 17 00:00:00 2001
From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com>
Date: Tue, 17 Feb 2026 17:14:28 +0900
Subject: [PATCH 09/16] =?UTF-8?q?feat=20:=20=ED=8F=BC=20=EC=9E=90=EB=8F=99?=
=?UTF-8?q?=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=A6=AC=EB=8D=94=20=ED=8C=90=EB=B3=84?=
=?UTF-8?q?=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- useScrollToNextField 훅 추가 (data-scroll-field 속성 기반 다음 필드 자동 스크롤)
- FieldControl에 onAfterChange, onAfterBlurFilled 콜백 지원 추가
- FormField에 scrollable prop 및 data-scroll-field 마킹 지원 추가
- 스터디 개설 폼(step1, step2), 문의/과제 제출/평가 모달에 자동 스크롤 적용
- StudyInfoSection isLeader를 props로 직접 전달하여 렌더 타이밍 버그 수정
- useLeaderStore/useUserStore 의존성 제거 (homework-detail-content 포함)
Co-Authored-By: Claude Sonnet 4.5
---
.../contents/homework-detail-content.tsx | 751 +++++++++---------
.../modals/create-evaluation-modal.tsx | 311 ++++----
src/components/modals/inquiry-modal.tsx | 7 +
.../modals/submit-homework-modal.tsx | 301 +++----
.../pages/group-study-detail-page.tsx | 462 +++++------
.../section/group-study-info-section.tsx | 6 +-
src/components/ui/form/field-control.tsx | 31 +-
src/components/ui/form/form-field.tsx | 13 +
.../study/group/ui/step/step1-group.tsx | 527 ++++++------
.../study/group/ui/step/step2-group.tsx | 226 +++---
src/hooks/use-scroll-to-next-field.ts | 22 +
11 files changed, 1384 insertions(+), 1273 deletions(-)
create mode 100644 src/hooks/use-scroll-to-next-field.ts
diff --git a/src/components/contents/homework-detail-content.tsx b/src/components/contents/homework-detail-content.tsx
index c4eb6a88..d0732d1f 100644
--- a/src/components/contents/homework-detail-content.tsx
+++ b/src/components/contents/homework-detail-content.tsx
@@ -1,405 +1,402 @@
-'use client';
-
-import { useRouter, useSearchParams } from 'next/navigation';
-import { useState } from 'react';
-
-import type { PeerReviewResponse } from '@/api/openapi/models';
-import Avatar from '@/components/ui/avatar';
-import Button from '@/components/ui/button';
-import MoreMenu from '@/components/ui/dropdown/more-menu';
-import ConfirmDeleteModal from '@/features/study/group/ui/confirm-delete-modal';
-import { useGetHomework } from '@/hooks/queries/group-study-homework-api';
-import { useGetMission } from '@/hooks/queries/mission-api';
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import { useState } from "react";
+
+import type { PeerReviewResponse } from "@/api/openapi/models";
+import Avatar from "@/components/ui/avatar";
+import Button from "@/components/ui/button";
+import MoreMenu from "@/components/ui/dropdown/more-menu";
+import ConfirmDeleteModal from "@/features/study/group/ui/confirm-delete-modal";
+import { useGetHomework } from "@/hooks/queries/group-study-homework-api";
+import { useGetMission } from "@/hooks/queries/mission-api";
import {
- useCreatePeerReview,
- useDeletePeerReview,
- useUpdatePeerReview,
-} from '@/hooks/queries/peer-review-api';
-import { useIsLeader } from '@/stores/useLeaderStore';
-import { useUserStore } from '@/stores/useUserStore';
-import DeleteHomeworkModal from '../modals/delete-homework-modal';
-import EditHomeworkModal from '../modals/edit-homework-modal';
+ useCreatePeerReview,
+ useDeletePeerReview,
+ useUpdatePeerReview,
+} from "@/hooks/queries/peer-review-api";
+
+import { useUserStore } from "@/stores/useUserStore";
+import DeleteHomeworkModal from "../modals/delete-homework-modal";
+import EditHomeworkModal from "../modals/edit-homework-modal";
interface HomeworkDetailContentProps {
- missionId: number;
- homeworkId: number;
+ missionId: number;
+ homeworkId: number;
}
export default function HomeworkDetailContent({
- homeworkId,
- missionId,
+ homeworkId,
+ missionId,
}: HomeworkDetailContentProps) {
- const router = useRouter();
- const searchParams = useSearchParams();
- const currentUserId = useUserStore((state) => state.memberId);
- const { data: homework, isLoading: isHomeworkLoading } =
- useGetHomework(homeworkId);
- const {
- data: mission,
- isLoading: isMissionLoading,
- refetch: refetchMission,
- } = useGetMission(missionId);
-
- const handleDeleteSuccess = async () => {
- const params = new URLSearchParams(searchParams.toString());
- params.delete('homeworkId');
- router.push(`?${params.toString()}`);
- await refetchMission();
- };
-
- if (isHomeworkLoading || !homework || isMissionLoading || !mission) {
- return null;
- }
-
- const peerReviews = homework.peerReviews ?? [];
- const isEvaluated = !!homework.evaluation;
-
- // 미션 제출 가능 기간이 지나지 않았는지 확인
- const isMissionActive = mission.status !== 'ENDED';
-
- // 수정/삭제 가능 조건: 본인 과제이면서 평가 전이면서 미션 제출 가능 기간이 지나지 않은 상태
- const isMyHomework = homework.submitterId === currentUserId;
- const canEditOrDelete = isMyHomework && !isEvaluated && isMissionActive;
-
- const profileImageUrl =
- homework.submitterProfileImage?.resizedImages?.[0]?.resizedImageUrl ??
- '/profile-default.svg';
-
- const formatDate = (dateString?: string) => {
- if (!dateString) return '';
- const date = new Date(dateString);
-
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} 제출`;
- };
-
- return (
-
- {/* 제출자 정보 및 과제 내용 */}
-
- {/* 제출자 정보 */}
-
-
-
-
-
- {homework.submitterNickname}
-
-
- {formatDate(homework.submissionTime)}
-
-
-
-
- {/* 수정/삭제 버튼 - 본인 과제이면서 평가 전이면서 미션 제출 가능 기간이 지나지 않은 경우에만 노출 */}
- {canEditOrDelete && (
-
-
-
-
- )}
-
-
- {/* 과제 내용 */}
-
- {homework.homeworkContent?.textContent}
-
-
- {/* 제출한 과제 링크 */}
- {homework.homeworkContent?.optionalContent?.link && (
-
- )}
-
-
- {/* 피어 리뷰 */}
-
-
- );
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const currentUserId = useUserStore((state) => state.memberId);
+ const { data: homework, isLoading: isHomeworkLoading } =
+ useGetHomework(homeworkId);
+ const {
+ data: mission,
+ isLoading: isMissionLoading,
+ refetch: refetchMission,
+ } = useGetMission(missionId);
+
+ const handleDeleteSuccess = async () => {
+ const params = new URLSearchParams(searchParams.toString());
+ params.delete("homeworkId");
+ router.push(`?${params.toString()}`);
+ await refetchMission();
+ };
+
+ if (isHomeworkLoading || !homework || isMissionLoading || !mission) {
+ return null;
+ }
+
+ const peerReviews = homework.peerReviews ?? [];
+ const isEvaluated = !!homework.evaluation;
+
+ // 미션 제출 가능 기간이 지나지 않았는지 확인
+ const isMissionActive = mission.status !== "ENDED";
+
+ // 수정/삭제 가능 조건: 본인 과제이면서 평가 전이면서 미션 제출 가능 기간이 지나지 않은 상태
+ const isMyHomework = homework.submitterId === currentUserId;
+ const canEditOrDelete = isMyHomework && !isEvaluated && isMissionActive;
+
+ const profileImageUrl =
+ homework.submitterProfileImage?.resizedImages?.[0]?.resizedImageUrl ??
+ "/profile-default.svg";
+
+ const formatDate = (dateString?: string) => {
+ if (!dateString) return "";
+ const date = new Date(dateString);
+
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} 제출`;
+ };
+
+ return (
+
+ {/* 제출자 정보 및 과제 내용 */}
+
+ {/* 제출자 정보 */}
+
+
+
+
+
+ {homework.submitterNickname}
+
+
+ {formatDate(homework.submissionTime)}
+
+
+
+
+ {/* 수정/삭제 버튼 - 본인 과제이면서 평가 전이면서 미션 제출 가능 기간이 지나지 않은 경우에만 노출 */}
+ {canEditOrDelete && (
+
+
+
+
+ )}
+
+
+ {/* 과제 내용 */}
+
+ {homework.homeworkContent?.textContent}
+
+
+ {/* 제출한 과제 링크 */}
+ {homework.homeworkContent?.optionalContent?.link && (
+
+ )}
+
+
+ {/* 피어 리뷰 */}
+
+
+ );
}
interface PeerReviewSectionProps {
- homeworkId: number;
- peerReviews: PeerReviewResponse[];
- isMyHomework: boolean;
+ homeworkId: number;
+ peerReviews: PeerReviewResponse[];
+ isMyHomework: boolean;
}
function PeerReviewSection({
- homeworkId,
- peerReviews,
- isMyHomework,
+ homeworkId,
+ peerReviews,
+ isMyHomework,
}: PeerReviewSectionProps) {
- const currentUserId = useUserStore((state) => state.memberId);
- const isMissionCreator = useIsLeader(currentUserId);
-
- // 자기 과제가 아니고, 미션 생성자(리더)가 아닌 경우에만 리뷰 작성 가능
- const canWriteReview = !isMyHomework && !isMissionCreator;
- const [reviewText, setReviewText] = useState('');
- const { mutate: createPeerReview, isPending } = useCreatePeerReview();
-
- const handleSubmitReview = () => {
- if (!reviewText.trim()) return;
-
- createPeerReview(
- {
- homeworkId,
- request: { comment: reviewText },
- },
- {
- onSuccess: () => {
- setReviewText('');
- },
- },
- );
- };
-
- return (
-
-
- 피어 리뷰
-
- {peerReviews.length}건
-
-
-
-
- {/* 피어 리뷰 목록 */}
- {peerReviews.length > 0 && (
-
- {peerReviews.map((review) => (
-
- ))}
-
- )}
-
- {/* 리뷰 입력 - 자기 과제가 아닌 경우에만 표시 */}
- {canWriteReview && (
-
- )}
-
-
- );
+ // 자기 과제가 아닌 경우에만 리뷰 작성 가능 (리더도 허용)
+ const canWriteReview = !isMyHomework;
+ const [reviewText, setReviewText] = useState("");
+ const { mutate: createPeerReview, isPending } = useCreatePeerReview();
+
+ const handleSubmitReview = () => {
+ if (!reviewText.trim()) return;
+
+ createPeerReview(
+ {
+ homeworkId,
+ request: { comment: reviewText },
+ },
+ {
+ onSuccess: () => {
+ setReviewText("");
+ },
+ },
+ );
+ };
+
+ return (
+
+
+ 피어 리뷰
+
+ {peerReviews.length}건
+
+
+
+
+ {/* 피어 리뷰 목록 */}
+ {peerReviews.length > 0 && (
+
+ {peerReviews.map((review) => (
+
+ ))}
+
+ )}
+
+ {/* 리뷰 입력 - 자기 과제가 아닌 경우에만 표시 */}
+ {canWriteReview && (
+
+ )}
+
+
+ );
}
interface PeerReviewItemProps {
- review: PeerReviewResponse;
- homeworkId: number;
+ review: PeerReviewResponse;
+ homeworkId: number;
}
function PeerReviewItem({ review, homeworkId }: PeerReviewItemProps) {
- const currentUserId = useUserStore((state) => state.memberId);
- const [isEditing, setIsEditing] = useState(false);
- const [editValue, setEditValue] = useState(review.comment ?? '');
- const [showDeleteModal, setShowDeleteModal] = useState(false);
-
- const { mutate: updatePeerReview, isPending: isUpdating } =
- useUpdatePeerReview();
- const { mutate: deletePeerReview } = useDeletePeerReview();
-
- const isMyReview = review.reviewerId === currentUserId;
-
- const profileImageUrl =
- review.reviewerProfileImage?.resizedImages?.[0]?.resizedImageUrl ??
- '/profile-default.svg';
-
- const formatDateTime = (dateString?: string) => {
- if (!dateString) return '';
- const date = new Date(dateString);
- const year = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, '0');
- const day = String(date.getDate()).padStart(2, '0');
- const hours = String(date.getHours()).padStart(2, '0');
- const minutes = String(date.getMinutes()).padStart(2, '0');
-
- return `${year}.${month}.${day} ${hours}:${minutes}`;
- };
-
- const handleUpdate = () => {
- if (!editValue.trim() || !review.peerReviewId) return;
-
- updatePeerReview(
- {
- peerReviewId: review.peerReviewId,
- request: { comment: editValue },
- },
- {
- onSuccess: () => {
- setIsEditing(false);
- },
- },
- );
- };
-
- const handleDelete = () => {
- if (!review.peerReviewId) return;
-
- deletePeerReview(review.peerReviewId, {
- onSuccess: () => {
- setShowDeleteModal(false);
- },
- });
- };
-
- const getMenuOptions = () => {
- if (!isMyReview) return [];
-
- return [
- {
- label: '수정하기',
- value: 'edit',
- onMenuClick: () => {
- setEditValue(review.comment ?? '');
- setIsEditing(true);
- },
- },
- {
- label: '삭제하기',
- value: 'delete',
- onMenuClick: () => {
- setShowDeleteModal(true);
- },
- },
- ];
- };
-
- return (
-
-
setShowDeleteModal(false)}
- title="피어 리뷰를 삭제하시겠습니까?"
- content={
- <>
- 삭제 시 모든 데이터가 영구적으로 제거됩니다.
-
이 동작은 되돌릴 수 없습니다.
- >
- }
- confirmText="삭제"
- onConfirm={handleDelete}
- />
-
-
-
-
-
- {review.reviewerNickname}
-
-
- {formatDateTime(review.createdAt)}
- {review.updated && ' (수정됨)'}
-
-
-
- {isMyReview &&
}
-
-
- {isEditing ? (
-
- ) : (
-
- {review.comment}
-
- )}
-
- );
+ const currentUserId = useUserStore((state) => state.memberId);
+ const [isEditing, setIsEditing] = useState(false);
+ const [editValue, setEditValue] = useState(review.comment ?? "");
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
+
+ const { mutate: updatePeerReview, isPending: isUpdating } =
+ useUpdatePeerReview();
+ const { mutate: deletePeerReview } = useDeletePeerReview();
+
+ const isMyReview = review.reviewerId === currentUserId;
+
+ const profileImageUrl =
+ review.reviewerProfileImage?.resizedImages?.[0]?.resizedImageUrl ??
+ "/profile-default.svg";
+
+ const formatDateTime = (dateString?: string) => {
+ if (!dateString) return "";
+ const date = new Date(dateString);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ const hours = String(date.getHours()).padStart(2, "0");
+ const minutes = String(date.getMinutes()).padStart(2, "0");
+
+ return `${year}.${month}.${day} ${hours}:${minutes}`;
+ };
+
+ const handleUpdate = () => {
+ if (!editValue.trim() || !review.peerReviewId) return;
+
+ updatePeerReview(
+ {
+ peerReviewId: review.peerReviewId,
+ request: { comment: editValue },
+ },
+ {
+ onSuccess: () => {
+ setIsEditing(false);
+ },
+ },
+ );
+ };
+
+ const handleDelete = () => {
+ if (!review.peerReviewId) return;
+
+ deletePeerReview(review.peerReviewId, {
+ onSuccess: () => {
+ setShowDeleteModal(false);
+ },
+ });
+ };
+
+ const getMenuOptions = () => {
+ if (!isMyReview) return [];
+
+ return [
+ {
+ label: "수정하기",
+ value: "edit",
+ onMenuClick: () => {
+ setEditValue(review.comment ?? "");
+ setIsEditing(true);
+ },
+ },
+ {
+ label: "삭제하기",
+ value: "delete",
+ onMenuClick: () => {
+ setShowDeleteModal(true);
+ },
+ },
+ ];
+ };
+
+ return (
+
+
setShowDeleteModal(false)}
+ title="피어 리뷰를 삭제하시겠습니까?"
+ content={
+ <>
+ 삭제 시 모든 데이터가 영구적으로 제거됩니다.
+
이 동작은 되돌릴 수 없습니다.
+ >
+ }
+ confirmText="삭제"
+ onConfirm={handleDelete}
+ />
+
+
+
+
+
+ {review.reviewerNickname}
+
+
+ {formatDateTime(review.createdAt)}
+ {review.updated && " (수정됨)"}
+
+
+
+ {isMyReview &&
}
+
+
+ {isEditing ? (
+
+ ) : (
+
+ {review.comment}
+
+ )}
+
+ );
}
interface PeerReviewInputProps {
- value: string;
- onChange: (value: string) => void;
- onSubmit: () => void;
- isLoading?: boolean;
+ value: string;
+ onChange: (value: string) => void;
+ onSubmit: () => void;
+ isLoading?: boolean;
}
function PeerReviewInput({
- value,
- onChange,
- onSubmit,
- isLoading,
+ value,
+ onChange,
+ onSubmit,
+ isLoading,
}: PeerReviewInputProps) {
- return (
-
- );
+ return (
+
+ );
}
diff --git a/src/components/modals/create-evaluation-modal.tsx b/src/components/modals/create-evaluation-modal.tsx
index 1645baa8..ab87a8b3 100644
--- a/src/components/modals/create-evaluation-modal.tsx
+++ b/src/components/modals/create-evaluation-modal.tsx
@@ -1,173 +1,178 @@
-import { zodResolver } from '@hookform/resolvers/zod';
-import { useState } from 'react';
-import { FormProvider, useForm } from 'react-hook-form';
-import { z } from 'zod';
-import Button from '@/components/ui/button';
-import FormField from '@/components/ui/form/form-field';
-import { Modal } from '@/components/ui/modal';
-import { GroupItems } from '@/components/ui/toggle';
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useState } from "react";
+import { FormProvider, useForm } from "react-hook-form";
+import { z } from "zod";
+import Button from "@/components/ui/button";
+import FormField from "@/components/ui/form/form-field";
+import { Modal } from "@/components/ui/modal";
+import { GroupItems } from "@/components/ui/toggle";
import {
- useCreateEvaluation,
- useGetMissionEvaluationGrades,
-} from '@/hooks/queries/evaluation-api';
-import { useToastStore } from '@/stores/use-toast-store';
-import { TextAreaInput } from '../ui/input';
+ useCreateEvaluation,
+ useGetMissionEvaluationGrades,
+} from "@/hooks/queries/evaluation-api";
+import { useScrollToNextField } from "@/hooks/use-scroll-to-next-field";
+import { useToastStore } from "@/stores/use-toast-store";
+import { TextAreaInput } from "../ui/input";
const CreateEvaluationFormSchema = z.object({
- gradeCode: z.enum([
- 'A_PLUS',
- 'A_MINUS',
- 'B_PLUS',
- 'B_MINUS',
- 'C_PLUS',
- 'C_MINUS',
- 'F',
- ]),
- comment: z.string().min(1, '정성 코멘트를 입력해주세요.').max(1000),
+ gradeCode: z.enum([
+ "A_PLUS",
+ "A_MINUS",
+ "B_PLUS",
+ "B_MINUS",
+ "C_PLUS",
+ "C_MINUS",
+ "F",
+ ]),
+ comment: z.string().min(1, "정성 코멘트를 입력해주세요.").max(1000),
});
type CreateEvaluationFormValues = z.infer;
interface CreateEvaluationModalProps {
- homeworkId: number; // todo api response 타입 적용
+ homeworkId: number; // todo api response 타입 적용
}
export default function CreateEvaluationModal({
- homeworkId,
+ homeworkId,
}: CreateEvaluationModalProps) {
- const [open, setOpen] = useState(false);
-
- return (
-
-
-
-
-
-
-
-
-
-
- 평가하기
-
- setOpen(false)} />
-
-
- setOpen(false)}
- />
-
-
-
- );
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ 평가하기
+
+ setOpen(false)} />
+
+
+ setOpen(false)}
+ />
+
+
+
+ );
}
interface CreateEvaluationFormProps {
- homeworkId: number; // todo api response 타입 적용
- onClose: () => void;
+ homeworkId: number; // todo api response 타입 적용
+ onClose: () => void;
}
function CreateEvaluationForm({
- homeworkId,
- onClose,
+ homeworkId,
+ onClose,
}: CreateEvaluationFormProps) {
- const methods = useForm({
- resolver: zodResolver(CreateEvaluationFormSchema),
- mode: 'onChange',
- defaultValues: {
- gradeCode: undefined,
- comment: '',
- },
- });
-
- const { handleSubmit, formState } = methods;
-
- const { data: grades } = useGetMissionEvaluationGrades();
- const { mutate: createEvaluation } = useCreateEvaluation();
- const showToast = useToastStore((state) => state.showToast);
-
- const onValidSubmit = (values: CreateEvaluationFormValues) => {
- createEvaluation(
- {
- homeworkId,
- request: values,
- },
- {
- onSuccess: () => {
- showToast('평가가 성공적으로 제출되었습니다!');
- onClose();
- },
- onError: () => {
- showToast('평가 제출에 실패했습니다. 다시 시도해주세요.', 'error');
- },
- },
- );
- };
-
- const gradeOptions = grades
- ?.sort((a, b) => a.orderNum - b.orderNum)
- .map((grade) => ({
- value: grade.code,
- label: `${grade.label} (${grade.score === 0 ? '0' : grade.score.toFixed(1)})`,
- }));
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- );
+ const methods = useForm({
+ resolver: zodResolver(CreateEvaluationFormSchema),
+ mode: "onChange",
+ defaultValues: {
+ gradeCode: undefined,
+ comment: "",
+ },
+ });
+
+ const { handleSubmit, formState } = methods;
+
+ const scrollToNext = useScrollToNextField();
+ const { data: grades } = useGetMissionEvaluationGrades();
+ const { mutate: createEvaluation } = useCreateEvaluation();
+ const showToast = useToastStore((state) => state.showToast);
+
+ const onValidSubmit = (values: CreateEvaluationFormValues) => {
+ createEvaluation(
+ {
+ homeworkId,
+ request: values,
+ },
+ {
+ onSuccess: () => {
+ showToast("평가가 성공적으로 제출되었습니다!");
+ onClose();
+ },
+ onError: () => {
+ showToast("평가 제출에 실패했습니다. 다시 시도해주세요.", "error");
+ },
+ },
+ );
+ };
+
+ const gradeOptions = grades
+ ?.sort((a, b) => a.orderNum - b.orderNum)
+ .map((grade) => ({
+ value: grade.code,
+ label: `${grade.label} (${grade.score === 0 ? "0" : grade.score.toFixed(1)})`,
+ }));
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
}
diff --git a/src/components/modals/inquiry-modal.tsx b/src/components/modals/inquiry-modal.tsx
index 7ed8ab28..be8f84bf 100644
--- a/src/components/modals/inquiry-modal.tsx
+++ b/src/components/modals/inquiry-modal.tsx
@@ -14,6 +14,7 @@ import {
INQUIRY_TITLE_MAX_LENGTH,
} from '@/features/study/group/model/inquiry.schema';
import { useCreateInquiry } from '@/hooks/queries/inquiry-api';
+import { useScrollToNextField } from '@/hooks/use-scroll-to-next-field';
import { useToastStore } from '@/stores/use-toast-store';
import { SingleDropdown } from '../ui/dropdown';
import FormField from '../ui/form/form-field';
@@ -49,6 +50,7 @@ export default function InquiryModal({
});
const { handleSubmit, reset } = form;
+ const scrollToNext = useScrollToNextField();
const onSubmit = (data: InquiryFormValues) => {
createInquiry(
@@ -102,6 +104,8 @@ export default function InquiryModal({
label="문의 종류"
direction="vertical"
required
+ scrollable
+ onAfterChange={() => scrollToNext('category')}
>
scrollToNext('title')}
>
;
interface SubmitHomeworkModalProps {
- missionId: number; // todo api response 타입 적용
- onSuccess?: () => void;
+ missionId: number; // todo api response 타입 적용
+ onSuccess?: () => void;
}
// 과제 제출 모달
export default function SubmitHomeworkModal({
- missionId,
- onSuccess,
+ missionId,
+ onSuccess,
}: SubmitHomeworkModalProps) {
- const [open, setOpen] = useState(false);
-
- return (
-
-
-
-
-
-
-
-
-
-
- 과제 제출하기
-
- setOpen(false)} />
-
-
- setOpen(false)}
- onSuccess={onSuccess}
- />
-
-
-
- );
+ const [open, setOpen] = useState(false);
+ const methods = useForm({
+ resolver: zodResolver(SubmitHomeworkFormSchema),
+ mode: "onChange",
+ defaultValues: {
+ textContent: "",
+ attachmentLink: "",
+ },
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ 과제 제출하기
+
+ setOpen(false)} />
+
+
+ setOpen(false)}
+ onSuccess={() => {
+ methods.reset();
+ onSuccess?.();
+ }}
+ />
+
+
+
+
+ );
}
interface SubmitHomeworkFormProps {
- missionId: number;
- onClose: () => void;
- onSuccess?: () => void;
+ missionId: number;
+ onClose: () => void;
+ onSuccess?: () => void;
}
function SubmitHomeworkForm({
- missionId,
- onClose,
- onSuccess,
+ missionId,
+ onClose,
+ onSuccess,
}: SubmitHomeworkFormProps) {
- const methods = useForm({
- resolver: zodResolver(SubmitHomeworkFormSchema),
- mode: 'onChange',
- defaultValues: {
- textContent: '',
- attachmentLink: '',
- },
- });
-
- const { handleSubmit, formState } = methods;
-
- const { mutate: submitHomework } = useSubmitHomework();
- const showToast = useToastStore((state) => state.showToast);
-
- const onValidSubmit = (values: SubmitHomeworkFormValues) => {
- submitHomework(
- {
- missionId,
- request: {
- textContent: values.textContent,
- optionalSubmission: { link: values.attachmentLink },
- },
- },
- {
- onSuccess: async () => {
- showToast('과제가 성공적으로 제출되었습니다!');
- onClose();
- onSuccess?.();
- },
- onError: () => {
- showToast('과제 제출에 실패했습니다. 다시 시도해주세요.', 'error');
- },
- },
- );
- };
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- );
+ const methods = useFormContext();
+ const { handleSubmit, formState } = methods;
+
+ const { mutate: submitHomework } = useSubmitHomework();
+ const showToast = useToastStore((state) => state.showToast);
+
+ const onValidSubmit = (values: SubmitHomeworkFormValues) => {
+ submitHomework(
+ {
+ missionId,
+ request: {
+ textContent: values.textContent,
+ optionalSubmission: { link: values.attachmentLink },
+ },
+ },
+ {
+ onSuccess: async () => {
+ showToast("과제가 성공적으로 제출되었습니다!");
+ onClose();
+ onSuccess?.();
+ },
+ onError: () => {
+ showToast("과제 제출에 실패했습니다. 다시 시도해주세요.", "error");
+ },
+ },
+ );
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ );
}
diff --git a/src/components/pages/group-study-detail-page.tsx b/src/components/pages/group-study-detail-page.tsx
index 0905d568..f1bee44e 100644
--- a/src/components/pages/group-study-detail-page.tsx
+++ b/src/components/pages/group-study-detail-page.tsx
@@ -1,258 +1,260 @@
-'use client';
+"use client";
-import { sendGTMEvent } from '@next/third-parties/google';
-import { useRouter, useSearchParams } from 'next/navigation';
-import { useEffect, useState } from 'react';
-import InquiryModal from '@/components/modals/inquiry-modal';
-import Button from '@/components/ui/button';
-import MoreMenu from '@/components/ui/dropdown/more-menu';
-import Tabs from '@/components/ui/tabs';
-import { STUDY_DETAIL_TABS, StudyTabValue } from '@/config/constants';
-import { useGetGroupStudyMyStatus } from '@/hooks/queries/group-study-member-api';
-import { useToastStore } from '@/stores/use-toast-store';
-import { useLeaderStore } from '@/stores/useLeaderStore';
-import { Leader } from '../../features/study/group/api/group-study-types';
-import ChannelSection from '../../features/study/group/channel/ui/lounge-section';
+import { sendGTMEvent } from "@next/third-parties/google";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useEffect, useState } from "react";
+import InquiryModal from "@/components/modals/inquiry-modal";
+import Button from "@/components/ui/button";
+import MoreMenu from "@/components/ui/dropdown/more-menu";
+import Tabs from "@/components/ui/tabs";
+import { STUDY_DETAIL_TABS, StudyTabValue } from "@/config/constants";
+import { useGetGroupStudyMyStatus } from "@/hooks/queries/group-study-member-api";
+import { useToastStore } from "@/stores/use-toast-store";
+import { useLeaderStore } from "@/stores/useLeaderStore";
+import { Leader } from "../../features/study/group/api/group-study-types";
+import ChannelSection from "../../features/study/group/channel/ui/lounge-section";
import {
- useCompleteGroupStudyMutation,
- useDeleteGroupStudyMutation,
- useGroupStudyDetailQuery,
-} from '../../features/study/group/model/use-study-query';
-import ConfirmDeleteModal from '../../features/study/group/ui/confirm-delete-modal';
-import GroupStudyFormModal from '../../features/study/group/ui/group-study-form-modal';
-import GroupStudyMemberList from '../lists/study-member-list';
-import StudyInfoSection from '../section/group-study-info-section';
-import MissionSection from '../section/mission-section';
+ useCompleteGroupStudyMutation,
+ useDeleteGroupStudyMutation,
+ useGroupStudyDetailQuery,
+} from "../../features/study/group/model/use-study-query";
+import ConfirmDeleteModal from "../../features/study/group/ui/confirm-delete-modal";
+import GroupStudyFormModal from "../../features/study/group/ui/group-study-form-modal";
+import GroupStudyMemberList from "../lists/study-member-list";
+import StudyInfoSection from "../section/group-study-info-section";
+import MissionSection from "../section/mission-section";
-type ActionKey = 'end' | 'delete'; // 필요 시 'edit' 등 추가
+type ActionKey = "end" | "delete"; // 필요 시 'edit' 등 추가
interface StudyDetailPageProps {
- groupStudyId: number;
- memberId?: number;
+ groupStudyId: number;
+ memberId?: number;
}
export default function StudyDetailPage({
- groupStudyId,
- memberId,
+ groupStudyId,
+ memberId,
}: StudyDetailPageProps) {
- const router = useRouter();
- const searchParams = useSearchParams();
- const setLeaderInfo = useLeaderStore((state) => state.setLeaderInfo);
- const showToast = useToastStore((state) => state.showToast);
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const setLeaderInfo = useLeaderStore((state) => state.setLeaderInfo);
+ const showToast = useToastStore((state) => state.showToast);
- const tabFromUrl = searchParams.get('tab') as StudyTabValue | null;
+ const tabFromUrl = searchParams.get("tab") as StudyTabValue | null;
- const { data: studyDetail, isLoading } =
- useGroupStudyDetailQuery(groupStudyId);
+ const { data: studyDetail, isLoading } =
+ useGroupStudyDetailQuery(groupStudyId);
- const leaderId = studyDetail?.basicInfo.leader.memberId;
+ const leaderId = studyDetail?.basicInfo.leader.memberId;
- const isLeader = leaderId === memberId;
+ const isLeader = leaderId === memberId;
- // 리더 정보를 Zustand store에 저장
- useEffect(() => {
- if (studyDetail?.basicInfo.leader) {
- setLeaderInfo(studyDetail.basicInfo.leader as Leader);
- }
- }, [studyDetail?.basicInfo.leader, setLeaderInfo]);
+ // 리더 정보를 Zustand store에 저장
+ useEffect(() => {
+ if (studyDetail?.basicInfo.leader) {
+ setLeaderInfo(studyDetail.basicInfo.leader as Leader);
+ }
+ }, [studyDetail?.basicInfo.leader, setLeaderInfo]);
- const [active, setActive] = useState(tabFromUrl || 'intro');
- const [showModal, setShowModal] = useState(false);
- const [action, setAction] = useState(null);
- const [showStudyFormModal, setShowStudyFormModal] = useState(false);
- const [showInquiryModal, setShowInquiryModal] = useState(false);
+ const [active, setActive] = useState(tabFromUrl || "intro");
+ const [showModal, setShowModal] = useState(false);
+ const [action, setAction] = useState(null);
+ const [showStudyFormModal, setShowStudyFormModal] = useState(false);
+ const [showInquiryModal, setShowInquiryModal] = useState(false);
- const { data: myApplicationStatus } = useGetGroupStudyMyStatus({
- groupStudyId,
- isLeader,
- });
+ const { data: myApplicationStatus } = useGetGroupStudyMyStatus({
+ groupStudyId,
+ isLeader,
+ });
- const { mutate: deleteGroupStudy } = useDeleteGroupStudyMutation();
- const { mutate: completeStudy } = useCompleteGroupStudyMutation();
+ const { mutate: deleteGroupStudy } = useDeleteGroupStudyMutation();
+ const { mutate: completeStudy } = useCompleteGroupStudyMutation();
- const ModalContent = {
- end: {
- title: '스터디를 종료하시겠어요?',
- content: (
- <>
- 종료 후에는 더 이상 모집/활동이 불가합니다.
-
이 동작은 되돌릴 수 없습니다.
- >
- ),
- confirmText: '스터디 종료',
- onConfirm: () => {
- completeStudy(
- { groupStudyId },
- {
- onSuccess: () => {
- sendGTMEvent({
- event: 'group_study_end',
- group_study_id: String(groupStudyId),
- });
- showToast('스터디가 종료되었습니다.');
- router.push('/group-study');
- },
- onError: () => {
- showToast('스터디 종료에 실패하였습니다.', 'error');
- },
- onSettled: () => {
- setShowModal(false);
- },
- },
- );
- },
- },
- delete: {
- title: '스터디를 삭제하시겠어요?',
- content: (
- <>
- 삭제 시 모든 데이터가 영구적으로 제거됩니다.
-
이 동작은 되돌릴 수 없습니다.
- >
- ),
- confirmText: '스터디 삭제',
- onConfirm: () => {
- deleteGroupStudy(
- { groupStudyId },
- {
- onSuccess: () => {
- sendGTMEvent({
- event: 'group_study_delete',
- group_study_id: String(groupStudyId),
- });
- showToast('스터디가 삭제되었습니다.');
- router.push('/group-study');
- },
- onError: () => {
- showToast('스터디 삭제에 실패하였습니다.', 'error');
- },
- onSettled: () => {
- setShowModal(false);
- },
- },
- );
- },
- },
- };
+ const ModalContent = {
+ end: {
+ title: "스터디를 종료하시겠어요?",
+ content: (
+ <>
+ 종료 후에는 더 이상 모집/활동이 불가합니다.
+
이 동작은 되돌릴 수 없습니다.
+ >
+ ),
+ confirmText: "스터디 종료",
+ onConfirm: () => {
+ completeStudy(
+ { groupStudyId },
+ {
+ onSuccess: () => {
+ sendGTMEvent({
+ event: "group_study_end",
+ group_study_id: String(groupStudyId),
+ });
+ showToast("스터디가 종료되었습니다.");
+ router.push("/group-study");
+ },
+ onError: () => {
+ showToast("스터디 종료에 실패하였습니다.", "error");
+ },
+ onSettled: () => {
+ setShowModal(false);
+ },
+ },
+ );
+ },
+ },
+ delete: {
+ title: "스터디를 삭제하시겠어요?",
+ content: (
+ <>
+ 삭제 시 모든 데이터가 영구적으로 제거됩니다.
+
이 동작은 되돌릴 수 없습니다.
+ >
+ ),
+ confirmText: "스터디 삭제",
+ onConfirm: () => {
+ deleteGroupStudy(
+ { groupStudyId },
+ {
+ onSuccess: () => {
+ sendGTMEvent({
+ event: "group_study_delete",
+ group_study_id: String(groupStudyId),
+ });
+ showToast("스터디가 삭제되었습니다.");
+ router.push("/group-study");
+ },
+ onError: () => {
+ showToast("스터디 삭제에 실패하였습니다.", "error");
+ },
+ onSettled: () => {
+ setShowModal(false);
+ },
+ },
+ );
+ },
+ },
+ };
- // 참가자, 채널 탭 접근 가능 여부 = 스터디 참가자 또는 방장만 가능
- const isMember =
- myApplicationStatus?.status === 'APPROVED' ||
- myApplicationStatus?.status === 'KICKED';
+ // 참가자, 채널 탭 접근 가능 여부 = 스터디 참가자 또는 방장만 가능
+ const isMember =
+ myApplicationStatus?.status === "APPROVED" ||
+ myApplicationStatus?.status === "KICKED";
- if (isLoading || !studyDetail) {
- return 로딩중...
;
- }
+ if (isLoading || !studyDetail) {
+ return 로딩중...
;
+ }
- return (
-
-
setShowModal(!showModal)}
- title={ModalContent[action]?.title}
- content={ModalContent[action]?.content}
- confirmText={ModalContent[action]?.confirmText}
- onConfirm={ModalContent[action]?.onConfirm}
- />
- setShowStudyFormModal(!showStudyFormModal)}
- />
-
+ return (
+
+
setShowModal(!showModal)}
+ title={ModalContent[action]?.title}
+ content={ModalContent[action]?.content}
+ confirmText={ModalContent[action]?.confirmText}
+ onConfirm={ModalContent[action]?.onConfirm}
+ />
+ setShowStudyFormModal(!showStudyFormModal)}
+ />
+
-
-
-
- {studyDetail?.detailInfo.title}
-
-
-
- {studyDetail?.detailInfo.summary}
-
-
- {isLeader && (
-
{
- setShowStudyFormModal(true);
- },
- },
- {
- label: '스터디 종료',
- value: 'end',
- onMenuClick: () => {
- setAction('end');
- setShowModal(true);
- },
- },
- {
- label: '스터디 삭제',
- value: 'delete',
- onMenuClick: () => {
- setAction('delete');
- setShowModal(true);
- },
- },
- ]}
- iconSize={35}
- />
- )}
-
+
+
+
+ {studyDetail?.detailInfo.title}
+
+
+
+ {studyDetail?.detailInfo.summary}
+
+
+ {isLeader && (
+
{
+ setShowStudyFormModal(true);
+ },
+ },
+ {
+ label: "스터디 종료",
+ value: "end",
+ onMenuClick: () => {
+ setAction("end");
+ setShowModal(true);
+ },
+ },
+ {
+ label: "스터디 삭제",
+ value: "delete",
+ onMenuClick: () => {
+ setAction("delete");
+ setShowModal(true);
+ },
+ },
+ ]}
+ iconSize={35}
+ />
+ )}
+
- {/** 탭리스트 */}
- tab.value === 'intro' || isLeader || isMember,
- )}
- activeTab={active}
- onChange={(value: StudyTabValue) => {
- setActive(value);
+ {/** 탭리스트 */}
+ tab.value === "intro" || isLeader || isMember,
+ )}
+ activeTab={active}
+ onChange={(value: StudyTabValue) => {
+ setActive(value);
- // 탭 변경 시 URL 파라미터 초기화 및 탭 값 설정
- router.replace(`?tab=${value}`);
+ // 탭 변경 시 URL 파라미터 초기화 및 탭 값 설정
+ router.replace(`?tab=${value}`);
- sendGTMEvent({
- event: 'group_study_tab_change',
- group_study_id: String(groupStudyId),
- tab: value,
- });
- }}
- />
- {active === 'intro' && }
- {active === 'members' && (
-
- )}
+ sendGTMEvent({
+ event: "group_study_tab_change",
+ group_study_id: String(groupStudyId),
+ tab: value,
+ });
+ }}
+ />
+ {active === "intro" && (
+
+ )}
+ {active === "members" && (
+
+ )}
- {active === 'mission' && }
- {active === 'lounge' && (
-
- )}
-
- );
+ {active === "mission" && }
+ {active === "lounge" && (
+
+ )}
+
+ );
}
diff --git a/src/components/section/group-study-info-section.tsx b/src/components/section/group-study-info-section.tsx
index dc32e7d9..660a3bb6 100644
--- a/src/components/section/group-study-info-section.tsx
+++ b/src/components/section/group-study-info-section.tsx
@@ -10,22 +10,20 @@ import type { AvatarStackMember } from "@/components/ui/avatar-stack";
import Button from "@/components/ui/button";
import UserProfileModal from "@/entities/user/ui/user-profile-modal";
import { useApplicantsByStatusQuery } from "@/features/study/group/application/model/use-applicant-qeury";
-import { useIsLeader } from "@/stores/useLeaderStore";
-import { useUserStore } from "@/stores/useUserStore";
import SummaryStudyInfo from "../summary/study-info-summary";
interface StudyInfoSectionProps {
study: GroupStudyFullResponseDto;
+ isLeader: boolean;
}
export default function StudyInfoSection({
study: studyDetail,
+ isLeader,
}: StudyInfoSectionProps) {
const router = useRouter();
const params = useParams();
- const memberId = useUserStore((state) => state.memberId);
- const isLeader = useIsLeader(memberId);
const groupStudyId = Number(params.id);
diff --git a/src/components/ui/form/field-control.tsx b/src/components/ui/form/field-control.tsx
index d2853387..2b8e9dbf 100644
--- a/src/components/ui/form/field-control.tsx
+++ b/src/components/ui/form/field-control.tsx
@@ -33,6 +33,10 @@ export interface FieldControlProps<
describedById?: string;
children: React.ReactElement>;
controlId?: string;
+ /** 필드 값이 변경된 직후 호출되는 콜백. 자동 스크롤 등 부가 동작에 사용 */
+ onAfterChange?: (value: unknown) => void;
+ /** blur 시 필드 값이 채워져 있으면 호출되는 콜백. 텍스트 입력 등에서 자동 스크롤에 사용 */
+ onAfterBlurFilled?: () => void;
}
export function FieldControl<
@@ -45,6 +49,8 @@ export function FieldControl<
describedById,
controlId,
children,
+ onAfterChange,
+ onAfterBlurFilled,
}: FieldControlProps) {
const { control } = useFormContext();
@@ -65,19 +71,40 @@ export function FieldControl<
!Array.isArray(children)
) {
const child = children;
- const nextOnChange: ChangeHandler =
+ const coreOnChange: ChangeHandler =
child.props.onChange ??
((arg: V | React.ChangeEvent) => {
if (isReactChangeEvent(arg)) field.onChange(arg);
else field.onChange(arg);
});
+ const nextOnChange: ChangeHandler = onAfterChange
+ ? ((arg: V | React.ChangeEvent) => {
+ if (isReactChangeEvent(arg)) {
+ (coreOnChange as EventChange)(arg);
+ onAfterChange((arg.target as HTMLInputElement).value);
+ } else {
+ (coreOnChange as ValueChange)(arg);
+ onAfterChange(arg);
+ }
+ }) as ChangeHandler
+ : coreOnChange;
+
+ const baseOnBlur: () => void =
+ child.props.onBlur ?? field.onBlur;
+ const nextOnBlur = onAfterBlurFilled
+ ? () => {
+ baseOnBlur();
+ if (field.value) onAfterBlurFilled();
+ }
+ : baseOnBlur;
+
injected = cloneElement(child, {
id: child.props.id ?? controlId,
name: child.props.name ?? field.name,
value: (child.props.value ?? field.value) as V,
onChange: nextOnChange,
- onBlur: child.props.onBlur ?? field.onBlur,
+ onBlur: nextOnBlur,
'aria-invalid':
child.props['aria-invalid'] ?? (fieldState.invalid || undefined),
'aria-describedby':
diff --git a/src/components/ui/form/form-field.tsx b/src/components/ui/form/form-field.tsx
index 8f9dcebf..c81d86f8 100644
--- a/src/components/ui/form/form-field.tsx
+++ b/src/components/ui/form/form-field.tsx
@@ -49,6 +49,13 @@ export interface FormFieldProps<
showCounterRight?: boolean;
counterMax?: number;
+ /** true로 설정하면 data-scroll-field 속성을 wrapper div에 추가 (자동 스크롤 위치 마킹) */
+ scrollable?: boolean;
+ /** 필드 값이 변경된 직후 호출되는 콜백. 자동 스크롤 등 부가 동작에 사용 */
+ onAfterChange?: (value: unknown) => void;
+ /** blur 시 필드 값이 채워져 있으면 호출되는 콜백. 텍스트 입력 등에서 자동 스크롤에 사용 */
+ onAfterBlurFilled?: () => void;
+
children: React.ReactElement>;
}
@@ -69,6 +76,9 @@ export default function FormField<
children,
showCounterRight = false,
counterMax,
+ scrollable,
+ onAfterChange,
+ onAfterBlurFilled,
}: FormFieldProps) {
const { control, formState } = useFormContext();
const autoId = useId();
@@ -89,6 +99,7 @@ export default function FormField<
return (
{children}
diff --git a/src/features/study/group/ui/step/step1-group.tsx b/src/features/study/group/ui/step/step1-group.tsx
index f25a7f25..67682e2b 100644
--- a/src/features/study/group/ui/step/step1-group.tsx
+++ b/src/features/study/group/ui/step/step1-group.tsx
@@ -1,272 +1,299 @@
-'use client';
+"use client";
-import { addDays } from 'date-fns';
-import { useEffect } from 'react';
+import { addDays } from "date-fns";
import {
- Controller,
- useController,
- useFormContext,
- useWatch,
-} from 'react-hook-form';
+ Controller,
+ useController,
+ useFormContext,
+ useWatch,
+} from "react-hook-form";
-import { SingleDropdown } from '@/components/ui/dropdown';
-import FormField from '@/components/ui/form/form-field';
-import { BaseInput } from '@/components/ui/input';
-import { RadioGroup, RadioGroupItem } from '@/components/ui/radio';
-import { GroupItems } from '@/components/ui/toggle';
-import { formatKoreaYMD, getKoreaDate } from '@/utils/time';
-import { TargetRole } from '../../api/group-study-types';
+import { SingleDropdown } from "@/components/ui/dropdown";
+import FormField from "@/components/ui/form/form-field";
+import { BaseInput } from "@/components/ui/input";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio";
+import { GroupItems } from "@/components/ui/toggle";
import {
- STUDY_TYPES,
- ROLE_OPTIONS_UI,
- EXPERIENCE_LEVEL_OPTIONS_UI,
- STUDY_METHODS,
- STUDY_METHOD_LABELS,
- STUDY_TYPE_LABELS,
- REGULAR_MEETINGS,
- REGULAR_MEETING_LABELS,
-} from '../../const/group-study-const';
-import { GroupStudyFormValues } from '../../model/group-study-form.schema';
-import { useClassification } from '../group-study-form';
+ useScrollToNextField,
+ SCROLL_FIELD_ATTR,
+} from "@/hooks/use-scroll-to-next-field";
+import { formatKoreaYMD, getKoreaDate } from "@/utils/time";
+import { TargetRole } from "../../api/group-study-types";
+import {
+ STUDY_TYPES,
+ ROLE_OPTIONS_UI,
+ EXPERIENCE_LEVEL_OPTIONS_UI,
+ STUDY_METHODS,
+ STUDY_METHOD_LABELS,
+ STUDY_TYPE_LABELS,
+ REGULAR_MEETINGS,
+ REGULAR_MEETING_LABELS,
+} from "../../const/group-study-const";
+import { GroupStudyFormValues } from "../../model/group-study-form.schema";
+import { useClassification } from "../group-study-form";
const methodOptions = STUDY_METHODS.map((v) => ({
- label: STUDY_METHOD_LABELS[v],
- value: v,
+ label: STUDY_METHOD_LABELS[v],
+ value: v,
}));
const memberOptions = Array.from({ length: 20 }, (_, i) => {
- const value = (i + 1).toString();
+ const value = (i + 1).toString();
- return { label: `${value}명`, value };
+ return { label: `${value}명`, value };
});
export default function Step1OpenGroupStudy() {
- const { control, formState, watch } = useFormContext
();
- const classification = useClassification();
- const isPremiumStudy = classification === 'PREMIUM_STUDY';
+ const { control, formState, watch } = useFormContext();
+ const classification = useClassification();
+ const isPremiumStudy = classification === "PREMIUM_STUDY";
+
+ const { field: typeField } = useController({
+ name: "type",
+ control,
+ });
+ const { field: regularMeetingField } = useController({
+ name: "regularMeeting",
+ control,
+ });
- const { field: typeField } = useController({
- name: 'type',
- control,
- });
- const { field: regularMeetingField } = useController({
- name: 'regularMeeting',
- control,
- });
+ const methodValue = useWatch({
+ name: "method",
+ control,
+ });
- const methodValue = useWatch({
- name: 'method',
- control,
- });
+ const scrollToNext = useScrollToNextField();
- const filteredStudyTypes =
- classification === 'GROUP_STUDY'
- ? STUDY_TYPES.filter((type) => type !== 'MENTORING')
- : STUDY_TYPES;
+ const filteredStudyTypes =
+ classification === "GROUP_STUDY"
+ ? STUDY_TYPES.filter((type) => type !== "MENTORING")
+ : STUDY_TYPES;
- return (
- <>
- 기본 정보 설정
-
- name="type"
- label="스터디 유형"
- helper="어떤 방식으로 진행되는 스터디인지 선택해주세요."
- direction="vertical"
- size="medium"
- required
- >
-
- {filteredStudyTypes.map((type) => (
-
-
-
-
- ))}
-
-
-
- name="targetRoles"
- label="모집 대상"
- helper="함께하고 싶은 대상(직무·관심사 등)을 선택해주세요. (복수 선택 가능)"
- direction="vertical"
- size="medium"
- required
- >
-
-
-
- name="maxMembersCount"
- label="모집 인원"
- helper="모집할 최대 참여 인원을 선택해주세요."
- direction="vertical"
- size="medium"
- required
- >
-
-
-
- name="experienceLevels"
- label="경력 여부"
- helper="스터디 참여에 필요한 경력 조건을 선택해주세요.(복수 선택 가능)"
- direction="vertical"
- size="medium"
- required
- >
-
-
-
-
-
- 필수
-
-
- 스터디가 진행되는 방식을 선택해주세요.
-
+ return (
+ <>
+
기본 정보 설정
+
+ name="type"
+ label="스터디 유형"
+ helper="어떤 방식으로 진행되는 스터디인지 선택해주세요."
+ direction="vertical"
+ size="medium"
+ required
+ scrollable
+ >
+ {
+ typeField.onChange(v);
+ scrollToNext("type");
+ }}
+ >
+ {filteredStudyTypes.map((type) => (
+
+
+
+
+ ))}
+
+
+
+ name="targetRoles"
+ label="모집 대상"
+ helper="함께하고 싶은 대상(직무·관심사 등)을 선택해주세요. (복수 선택 가능)"
+ direction="vertical"
+ size="medium"
+ required
+ scrollable
+ >
+
+
+
+ name="maxMembersCount"
+ label="모집 인원"
+ helper="모집할 최대 참여 인원을 선택해주세요."
+ direction="vertical"
+ size="medium"
+ required
+ scrollable
+ onAfterChange={() => scrollToNext("maxMembersCount")}
+ >
+
+
+
+ name="experienceLevels"
+ label="경력 여부"
+ helper="스터디 참여에 필요한 경력 조건을 선택해주세요.(복수 선택 가능)"
+ direction="vertical"
+ size="medium"
+ required
+ scrollable
+ >
+
+
+
+
+
+ 필수
+
+
+ 스터디가 진행되는 방식을 선택해주세요.
+
-
- (
-
- )}
- />
- (
-
- )}
- />
-
+
+ (
+ {
+ field.onChange(v);
+ scrollToNext("method");
+ }}
+ placeholder="선택해주세요"
+ />
+ )}
+ />
+ (
+
+ )}
+ />
+
- {(formState.errors.method || formState.errors.location) && (
-
- {formState.errors.method?.message ||
- formState.errors.location?.message}
-
- )}
-
-
- name="regularMeeting"
- label="정기 모임"
- helper="정기적으로 모일 빈도를 선택해주세요."
- direction="vertical"
- size="medium"
- required
- >
-
- {REGULAR_MEETINGS.map((type) => (
-
-
-
-
- ))}
-
-
-
-
-
- 필수
-
-
- 스터디 진행 시작일과 종료일을 선택해주세요.
-
+ {(formState.errors.method || formState.errors.location) && (
+
+ {formState.errors.method?.message ||
+ formState.errors.location?.message}
+
+ )}
+
+
+ name="regularMeeting"
+ label="정기 모임"
+ helper="정기적으로 모일 빈도를 선택해주세요."
+ direction="vertical"
+ size="medium"
+ required
+ scrollable
+ >
+ {
+ regularMeetingField.onChange(v);
+ scrollToNext("regularMeeting");
+ }}
+ >
+ {REGULAR_MEETINGS.map((type) => (
+
+
+
+
+ ))}
+
+
+
+
+
+ 필수
+
+
+ 스터디 진행 시작일과 종료일을 선택해주세요.
+
-
- (
-
- )}
- />
- ~
- (
-
- )}
- />
-
+
+ (
+
+ )}
+ />
+ ~
+ (
+
+ )}
+ />
+
- {(formState.errors.startDate || formState.errors.endDate) && (
-
- {formState.errors.startDate?.message ||
- formState.errors.endDate?.message}
-
- )}
-
- {isPremiumStudy && (
-
- name="price"
- label="참가비"
- direction="vertical"
- size="medium"
- >
- (
-
- )}
- />
-
- )}
- >
- );
+ {(formState.errors.startDate || formState.errors.endDate) && (
+
+ {formState.errors.startDate?.message ||
+ formState.errors.endDate?.message}
+
+ )}
+
+ {isPremiumStudy && (
+
+ name="price"
+ label="참가비"
+ direction="vertical"
+ size="medium"
+ scrollable
+ >
+ (
+
+ )}
+ />
+
+ )}
+ >
+ );
}
diff --git a/src/features/study/group/ui/step/step2-group.tsx b/src/features/study/group/ui/step/step2-group.tsx
index 5b547bfe..f91f77e9 100644
--- a/src/features/study/group/ui/step/step2-group.tsx
+++ b/src/features/study/group/ui/step/step2-group.tsx
@@ -1,114 +1,122 @@
-'use client';
+"use client";
-import { useEffect, useState } from 'react';
-import { useFormContext, useWatch } from 'react-hook-form';
-import FormField from '@/components/ui/form/form-field';
-import { BaseInput, TextAreaInput } from '@/components/ui/input';
-import { THUMBNAIL_EXTENSION } from '../../const/group-study-const';
+import { useEffect, useState } from "react";
+import { useFormContext, useWatch } from "react-hook-form";
+import FormField from "@/components/ui/form/form-field";
+import { BaseInput, TextAreaInput } from "@/components/ui/input";
+import { useScrollToNextField } from "@/hooks/use-scroll-to-next-field";
+import { THUMBNAIL_EXTENSION } from "../../const/group-study-const";
-import { GroupStudyFormValues } from '../../model/group-study-form.schema';
-import GroupStudyThumbnailInput from '../group-study-thumbnail-input';
+import { GroupStudyFormValues } from "../../model/group-study-form.schema";
+import GroupStudyThumbnailInput from "../group-study-thumbnail-input";
export default function Step2OpenGroupStudy() {
- const { setValue, getValues } = useFormContext();
-
- const thumbnailFile = useWatch({
- name: 'thumbnailFile',
- });
- const thumbnailExtension = useWatch({
- name: 'thumbnailExtension',
- });
-
- const [image, setImage] = useState(
- getValues('thumbnailUrl') || undefined,
- );
-
- useEffect(() => {
- if (thumbnailFile && thumbnailFile instanceof File) {
- setImage(URL.createObjectURL(thumbnailFile));
- } else if (thumbnailExtension === 'DEFAULT') {
- setImage(undefined);
- }
- }, [thumbnailFile, thumbnailExtension]);
-
- const handleImageChange = (file: File | null) => {
- if (!file) {
- setValue('thumbnailExtension', 'DEFAULT', { shouldValidate: true });
- setValue('thumbnailFile', null);
- setImage(undefined);
-
- return;
- }
-
- const MAX_SIZE = 5 * 1024 * 1024; // 5MB
- if (file.size > MAX_SIZE) {
- alert('이미지 용량은 5MB 이하만 업로드할 수 있어요.');
-
- return;
- }
-
- const ext = file.name.split('.').pop()?.toUpperCase();
- const validExt =
- ext && THUMBNAIL_EXTENSION.includes(ext as any)
- ? (ext as GroupStudyFormValues['thumbnailExtension'])
- : 'DEFAULT';
-
- setValue('thumbnailExtension', validExt, { shouldValidate: true });
- setValue('thumbnailFile', file, { shouldValidate: true });
- setImage(URL.createObjectURL(file));
- };
-
- return (
- <>
-
- 스터디 소개 작성
-
-
-
- name="thumbnailExtension"
- label="썸네일"
- direction="vertical"
- size="medium"
- required
- >
-
-
-
-
- name="title"
- label="스터디 제목"
- direction="vertical"
- size="medium"
- required
- >
-
-
-
-
- name="summary"
- label="스터디 한 줄 소개"
- direction="vertical"
- size="medium"
- required
- >
-
-
-
-
- name="description"
- label="스터디 소개"
- direction="vertical"
- size="medium"
- required
- >
-
-
- >
- );
+ const { setValue, getValues } = useFormContext();
+ const scrollToNext = useScrollToNextField();
+
+ const thumbnailFile = useWatch({
+ name: "thumbnailFile",
+ });
+ const thumbnailExtension = useWatch({
+ name: "thumbnailExtension",
+ });
+
+ const [image, setImage] = useState(
+ getValues("thumbnailUrl") || undefined,
+ );
+
+ useEffect(() => {
+ if (thumbnailFile && thumbnailFile instanceof File) {
+ setImage(URL.createObjectURL(thumbnailFile));
+ } else if (thumbnailExtension === "DEFAULT") {
+ setImage(undefined);
+ }
+ }, [thumbnailFile, thumbnailExtension]);
+
+ const handleImageChange = (file: File | null) => {
+ if (!file) {
+ setValue("thumbnailExtension", "DEFAULT", { shouldValidate: true });
+ setValue("thumbnailFile", null);
+ setImage(undefined);
+
+ return;
+ }
+
+ const MAX_SIZE = 5 * 1024 * 1024; // 5MB
+ if (file.size > MAX_SIZE) {
+ alert("이미지 용량은 5MB 이하만 업로드할 수 있어요.");
+
+ return;
+ }
+
+ const ext = file.name.split(".").pop()?.toUpperCase();
+ const validExt =
+ ext && THUMBNAIL_EXTENSION.includes(ext as any)
+ ? (ext as GroupStudyFormValues["thumbnailExtension"])
+ : "DEFAULT";
+
+ setValue("thumbnailExtension", validExt, { shouldValidate: true });
+ setValue("thumbnailFile", file, { shouldValidate: true });
+ setImage(URL.createObjectURL(file));
+ };
+
+ return (
+ <>
+
+ 스터디 소개 작성
+
+
+
+ name="thumbnailExtension"
+ label="썸네일"
+ direction="vertical"
+ size="medium"
+ required
+ scrollable
+ >
+
+
+
+
+ name="title"
+ label="스터디 제목"
+ direction="vertical"
+ size="medium"
+ required
+ scrollable
+ onAfterBlurFilled={() => scrollToNext("title")}
+ >
+
+
+
+
+ name="summary"
+ label="스터디 한 줄 소개"
+ direction="vertical"
+ size="medium"
+ required
+ scrollable
+ onAfterBlurFilled={() => scrollToNext("summary")}
+ >
+
+
+
+
+ name="description"
+ label="스터디 소개"
+ direction="vertical"
+ size="medium"
+ required
+ scrollable
+ >
+
+
+ >
+ );
}
diff --git a/src/hooks/use-scroll-to-next-field.ts b/src/hooks/use-scroll-to-next-field.ts
new file mode 100644
index 00000000..13658910
--- /dev/null
+++ b/src/hooks/use-scroll-to-next-field.ts
@@ -0,0 +1,22 @@
+import { useCallback } from "react";
+
+export const SCROLL_FIELD_ATTR = "data-scroll-field";
+
+/**
+ * 모달 폼에서 단일 선택 필드를 선택했을 때 다음 필드로 자동 스크롤하는 훅.
+ * 각 필드 컨테이너에 `data-scroll-field="fieldName"` 속성을 추가한 뒤
+ * 선택 이벤트 핸들러에서 `scrollToNext('fieldName')` 을 호출하세요.
+ */
+export function useScrollToNextField() {
+ return useCallback((currentFieldName: string) => {
+ const all = document.querySelectorAll(`[${SCROLL_FIELD_ATTR}]`);
+ const arr = Array.from(all);
+ const idx = arr.findIndex(
+ (el) => el.getAttribute(SCROLL_FIELD_ATTR) === currentFieldName,
+ );
+ if (idx === -1 || idx >= arr.length - 1) return;
+
+ const next = arr[idx + 1] as HTMLElement;
+ next.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ }, []);
+}
From dd3296506eae74cdcb40fb3cb01524c7121c427a Mon Sep 17 00:00:00 2001
From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com>
Date: Tue, 17 Feb 2026 17:29:04 +0900
Subject: [PATCH 10/16] =?UTF-8?q?fix=20:=20=ED=8F=AC=EB=A7=B7=ED=8C=85=20?=
=?UTF-8?q?=EC=A0=95=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../contents/homework-detail-content.tsx | 746 +++++++++---------
.../modals/create-evaluation-modal.tsx | 316 ++++----
.../modals/submit-homework-modal.tsx | 288 +++----
.../pages/group-study-detail-page.tsx | 464 +++++------
.../section/group-study-info-section.tsx | 260 +++---
.../study/group/ui/step/step1-group.tsx | 550 ++++++-------
.../study/group/ui/step/step2-group.tsx | 234 +++---
7 files changed, 1429 insertions(+), 1429 deletions(-)
diff --git a/src/components/contents/homework-detail-content.tsx b/src/components/contents/homework-detail-content.tsx
index d0732d1f..258dc613 100644
--- a/src/components/contents/homework-detail-content.tsx
+++ b/src/components/contents/homework-detail-content.tsx
@@ -1,402 +1,402 @@
-"use client";
-
-import { useRouter, useSearchParams } from "next/navigation";
-import { useState } from "react";
-
-import type { PeerReviewResponse } from "@/api/openapi/models";
-import Avatar from "@/components/ui/avatar";
-import Button from "@/components/ui/button";
-import MoreMenu from "@/components/ui/dropdown/more-menu";
-import ConfirmDeleteModal from "@/features/study/group/ui/confirm-delete-modal";
-import { useGetHomework } from "@/hooks/queries/group-study-homework-api";
-import { useGetMission } from "@/hooks/queries/mission-api";
+'use client';
+
+import { useRouter, useSearchParams } from 'next/navigation';
+import { useState } from 'react';
+
+import type { PeerReviewResponse } from '@/api/openapi/models';
+import Avatar from '@/components/ui/avatar';
+import Button from '@/components/ui/button';
+import MoreMenu from '@/components/ui/dropdown/more-menu';
+import ConfirmDeleteModal from '@/features/study/group/ui/confirm-delete-modal';
+import { useGetHomework } from '@/hooks/queries/group-study-homework-api';
+import { useGetMission } from '@/hooks/queries/mission-api';
import {
- useCreatePeerReview,
- useDeletePeerReview,
- useUpdatePeerReview,
-} from "@/hooks/queries/peer-review-api";
+ useCreatePeerReview,
+ useDeletePeerReview,
+ useUpdatePeerReview,
+} from '@/hooks/queries/peer-review-api';
-import { useUserStore } from "@/stores/useUserStore";
-import DeleteHomeworkModal from "../modals/delete-homework-modal";
-import EditHomeworkModal from "../modals/edit-homework-modal";
+import { useUserStore } from '@/stores/useUserStore';
+import DeleteHomeworkModal from '../modals/delete-homework-modal';
+import EditHomeworkModal from '../modals/edit-homework-modal';
interface HomeworkDetailContentProps {
- missionId: number;
- homeworkId: number;
+ missionId: number;
+ homeworkId: number;
}
export default function HomeworkDetailContent({
- homeworkId,
- missionId,
+ homeworkId,
+ missionId,
}: HomeworkDetailContentProps) {
- const router = useRouter();
- const searchParams = useSearchParams();
- const currentUserId = useUserStore((state) => state.memberId);
- const { data: homework, isLoading: isHomeworkLoading } =
- useGetHomework(homeworkId);
- const {
- data: mission,
- isLoading: isMissionLoading,
- refetch: refetchMission,
- } = useGetMission(missionId);
-
- const handleDeleteSuccess = async () => {
- const params = new URLSearchParams(searchParams.toString());
- params.delete("homeworkId");
- router.push(`?${params.toString()}`);
- await refetchMission();
- };
-
- if (isHomeworkLoading || !homework || isMissionLoading || !mission) {
- return null;
- }
-
- const peerReviews = homework.peerReviews ?? [];
- const isEvaluated = !!homework.evaluation;
-
- // 미션 제출 가능 기간이 지나지 않았는지 확인
- const isMissionActive = mission.status !== "ENDED";
-
- // 수정/삭제 가능 조건: 본인 과제이면서 평가 전이면서 미션 제출 가능 기간이 지나지 않은 상태
- const isMyHomework = homework.submitterId === currentUserId;
- const canEditOrDelete = isMyHomework && !isEvaluated && isMissionActive;
-
- const profileImageUrl =
- homework.submitterProfileImage?.resizedImages?.[0]?.resizedImageUrl ??
- "/profile-default.svg";
-
- const formatDate = (dateString?: string) => {
- if (!dateString) return "";
- const date = new Date(dateString);
-
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} 제출`;
- };
-
- return (
-
- {/* 제출자 정보 및 과제 내용 */}
-
- {/* 제출자 정보 */}
-
-
-
-
-
- {homework.submitterNickname}
-
-
- {formatDate(homework.submissionTime)}
-
-
-
-
- {/* 수정/삭제 버튼 - 본인 과제이면서 평가 전이면서 미션 제출 가능 기간이 지나지 않은 경우에만 노출 */}
- {canEditOrDelete && (
-
-
-
-
- )}
-
-
- {/* 과제 내용 */}
-
- {homework.homeworkContent?.textContent}
-
-
- {/* 제출한 과제 링크 */}
- {homework.homeworkContent?.optionalContent?.link && (
-
- )}
-
-
- {/* 피어 리뷰 */}
-
-
- );
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const currentUserId = useUserStore((state) => state.memberId);
+ const { data: homework, isLoading: isHomeworkLoading } =
+ useGetHomework(homeworkId);
+ const {
+ data: mission,
+ isLoading: isMissionLoading,
+ refetch: refetchMission,
+ } = useGetMission(missionId);
+
+ const handleDeleteSuccess = async () => {
+ const params = new URLSearchParams(searchParams.toString());
+ params.delete('homeworkId');
+ router.push(`?${params.toString()}`);
+ await refetchMission();
+ };
+
+ if (isHomeworkLoading || !homework || isMissionLoading || !mission) {
+ return null;
+ }
+
+ const peerReviews = homework.peerReviews ?? [];
+ const isEvaluated = !!homework.evaluation;
+
+ // 미션 제출 가능 기간이 지나지 않았는지 확인
+ const isMissionActive = mission.status !== 'ENDED';
+
+ // 수정/삭제 가능 조건: 본인 과제이면서 평가 전이면서 미션 제출 가능 기간이 지나지 않은 상태
+ const isMyHomework = homework.submitterId === currentUserId;
+ const canEditOrDelete = isMyHomework && !isEvaluated && isMissionActive;
+
+ const profileImageUrl =
+ homework.submitterProfileImage?.resizedImages?.[0]?.resizedImageUrl ??
+ '/profile-default.svg';
+
+ const formatDate = (dateString?: string) => {
+ if (!dateString) return '';
+ const date = new Date(dateString);
+
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} 제출`;
+ };
+
+ return (
+
+ {/* 제출자 정보 및 과제 내용 */}
+
+ {/* 제출자 정보 */}
+
+
+
+
+
+ {homework.submitterNickname}
+
+
+ {formatDate(homework.submissionTime)}
+
+
+
+
+ {/* 수정/삭제 버튼 - 본인 과제이면서 평가 전이면서 미션 제출 가능 기간이 지나지 않은 경우에만 노출 */}
+ {canEditOrDelete && (
+
+
+
+
+ )}
+
+
+ {/* 과제 내용 */}
+
+ {homework.homeworkContent?.textContent}
+
+
+ {/* 제출한 과제 링크 */}
+ {homework.homeworkContent?.optionalContent?.link && (
+
+ )}
+
+
+ {/* 피어 리뷰 */}
+
+
+ );
}
interface PeerReviewSectionProps {
- homeworkId: number;
- peerReviews: PeerReviewResponse[];
- isMyHomework: boolean;
+ homeworkId: number;
+ peerReviews: PeerReviewResponse[];
+ isMyHomework: boolean;
}
function PeerReviewSection({
- homeworkId,
- peerReviews,
- isMyHomework,
+ homeworkId,
+ peerReviews,
+ isMyHomework,
}: PeerReviewSectionProps) {
- // 자기 과제가 아닌 경우에만 리뷰 작성 가능 (리더도 허용)
- const canWriteReview = !isMyHomework;
- const [reviewText, setReviewText] = useState("");
- const { mutate: createPeerReview, isPending } = useCreatePeerReview();
-
- const handleSubmitReview = () => {
- if (!reviewText.trim()) return;
-
- createPeerReview(
- {
- homeworkId,
- request: { comment: reviewText },
- },
- {
- onSuccess: () => {
- setReviewText("");
- },
- },
- );
- };
-
- return (
-
-
- 피어 리뷰
-
- {peerReviews.length}건
-
-
-
-
- {/* 피어 리뷰 목록 */}
- {peerReviews.length > 0 && (
-
- {peerReviews.map((review) => (
-
- ))}
-
- )}
-
- {/* 리뷰 입력 - 자기 과제가 아닌 경우에만 표시 */}
- {canWriteReview && (
-
- )}
-
-
- );
+ // 자기 과제가 아닌 경우에만 리뷰 작성 가능 (리더도 허용)
+ const canWriteReview = !isMyHomework;
+ const [reviewText, setReviewText] = useState('');
+ const { mutate: createPeerReview, isPending } = useCreatePeerReview();
+
+ const handleSubmitReview = () => {
+ if (!reviewText.trim()) return;
+
+ createPeerReview(
+ {
+ homeworkId,
+ request: { comment: reviewText },
+ },
+ {
+ onSuccess: () => {
+ setReviewText('');
+ },
+ },
+ );
+ };
+
+ return (
+
+
+ 피어 리뷰
+
+ {peerReviews.length}건
+
+
+
+
+ {/* 피어 리뷰 목록 */}
+ {peerReviews.length > 0 && (
+
+ {peerReviews.map((review) => (
+
+ ))}
+
+ )}
+
+ {/* 리뷰 입력 - 자기 과제가 아닌 경우에만 표시 */}
+ {canWriteReview && (
+
+ )}
+
+
+ );
}
interface PeerReviewItemProps {
- review: PeerReviewResponse;
- homeworkId: number;
+ review: PeerReviewResponse;
+ homeworkId: number;
}
function PeerReviewItem({ review, homeworkId }: PeerReviewItemProps) {
- const currentUserId = useUserStore((state) => state.memberId);
- const [isEditing, setIsEditing] = useState(false);
- const [editValue, setEditValue] = useState(review.comment ?? "");
- const [showDeleteModal, setShowDeleteModal] = useState(false);
-
- const { mutate: updatePeerReview, isPending: isUpdating } =
- useUpdatePeerReview();
- const { mutate: deletePeerReview } = useDeletePeerReview();
-
- const isMyReview = review.reviewerId === currentUserId;
-
- const profileImageUrl =
- review.reviewerProfileImage?.resizedImages?.[0]?.resizedImageUrl ??
- "/profile-default.svg";
-
- const formatDateTime = (dateString?: string) => {
- if (!dateString) return "";
- const date = new Date(dateString);
- const year = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, "0");
- const day = String(date.getDate()).padStart(2, "0");
- const hours = String(date.getHours()).padStart(2, "0");
- const minutes = String(date.getMinutes()).padStart(2, "0");
-
- return `${year}.${month}.${day} ${hours}:${minutes}`;
- };
-
- const handleUpdate = () => {
- if (!editValue.trim() || !review.peerReviewId) return;
-
- updatePeerReview(
- {
- peerReviewId: review.peerReviewId,
- request: { comment: editValue },
- },
- {
- onSuccess: () => {
- setIsEditing(false);
- },
- },
- );
- };
-
- const handleDelete = () => {
- if (!review.peerReviewId) return;
-
- deletePeerReview(review.peerReviewId, {
- onSuccess: () => {
- setShowDeleteModal(false);
- },
- });
- };
-
- const getMenuOptions = () => {
- if (!isMyReview) return [];
-
- return [
- {
- label: "수정하기",
- value: "edit",
- onMenuClick: () => {
- setEditValue(review.comment ?? "");
- setIsEditing(true);
- },
- },
- {
- label: "삭제하기",
- value: "delete",
- onMenuClick: () => {
- setShowDeleteModal(true);
- },
- },
- ];
- };
-
- return (
-
-
setShowDeleteModal(false)}
- title="피어 리뷰를 삭제하시겠습니까?"
- content={
- <>
- 삭제 시 모든 데이터가 영구적으로 제거됩니다.
-
이 동작은 되돌릴 수 없습니다.
- >
- }
- confirmText="삭제"
- onConfirm={handleDelete}
- />
-
-
-
-
-
- {review.reviewerNickname}
-
-
- {formatDateTime(review.createdAt)}
- {review.updated && " (수정됨)"}
-
-
-
- {isMyReview &&
}
-
-
- {isEditing ? (
-
- ) : (
-
- {review.comment}
-
- )}
-
- );
+ const currentUserId = useUserStore((state) => state.memberId);
+ const [isEditing, setIsEditing] = useState(false);
+ const [editValue, setEditValue] = useState(review.comment ?? '');
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
+
+ const { mutate: updatePeerReview, isPending: isUpdating } =
+ useUpdatePeerReview();
+ const { mutate: deletePeerReview } = useDeletePeerReview();
+
+ const isMyReview = review.reviewerId === currentUserId;
+
+ const profileImageUrl =
+ review.reviewerProfileImage?.resizedImages?.[0]?.resizedImageUrl ??
+ '/profile-default.svg';
+
+ const formatDateTime = (dateString?: string) => {
+ if (!dateString) return '';
+ const date = new Date(dateString);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+
+ return `${year}.${month}.${day} ${hours}:${minutes}`;
+ };
+
+ const handleUpdate = () => {
+ if (!editValue.trim() || !review.peerReviewId) return;
+
+ updatePeerReview(
+ {
+ peerReviewId: review.peerReviewId,
+ request: { comment: editValue },
+ },
+ {
+ onSuccess: () => {
+ setIsEditing(false);
+ },
+ },
+ );
+ };
+
+ const handleDelete = () => {
+ if (!review.peerReviewId) return;
+
+ deletePeerReview(review.peerReviewId, {
+ onSuccess: () => {
+ setShowDeleteModal(false);
+ },
+ });
+ };
+
+ const getMenuOptions = () => {
+ if (!isMyReview) return [];
+
+ return [
+ {
+ label: '수정하기',
+ value: 'edit',
+ onMenuClick: () => {
+ setEditValue(review.comment ?? '');
+ setIsEditing(true);
+ },
+ },
+ {
+ label: '삭제하기',
+ value: 'delete',
+ onMenuClick: () => {
+ setShowDeleteModal(true);
+ },
+ },
+ ];
+ };
+
+ return (
+
+
setShowDeleteModal(false)}
+ title="피어 리뷰를 삭제하시겠습니까?"
+ content={
+ <>
+ 삭제 시 모든 데이터가 영구적으로 제거됩니다.
+
이 동작은 되돌릴 수 없습니다.
+ >
+ }
+ confirmText="삭제"
+ onConfirm={handleDelete}
+ />
+
+
+
+
+
+ {review.reviewerNickname}
+
+
+ {formatDateTime(review.createdAt)}
+ {review.updated && ' (수정됨)'}
+
+
+
+ {isMyReview &&
}
+
+
+ {isEditing ? (
+
+ ) : (
+
+ {review.comment}
+
+ )}
+
+ );
}
interface PeerReviewInputProps {
- value: string;
- onChange: (value: string) => void;
- onSubmit: () => void;
- isLoading?: boolean;
+ value: string;
+ onChange: (value: string) => void;
+ onSubmit: () => void;
+ isLoading?: boolean;
}
function PeerReviewInput({
- value,
- onChange,
- onSubmit,
- isLoading,
+ value,
+ onChange,
+ onSubmit,
+ isLoading,
}: PeerReviewInputProps) {
- return (
-
- );
+ return (
+
+ );
}
diff --git a/src/components/modals/create-evaluation-modal.tsx b/src/components/modals/create-evaluation-modal.tsx
index ab87a8b3..81e52e0e 100644
--- a/src/components/modals/create-evaluation-modal.tsx
+++ b/src/components/modals/create-evaluation-modal.tsx
@@ -1,178 +1,178 @@
-import { zodResolver } from "@hookform/resolvers/zod";
-import { useState } from "react";
-import { FormProvider, useForm } from "react-hook-form";
-import { z } from "zod";
-import Button from "@/components/ui/button";
-import FormField from "@/components/ui/form/form-field";
-import { Modal } from "@/components/ui/modal";
-import { GroupItems } from "@/components/ui/toggle";
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useState } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+import { z } from 'zod';
+import Button from '@/components/ui/button';
+import FormField from '@/components/ui/form/form-field';
+import { Modal } from '@/components/ui/modal';
+import { GroupItems } from '@/components/ui/toggle';
import {
- useCreateEvaluation,
- useGetMissionEvaluationGrades,
-} from "@/hooks/queries/evaluation-api";
-import { useScrollToNextField } from "@/hooks/use-scroll-to-next-field";
-import { useToastStore } from "@/stores/use-toast-store";
-import { TextAreaInput } from "../ui/input";
+ useCreateEvaluation,
+ useGetMissionEvaluationGrades,
+} from '@/hooks/queries/evaluation-api';
+import { useScrollToNextField } from '@/hooks/use-scroll-to-next-field';
+import { useToastStore } from '@/stores/use-toast-store';
+import { TextAreaInput } from '../ui/input';
const CreateEvaluationFormSchema = z.object({
- gradeCode: z.enum([
- "A_PLUS",
- "A_MINUS",
- "B_PLUS",
- "B_MINUS",
- "C_PLUS",
- "C_MINUS",
- "F",
- ]),
- comment: z.string().min(1, "정성 코멘트를 입력해주세요.").max(1000),
+ gradeCode: z.enum([
+ 'A_PLUS',
+ 'A_MINUS',
+ 'B_PLUS',
+ 'B_MINUS',
+ 'C_PLUS',
+ 'C_MINUS',
+ 'F',
+ ]),
+ comment: z.string().min(1, '정성 코멘트를 입력해주세요.').max(1000),
});
type CreateEvaluationFormValues = z.infer;
interface CreateEvaluationModalProps {
- homeworkId: number; // todo api response 타입 적용
+ homeworkId: number; // todo api response 타입 적용
}
export default function CreateEvaluationModal({
- homeworkId,
+ homeworkId,
}: CreateEvaluationModalProps) {
- const [open, setOpen] = useState(false);
-
- return (
-
-
-
-
-
-
-
-
-
-
- 평가하기
-
- setOpen(false)} />
-
-
- setOpen(false)}
- />
-
-
-
- );
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ 평가하기
+
+ setOpen(false)} />
+
+
+ setOpen(false)}
+ />
+
+
+
+ );
}
interface CreateEvaluationFormProps {
- homeworkId: number; // todo api response 타입 적용
- onClose: () => void;
+ homeworkId: number; // todo api response 타입 적용
+ onClose: () => void;
}
function CreateEvaluationForm({
- homeworkId,
- onClose,
+ homeworkId,
+ onClose,
}: CreateEvaluationFormProps) {
- const methods = useForm({
- resolver: zodResolver(CreateEvaluationFormSchema),
- mode: "onChange",
- defaultValues: {
- gradeCode: undefined,
- comment: "",
- },
- });
-
- const { handleSubmit, formState } = methods;
-
- const scrollToNext = useScrollToNextField();
- const { data: grades } = useGetMissionEvaluationGrades();
- const { mutate: createEvaluation } = useCreateEvaluation();
- const showToast = useToastStore((state) => state.showToast);
-
- const onValidSubmit = (values: CreateEvaluationFormValues) => {
- createEvaluation(
- {
- homeworkId,
- request: values,
- },
- {
- onSuccess: () => {
- showToast("평가가 성공적으로 제출되었습니다!");
- onClose();
- },
- onError: () => {
- showToast("평가 제출에 실패했습니다. 다시 시도해주세요.", "error");
- },
- },
- );
- };
-
- const gradeOptions = grades
- ?.sort((a, b) => a.orderNum - b.orderNum)
- .map((grade) => ({
- value: grade.code,
- label: `${grade.label} (${grade.score === 0 ? "0" : grade.score.toFixed(1)})`,
- }));
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- );
+ const methods = useForm({
+ resolver: zodResolver(CreateEvaluationFormSchema),
+ mode: 'onChange',
+ defaultValues: {
+ gradeCode: undefined,
+ comment: '',
+ },
+ });
+
+ const { handleSubmit, formState } = methods;
+
+ const scrollToNext = useScrollToNextField();
+ const { data: grades } = useGetMissionEvaluationGrades();
+ const { mutate: createEvaluation } = useCreateEvaluation();
+ const showToast = useToastStore((state) => state.showToast);
+
+ const onValidSubmit = (values: CreateEvaluationFormValues) => {
+ createEvaluation(
+ {
+ homeworkId,
+ request: values,
+ },
+ {
+ onSuccess: () => {
+ showToast('평가가 성공적으로 제출되었습니다!');
+ onClose();
+ },
+ onError: () => {
+ showToast('평가 제출에 실패했습니다. 다시 시도해주세요.', 'error');
+ },
+ },
+ );
+ };
+
+ const gradeOptions = grades
+ ?.sort((a, b) => a.orderNum - b.orderNum)
+ .map((grade) => ({
+ value: grade.code,
+ label: `${grade.label} (${grade.score === 0 ? '0' : grade.score.toFixed(1)})`,
+ }));
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
}
diff --git a/src/components/modals/submit-homework-modal.tsx b/src/components/modals/submit-homework-modal.tsx
index f9a8b4b4..22473d3b 100644
--- a/src/components/modals/submit-homework-modal.tsx
+++ b/src/components/modals/submit-homework-modal.tsx
@@ -1,173 +1,173 @@
-import { zodResolver } from "@hookform/resolvers/zod";
-import { useState } from "react";
-import { FormProvider, useForm, useFormContext } from "react-hook-form";
-import { z } from "zod";
-import Button from "@/components/ui/button";
-import FormField from "@/components/ui/form/form-field";
-import { Modal } from "@/components/ui/modal";
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useState } from 'react';
+import { FormProvider, useForm, useFormContext } from 'react-hook-form';
+import { z } from 'zod';
+import Button from '@/components/ui/button';
+import FormField from '@/components/ui/form/form-field';
+import { Modal } from '@/components/ui/modal';
-import { useSubmitHomework } from "@/hooks/queries/group-study-homework-api";
-import { useToastStore } from "@/stores/use-toast-store";
-import { BaseInput, TextAreaInput } from "../ui/input";
+import { useSubmitHomework } from '@/hooks/queries/group-study-homework-api';
+import { useToastStore } from '@/stores/use-toast-store';
+import { BaseInput, TextAreaInput } from '../ui/input';
const SubmitHomeworkFormSchema = z.object({
- textContent: z.string().min(1, "과제 상세 내용을 입력해주세요.").max(1000),
- attachmentLink: z.string().optional(),
+ textContent: z.string().min(1, '과제 상세 내용을 입력해주세요.').max(1000),
+ attachmentLink: z.string().optional(),
});
type SubmitHomeworkFormValues = z.infer;
interface SubmitHomeworkModalProps {
- missionId: number; // todo api response 타입 적용
- onSuccess?: () => void;
+ missionId: number; // todo api response 타입 적용
+ onSuccess?: () => void;
}
// 과제 제출 모달
export default function SubmitHomeworkModal({
- missionId,
- onSuccess,
+ missionId,
+ onSuccess,
}: SubmitHomeworkModalProps) {
- const [open, setOpen] = useState(false);
- const methods = useForm({
- resolver: zodResolver(SubmitHomeworkFormSchema),
- mode: "onChange",
- defaultValues: {
- textContent: "",
- attachmentLink: "",
- },
- });
+ const [open, setOpen] = useState(false);
+ const methods = useForm({
+ resolver: zodResolver(SubmitHomeworkFormSchema),
+ mode: 'onChange',
+ defaultValues: {
+ textContent: '',
+ attachmentLink: '',
+ },
+ });
- return (
-
-
-
-
-
+ return (
+
+
+
+
+
-
-
-
-
-
- 과제 제출하기
-
- setOpen(false)} />
-
+
+
+
+
+
+ 과제 제출하기
+
+ setOpen(false)} />
+
- setOpen(false)}
- onSuccess={() => {
- methods.reset();
- onSuccess?.();
- }}
- />
-
-
-
-
- );
+ setOpen(false)}
+ onSuccess={() => {
+ methods.reset();
+ onSuccess?.();
+ }}
+ />
+
+
+
+
+ );
}
interface SubmitHomeworkFormProps {
- missionId: number;
- onClose: () => void;
- onSuccess?: () => void;
+ missionId: number;
+ onClose: () => void;
+ onSuccess?: () => void;
}
function SubmitHomeworkForm({
- missionId,
- onClose,
- onSuccess,
+ missionId,
+ onClose,
+ onSuccess,
}: SubmitHomeworkFormProps) {
- const methods = useFormContext();
- const { handleSubmit, formState } = methods;
+ const methods = useFormContext();
+ const { handleSubmit, formState } = methods;
- const { mutate: submitHomework } = useSubmitHomework();
- const showToast = useToastStore((state) => state.showToast);
+ const { mutate: submitHomework } = useSubmitHomework();
+ const showToast = useToastStore((state) => state.showToast);
- const onValidSubmit = (values: SubmitHomeworkFormValues) => {
- submitHomework(
- {
- missionId,
- request: {
- textContent: values.textContent,
- optionalSubmission: { link: values.attachmentLink },
- },
- },
- {
- onSuccess: async () => {
- showToast("과제가 성공적으로 제출되었습니다!");
- onClose();
- onSuccess?.();
- },
- onError: () => {
- showToast("과제 제출에 실패했습니다. 다시 시도해주세요.", "error");
- },
- },
- );
- };
+ const onValidSubmit = (values: SubmitHomeworkFormValues) => {
+ submitHomework(
+ {
+ missionId,
+ request: {
+ textContent: values.textContent,
+ optionalSubmission: { link: values.attachmentLink },
+ },
+ },
+ {
+ onSuccess: async () => {
+ showToast('과제가 성공적으로 제출되었습니다!');
+ onClose();
+ onSuccess?.();
+ },
+ onError: () => {
+ showToast('과제 제출에 실패했습니다. 다시 시도해주세요.', 'error');
+ },
+ },
+ );
+ };
- return (
- <>
-
-
+
-
-
-
-
-
-
- >
- );
+
+
+
+
+
+
+ >
+ );
}
diff --git a/src/components/pages/group-study-detail-page.tsx b/src/components/pages/group-study-detail-page.tsx
index f1bee44e..7572fc04 100644
--- a/src/components/pages/group-study-detail-page.tsx
+++ b/src/components/pages/group-study-detail-page.tsx
@@ -1,260 +1,260 @@
-"use client";
+'use client';
-import { sendGTMEvent } from "@next/third-parties/google";
-import { useRouter, useSearchParams } from "next/navigation";
-import { useEffect, useState } from "react";
-import InquiryModal from "@/components/modals/inquiry-modal";
-import Button from "@/components/ui/button";
-import MoreMenu from "@/components/ui/dropdown/more-menu";
-import Tabs from "@/components/ui/tabs";
-import { STUDY_DETAIL_TABS, StudyTabValue } from "@/config/constants";
-import { useGetGroupStudyMyStatus } from "@/hooks/queries/group-study-member-api";
-import { useToastStore } from "@/stores/use-toast-store";
-import { useLeaderStore } from "@/stores/useLeaderStore";
-import { Leader } from "../../features/study/group/api/group-study-types";
-import ChannelSection from "../../features/study/group/channel/ui/lounge-section";
+import { sendGTMEvent } from '@next/third-parties/google';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { useEffect, useState } from 'react';
+import InquiryModal from '@/components/modals/inquiry-modal';
+import Button from '@/components/ui/button';
+import MoreMenu from '@/components/ui/dropdown/more-menu';
+import Tabs from '@/components/ui/tabs';
+import { STUDY_DETAIL_TABS, StudyTabValue } from '@/config/constants';
+import { useGetGroupStudyMyStatus } from '@/hooks/queries/group-study-member-api';
+import { useToastStore } from '@/stores/use-toast-store';
+import { useLeaderStore } from '@/stores/useLeaderStore';
+import { Leader } from '../../features/study/group/api/group-study-types';
+import ChannelSection from '../../features/study/group/channel/ui/lounge-section';
import {
- useCompleteGroupStudyMutation,
- useDeleteGroupStudyMutation,
- useGroupStudyDetailQuery,
-} from "../../features/study/group/model/use-study-query";
-import ConfirmDeleteModal from "../../features/study/group/ui/confirm-delete-modal";
-import GroupStudyFormModal from "../../features/study/group/ui/group-study-form-modal";
-import GroupStudyMemberList from "../lists/study-member-list";
-import StudyInfoSection from "../section/group-study-info-section";
-import MissionSection from "../section/mission-section";
+ useCompleteGroupStudyMutation,
+ useDeleteGroupStudyMutation,
+ useGroupStudyDetailQuery,
+} from '../../features/study/group/model/use-study-query';
+import ConfirmDeleteModal from '../../features/study/group/ui/confirm-delete-modal';
+import GroupStudyFormModal from '../../features/study/group/ui/group-study-form-modal';
+import GroupStudyMemberList from '../lists/study-member-list';
+import StudyInfoSection from '../section/group-study-info-section';
+import MissionSection from '../section/mission-section';
-type ActionKey = "end" | "delete"; // 필요 시 'edit' 등 추가
+type ActionKey = 'end' | 'delete'; // 필요 시 'edit' 등 추가
interface StudyDetailPageProps {
- groupStudyId: number;
- memberId?: number;
+ groupStudyId: number;
+ memberId?: number;
}
export default function StudyDetailPage({
- groupStudyId,
- memberId,
+ groupStudyId,
+ memberId,
}: StudyDetailPageProps) {
- const router = useRouter();
- const searchParams = useSearchParams();
- const setLeaderInfo = useLeaderStore((state) => state.setLeaderInfo);
- const showToast = useToastStore((state) => state.showToast);
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const setLeaderInfo = useLeaderStore((state) => state.setLeaderInfo);
+ const showToast = useToastStore((state) => state.showToast);
- const tabFromUrl = searchParams.get("tab") as StudyTabValue | null;
+ const tabFromUrl = searchParams.get('tab') as StudyTabValue | null;
- const { data: studyDetail, isLoading } =
- useGroupStudyDetailQuery(groupStudyId);
+ const { data: studyDetail, isLoading } =
+ useGroupStudyDetailQuery(groupStudyId);
- const leaderId = studyDetail?.basicInfo.leader.memberId;
+ const leaderId = studyDetail?.basicInfo.leader.memberId;
- const isLeader = leaderId === memberId;
+ const isLeader = leaderId === memberId;
- // 리더 정보를 Zustand store에 저장
- useEffect(() => {
- if (studyDetail?.basicInfo.leader) {
- setLeaderInfo(studyDetail.basicInfo.leader as Leader);
- }
- }, [studyDetail?.basicInfo.leader, setLeaderInfo]);
+ // 리더 정보를 Zustand store에 저장
+ useEffect(() => {
+ if (studyDetail?.basicInfo.leader) {
+ setLeaderInfo(studyDetail.basicInfo.leader as Leader);
+ }
+ }, [studyDetail?.basicInfo.leader, setLeaderInfo]);
- const [active, setActive] = useState(tabFromUrl || "intro");
- const [showModal, setShowModal] = useState(false);
- const [action, setAction] = useState(null);
- const [showStudyFormModal, setShowStudyFormModal] = useState(false);
- const [showInquiryModal, setShowInquiryModal] = useState(false);
+ const [active, setActive] = useState(tabFromUrl || 'intro');
+ const [showModal, setShowModal] = useState(false);
+ const [action, setAction] = useState(null);
+ const [showStudyFormModal, setShowStudyFormModal] = useState(false);
+ const [showInquiryModal, setShowInquiryModal] = useState(false);
- const { data: myApplicationStatus } = useGetGroupStudyMyStatus({
- groupStudyId,
- isLeader,
- });
+ const { data: myApplicationStatus } = useGetGroupStudyMyStatus({
+ groupStudyId,
+ isLeader,
+ });
- const { mutate: deleteGroupStudy } = useDeleteGroupStudyMutation();
- const { mutate: completeStudy } = useCompleteGroupStudyMutation();
+ const { mutate: deleteGroupStudy } = useDeleteGroupStudyMutation();
+ const { mutate: completeStudy } = useCompleteGroupStudyMutation();
- const ModalContent = {
- end: {
- title: "스터디를 종료하시겠어요?",
- content: (
- <>
- 종료 후에는 더 이상 모집/활동이 불가합니다.
-
이 동작은 되돌릴 수 없습니다.
- >
- ),
- confirmText: "스터디 종료",
- onConfirm: () => {
- completeStudy(
- { groupStudyId },
- {
- onSuccess: () => {
- sendGTMEvent({
- event: "group_study_end",
- group_study_id: String(groupStudyId),
- });
- showToast("스터디가 종료되었습니다.");
- router.push("/group-study");
- },
- onError: () => {
- showToast("스터디 종료에 실패하였습니다.", "error");
- },
- onSettled: () => {
- setShowModal(false);
- },
- },
- );
- },
- },
- delete: {
- title: "스터디를 삭제하시겠어요?",
- content: (
- <>
- 삭제 시 모든 데이터가 영구적으로 제거됩니다.
-
이 동작은 되돌릴 수 없습니다.
- >
- ),
- confirmText: "스터디 삭제",
- onConfirm: () => {
- deleteGroupStudy(
- { groupStudyId },
- {
- onSuccess: () => {
- sendGTMEvent({
- event: "group_study_delete",
- group_study_id: String(groupStudyId),
- });
- showToast("스터디가 삭제되었습니다.");
- router.push("/group-study");
- },
- onError: () => {
- showToast("스터디 삭제에 실패하였습니다.", "error");
- },
- onSettled: () => {
- setShowModal(false);
- },
- },
- );
- },
- },
- };
+ const ModalContent = {
+ end: {
+ title: '스터디를 종료하시겠어요?',
+ content: (
+ <>
+ 종료 후에는 더 이상 모집/활동이 불가합니다.
+
이 동작은 되돌릴 수 없습니다.
+ >
+ ),
+ confirmText: '스터디 종료',
+ onConfirm: () => {
+ completeStudy(
+ { groupStudyId },
+ {
+ onSuccess: () => {
+ sendGTMEvent({
+ event: 'group_study_end',
+ group_study_id: String(groupStudyId),
+ });
+ showToast('스터디가 종료되었습니다.');
+ router.push('/group-study');
+ },
+ onError: () => {
+ showToast('스터디 종료에 실패하였습니다.', 'error');
+ },
+ onSettled: () => {
+ setShowModal(false);
+ },
+ },
+ );
+ },
+ },
+ delete: {
+ title: '스터디를 삭제하시겠어요?',
+ content: (
+ <>
+ 삭제 시 모든 데이터가 영구적으로 제거됩니다.
+
이 동작은 되돌릴 수 없습니다.
+ >
+ ),
+ confirmText: '스터디 삭제',
+ onConfirm: () => {
+ deleteGroupStudy(
+ { groupStudyId },
+ {
+ onSuccess: () => {
+ sendGTMEvent({
+ event: 'group_study_delete',
+ group_study_id: String(groupStudyId),
+ });
+ showToast('스터디가 삭제되었습니다.');
+ router.push('/group-study');
+ },
+ onError: () => {
+ showToast('스터디 삭제에 실패하였습니다.', 'error');
+ },
+ onSettled: () => {
+ setShowModal(false);
+ },
+ },
+ );
+ },
+ },
+ };
- // 참가자, 채널 탭 접근 가능 여부 = 스터디 참가자 또는 방장만 가능
- const isMember =
- myApplicationStatus?.status === "APPROVED" ||
- myApplicationStatus?.status === "KICKED";
+ // 참가자, 채널 탭 접근 가능 여부 = 스터디 참가자 또는 방장만 가능
+ const isMember =
+ myApplicationStatus?.status === 'APPROVED' ||
+ myApplicationStatus?.status === 'KICKED';
- if (isLoading || !studyDetail) {
- return 로딩중...
;
- }
+ if (isLoading || !studyDetail) {
+ return 로딩중...
;
+ }
- return (
-
-
setShowModal(!showModal)}
- title={ModalContent[action]?.title}
- content={ModalContent[action]?.content}
- confirmText={ModalContent[action]?.confirmText}
- onConfirm={ModalContent[action]?.onConfirm}
- />
- setShowStudyFormModal(!showStudyFormModal)}
- />
-
+ return (
+
+
setShowModal(!showModal)}
+ title={ModalContent[action]?.title}
+ content={ModalContent[action]?.content}
+ confirmText={ModalContent[action]?.confirmText}
+ onConfirm={ModalContent[action]?.onConfirm}
+ />
+ setShowStudyFormModal(!showStudyFormModal)}
+ />
+
-
-
-
- {studyDetail?.detailInfo.title}
-
-
-
- {studyDetail?.detailInfo.summary}
-
-
- {isLeader && (
-
{
- setShowStudyFormModal(true);
- },
- },
- {
- label: "스터디 종료",
- value: "end",
- onMenuClick: () => {
- setAction("end");
- setShowModal(true);
- },
- },
- {
- label: "스터디 삭제",
- value: "delete",
- onMenuClick: () => {
- setAction("delete");
- setShowModal(true);
- },
- },
- ]}
- iconSize={35}
- />
- )}
-
+
+
+
+ {studyDetail?.detailInfo.title}
+
+
+
+ {studyDetail?.detailInfo.summary}
+
+
+ {isLeader && (
+
{
+ setShowStudyFormModal(true);
+ },
+ },
+ {
+ label: '스터디 종료',
+ value: 'end',
+ onMenuClick: () => {
+ setAction('end');
+ setShowModal(true);
+ },
+ },
+ {
+ label: '스터디 삭제',
+ value: 'delete',
+ onMenuClick: () => {
+ setAction('delete');
+ setShowModal(true);
+ },
+ },
+ ]}
+ iconSize={35}
+ />
+ )}
+
- {/** 탭리스트 */}
- tab.value === "intro" || isLeader || isMember,
- )}
- activeTab={active}
- onChange={(value: StudyTabValue) => {
- setActive(value);
+ {/** 탭리스트 */}
+ tab.value === 'intro' || isLeader || isMember,
+ )}
+ activeTab={active}
+ onChange={(value: StudyTabValue) => {
+ setActive(value);
- // 탭 변경 시 URL 파라미터 초기화 및 탭 값 설정
- router.replace(`?tab=${value}`);
+ // 탭 변경 시 URL 파라미터 초기화 및 탭 값 설정
+ router.replace(`?tab=${value}`);
- sendGTMEvent({
- event: "group_study_tab_change",
- group_study_id: String(groupStudyId),
- tab: value,
- });
- }}
- />
- {active === "intro" && (
-
- )}
- {active === "members" && (
-
- )}
+ sendGTMEvent({
+ event: 'group_study_tab_change',
+ group_study_id: String(groupStudyId),
+ tab: value,
+ });
+ }}
+ />
+ {active === 'intro' && (
+
+ )}
+ {active === 'members' && (
+
+ )}
- {active === "mission" && }
- {active === "lounge" && (
-
- )}
-
- );
+ {active === 'mission' && }
+ {active === 'lounge' && (
+
+ )}
+
+ );
}
diff --git a/src/components/section/group-study-info-section.tsx b/src/components/section/group-study-info-section.tsx
index 660a3bb6..57c7731b 100644
--- a/src/components/section/group-study-info-section.tsx
+++ b/src/components/section/group-study-info-section.tsx
@@ -1,150 +1,150 @@
-"use client";
+'use client';
-import Image from "next/image";
-import { useParams, useRouter } from "next/navigation";
-import { useMemo } from "react";
-import { GroupStudyFullResponseDto } from "@/api/openapi";
-import UserAvatar from "@/components/ui/avatar";
-import AvatarStack from "@/components/ui/avatar-stack";
-import type { AvatarStackMember } from "@/components/ui/avatar-stack";
-import Button from "@/components/ui/button";
-import UserProfileModal from "@/entities/user/ui/user-profile-modal";
-import { useApplicantsByStatusQuery } from "@/features/study/group/application/model/use-applicant-qeury";
+import Image from 'next/image';
+import { useParams, useRouter } from 'next/navigation';
+import { useMemo } from 'react';
+import { GroupStudyFullResponseDto } from '@/api/openapi';
+import UserAvatar from '@/components/ui/avatar';
+import AvatarStack from '@/components/ui/avatar-stack';
+import type { AvatarStackMember } from '@/components/ui/avatar-stack';
+import Button from '@/components/ui/button';
+import UserProfileModal from '@/entities/user/ui/user-profile-modal';
+import { useApplicantsByStatusQuery } from '@/features/study/group/application/model/use-applicant-qeury';
-import SummaryStudyInfo from "../summary/study-info-summary";
+import SummaryStudyInfo from '../summary/study-info-summary';
interface StudyInfoSectionProps {
- study: GroupStudyFullResponseDto;
- isLeader: boolean;
+ study: GroupStudyFullResponseDto;
+ isLeader: boolean;
}
export default function StudyInfoSection({
- study: studyDetail,
- isLeader,
+ study: studyDetail,
+ isLeader,
}: StudyInfoSectionProps) {
- const router = useRouter();
- const params = useParams();
+ const router = useRouter();
+ const params = useParams();
- const groupStudyId = Number(params.id);
+ const groupStudyId = Number(params.id);
- const { data: approvedApplicants } = useApplicantsByStatusQuery({
- groupStudyId,
- status: "APPROVED",
- });
- const applicants = approvedApplicants?.pages[0]?.content;
+ const { data: approvedApplicants } = useApplicantsByStatusQuery({
+ groupStudyId,
+ status: 'APPROVED',
+ });
+ const applicants = approvedApplicants?.pages[0]?.content;
- const avatarMembers = useMemo(() => {
- if (!applicants) return [];
+ const avatarMembers = useMemo(() => {
+ if (!applicants) return [];
- const leader = applicants.find((applicant) => applicant.role === "LEADER");
- const participants = applicants.filter(
- (applicant) => applicant.role !== "LEADER",
- );
+ const leader = applicants.find((applicant) => applicant.role === 'LEADER');
+ const participants = applicants.filter(
+ (applicant) => applicant.role !== 'LEADER',
+ );
- const sortedParticipants = [...participants].sort(
- (a, b) =>
- new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
- );
+ const sortedParticipants = [...participants].sort(
+ (a, b) =>
+ new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
+ );
- const sortedApplicants = leader
- ? [leader, ...sortedParticipants]
- : sortedParticipants;
+ const sortedApplicants = leader
+ ? [leader, ...sortedParticipants]
+ : sortedParticipants;
- return sortedApplicants.map((data) => ({
- memberId: data.applicantInfo.memberId,
- nickname:
- data.role === "LEADER"
- ? `👑 ${data.applicantInfo.memberNickname || "익명"}`
- : data.applicantInfo.memberNickname || "익명",
- profileImageUrl:
- data.applicantInfo.profileImage?.resizedImages[0]?.resizedImageUrl ??
- "",
- isLeader: data.role === "LEADER",
- }));
- }, [applicants]);
+ return sortedApplicants.map((data) => ({
+ memberId: data.applicantInfo.memberId,
+ nickname:
+ data.role === 'LEADER'
+ ? `👑 ${data.applicantInfo.memberNickname || '익명'}`
+ : data.applicantInfo.memberNickname || '익명',
+ profileImageUrl:
+ data.applicantInfo.profileImage?.resizedImages[0]?.resizedImageUrl ??
+ '',
+ isLeader: data.role === 'LEADER',
+ }));
+ }, [applicants]);
- return (
-
-
-
-
-
+ return (
+
+
+
+
+
-
-
-
스터디 소개
-
-
-
-
-
-
- {studyDetail.basicInfo.leader.memberNickname}
-
-
- 스터디 리더
-
-
- {studyDetail.basicInfo.leader.simpleIntroduction}
-
-
-
-
-
-
- 프로필
-
- }
- />
-
-
- {studyDetail?.detailInfo.description}
-
-
+
+
+
스터디 소개
+
+
+
+
+
+
+ {studyDetail.basicInfo.leader.memberNickname}
+
+
+ 스터디 리더
+
+
+ {studyDetail.basicInfo.leader.simpleIntroduction}
+
+
+
+
+
+
+ 프로필
+
+ }
+ />
+
+
+ {studyDetail?.detailInfo.description}
+
+
-
-
-
- 참가자 목록
- {`${applicants?.length ?? 0}명`}
-
- {isLeader && (
-
- )}
-
+
+
+
+ 참가자 목록
+ {`${applicants?.length ?? 0}명`}
+
+ {isLeader && (
+
+ )}
+
-
-
-
-
-
-
- );
+
+
+
+
+
+
+ );
}
diff --git a/src/features/study/group/ui/step/step1-group.tsx b/src/features/study/group/ui/step/step1-group.tsx
index 67682e2b..99948ada 100644
--- a/src/features/study/group/ui/step/step1-group.tsx
+++ b/src/features/study/group/ui/step/step1-group.tsx
@@ -1,299 +1,299 @@
-"use client";
+'use client';
-import { addDays } from "date-fns";
+import { addDays } from 'date-fns';
import {
- Controller,
- useController,
- useFormContext,
- useWatch,
-} from "react-hook-form";
+ Controller,
+ useController,
+ useFormContext,
+ useWatch,
+} from 'react-hook-form';
-import { SingleDropdown } from "@/components/ui/dropdown";
-import FormField from "@/components/ui/form/form-field";
-import { BaseInput } from "@/components/ui/input";
-import { RadioGroup, RadioGroupItem } from "@/components/ui/radio";
-import { GroupItems } from "@/components/ui/toggle";
+import { SingleDropdown } from '@/components/ui/dropdown';
+import FormField from '@/components/ui/form/form-field';
+import { BaseInput } from '@/components/ui/input';
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio';
+import { GroupItems } from '@/components/ui/toggle';
import {
- useScrollToNextField,
- SCROLL_FIELD_ATTR,
-} from "@/hooks/use-scroll-to-next-field";
-import { formatKoreaYMD, getKoreaDate } from "@/utils/time";
-import { TargetRole } from "../../api/group-study-types";
+ useScrollToNextField,
+ SCROLL_FIELD_ATTR,
+} from '@/hooks/use-scroll-to-next-field';
+import { formatKoreaYMD, getKoreaDate } from '@/utils/time';
+import { TargetRole } from '../../api/group-study-types';
import {
- STUDY_TYPES,
- ROLE_OPTIONS_UI,
- EXPERIENCE_LEVEL_OPTIONS_UI,
- STUDY_METHODS,
- STUDY_METHOD_LABELS,
- STUDY_TYPE_LABELS,
- REGULAR_MEETINGS,
- REGULAR_MEETING_LABELS,
-} from "../../const/group-study-const";
-import { GroupStudyFormValues } from "../../model/group-study-form.schema";
-import { useClassification } from "../group-study-form";
+ STUDY_TYPES,
+ ROLE_OPTIONS_UI,
+ EXPERIENCE_LEVEL_OPTIONS_UI,
+ STUDY_METHODS,
+ STUDY_METHOD_LABELS,
+ STUDY_TYPE_LABELS,
+ REGULAR_MEETINGS,
+ REGULAR_MEETING_LABELS,
+} from '../../const/group-study-const';
+import { GroupStudyFormValues } from '../../model/group-study-form.schema';
+import { useClassification } from '../group-study-form';
const methodOptions = STUDY_METHODS.map((v) => ({
- label: STUDY_METHOD_LABELS[v],
- value: v,
+ label: STUDY_METHOD_LABELS[v],
+ value: v,
}));
const memberOptions = Array.from({ length: 20 }, (_, i) => {
- const value = (i + 1).toString();
+ const value = (i + 1).toString();
- return { label: `${value}명`, value };
+ return { label: `${value}명`, value };
});
export default function Step1OpenGroupStudy() {
- const { control, formState, watch } = useFormContext
();
- const classification = useClassification();
- const isPremiumStudy = classification === "PREMIUM_STUDY";
+ const { control, formState, watch } = useFormContext();
+ const classification = useClassification();
+ const isPremiumStudy = classification === 'PREMIUM_STUDY';
- const { field: typeField } = useController({
- name: "type",
- control,
- });
- const { field: regularMeetingField } = useController({
- name: "regularMeeting",
- control,
- });
+ const { field: typeField } = useController({
+ name: 'type',
+ control,
+ });
+ const { field: regularMeetingField } = useController({
+ name: 'regularMeeting',
+ control,
+ });
- const methodValue = useWatch({
- name: "method",
- control,
- });
+ const methodValue = useWatch({
+ name: 'method',
+ control,
+ });
- const scrollToNext = useScrollToNextField();
+ const scrollToNext = useScrollToNextField();
- const filteredStudyTypes =
- classification === "GROUP_STUDY"
- ? STUDY_TYPES.filter((type) => type !== "MENTORING")
- : STUDY_TYPES;
+ const filteredStudyTypes =
+ classification === 'GROUP_STUDY'
+ ? STUDY_TYPES.filter((type) => type !== 'MENTORING')
+ : STUDY_TYPES;
- return (
- <>
- 기본 정보 설정
-
- name="type"
- label="스터디 유형"
- helper="어떤 방식으로 진행되는 스터디인지 선택해주세요."
- direction="vertical"
- size="medium"
- required
- scrollable
- >
- {
- typeField.onChange(v);
- scrollToNext("type");
- }}
- >
- {filteredStudyTypes.map((type) => (
-
-
-
-
- ))}
-
-
-
- name="targetRoles"
- label="모집 대상"
- helper="함께하고 싶은 대상(직무·관심사 등)을 선택해주세요. (복수 선택 가능)"
- direction="vertical"
- size="medium"
- required
- scrollable
- >
-
-
-
- name="maxMembersCount"
- label="모집 인원"
- helper="모집할 최대 참여 인원을 선택해주세요."
- direction="vertical"
- size="medium"
- required
- scrollable
- onAfterChange={() => scrollToNext("maxMembersCount")}
- >
-
-
-
- name="experienceLevels"
- label="경력 여부"
- helper="스터디 참여에 필요한 경력 조건을 선택해주세요.(복수 선택 가능)"
- direction="vertical"
- size="medium"
- required
- scrollable
- >
-
-
-
-
-
- 필수
-
-
- 스터디가 진행되는 방식을 선택해주세요.
-
+ return (
+ <>
+
기본 정보 설정
+
+ name="type"
+ label="스터디 유형"
+ helper="어떤 방식으로 진행되는 스터디인지 선택해주세요."
+ direction="vertical"
+ size="medium"
+ required
+ scrollable
+ >
+ {
+ typeField.onChange(v);
+ scrollToNext('type');
+ }}
+ >
+ {filteredStudyTypes.map((type) => (
+
+
+
+
+ ))}
+
+
+
+ name="targetRoles"
+ label="모집 대상"
+ helper="함께하고 싶은 대상(직무·관심사 등)을 선택해주세요. (복수 선택 가능)"
+ direction="vertical"
+ size="medium"
+ required
+ scrollable
+ >
+
+
+
+ name="maxMembersCount"
+ label="모집 인원"
+ helper="모집할 최대 참여 인원을 선택해주세요."
+ direction="vertical"
+ size="medium"
+ required
+ scrollable
+ onAfterChange={() => scrollToNext('maxMembersCount')}
+ >
+
+
+
+ name="experienceLevels"
+ label="경력 여부"
+ helper="스터디 참여에 필요한 경력 조건을 선택해주세요.(복수 선택 가능)"
+ direction="vertical"
+ size="medium"
+ required
+ scrollable
+ >
+
+
+
+
+
+ 필수
+
+
+ 스터디가 진행되는 방식을 선택해주세요.
+
-
- (
- {
- field.onChange(v);
- scrollToNext("method");
- }}
- placeholder="선택해주세요"
- />
- )}
- />
- (
-
- )}
- />
-
+
+ (
+ {
+ field.onChange(v);
+ scrollToNext('method');
+ }}
+ placeholder="선택해주세요"
+ />
+ )}
+ />
+ (
+
+ )}
+ />
+
- {(formState.errors.method || formState.errors.location) && (
-
- {formState.errors.method?.message ||
- formState.errors.location?.message}
-
- )}
-
-
- name="regularMeeting"
- label="정기 모임"
- helper="정기적으로 모일 빈도를 선택해주세요."
- direction="vertical"
- size="medium"
- required
- scrollable
- >
- {
- regularMeetingField.onChange(v);
- scrollToNext("regularMeeting");
- }}
- >
- {REGULAR_MEETINGS.map((type) => (
-
-
-
-
- ))}
-
-
-
-
-
- 필수
-
-
- 스터디 진행 시작일과 종료일을 선택해주세요.
-
+ {(formState.errors.method || formState.errors.location) && (
+
+ {formState.errors.method?.message ||
+ formState.errors.location?.message}
+
+ )}
+
+
+ name="regularMeeting"
+ label="정기 모임"
+ helper="정기적으로 모일 빈도를 선택해주세요."
+ direction="vertical"
+ size="medium"
+ required
+ scrollable
+ >
+ {
+ regularMeetingField.onChange(v);
+ scrollToNext('regularMeeting');
+ }}
+ >
+ {REGULAR_MEETINGS.map((type) => (
+
+
+
+
+ ))}
+
+
+
+
+
+ 필수
+
+
+ 스터디 진행 시작일과 종료일을 선택해주세요.
+
-
- (
-
- )}
- />
- ~
- (
-
- )}
- />
-
+
+ (
+
+ )}
+ />
+ ~
+ (
+
+ )}
+ />
+
- {(formState.errors.startDate || formState.errors.endDate) && (
-
- {formState.errors.startDate?.message ||
- formState.errors.endDate?.message}
-
- )}
-
- {isPremiumStudy && (
-
- name="price"
- label="참가비"
- direction="vertical"
- size="medium"
- scrollable
- >
- (
-
- )}
- />
-
- )}
- >
- );
+ {(formState.errors.startDate || formState.errors.endDate) && (
+
+ {formState.errors.startDate?.message ||
+ formState.errors.endDate?.message}
+
+ )}
+
+ {isPremiumStudy && (
+
+ name="price"
+ label="참가비"
+ direction="vertical"
+ size="medium"
+ scrollable
+ >
+ (
+
+ )}
+ />
+
+ )}
+ >
+ );
}
diff --git a/src/features/study/group/ui/step/step2-group.tsx b/src/features/study/group/ui/step/step2-group.tsx
index f91f77e9..4e19cda0 100644
--- a/src/features/study/group/ui/step/step2-group.tsx
+++ b/src/features/study/group/ui/step/step2-group.tsx
@@ -1,122 +1,122 @@
-"use client";
+'use client';
-import { useEffect, useState } from "react";
-import { useFormContext, useWatch } from "react-hook-form";
-import FormField from "@/components/ui/form/form-field";
-import { BaseInput, TextAreaInput } from "@/components/ui/input";
-import { useScrollToNextField } from "@/hooks/use-scroll-to-next-field";
-import { THUMBNAIL_EXTENSION } from "../../const/group-study-const";
+import { useEffect, useState } from 'react';
+import { useFormContext, useWatch } from 'react-hook-form';
+import FormField from '@/components/ui/form/form-field';
+import { BaseInput, TextAreaInput } from '@/components/ui/input';
+import { useScrollToNextField } from '@/hooks/use-scroll-to-next-field';
+import { THUMBNAIL_EXTENSION } from '../../const/group-study-const';
-import { GroupStudyFormValues } from "../../model/group-study-form.schema";
-import GroupStudyThumbnailInput from "../group-study-thumbnail-input";
+import { GroupStudyFormValues } from '../../model/group-study-form.schema';
+import GroupStudyThumbnailInput from '../group-study-thumbnail-input';
export default function Step2OpenGroupStudy() {
- const { setValue, getValues } = useFormContext();
- const scrollToNext = useScrollToNextField();
-
- const thumbnailFile = useWatch({
- name: "thumbnailFile",
- });
- const thumbnailExtension = useWatch({
- name: "thumbnailExtension",
- });
-
- const [image, setImage] = useState(
- getValues("thumbnailUrl") || undefined,
- );
-
- useEffect(() => {
- if (thumbnailFile && thumbnailFile instanceof File) {
- setImage(URL.createObjectURL(thumbnailFile));
- } else if (thumbnailExtension === "DEFAULT") {
- setImage(undefined);
- }
- }, [thumbnailFile, thumbnailExtension]);
-
- const handleImageChange = (file: File | null) => {
- if (!file) {
- setValue("thumbnailExtension", "DEFAULT", { shouldValidate: true });
- setValue("thumbnailFile", null);
- setImage(undefined);
-
- return;
- }
-
- const MAX_SIZE = 5 * 1024 * 1024; // 5MB
- if (file.size > MAX_SIZE) {
- alert("이미지 용량은 5MB 이하만 업로드할 수 있어요.");
-
- return;
- }
-
- const ext = file.name.split(".").pop()?.toUpperCase();
- const validExt =
- ext && THUMBNAIL_EXTENSION.includes(ext as any)
- ? (ext as GroupStudyFormValues["thumbnailExtension"])
- : "DEFAULT";
-
- setValue("thumbnailExtension", validExt, { shouldValidate: true });
- setValue("thumbnailFile", file, { shouldValidate: true });
- setImage(URL.createObjectURL(file));
- };
-
- return (
- <>
-
- 스터디 소개 작성
-
-
-
- name="thumbnailExtension"
- label="썸네일"
- direction="vertical"
- size="medium"
- required
- scrollable
- >
-
-
-
-
- name="title"
- label="스터디 제목"
- direction="vertical"
- size="medium"
- required
- scrollable
- onAfterBlurFilled={() => scrollToNext("title")}
- >
-
-
-
-
- name="summary"
- label="스터디 한 줄 소개"
- direction="vertical"
- size="medium"
- required
- scrollable
- onAfterBlurFilled={() => scrollToNext("summary")}
- >
-
-
-
-
- name="description"
- label="스터디 소개"
- direction="vertical"
- size="medium"
- required
- scrollable
- >
-
-
- >
- );
+ const { setValue, getValues } = useFormContext();
+ const scrollToNext = useScrollToNextField();
+
+ const thumbnailFile = useWatch({
+ name: 'thumbnailFile',
+ });
+ const thumbnailExtension = useWatch({
+ name: 'thumbnailExtension',
+ });
+
+ const [image, setImage] = useState(
+ getValues('thumbnailUrl') || undefined,
+ );
+
+ useEffect(() => {
+ if (thumbnailFile && thumbnailFile instanceof File) {
+ setImage(URL.createObjectURL(thumbnailFile));
+ } else if (thumbnailExtension === 'DEFAULT') {
+ setImage(undefined);
+ }
+ }, [thumbnailFile, thumbnailExtension]);
+
+ const handleImageChange = (file: File | null) => {
+ if (!file) {
+ setValue('thumbnailExtension', 'DEFAULT', { shouldValidate: true });
+ setValue('thumbnailFile', null);
+ setImage(undefined);
+
+ return;
+ }
+
+ const MAX_SIZE = 5 * 1024 * 1024; // 5MB
+ if (file.size > MAX_SIZE) {
+ alert('이미지 용량은 5MB 이하만 업로드할 수 있어요.');
+
+ return;
+ }
+
+ const ext = file.name.split('.').pop()?.toUpperCase();
+ const validExt =
+ ext && THUMBNAIL_EXTENSION.includes(ext as any)
+ ? (ext as GroupStudyFormValues['thumbnailExtension'])
+ : 'DEFAULT';
+
+ setValue('thumbnailExtension', validExt, { shouldValidate: true });
+ setValue('thumbnailFile', file, { shouldValidate: true });
+ setImage(URL.createObjectURL(file));
+ };
+
+ return (
+ <>
+
+ 스터디 소개 작성
+
+
+
+ name="thumbnailExtension"
+ label="썸네일"
+ direction="vertical"
+ size="medium"
+ required
+ scrollable
+ >
+
+
+
+
+ name="title"
+ label="스터디 제목"
+ direction="vertical"
+ size="medium"
+ required
+ scrollable
+ onAfterBlurFilled={() => scrollToNext('title')}
+ >
+
+
+
+
+ name="summary"
+ label="스터디 한 줄 소개"
+ direction="vertical"
+ size="medium"
+ required
+ scrollable
+ onAfterBlurFilled={() => scrollToNext('summary')}
+ >
+
+
+
+
+ name="description"
+ label="스터디 소개"
+ direction="vertical"
+ size="medium"
+ required
+ scrollable
+ >
+
+
+ >
+ );
}
From d66a9ae44ff5ce133ac52d725e9a2321916cda0a Mon Sep 17 00:00:00 2001
From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com>
Date: Tue, 17 Feb 2026 17:47:09 +0900
Subject: [PATCH 11/16] =?UTF-8?q?fix=20:=20=ED=8F=AC=EB=A7=B7=ED=8C=85=20?=
=?UTF-8?q?=EC=A0=95=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../section/premium-study-info-section.tsx | 9 +++++--
src/components/ui/form/field-control.tsx | 7 +++---
src/features/my-page/ui/applicant-page.tsx | 12 ++++++++--
.../study/group/model/inquiry.schema.ts | 14 +++++++----
src/hooks/use-scroll-to-next-field.ts | 24 +++++++++----------
5 files changed, 41 insertions(+), 25 deletions(-)
diff --git a/src/components/section/premium-study-info-section.tsx b/src/components/section/premium-study-info-section.tsx
index 77685d24..62bf80e0 100644
--- a/src/components/section/premium-study-info-section.tsx
+++ b/src/components/section/premium-study-info-section.tsx
@@ -45,11 +45,16 @@ export default function PremiumStudyInfoSection({
const avatarMembers = useMemo(() => {
return [...applicantsList]
- .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
+ .sort(
+ (a, b) =>
+ new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
+ )
.map((data) => ({
memberId: data.applicantInfo.memberId,
nickname: data.applicantInfo.memberNickname || '익명',
- profileImageUrl: data.applicantInfo.profileImage?.resizedImages[0]?.resizedImageUrl ?? '',
+ profileImageUrl:
+ data.applicantInfo.profileImage?.resizedImages[0]?.resizedImageUrl ??
+ '',
isLeader: data.role === 'LEADER',
}));
}, [applicantsList]);
diff --git a/src/components/ui/form/field-control.tsx b/src/components/ui/form/field-control.tsx
index 2b8e9dbf..fa612621 100644
--- a/src/components/ui/form/field-control.tsx
+++ b/src/components/ui/form/field-control.tsx
@@ -79,7 +79,7 @@ export function FieldControl<
});
const nextOnChange: ChangeHandler = onAfterChange
- ? ((arg: V | React.ChangeEvent) => {
+ ? (((arg: V | React.ChangeEvent) => {
if (isReactChangeEvent(arg)) {
(coreOnChange as EventChange)(arg);
onAfterChange((arg.target as HTMLInputElement).value);
@@ -87,11 +87,10 @@ export function FieldControl<
(coreOnChange as ValueChange)(arg);
onAfterChange(arg);
}
- }) as ChangeHandler
+ }) as ChangeHandler)
: coreOnChange;
- const baseOnBlur: () => void =
- child.props.onBlur ?? field.onBlur;
+ const baseOnBlur: () => void = child.props.onBlur ?? field.onBlur;
const nextOnBlur = onAfterBlurFilled
? () => {
baseOnBlur();
diff --git a/src/features/my-page/ui/applicant-page.tsx b/src/features/my-page/ui/applicant-page.tsx
index 76da8d1a..f177600d 100644
--- a/src/features/my-page/ui/applicant-page.tsx
+++ b/src/features/my-page/ui/applicant-page.tsx
@@ -65,13 +65,21 @@ export default function ApplicantPage(props: ApplicantListProps) {
{page.content
.slice()
- .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
+ .sort(
+ (a, b) =>
+ new Date(a.createdAt).getTime() -
+ new Date(b.createdAt).getTime(),
+ )
.map((applicant) => (
- handleApprove(Number(props.studyId), applicant.applyId, status)
+ handleApprove(
+ Number(props.studyId),
+ applicant.applyId,
+ status,
+ )
}
/>
))}
diff --git a/src/features/study/group/model/inquiry.schema.ts b/src/features/study/group/model/inquiry.schema.ts
index e34d8f05..bdd47dbc 100644
--- a/src/features/study/group/model/inquiry.schema.ts
+++ b/src/features/study/group/model/inquiry.schema.ts
@@ -7,14 +7,18 @@ export enum InquiryCategory {
ETC = 'ETC',
}
-export const INQUIRY_TITLE_MAX_LENGTH=50;
+export const INQUIRY_TITLE_MAX_LENGTH = 50;
export const INQUIRY_CONTENT_MAX_LENGTH = 500;
-
export const inquirySchema = z.object({
- title: z.string().min(1, '제목을 입력해주세요.').min(1).max(
- INQUIRY_TITLE_MAX_LENGTH,`제목은 ${INQUIRY_TITLE_MAX_LENGTH}자 이하로 입력해주세요.`
- ),
+ title: z
+ .string()
+ .min(1, '제목을 입력해주세요.')
+ .min(1)
+ .max(
+ INQUIRY_TITLE_MAX_LENGTH,
+ `제목은 ${INQUIRY_TITLE_MAX_LENGTH}자 이하로 입력해주세요.`,
+ ),
content: z
.string()
.min(1, '내용을 입력해주세요.')
diff --git a/src/hooks/use-scroll-to-next-field.ts b/src/hooks/use-scroll-to-next-field.ts
index 13658910..d81bc033 100644
--- a/src/hooks/use-scroll-to-next-field.ts
+++ b/src/hooks/use-scroll-to-next-field.ts
@@ -1,6 +1,6 @@
-import { useCallback } from "react";
+import { useCallback } from 'react';
-export const SCROLL_FIELD_ATTR = "data-scroll-field";
+export const SCROLL_FIELD_ATTR = 'data-scroll-field';
/**
* 모달 폼에서 단일 선택 필드를 선택했을 때 다음 필드로 자동 스크롤하는 훅.
@@ -8,15 +8,15 @@ export const SCROLL_FIELD_ATTR = "data-scroll-field";
* 선택 이벤트 핸들러에서 `scrollToNext('fieldName')` 을 호출하세요.
*/
export function useScrollToNextField() {
- return useCallback((currentFieldName: string) => {
- const all = document.querySelectorAll(`[${SCROLL_FIELD_ATTR}]`);
- const arr = Array.from(all);
- const idx = arr.findIndex(
- (el) => el.getAttribute(SCROLL_FIELD_ATTR) === currentFieldName,
- );
- if (idx === -1 || idx >= arr.length - 1) return;
+ return useCallback((currentFieldName: string) => {
+ const all = document.querySelectorAll(`[${SCROLL_FIELD_ATTR}]`);
+ const arr = Array.from(all);
+ const idx = arr.findIndex(
+ (el) => el.getAttribute(SCROLL_FIELD_ATTR) === currentFieldName,
+ );
+ if (idx === -1 || idx >= arr.length - 1) return;
- const next = arr[idx + 1] as HTMLElement;
- next.scrollIntoView({ behavior: "smooth", block: "nearest" });
- }, []);
+ const next = arr[idx + 1] as HTMLElement;
+ next.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ }, []);
}
From 06740dfc5f40761636333c609b724bc12d1221e1 Mon Sep 17 00:00:00 2001
From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com>
Date: Tue, 17 Feb 2026 17:57:45 +0900
Subject: [PATCH 12/16] =?UTF-8?q?fix=20:=20=EC=99=95=EA=B4=80=20=EC=A0=9C?=
=?UTF-8?q?=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/section/group-study-info-section.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/section/group-study-info-section.tsx b/src/components/section/group-study-info-section.tsx
index 57c7731b..7f547258 100644
--- a/src/components/section/group-study-info-section.tsx
+++ b/src/components/section/group-study-info-section.tsx
@@ -54,7 +54,7 @@ export default function StudyInfoSection({
memberId: data.applicantInfo.memberId,
nickname:
data.role === 'LEADER'
- ? `👑 ${data.applicantInfo.memberNickname || '익명'}`
+ ? `${data.applicantInfo.memberNickname || '익명'}`
: data.applicantInfo.memberNickname || '익명',
profileImageUrl:
data.applicantInfo.profileImage?.resizedImages[0]?.resizedImageUrl ??
From bffa808aaf9b1a44696e9475ce6c769de7795d6e Mon Sep 17 00:00:00 2001
From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com>
Date: Tue, 17 Feb 2026 18:12:07 +0900
Subject: [PATCH 13/16] =?UTF-8?q?fix=20:=20min=20=EC=A4=91=EB=B3=B5=20?=
=?UTF-8?q?=EC=82=AD=EC=A0=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/features/study/group/model/inquiry.schema.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/features/study/group/model/inquiry.schema.ts b/src/features/study/group/model/inquiry.schema.ts
index bdd47dbc..e79358ad 100644
--- a/src/features/study/group/model/inquiry.schema.ts
+++ b/src/features/study/group/model/inquiry.schema.ts
@@ -14,7 +14,6 @@ export const inquirySchema = z.object({
title: z
.string()
.min(1, '제목을 입력해주세요.')
- .min(1)
.max(
INQUIRY_TITLE_MAX_LENGTH,
`제목은 ${INQUIRY_TITLE_MAX_LENGTH}자 이하로 입력해주세요.`,
From cfa704c71fb8d350fec059f43aa728e500c70aa3 Mon Sep 17 00:00:00 2001
From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com>
Date: Tue, 17 Feb 2026 18:20:17 +0900
Subject: [PATCH 14/16] =?UTF-8?q?fix=20:=20=EC=83=88=20=EA=B2=80=EC=83=89?=
=?UTF-8?q?=EC=96=B4=20=EC=9E=85=EB=A0=A5=20=EC=8B=9C=201=ED=8E=98?=
=?UTF-8?q?=EC=9D=B4=EC=A7=80=EB=A1=9C=20=EB=A6=AC=EC=85=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/hooks/common/use-study-list-filter.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/hooks/common/use-study-list-filter.ts b/src/hooks/common/use-study-list-filter.ts
index ef12c5e9..a0d8509f 100644
--- a/src/hooks/common/use-study-list-filter.ts
+++ b/src/hooks/common/use-study-list-filter.ts
@@ -60,6 +60,7 @@ export function useStudyListFilter({
const handleSearch = useCallback((query: string) => {
setSearchQuery(query);
+ setCurrentPage(1);
}, []);
// 클라이언트 사이드 검색 필터링 (스터디명만 검색)
From cd25f85c8a510ce8ec96997ed17814d3e825c339 Mon Sep 17 00:00:00 2001
From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com>
Date: Tue, 17 Feb 2026 18:38:47 +0900
Subject: [PATCH 15/16] =?UTF-8?q?fix=20:=20=ED=88=B4=ED=8C=81=20=EC=BB=B4?=
=?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../section/group-study-info-section.tsx | 13 +++----
src/components/ui/avatar-stack.tsx | 34 ++++++++++++-------
2 files changed, 29 insertions(+), 18 deletions(-)
diff --git a/src/components/section/group-study-info-section.tsx b/src/components/section/group-study-info-section.tsx
index 7f547258..b5a2dc7f 100644
--- a/src/components/section/group-study-info-section.tsx
+++ b/src/components/section/group-study-info-section.tsx
@@ -31,10 +31,14 @@ export default function StudyInfoSection({
groupStudyId,
status: 'APPROVED',
});
- const applicants = approvedApplicants?.pages[0]?.content;
+
+ const applicants = useMemo(
+ () => approvedApplicants?.pages[0]?.content ?? [],
+ [approvedApplicants?.pages],
+ );
const avatarMembers = useMemo(() => {
- if (!applicants) return [];
+ if (!applicants.length) return [];
const leader = applicants.find((applicant) => applicant.role === 'LEADER');
const participants = applicants.filter(
@@ -52,10 +56,7 @@ export default function StudyInfoSection({
return sortedApplicants.map((data) => ({
memberId: data.applicantInfo.memberId,
- nickname:
- data.role === 'LEADER'
- ? `${data.applicantInfo.memberNickname || '익명'}`
- : data.applicantInfo.memberNickname || '익명',
+ nickname: data.applicantInfo.memberNickname || '익명',
profileImageUrl:
data.applicantInfo.profileImage?.resizedImages[0]?.resizedImageUrl ??
'',
diff --git a/src/components/ui/avatar-stack.tsx b/src/components/ui/avatar-stack.tsx
index 3d07c4f3..b3072e3d 100644
--- a/src/components/ui/avatar-stack.tsx
+++ b/src/components/ui/avatar-stack.tsx
@@ -1,11 +1,10 @@
'use client';
import { X } from 'lucide-react';
-import { useState, useRef, useCallback } from 'react';
+import { useState, useCallback, useRef, useEffect } from 'react';
import UserAvatar from '@/components/ui/avatar';
import UserProfileModal from '@/entities/user/ui/user-profile-modal';
import { cn } from './(shadcn)/lib/utils';
-import Tooltip from './tooltip';
export interface AvatarStackMember {
memberId: number;
@@ -28,6 +27,23 @@ export default function AvatarStack({
const [showOverflow, setShowOverflow] = useState(false);
const overflowRef = useRef(null);
+ useEffect(() => {
+ if (!showOverflow) return;
+
+ const handleClickOutside = (e: MouseEvent) => {
+ if (
+ overflowRef.current &&
+ !overflowRef.current.contains(e.target as Node)
+ ) {
+ setShowOverflow(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [showOverflow]);
+
// 리더를 맨 앞으로, 나머지는 가입순(원본 순서) 유지
const sorted = [...members].sort((a, b) => {
if (a.isLeader && !b.isLeader) return -1;
@@ -66,7 +82,7 @@ export default function AvatarStack({
{showOverflow && (
-
+
참가자 목록
@@ -157,15 +173,9 @@ function AvatarItem({
{/* 닉네임 툴팁 */}
{hovered && (
-
- {member.nickname || '익명'}
-
- }
- />
+
+ {member.nickname || '익명'}
+
)}
}
From 4351523134db414ba5cdae5e951b209ce03e6f92 Mon Sep 17 00:00:00 2001
From: Jeong Ha Seung <88266129+HA-SEUNG-JEONG@users.noreply.github.com>
Date: Tue, 17 Feb 2026 18:48:08 +0900
Subject: [PATCH 16/16] =?UTF-8?q?fix=20:=20=ED=81=B4=EB=9E=98=EC=8A=A4?=
=?UTF-8?q?=EB=AA=85=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/ui/avatar-stack.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/ui/avatar-stack.tsx b/src/components/ui/avatar-stack.tsx
index b3072e3d..7698973f 100644
--- a/src/components/ui/avatar-stack.tsx
+++ b/src/components/ui/avatar-stack.tsx
@@ -173,7 +173,7 @@ function AvatarItem({
{/* 닉네임 툴팁 */}
{hovered && (
-
+
{member.nickname || '익명'}
)}