diff --git a/.env.example b/.env.example deleted file mode 100644 index 73ab239..0000000 --- a/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -# API Endpoints -NEXT_PUBLIC_API_URL=https://api.checkmo.co.kr/api diff --git a/public/Danger_Circle.svg b/public/Danger_Circle.svg new file mode 100644 index 0000000..4eaa1fb --- /dev/null +++ b/public/Danger_Circle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/Delete_2.svg b/public/Delete_2.svg new file mode 100644 index 0000000..bd31aaa --- /dev/null +++ b/public/Delete_2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/Edit.svg b/public/Edit.svg new file mode 100644 index 0000000..802f9a6 --- /dev/null +++ b/public/Edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/app/groups/[id]/admin/applicant/page.tsx b/src/app/groups/[id]/admin/applicant/page.tsx index 56f1089..e1384cf 100644 --- a/src/app/groups/[id]/admin/applicant/page.tsx +++ b/src/app/groups/[id]/admin/applicant/page.tsx @@ -1,23 +1,13 @@ 'use client'; -import { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useMemo } from 'react'; import { useParams, useRouter } from 'next/navigation'; import Image from 'next/image'; import { useHeaderTitle } from '@/contexts/HeaderTitleContext'; -// 더미 데이터 -const DUMMY_APPLICANTS = [ - { id: 1, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '저 가입시켜주세요. 저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.저 가입시켜주세요.' }, - { id: 2, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '가입하고 싶습니다!' }, - { id: 3, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '책 모임에 관심이 많습니다.' }, - { id: 4, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '안녕하세요, 가입 신청합니다.' }, - { id: 5, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '열심히 활동하겠습니다!' }, - { id: 6, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '좋은 모임이라 들었습니다.' }, - { id: 7, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '독서를 좋아합니다.' }, - { id: 8, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '가입 부탁드립니다.' }, - { id: 9, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '함께하고 싶습니다.' }, - { id: 10, userId: 'hy_0716', name: '윤현일', email: 'yhi9839@naver.com', applyDate: '2000.00.00', message: '잘 부탁드립니다!' }, -]; +import { useInfiniteClubMembersQuery } from '@/hooks/queries/useClubMemberQueries'; +import { useUpdateClubMemberStatusMutation } from '@/hooks/mutations/useClubMemberMutations'; +import type { ClubMemberItem } from '@/types/groups/clubMembers'; type ActionType = 'delete' | 'approve'; @@ -27,7 +17,11 @@ type ApplicantActionDropdownProps = { buttonRef: React.RefObject; }; -function ApplicantActionDropdown({ isOpen, onSelectAction, buttonRef }: ApplicantActionDropdownProps) { +function ApplicantActionDropdown({ + isOpen, + onSelectAction, + buttonRef, +}: ApplicantActionDropdownProps) { const [position, setPosition] = useState({ top: 0, left: 0 }); useEffect(() => { @@ -92,22 +86,69 @@ function JoinMessageModal({ isOpen, onClose, message }: JoinMessageModalProps) { ); } +function formatYYYYMMDD(iso: string) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '0000.00.00'; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}.${m}.${day}`; +} + export default function AdminApplicantPage() { const params = useParams(); const router = useRouter(); - const groupId = params.id as string; + const groupId = params.id as string; // clubId + const clubId = Number(groupId); + const { setCustomTitle } = useHeaderTitle(); - const [applicants, setApplicants] = useState(DUMMY_APPLICANTS); + + // dropdown / modal const [openMenuId, setOpenMenuId] = useState(null); const [messageModal, setMessageModal] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '', }); + const menuRefs = useRef<{ [key: number]: HTMLDivElement | null }>({}); const buttonRefs = useRef<{ [key: number]: HTMLButtonElement | null }>({}); + + // pagination UI는 유지하되, 데이터는 cursor 기반으로 누적 로드해서 slice로 보여줌 const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 10; - const totalPages = Math.ceil(applicants.length / itemsPerPage); + + // ✅ PENDING만 조회 (cursorId 무한 스크롤) + const membersQuery = useInfiniteClubMembersQuery( + clubId, + 'PENDING', + Number.isFinite(clubId) && clubId > 0 + ); + + const { mutateAsync: updateStatus, isPending: isUpdating } = + useUpdateClubMemberStatusMutation(); + + // 서버 데이터 -> 기존 UI shape에 맞춰 가공 + const applicants = useMemo(() => { + const pages = membersQuery.data?.pages ?? []; + const raw: ClubMemberItem[] = pages.flatMap((p) => p.clubMembers ?? []); + + return raw.map((m) => ({ + id: m.clubMemberId, + userId: m.detailInfo.nickname, // 기존 UI에 보이던 ID 자리에 nickname + name: m.detailInfo.name, + email: m.detailInfo.email, + applyDate: formatYYYYMMDD(m.appliedAt), + message: m.joinMessage ?? '', + profileImageUrl: m.detailInfo.profileImageUrl ?? null, + nickname: m.detailInfo.nickname, + })); + }, [membersQuery.data]); + + // 페이지네이션 계산 + const totalPages = Math.max(1, Math.ceil(applicants.length / itemsPerPage)); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const currentApplicants = applicants.slice(startIndex, endIndex); // 모바일 헤더 타이틀 설정 useEffect(() => { @@ -115,34 +156,69 @@ export default function AdminApplicantPage() { return () => setCustomTitle(null); }, [setCustomTitle]); - // 바깥 클릭 시 메뉴 닫기 + // 바깥 클릭 시 메뉴 닫기 (기존 로직 유지) useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (openMenuId === null) return; const menuRef = menuRefs.current[openMenuId]; - if (menuRef && !menuRef.contains(e.target as Node)) { - setOpenMenuId(null); - } + const buttonRef = buttonRefs.current[openMenuId]; + const target = e.target as Node; + + if (menuRef && menuRef.contains(target)) return; + if (buttonRef && buttonRef.contains(target)) return; + + setOpenMenuId(null); }; + document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [openMenuId]); + // ✅ “페이지 넘겼는데 데이터가 모자라면” 다음 cursor 페이지 자동 로드 + useEffect(() => { + if (!membersQuery.hasNextPage) return; + if (membersQuery.isFetchingNextPage) return; + + const needCount = currentPage * itemsPerPage; + if (applicants.length < needCount) { + membersQuery.fetchNextPage(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentPage, applicants.length, membersQuery.hasNextPage, membersQuery.isFetchingNextPage]); + + // 데이터 줄어들어서 현재 페이지가 totalPages를 넘는 상황 방지 + useEffect(() => { + if (currentPage > totalPages) setCurrentPage(totalPages); + }, [currentPage, totalPages]); + const handleActionClick = (applicantId: number) => { setOpenMenuId(openMenuId === applicantId ? null : applicantId); }; - const handleSelectAction = (applicantId: number, action: ActionType) => { - if (action === 'delete') { - // 신청 삭제 - setApplicants(applicants.filter((a) => a.id !== applicantId)); - } else if (action === 'approve') { - // 가입 처리 - console.log('가입 처리:', applicantId); - setApplicants(applicants.filter((a) => a.id !== applicantId)); - } - setOpenMenuId(null); - }; + const handleSelectAction = async (clubMemberId: number, action: ActionType) => { + try { + if (action === 'delete') { + await updateStatus({ + clubId, + clubMemberId, + body: { command: 'REJECT' }, + }); + } else { + await updateStatus({ + clubId, + clubMemberId, + body: { command: 'APPROVE' }, + }); + } + } catch (err) { + const message = action === 'delete' ? '거절 처리에 실패했습니다.' : '가입 처리에 실패했습니다.'; + // Consider using toast or alert here + console.error(message, err); + // toast.error(message); // if using react-hot-toast + } finally { + setOpenMenuId(null); + } + }; const handleMessageClick = (message: string) => { setMessageModal({ isOpen: true, message }); @@ -152,13 +228,12 @@ export default function AdminApplicantPage() { setMessageModal({ isOpen: false, message: '' }); }; - const startIndex = (currentPage - 1) * itemsPerPage; - const endIndex = startIndex + itemsPerPage; - const currentApplicants = applicants.slice(startIndex, endIndex); + const goProfile = (nickname: string) => { + router.push(`/profile/${nickname}`); + }; return (
-
-
-

- 운영진은 가입 처리를 통해 회원의 가입 유무를 선택 가능합니다.
이때 공개모임의 경우 모든 신청마다 즉시 가입완료 처리 됩니다. -

-
+
+

+ 운영진은 가입 처리를 통해 회원의 가입 유무를 선택 가능합니다.
이때 공개모임의 경우 + 모든 신청마다 즉시 가입완료 처리 됩니다. +

+
- {/* 테이블 */} -
-
- {/* 테이블 헤더 */} -
- {/* ID - 태블릿/데스크탑에서만 */} -
-

ID

-
-
-

이름

-
- {/* 이메일 - 태블릿/데스크탑에서만 */} -
-

이메일

-
-
-

신청 일자

-
-
-

프로필

-

프로필 보기

-
-
-

가입메시지

-
-
-

수정

+ {/* 로딩/에러 표시(기존 UI는 유지하면서 최소만 추가) */} + {membersQuery.isLoading && ( +

불러오는 중…

+ )} + {membersQuery.isError && ( +

목록을 불러오지 못했습니다.

+ )} + + {/* 테이블 */} +
+
+ {/* 테이블 헤더 */} +
+
+

ID

+
+
+

이름

+
+
+

이메일

+
+
+

신청 일자

+
+
+

프로필

+

프로필 보기

+
+
+

가입메시지

+
+
+

수정

+
-
- {/* 테이블 바디 */} -
- {currentApplicants.map((applicant) => ( -
- {/* ID - 태블릿/데스크탑에서만 */} -
-
- {applicant.name} + {/* 테이블 바디 */} +
+ {currentApplicants.map((applicant) => ( +
+
+
+ {applicant.name} +
+

{applicant.userId}

-

{applicant.userId}

-
-
-

{applicant.name}

-
- {/* 이메일 - 태블릿/데스크탑에서만 */} -
-

{applicant.email}

-
-
-

{applicant.applyDate}

-
-
-

바로가기

-
-
- +
+ +
+ +
+ +
{ + menuRefs.current[applicant.id] = el; + }} > - 가입메시지 - + + + handleSelectAction(applicant.id, action)} + buttonRef={{ current: buttonRefs.current[applicant.id] }} + /> +
-
{ menuRefs.current[applicant.id] = el; }}> - - handleSelectAction(applicant.id, action)} - buttonRef={{ current: buttonRefs.current[applicant.id] }} - /> + ))} + + {!membersQuery.isLoading && applicants.length === 0 && !membersQuery.isError && ( +
+

가입 대기(PENDING) 멤버가 없습니다.

-
- ))} + )} +
-
- {/* 페이지네이션 */} -
- - {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + {/* 페이지네이션 (UI 그대로 유지) */} +
- ))} - -
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + + ))} + + +
+ + {/* “더 가져올 게 있는데 아직 페이지 수가 부족한” 케이스 안내 (선택) */} + {membersQuery.hasNextPage && ( +
+ +
+ )}
diff --git a/src/app/groups/[id]/admin/bookcase/[meetingId]/dummy.ts b/src/app/groups/[id]/admin/bookcase/[meetingId]/dummy.ts deleted file mode 100644 index d8ac0b9..0000000 --- a/src/app/groups/[id]/admin/bookcase/[meetingId]/dummy.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { GetMeetingTeamsResult } from "@/types/groups/bookcasedetail"; - - -export type ApiResponse = { - isSuccess: boolean; - code: string; - message: string; - result: T; -}; - -export const MEETING_TEAMS_DUMMY: ApiResponse = { - isSuccess: true, - code: "COMMON200", - message: "성공입니다.", - result: { - existingTeamNumbers: [1, 2], - members: [ - { - clubMemberId: 9, - memberInfo: { nickname: "문학러버", profileImageUrl: null }, - teamNumber: null, - }, - { - clubMemberId: 8, - memberInfo: { nickname: "독서광5", profileImageUrl: null }, - teamNumber: null, - }, - { - clubMemberId: 2, - memberInfo: { nickname: "테스터2", profileImageUrl: null }, - teamNumber: null, - }, - { - clubMemberId: 1, - memberInfo: { nickname: "테스터1", profileImageUrl: null }, - teamNumber: 1, - }, - ], - hasNext: false, - nextCursor: null, - }, -}; - -// 더미 fetch처럼 쓰려고 약간의 딜레이를 줌. -export async function fetchMeetingTeamsDummy() { - await new Promise((r) => setTimeout(r, 150)); - return MEETING_TEAMS_DUMMY; -} diff --git a/src/app/groups/[id]/admin/bookcase/[meetingId]/edit/layout.tsx b/src/app/groups/[id]/admin/bookcase/[meetingId]/edit/layout.tsx new file mode 100644 index 0000000..27837ec --- /dev/null +++ b/src/app/groups/[id]/admin/bookcase/[meetingId]/edit/layout.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from 'react'; + +export default function bookshelfeditLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/src/app/groups/[id]/admin/bookcase/[meetingId]/edit/page.tsx b/src/app/groups/[id]/admin/bookcase/[meetingId]/edit/page.tsx new file mode 100644 index 0000000..9bd35db --- /dev/null +++ b/src/app/groups/[id]/admin/bookcase/[meetingId]/edit/page.tsx @@ -0,0 +1,450 @@ +/* eslint-disable react-hooks/set-state-in-effect */ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Image from 'next/image'; +import toast from 'react-hot-toast'; + +import BookSelectModal from '@/components/layout/BookSelectModal'; +import BookstoryChoosebook from '@/components/base-ui/BookStory/bookstory_choosebook'; +import { useHeaderTitle } from '@/contexts/HeaderTitleContext'; + + +// 타입 +import { BookshelfPatchRequest } from '@/types/bookshelf'; +import { useBookshelfEditQuery } from '@/hooks/queries/useClubsBookshelfQueries'; +import { usePatchBookshelfMutation } from '@/hooks/mutations/useClubsBookshelfMutations'; + +const TAGS = [ + { label: '여행', colorClass: 'bg-Secondary-2' }, + { label: '외국어', colorClass: 'bg-Secondary-2' }, + { label: '어린이/청소년', colorClass: 'bg-Secondary-2' }, + { label: '종교/철학', colorClass: 'bg-Secondary-2' }, + { label: '인문학', colorClass: 'bg-Secondary-1' }, + { label: '에세이', colorClass: 'bg-Secondary-1' }, + { label: '소설/시/희곡', colorClass: 'bg-Secondary-1' }, + { label: '과학', colorClass: 'bg-Secondary-3' }, + { label: '컴퓨터/IT', colorClass: 'bg-Secondary-3' }, + { label: '경제/경영', colorClass: 'bg-Secondary-3' }, + { label: '자기계발', colorClass: 'bg-Secondary-3' }, + { label: '사회과학', colorClass: 'bg-Secondary-4' }, + { label: '정치/외교/국방', colorClass: 'bg-Secondary-4' }, + { label: '역사/문화', colorClass: 'bg-Secondary-4' }, + { label: '예술/대중문화', colorClass: 'bg-Secondary-4' }, +] as const; + +const TAG_LABELS = TAGS.map((tag) => tag.label); + +const getTagBgColor = (index: number) => { + return TAGS[index]?.colorClass ?? 'bg-Subbrown-4'; +}; + +// ✅ 날짜 입력: 뒤로가기/삭제 가능하게 "숫자만" 기반으로 포맷 +function formatMeetingDateLoose(input: string) { + const digits = input.replace(/\D/g, '').slice(0, 8); + const y = digits.slice(0, 4); + const m = digits.slice(4, 6); + const d = digits.slice(6, 8); + + if (digits.length <= 4) return y; + if (digits.length <= 6) return `${y}.${m}`; + return `${y}.${m}.${d}`; +} + +function meetingDateToISO(dateDot: string) { + const m = dateDot.trim().match(/^(\d{4})\.(\d{2})\.(\d{2})$/); + if (!m) return null; + + const y = Number(m[1]); + const mo = Number(m[2]) - 1; + const d = Number(m[3]); + + const dt = new Date(y, mo, d, 0, 0, 0); + if (Number.isNaN(dt.getTime())) return null; + return dt.toISOString(); +} + +function parseDateDotToLocalDate(dateDot: string) { + const m = dateDot.trim().match(/^(\d{4})\.(\d{2})\.(\d{2})$/); + if (!m) return null; + + const y = Number(m[1]); + const mo = Number(m[2]) - 1; + const d = Number(m[3]); + + const dt = new Date(y, mo, d, 0, 0, 0, 0); + if (Number.isNaN(dt.getTime())) return null; + + if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null; + + return dt; +} + +function isBeforeTodayLocal(date: Date) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return date.getTime() < today.getTime(); +} + +// ✅ ISO -> "YYYY.MM.DD" (edit에서 초기값 채우기용) +function isoToDateDot(iso: string) { + const dt = new Date(iso); + if (Number.isNaN(dt.getTime())) return ''; + const y = dt.getFullYear(); + const m = String(dt.getMonth() + 1).padStart(2, '0'); + const d = String(dt.getDate()).padStart(2, '0'); + return `${y}.${m}.${d}`; +} + +export default function EditBookshelfPage() { + const params = useParams(); + const router = useRouter(); + const clubId = Number(params.id as string); + const meetingId = Number(params.meetingId as string); + const { setCustomTitle } = useHeaderTitle(); + + const [meetingDateError, setMeetingDateError] = useState(''); + + // 모바일 헤더 타이틀 설정 + useEffect(() => { + setCustomTitle('책장 수정'); + return () => setCustomTitle(null); + }, [setCustomTitle]); + + + const { data: editData, isLoading } = useBookshelfEditQuery(clubId, meetingId); + const patchMutation = usePatchBookshelfMutation(clubId, meetingId); + + const selectedBook = editData?.bookDetailInfo; + + const [generation, setGeneration] = useState('1'); + const [isGenerationOpen, setIsGenerationOpen] = useState(false); + + const [selectedTags, setSelectedTags] = useState([]); + + const [meetingName, setMeetingName] = useState(''); + const [meetingLocation, setMeetingLocation] = useState(''); + const [meetingDate, setMeetingDate] = useState(''); + const [isBookSelectModalOpen, setIsBookSelectModalOpen] = useState(false); + + // ✅ GET 결과로 초기값 채우기 (UI 변경 없음, state만 채움) + useEffect(() => { + if (!editData) return; + + const { meetingInfo } = editData; + + setMeetingName(meetingInfo.title ?? ''); + setMeetingLocation(meetingInfo.location ?? ''); + setGeneration(String(meetingInfo.generation ?? 1)); + + const tag = (meetingInfo.tag ?? "") as (typeof TAG_LABELS)[number] | ""; + const idx = TAG_LABELS.indexOf(tag as (typeof TAG_LABELS)[number]); + setSelectedTags(idx >= 0 ? [idx] : []); + + // meetingTime ISO -> YYYY.MM.DD + setMeetingDate(isoToDateDot(meetingInfo.meetingTime ?? '')); + + // edit 초기값 세팅 시 에러 초기화 + setMeetingDateError(''); + }, [editData]); + + // ✅ 버튼 막기 조건 (기존 로직 유지) + const selectedTagIndex = selectedTags[0]; + const tagString = selectedTagIndex !== undefined ? TAG_LABELS[selectedTagIndex] : ''; + const meetingTimeISO = meetingDateToISO(meetingDate); + + const canSubmit = + !!selectedBook && + meetingName.trim().length > 0 && + meetingLocation.trim().length > 0 && + !!tagString && + !!meetingTimeISO && + !meetingDateError; + + const handleCancel = () => { + router.back(); + }; + + const handleSubmit = async () => { + if (!canSubmit) { + toast.error('필수 입력값을 확인해 주세요.'); + return; + } + + // ✅ PATCH payload는 이것만 + const body: BookshelfPatchRequest = { + title: meetingName.trim(), + meetingTime: meetingTimeISO as string, + location: meetingLocation.trim(), + generation: Number(generation), + tag: tagString, + }; + + try { + await patchMutation.mutateAsync({ + clubId, + meetingId, + body, + }); + + toast.success('책장 수정 완료!'); + router.push(`/groups/${clubId}/bookcase`); + } catch (e: any) { + console.error(e); + const msg = + e?.response?.data?.message || + e?.message || + '책장 수정에 실패했습니다.'; + toast.error(msg); + } + }; + + const handleTagToggle = (index: number) => { + setSelectedTags((prev) => { + if (prev[0] === index) return []; + return [index]; + }); + }; + + // ✅ 책 수정 불가: 선택 모달 열기/선택 함수는 "막기" + const handleBookSelect = (_selectedIsbn: string) => { + // 요구사항: 책은 수정 불가 + toast.error('책은 수정할 수 없습니다.'); + setIsBookSelectModalOpen(false); + }; + + const handleBack = () => { + router.back(); + }; + + // 로딩 처리(최소) + if (isLoading) { + return
로딩중...
; + } + + return ( +
+ {/* 뒤로가기 - 모바일에서만 */} +
+ +
+
+ +
+
+
+

책장 수정

+ + {/* 책 선택 */} +
+ + + {selectedBook ? ( + // ✅ UI 유지: BookstoryChoosebook 그대로 + // ✅ 단, 클릭해도 모달 안 열리게(요구사항) +
toast.error('책은 수정할 수 없습니다.')} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") toast.error('책은 수정할 수 없습니다.'); + }} + className="cursor-pointer" + > + +
+ ) : ( + // edit인데 book이 없다면 이상한 상태. UI는 유지하되 버튼은 비활성처럼 처리 + + )} +
+ + {/* 기수 */} +
+ +
+ + {isGenerationOpen && ( +
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => ( + + ))} +
+ )} +
+
+ + {/* 태그 */} +
+ +
+ {TAG_LABELS.map((label, index) => { + const isSelected = selectedTags.includes(index); + return ( + + ); + })} +
+
+ + {/* 정기모임이름 */} +
+ + setMeetingName(e.target.value)} + placeholder="정기모임 이름을 입력해주세요" + className="px-4 py-3 h-14 rounded-[8px] border border-Subbrown-4 bg-White text-Gray-7 body_1_3 placeholder:text-Gray-3" + /> +
+ + {/* 모임 장소 */} +
+ + setMeetingLocation(e.target.value)} + placeholder="모임 장소를 입력해주세요" + className="px-4 py-3 h-14 rounded-[8px] border border-Subbrown-4 bg-White text-Gray-7 body_1_3 placeholder:text-Gray-3" + /> +
+ + {/* 모임 날짜 */} +
+ +
+ { + const next = formatMeetingDateLoose(e.target.value); + setMeetingDate(next); + + const digits = next.replace(/\D/g, ''); + if (digits.length < 8) { + setMeetingDateError(''); + return; + } + + const dt = parseDateDotToLocalDate(next); + if (!dt) { + setMeetingDateError('날짜 형식이 아니거나 현재날짜보다 작습니다.'); + return; + } + + if (isBeforeTodayLocal(dt)) { + setMeetingDateError('날짜 형식이 아니거나 현재날짜보다 작습니다.'); + return; + } + + setMeetingDateError(''); + }} + placeholder="2000.00.00의 양식으로 작성해주세요" + className="flex-1 px-4 py-3 h-14 rounded-[8px] border border-Subbrown-4 bg-White text-Gray-7 body_1_3 placeholder:text-Gray-3" + /> +
+ {meetingDateError ? ( +

+ {meetingDateError} +

+ ) : null} +
+ + {/* 하단 버튼 */} +
+ + +
+
+
+
+ + {/* 책 선택 모달 */} + setIsBookSelectModalOpen(false)} + onSelect={handleBookSelect} + /> +
+ ); +} diff --git a/src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx b/src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx index 5f0cf3e..4b53797 100644 --- a/src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx +++ b/src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx @@ -10,60 +10,71 @@ import { type DragOverEvent, } from "@dnd-kit/core"; - import { useEffect, useMemo, useState } from "react"; import { useParams, useRouter, useSearchParams } from "next/navigation"; +import Image from "next/image"; - -import { fetchMeetingTeamsDummy } from "./dummy"; -import { normalizeTeams, TeamMember, TeamMemberListPutBody } from "@/types/groups/bookcasedetail"; import MemberPool from "@/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool"; import TeamBoard from "@/components/base-ui/Bookcase/Admin/bookdetailgrouping/TeamBoard"; -import Image from "next/image"; + + +import { useMeetingMembersQuery} from "@/hooks/queries/useMeetingQueries"; +import type { TeamMemberListPutBody, TeamMember } from "@/types/groups/bookcasedetail"; +import { normalizeTeams } from "@/types/groups/bookcasedetail"; +import { useUpdateMeetingTeamsMutation } from "@/hooks/mutations/useMeetingMutations"; + +function toNumber(v: string | string[] | undefined): number { + const s = Array.isArray(v) ? v[0] : v; + const n = Number(s); + return Number.isFinite(n) ? n : NaN; +} + export default function AdminMeetingTeamManagePage() { const router = useRouter(); const searchParams = useSearchParams(); const params = useParams(); - // Next가 params를 Record로 주는 케이스가 있어서 안전빵 - const groupId = Array.isArray(params?.id) ? params?.id[0] : (params?.id as string | undefined); - const meetingId = Array.isArray(params?.meetingId) - ? params?.meetingId[0] - : (params?.meetingId as string | undefined); + const clubId = toNumber(params?.id as any); + const meetingId = toNumber(params?.meetingId as any); const meetingName = searchParams.get("meetingName") || searchParams.get("name") || "정기모임 이름"; - const [isLoading, setIsLoading] = useState(true); const [teams, setTeams] = useState([1]); const [members, setMembers] = useState([]); + const [isInitialized, setIsInitialized] = useState(false); // 드래그 하이라이트용 const [dragOverTeamNumber, setDragOverTeamNumber] = useState(null); const [isDragOverPool, setIsDragOverPool] = useState(false); - useEffect(() => { - let alive = true; - - (async () => { - setIsLoading(true); - // TODO(API 연동): 여기서 GET /groups/{groupId}/meetings/{meetingId}/teams 같은 걸 호출해서 - // existingTeamNumbers + members를 받아오면 됨. - const res = await fetchMeetingTeamsDummy(); + const { data, isLoading, isFetching, isError } = useMeetingMembersQuery(clubId, meetingId); - if (!alive) return; + const { mutateAsync: updateTeams, isPending: isSaving } = useUpdateMeetingTeamsMutation(); - const normalized = normalizeTeams(res.result.existingTeamNumbers); - setTeams(normalized); - setMembers(res.result.members); - setIsLoading(false); - })(); - - return () => { - alive = false; - }; - }, []); + useEffect(() => { + if (isInitialized) return; + if (!data) return; + const existingTeamNumbers = + data.existingTeams?.map((t: any) => Number(t.teamNumber)).filter(Number.isFinite) ?? []; + + const normalized = normalizeTeams(existingTeamNumbers); + setTeams(normalized); + + const mappedMembers: TeamMember[] = + data.clubMembers?.map((cm: any) => ({ + clubMemberId: cm.clubMemberId, + memberInfo: { + nickname: cm.memberInfo?.nickname ?? "", + profileImageUrl: cm.memberInfo?.profileImageUrl ?? "", + }, + teamNumber: cm.teamKey?.teamNumber ?? null, + })) ?? []; + + setMembers(mappedMembers); + setIsInitialized(true); + }, [data, isInitialized]); const unassigned = useMemo( () => members.filter((m) => m.teamNumber == null), @@ -73,24 +84,26 @@ export default function AdminMeetingTeamManagePage() { const handleAddTeam = () => { setTeams((prev) => { if (prev.length >= 7) return prev; - return [...prev, prev.length + 1]; + const next = prev.length === 0 ? 1 : Math.max(...prev) + 1; + return [...prev, next]; }); }; const handleRemoveTeam = (teamNumber: number) => { if (teams.length <= 1) return; - // C(3) 삭제 -> A(1)B(2)C(3) 로 당기고, 기존 C에 있던 애들은 null로 빠지게. + // teamNumber 삭제 -> 뒤 팀들 번호 -1 당기고, 삭제된 팀 멤버는 null로 setTeams((prev) => { - const filtered = prev.filter((t) => t !== teamNumber); + const filtered = prev.filter((t) => t !== teamNumber).sort((a, b) => a - b); return filtered.map((t) => (t > teamNumber ? t - 1 : t)); }); setMembers((prev) => prev.map((m) => { if (m.teamNumber === teamNumber) return { ...m, teamNumber: null }; - if (m.teamNumber != null && m.teamNumber > teamNumber) + if (m.teamNumber != null && m.teamNumber > teamNumber) { return { ...m, teamNumber: m.teamNumber - 1 }; + } return m; }) ); @@ -98,164 +111,159 @@ export default function AdminMeetingTeamManagePage() { const handleMoveMember = (clubMemberId: number, toTeamNumber: number | null) => { setMembers((prev) => - prev.map((m) => (m.clubMemberId === clubMemberId ? { ...m, teamNumber: toTeamNumber } : m)) + prev.map((m) => + m.clubMemberId === clubMemberId ? { ...m, teamNumber: toTeamNumber } : m + ) ); }; const handleSubmit = async () => { - // PUT payload 만들기 + if (!Number.isFinite(clubId) || !Number.isFinite(meetingId)) return; + const body: TeamMemberListPutBody = { - teamMemberList: teams.map((teamNumber) => ({ - teamNumber, - clubMemberIds: members - .filter((m) => m.teamNumber === teamNumber) - .map((m) => m.clubMemberId), - })), + teamMemberList: teams + .sort((a, b) => a - b) + .map((teamNumber) => ({ + teamNumber, + clubMemberIds: members + .filter((m) => m.teamNumber === teamNumber) + .map((m) => m.clubMemberId), + })), }; - // TODO(API 연동): - // await fetch(`/api/groups/${groupId}/admin/bookcase/${meetingId}`, { method: 'PUT', body: JSON.stringify(body) }) - console.log("PUT payload:", body); + await updateTeams({ clubId, meetingId, body }); }; const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), - // 모바일에서 스크롤/탭과 드래그 충돌 줄이려고 “롱프레스” 약간 줌 - useSensor(TouchSensor, { - activationConstraint: { delay: 150, tolerance: 8 }, - }) -); - -const handleDndOver = ({ over }: DragOverEvent) => { - const id = over?.id?.toString(); - if (!id) { - setDragOverTeamNumber(null); - setIsDragOverPool(false); - return; - } - - if (id === "pool") { - setIsDragOverPool(true); - setDragOverTeamNumber(null); - return; - } - - if (id.startsWith("team-")) { - const teamNumber = Number(id.replace("team-", "")); - setDragOverTeamNumber(Number.isFinite(teamNumber) ? teamNumber : null); - setIsDragOverPool(false); - } -}; - -const handleDndEnd = ({ active, over }: DragEndEvent) => { - try { - if (!over) return; - - const clubMemberId = active.data.current?.clubMemberId as number | undefined; - - if (clubMemberId === undefined) { + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 8 } }) + ); + + const handleDndOver = ({ over }: DragOverEvent) => { + const id = over?.id?.toString(); + if (!id) { + setDragOverTeamNumber(null); + setIsDragOverPool(false); return; } - const overId = over.id.toString(); + if (id === "pool") { + setIsDragOverPool(true); + setDragOverTeamNumber(null); + return; + } - if (overId === "pool") { - handleMoveMember(clubMemberId, null); - } else if (overId.startsWith("team-")) { - const toTeamNumber = Number(overId.replace("team-", "")); - if (Number.isFinite(toTeamNumber)) handleMoveMember(clubMemberId, toTeamNumber); + if (id.startsWith("team-")) { + const teamNumber = Number(id.replace("team-", "")); + setDragOverTeamNumber(Number.isFinite(teamNumber) ? teamNumber : null); + setIsDragOverPool(false); } - } finally { - // 하이라이트 정리 - setDragOverTeamNumber(null); - setIsDragOverPool(false); - } -}; + }; + const handleDndEnd = ({ active, over }: DragEndEvent) => { + try { + if (!over) return; - return ( - -
- {/* 모바일 전용 뒤로가기 바 */} - + const clubMemberId = active.data.current?.clubMemberId as number | undefined; + if (clubMemberId === undefined) return; + + const overId = over.id.toString(); + + if (overId === "pool") { + handleMoveMember(clubMemberId, null); + } else if (overId.startsWith("team-")) { + const toTeamNumber = Number(overId.replace("team-", "")); + if (Number.isFinite(toTeamNumber)) handleMoveMember(clubMemberId, toTeamNumber); + } + } finally { + setDragOverTeamNumber(null); + setIsDragOverPool(false); + } + }; - {/* t 이상에서: 좌우 최소 40px 확보용 외곽 패딩 */} -
- {/* 실제 컨텐츠 래퍼 */} -
+
+ {/* 모바일 전용 뒤로가기 바 */} + + +
+
+

{meetingName}

+ + {isError && ( +
+ 불러오기에 실패했습니다. +
+ )} + +
+
+ - )} + {/* 저장중이면 UX라도 알려줘라 인간아 */} + {isSaving && ( +
+ 저장 중... +
+ )} +
+ +
+ {showLoading ? ( +
+ 불러오는 중... +
+ ) : ( + + )} +
-
- - - -); -} + + ); +} \ No newline at end of file diff --git a/src/app/groups/[id]/admin/bookcase/new/page.tsx b/src/app/groups/[id]/admin/bookcase/new/page.tsx index a27157f..a77460a 100644 --- a/src/app/groups/[id]/admin/bookcase/new/page.tsx +++ b/src/app/groups/[id]/admin/bookcase/new/page.tsx @@ -1,12 +1,16 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { useState, useEffect, useRef } from 'react'; +import { useParams, useRouter } from 'next/navigation'; import Image from 'next/image'; +import toast from 'react-hot-toast'; + import BookSelectModal from '@/components/layout/BookSelectModal'; import BookstoryChoosebook from '@/components/base-ui/BookStory/bookstory_choosebook'; import { useBookDetailQuery } from '@/hooks/queries/useBookQueries'; import { useHeaderTitle } from '@/contexts/HeaderTitleContext'; +import { useCreateBookshelfMutation } from '@/hooks/mutations/useClubsBookshelfMutations'; +import { CreateBookshelfRequest } from '@/types/bookshelf'; const TAGS = [ { label: '여행', colorClass: 'bg-Secondary-2' }, @@ -32,61 +36,158 @@ const getTagBgColor = (index: number) => { return TAGS[index]?.colorClass ?? 'bg-Subbrown-4'; }; +// ✅ 날짜 입력: 뒤로가기/삭제 가능하게 "숫자만" 기반으로 포맷 +function formatMeetingDateLoose(input: string) { + const digits = input.replace(/\D/g, '').slice(0, 8); + const y = digits.slice(0, 4); + const m = digits.slice(4, 6); + const d = digits.slice(6, 8); + + // 강제적으로 '.' 찍지 말고, 있는 만큼만 자연스럽게 보여준다. + if (digits.length <= 4) return y; + if (digits.length <= 6) return `${y}.${m}`; + return `${y}.${m}.${d}`; +} + +function meetingDateToISO(dateDot: string) { + // "YYYY.MM.DD" -> ISO + const m = dateDot.trim().match(/^(\d{4})\.(\d{2})\.(\d{2})$/); + if (!m) return null; + + const y = Number(m[1]); + const mo = Number(m[2]) - 1; + const d = Number(m[3]); + + const dt = new Date(y, mo, d, 0, 0, 0); + if (Number.isNaN(dt.getTime())) return null; + return dt.toISOString(); +} + +function parseDateDotToLocalDate(dateDot: string) { + // "YYYY.MM.DD" only + const m = dateDot.trim().match(/^(\d{4})\.(\d{2})\.(\d{2})$/); + if (!m) return null; + + const y = Number(m[1]); + const mo = Number(m[2]) - 1; + const d = Number(m[3]); + + const dt = new Date(y, mo, d, 0, 0, 0, 0); // local midnight + if (Number.isNaN(dt.getTime())) return null; + + // 날짜가 실제로 존재하는지(예: 2026.02.31 방지) + if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null; + + return dt; +} + +function isBeforeTodayLocal(date: Date) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return date.getTime() < today.getTime(); +} + export default function NewBookshelfPage() { const params = useParams(); const router = useRouter(); - const searchParams = useSearchParams(); const groupId = params.id as string; - const isbn = searchParams.get('isbn'); const { setCustomTitle } = useHeaderTitle(); + const [selectedIsbn, setSelectedIsbn] = useState(''); + const { data: selectedBook } = useBookDetailQuery(selectedIsbn); + + const [meetingDateError, setMeetingDateError] = useState(''); + // 모바일 헤더 타이틀 설정 useEffect(() => { setCustomTitle('책장 작성'); return () => setCustomTitle(null); }, [setCustomTitle]); - const { data: selectedBook } = useBookDetailQuery(isbn || ''); - const [generation, setGeneration] = useState('1'); const [isGenerationOpen, setIsGenerationOpen] = useState(false); + + // ✅ 태그는 1개만 const [selectedTags, setSelectedTags] = useState([]); + const [meetingName, setMeetingName] = useState(''); const [meetingLocation, setMeetingLocation] = useState(''); const [meetingDate, setMeetingDate] = useState(''); - const [title, setTitle] = useState(''); - const [content, setContent] = useState(''); const [isBookSelectModalOpen, setIsBookSelectModalOpen] = useState(false); + const createBookshelfMutation = useCreateBookshelfMutation(); + + // ✅ 버튼 막기 조건 + const selectedTagIndex = selectedTags[0]; + const tagString = selectedTagIndex !== undefined ? TAG_LABELS[selectedTagIndex] : ''; + const meetingTimeISO = meetingDateToISO(meetingDate); + + const canSubmit = + !!selectedBook && + meetingName.trim().length > 0 && + meetingLocation.trim().length > 0 && + !!tagString && + !!meetingTimeISO && + !meetingDateError; + const handleCancel = () => { router.back(); }; - const handleSubmit = () => { - // TODO: 실제 저장 로직 구현 - console.log('책장 저장:', { - book: selectedBook, - generation, - tags: selectedTags, - meetingName, - meetingLocation, - meetingDate, - title, - content, - }); - router.push(`/groups/${groupId}/admin/bookcase`); + const handleSubmit = async () => { + if (!canSubmit) { + toast.error('필수 입력값을 확인해 주세요.'); + return; + } + if (!selectedBook) return; + + // meetingTimeISO는 canSubmit에서 이미 보장 + const body: CreateBookshelfRequest = { + title: meetingName.trim(), + meetingTime: meetingTimeISO as string, + location: meetingLocation.trim(), + generation: Number(generation), + tag: tagString, + bookInfo: { + isbn: selectedBook.isbn, + title: selectedBook.title, + author: selectedBook.author, + imgUrl: selectedBook.imgUrl, + publisher: selectedBook.publisher, + description: selectedBook.description, + }, + }; + + try { + await createBookshelfMutation.mutateAsync({ + clubId: Number(groupId), + body, + }); + + toast.success('책장 생성 완료!'); + router.push(`/groups/${groupId}/bookcase`); + } catch (e: any) { + console.error(e); + const msg = + e?.response?.data?.message || + e?.message || + '책장 생성에 실패했습니다.'; + toast.error(msg); + } }; const handleTagToggle = (index: number) => { - setSelectedTags((prev) => - prev.includes(index) - ? prev.filter((i) => i !== index) - : [...prev, index], - ); + setSelectedTags((prev) => { + // 이미 선택한 태그면 해제 + if (prev[0] === index) return []; + // 다른 거 누르면 기존 선택 제거 후 새로 선택 + return [index]; + }); }; const handleBookSelect = (selectedIsbn: string) => { - router.push(`/groups/${groupId}/admin/bookcase/new?isbn=${selectedIsbn}`); + setSelectedIsbn(selectedIsbn); + setIsBookSelectModalOpen(false); }; const handleBack = () => { @@ -119,22 +220,31 @@ export default function NewBookshelfPage() { 책 선택* {selectedBook ? ( +
setIsBookSelectModalOpen(true)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") setIsBookSelectModalOpen(true); + }} + className="cursor-pointer" + > setIsBookSelectModalOpen(true)} /> - ) : ( - - )} +
+ ) : ( + + )}
{/* 기수 */} @@ -167,10 +277,11 @@ export default function NewBookshelfPage() { setGeneration(num.toString()); setIsGenerationOpen(false); }} - className={`w-22 h-8 px-3 text-left subhead_4_1 cursor-pointer ${generation === num.toString() - ? 'bg-Subbrown-4 text-Gray-7' - : 'bg-White text-Gray-7 hover:bg-Subbrown-4' - }`} + className={`w-22 h-8 px-3 text-left subhead_4_1 cursor-pointer ${ + generation === num.toString() + ? 'bg-Subbrown-4 text-Gray-7' + : 'bg-White text-Gray-7 hover:bg-Subbrown-4' + }`} > {num} @@ -191,10 +302,11 @@ export default function NewBookshelfPage() { key={index} type="button" onClick={() => handleTagToggle(index)} - className={`h-10 px-4 py-1 rounded-[8px] body_2_2 cursor-pointer transition-colors ${isSelected - ? `${getTagBgColor(index)} text-White` - : 'bg-transparent text-Gray-4 border border-Gray-2' - }`} + className={`h-10 px-4 py-1 rounded-[8px] body_2_2 cursor-pointer transition-colors ${ + isSelected + ? `${getTagBgColor(index)} text-White` + : 'bg-transparent text-Gray-4 border border-Gray-2' + }`} > {label} @@ -234,32 +346,39 @@ export default function NewBookshelfPage() { setMeetingDate(e.target.value)} + onChange={(e) => { + const next = formatMeetingDateLoose(e.target.value); + setMeetingDate(next); + + // 8자리(YYYYMMDD) 다 입력된 상태에서만 검사 + const digits = next.replace(/\D/g, ''); + if (digits.length < 8) { + setMeetingDateError(''); + return; + } + + const dt = parseDateDotToLocalDate(next); + if (!dt) { + setMeetingDateError('날짜 형식이 아니거나 현재날짜보다 작습니다.'); + return; + } + + if (isBeforeTodayLocal(dt)) { + setMeetingDateError('날짜 형식이 아니거나 현재날짜보다 작습니다.'); + return; + } + + setMeetingDateError(''); + }} placeholder="2000.00.00의 양식으로 작성해주세요" className="flex-1 px-4 py-3 h-14 rounded-[8px] border border-Subbrown-4 bg-White text-Gray-7 body_1_3 placeholder:text-Gray-3" />
-
- - {/* 본문 작성 */} -
- -
- setTitle(e.target.value)} - placeholder="제목을 입력해주세요." - className="w-full px-2 py-2 border-b border-Subbrown-4 bg-transparent outline-none text-Gray-7 subhead_3 t:subhead_4_1 placeholder:text-Gray-3" - /> -