From c5dcc6cf6fc5c65cd0de3e46c8f10ff038ec4988 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Wed, 25 Mar 2026 22:30:46 +0900 Subject: [PATCH 001/684] =?UTF-8?q?feat:=20=EB=A9=A4=EB=B2=84=20=EC=96=B4?= =?UTF-8?q?=EB=93=9C=EB=AF=BC=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 4 +- .../admin/member/MemberPageContent.tsx | 8 ++-- src/components/admin/member/MemberTable.tsx | 8 ++-- src/constants/admin/memberTable.constants.ts | 1 + src/hooks/index.ts | 1 + src/hooks/useAdminMemberQuery.ts | 17 +++++++++ src/lib/apis/adminMember.ts | 7 ++++ src/lib/apis/index.ts | 1 + src/types/admin/member.d.ts | 29 +++++++++++++-- src/utils/admin/memberMapper.ts | 37 +++++++++++++++++-- 10 files changed, 98 insertions(+), 15 deletions(-) create mode 100644 src/hooks/useAdminMemberQuery.ts create mode 100644 src/lib/apis/adminMember.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e87b7e02..4ed810de 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,9 @@ "permissions": { "allow": [ "Bash(gh pr:*)", - "Bash(git push:*)" + "Bash(git push:*)", + "Bash(ls \"D:/project/weeth-client/src/app/\\(public\\)/\\(landing\\)/\")", + "Bash(git fetch:*)" ] } } diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index ac06dc60..ce8f6326 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -14,20 +14,21 @@ import { import { toMemberDetail } from '@/utils/admin/memberMapper'; import { Card } from '@/components/ui'; import { useDragScroll } from '@/hooks'; -import { Member, MemberDetail } from '@/types/admin/member'; -import { MOCK_MEMBERS } from '@/constants/admin/memberTable.constants'; +import { useAdminMembers } from '@/hooks/useAdminMemberQuery'; +import type { Member, MemberDetail } from '@/types/admin/member'; function MemberPageContent() { const [selectedIds, setSelectedIds] = useState>(new Set()); const [searchValue, setSearchValue] = useState(''); const [detailMember, setDetailMember] = useState(null); const { ref: dragScrollRef, onMouseDown } = useDragScroll(); + const { data: members = [] } = useAdminMembers(); const handleMemberAction = (m: Member) => { setDetailMember(toMemberDetail(m)); }; - const selectedMembers = MOCK_MEMBERS.filter((m) => selectedIds.has(m.id)); + const selectedMembers = members.filter((m) => selectedIds.has(m.id)); const selectedCount = selectedMembers.length; const allUsers = selectedCount > 0 && selectedMembers.every((m) => m.position === '사용자'); @@ -93,6 +94,7 @@ function MemberPageContent() { {/* Member table */} { + members: Member[]; selectedIds?: Set; onSelectionChange?: (ids: Set) => void; onMemberAction?: (member: Member) => void; @@ -32,6 +33,7 @@ interface MemberTableProps extends React.HTMLAttributes { function MemberTable({ className, + members, selectedIds: controlledSelectedIds, onSelectionChange, onMemberAction, @@ -43,13 +45,13 @@ function MemberTable({ const selectedIds = controlledSelectedIds ?? internalSelectedIds; const setSelectedIds = onSelectionChange ?? setInternalSelectedIds; - const sortedMembers = sortMembers(MOCK_MEMBERS, sortBy); + const sortedMembers = sortMembers(members, sortBy); - const isAllSelected = selectedIds.size === MOCK_MEMBERS.length; + const isAllSelected = selectedIds.size === members.length; const isIndeterminate = selectedIds.size > 0 && !isAllSelected; const toggleAll = () => { - setSelectedIds(isAllSelected ? new Set() : new Set(MOCK_MEMBERS.map((m) => m.id))); + setSelectedIds(isAllSelected ? new Set() : new Set(members.map((m) => m.id))); }; const toggleOne = (id: string) => { diff --git a/src/constants/admin/memberTable.constants.ts b/src/constants/admin/memberTable.constants.ts index b75ea0ff..b11302cd 100644 --- a/src/constants/admin/memberTable.constants.ts +++ b/src/constants/admin/memberTable.constants.ts @@ -72,6 +72,7 @@ export const STATUS_BAR_COLOR: Record = { approved: 'bg-brand-primary', pending: 'bg-state-caution', banned: 'bg-state-error', + left: 'bg-container-neutral-alternative', }; export const COLUMNS: { label: string; key: keyof Member }[] = [ diff --git a/src/hooks/index.ts b/src/hooks/index.ts index eb0c3f6c..adcf2338 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -6,3 +6,4 @@ export { useGenerationConfirm } from './useGenerationConfirm'; export { useFileAttach } from './useFileAttach'; export { useScrollIntoView } from './useScrollIntoView'; export { useScrollOnGrow } from './useScrollOnGrow'; +export { useAdminMembers } from './useAdminMemberQuery'; diff --git a/src/hooks/useAdminMemberQuery.ts b/src/hooks/useAdminMemberQuery.ts new file mode 100644 index 00000000..2847d79d --- /dev/null +++ b/src/hooks/useAdminMemberQuery.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; + +import { adminMemberApi } from '@/lib/apis/adminMember'; +import { toMember } from '@/utils/admin/memberMapper'; + +// TODO: clubId를 실제 값으로 교체해야 함 (로그인 후 받아오거나 환경변수로 관리) +const CLUB_ID = 1; + +export function useAdminMembers() { + return useQuery({ + queryKey: ['admin', 'members', CLUB_ID], + queryFn: async () => { + const res = await adminMemberApi.getMembers(CLUB_ID); + return res.data.map(toMember); + }, + }); +} diff --git a/src/lib/apis/adminMember.ts b/src/lib/apis/adminMember.ts new file mode 100644 index 00000000..2bfdb399 --- /dev/null +++ b/src/lib/apis/adminMember.ts @@ -0,0 +1,7 @@ +import { apiClient } from '@/lib/apis/client'; +import type { ClubMember } from '@/types/admin/member'; + +export const adminMemberApi = { + getMembers: (clubId: number) => + apiClient.get(`/api/v4/admin/clubs/${clubId}/members`), +}; diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 7450ed74..e83a5ec0 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -1,2 +1,3 @@ export { apiClient } from './client'; export { apiServer } from './server'; +export { adminMemberApi } from './adminMember'; diff --git a/src/types/admin/member.d.ts b/src/types/admin/member.d.ts index 6198d79d..ac6e648b 100644 --- a/src/types/admin/member.d.ts +++ b/src/types/admin/member.d.ts @@ -1,3 +1,6 @@ +export type MemberStatus = 'approved' | 'pending' | 'banned' | 'left'; +export type MemberDetailStatus = 'approved' | 'pending' | 'banned' | 'left'; + export interface Member { id: string; name: string; @@ -12,10 +15,6 @@ export interface Member { status: MemberStatus; } -export type MemberStatus = 'approved' | 'pending' | 'banned'; - -export type MemberDetailStatus = 'approved' | 'pending' | 'banned'; - export interface MemberDetail { name: string; generation: number; @@ -32,3 +31,25 @@ export interface MemberDetail { attendance: number; absence: number; } + +// API response types +export type ClubMemberStatus = 'WAITING' | 'ACTIVE' | 'BANNED' | 'LEFT'; +export type ClubMemberRole = 'USER' | 'ADMIN' | 'LEAD'; + +export interface ClubMember { + userId: number; + clubMemberId: number; + name: string; + email: string; + tel: string; + school: string; + department: string; + studentId: string; + cardinals: number[]; + memberStatus: ClubMemberStatus; + memberRole: ClubMemberRole; + attendanceCount: number; + absenceCount: number; + attendanceRate: number; + penaltyCount: number; +} diff --git a/src/utils/admin/memberMapper.ts b/src/utils/admin/memberMapper.ts index b0cff05c..e0be6b17 100644 --- a/src/utils/admin/memberMapper.ts +++ b/src/utils/admin/memberMapper.ts @@ -1,4 +1,33 @@ -import { Member, MemberDetail } from '@/types/admin/member.d'; +import type { ClubMember, ClubMemberRole, ClubMemberStatus, Member, MemberDetail, MemberStatus } from '@/types/admin/member'; + +const STATUS_MAP: Record = { + ACTIVE: 'approved', + WAITING: 'pending', + BANNED: 'banned', + LEFT: 'left', +}; + +const ROLE_MAP: Record = { + USER: '사용자', + ADMIN: '관리자', + LEAD: '리더', +}; + +export function toMember(cm: ClubMember): Member { + return { + id: String(cm.userId), + name: cm.name, + role: '', + department: cm.department, + generation: cm.cardinals.join('.') + (cm.cardinals.length ? '.' : ''), + phone: cm.tel, + studentId: cm.studentId, + position: ROLE_MAP[cm.memberRole], + attendance: cm.attendanceCount, + absence: cm.absenceCount, + status: STATUS_MAP[cm.memberStatus], + }; +} export function toMemberDetail(m: Member): MemberDetail { return { @@ -10,10 +39,10 @@ export function toMemberDetail(m: Member): MemberDetail { department: m.department, phone: m.phone, studentId: m.studentId, - email: 'weeth123@gmail.com', + email: '', activeGenerations: m.generation, - memberStatus: '알럼나이', - joinDate: '2024.12.03.', + memberStatus: '', + joinDate: '', attendance: m.attendance, absence: m.absence, }; From 509e42d3658759eda73750c11bc05e9389460b55 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sat, 28 Mar 2026 12:21:46 +0900 Subject: [PATCH 002/684] =?UTF-8?q?feat:=20=EC=95=A1=EC=84=B8=EC=8A=A4=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/icons/admin/index.ts | 1 + src/lib/apis/cookies.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/assets/icons/admin/index.ts b/src/assets/icons/admin/index.ts index e7df19ee..f36180b1 100644 --- a/src/assets/icons/admin/index.ts +++ b/src/assets/icons/admin/index.ts @@ -6,6 +6,7 @@ export { default as AdminColumnMeatballIcon } from './ic_admin_column_meatball.s export { default as AdminChangeIcon } from './ic_admin_change.svg'; export { default as AdminGenerationIcon } from './ic_admin_generation.svg'; export { default as AdminCheckboxIcon } from './ic_admin_checkbox.svg'; +export { default as AdminCheckIcon } from './ic_admin_check.svg'; export { default as AdminUncheckboxIcon } from './ic_admin_uncheckbox.svg'; export { default as AdminUserIcon } from './ic_admin_user.svg'; export { default as AdminCloseIcon } from './ic_admin_close.svg'; diff --git a/src/lib/apis/cookies.ts b/src/lib/apis/cookies.ts index 3728b7ed..deee7620 100644 --- a/src/lib/apis/cookies.ts +++ b/src/lib/apis/cookies.ts @@ -1,4 +1,4 @@ -export const ACCESS_TOKEN_KEY = 'access_token'; +export const ACCESS_TOKEN_KEY = process.env.ACCESS_TOKEN; export const REFRESH_TOKEN_KEY = 'refresh_token'; const COOKIE_BASE = { From 26a4d69e5efd9deb9005a63b8710bbe501643ddd Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sat, 28 Mar 2026 13:24:25 +0900 Subject: [PATCH 003/684] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=A9=A4=EB=B2=84=20data=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/proxy/[...path]/route.ts | 1 + src/components/admin/member/MemberPageContent.tsx | 2 +- src/hooks/index.ts | 1 - src/hooks/mutations/admin/index.ts | 0 src/hooks/queries/admin/index.ts | 1 + src/hooks/{ => queries/admin}/useAdminMemberQuery.ts | 8 +++++--- src/lib/apis/adminMember.ts | 5 +++-- src/lib/apis/cookies.ts | 2 +- src/lib/apis/server.ts | 4 +++- 9 files changed, 15 insertions(+), 9 deletions(-) create mode 100644 src/hooks/mutations/admin/index.ts create mode 100644 src/hooks/queries/admin/index.ts rename src/hooks/{ => queries/admin}/useAdminMemberQuery.ts (66%) diff --git a/src/app/api/proxy/[...path]/route.ts b/src/app/api/proxy/[...path]/route.ts index a6efb562..e83b3ff0 100644 --- a/src/app/api/proxy/[...path]/route.ts +++ b/src/app/api/proxy/[...path]/route.ts @@ -34,6 +34,7 @@ async function handler(request: NextRequest, { params }: { params: Promise<{ pat const responseHeaders = new Headers(response.headers); responseHeaders.delete('transfer-encoding'); + responseHeaders.delete('content-encoding'); responseHeaders.delete('set-cookie'); if (response.status === 204) { diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index ce8f6326..6ec3d9e5 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -14,8 +14,8 @@ import { import { toMemberDetail } from '@/utils/admin/memberMapper'; import { Card } from '@/components/ui'; import { useDragScroll } from '@/hooks'; -import { useAdminMembers } from '@/hooks/useAdminMemberQuery'; import type { Member, MemberDetail } from '@/types/admin/member'; +import { useAdminMembers } from '@/hooks/queries/admin'; function MemberPageContent() { const [selectedIds, setSelectedIds] = useState>(new Set()); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 0b037be6..848b41d1 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -7,4 +7,3 @@ export { useFileAttach } from './useFileAttach'; export { useScrollIntoView } from './useScrollIntoView'; export { useRemainingTime } from './useRemainingTime'; export { useScrollOnGrow } from './useScrollOnGrow'; -export { useAdminMembers } from './useAdminMemberQuery'; diff --git a/src/hooks/mutations/admin/index.ts b/src/hooks/mutations/admin/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/hooks/queries/admin/index.ts b/src/hooks/queries/admin/index.ts new file mode 100644 index 00000000..2a7127fe --- /dev/null +++ b/src/hooks/queries/admin/index.ts @@ -0,0 +1 @@ +export { useAdminMembers } from './useAdminMemberQuery'; diff --git a/src/hooks/useAdminMemberQuery.ts b/src/hooks/queries/admin/useAdminMemberQuery.ts similarity index 66% rename from src/hooks/useAdminMemberQuery.ts rename to src/hooks/queries/admin/useAdminMemberQuery.ts index 2847d79d..35baf9a8 100644 --- a/src/hooks/useAdminMemberQuery.ts +++ b/src/hooks/queries/admin/useAdminMemberQuery.ts @@ -3,15 +3,17 @@ import { useQuery } from '@tanstack/react-query'; import { adminMemberApi } from '@/lib/apis/adminMember'; import { toMember } from '@/utils/admin/memberMapper'; -// TODO: clubId를 실제 값으로 교체해야 함 (로그인 후 받아오거나 환경변수로 관리) -const CLUB_ID = 1; +// TODO: clubId store에서 가져오도로 변경 +const CLUB_ID = 'YUNJcjFKMO'; export function useAdminMembers() { return useQuery({ queryKey: ['admin', 'members', CLUB_ID], queryFn: async () => { const res = await adminMemberApi.getMembers(CLUB_ID); - return res.data.map(toMember); + return res.data.data.map(toMember); }, + staleTime: 30 * 60 * 1000, + gcTime: 60 * 60 * 1000, }); } diff --git a/src/lib/apis/adminMember.ts b/src/lib/apis/adminMember.ts index 2bfdb399..58b5e9a7 100644 --- a/src/lib/apis/adminMember.ts +++ b/src/lib/apis/adminMember.ts @@ -1,7 +1,8 @@ import { apiClient } from '@/lib/apis/client'; import type { ClubMember } from '@/types/admin/member'; +import type { ApiResponse } from '@/types/common'; export const adminMemberApi = { - getMembers: (clubId: number) => - apiClient.get(`/api/v4/admin/clubs/${clubId}/members`), + getMembers: (clubId: string) => + apiClient.get>(`/api/v4/admin/clubs/${clubId}/members`), }; diff --git a/src/lib/apis/cookies.ts b/src/lib/apis/cookies.ts index deee7620..3728b7ed 100644 --- a/src/lib/apis/cookies.ts +++ b/src/lib/apis/cookies.ts @@ -1,4 +1,4 @@ -export const ACCESS_TOKEN_KEY = process.env.ACCESS_TOKEN; +export const ACCESS_TOKEN_KEY = 'access_token'; export const REFRESH_TOKEN_KEY = 'refresh_token'; const COOKIE_BASE = { diff --git a/src/lib/apis/server.ts b/src/lib/apis/server.ts index ef83f7de..2bb248a7 100644 --- a/src/lib/apis/server.ts +++ b/src/lib/apis/server.ts @@ -60,7 +60,9 @@ async function request( _retried = false, ): Promise { const cookieStore = await cookies(); - const accessToken = cookieStore.get(ACCESS_TOKEN_KEY)?.value; + const accessToken = + cookieStore.get(ACCESS_TOKEN_KEY)?.value ?? + (process.env.NODE_ENV === 'development' ? process.env.DEV_ACCESS_TOKEN : undefined); const { params, ...fetchOptions } = options; const url = buildUrl(path, params); From bfe1308403b738854df975a11f0791ccbbabb268 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sat, 28 Mar 2026 23:30:40 +0900 Subject: [PATCH 004/684] =?UTF-8?q?fix:=20dto=EB=9E=91=20data=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/member/MemberPageContent.tsx | 7 +-- .../admin/member/modal/MemberDetailModal.tsx | 36 ++++++----- .../admin/memberDetailModal.constants.ts | 60 +++++++++---------- src/constants/admin/memberTable.constants.ts | 47 +++++++++------ src/types/admin/member.d.ts | 27 ++------- src/utils/admin/memberMapper.ts | 36 ++--------- 6 files changed, 89 insertions(+), 124 deletions(-) diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index 6ec3d9e5..92618f70 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -11,21 +11,20 @@ import { MemberTable, MemberTopBar, } from '@/components/admin'; -import { toMemberDetail } from '@/utils/admin/memberMapper'; import { Card } from '@/components/ui'; import { useDragScroll } from '@/hooks'; -import type { Member, MemberDetail } from '@/types/admin/member'; +import type { Member } from '@/types/admin/member'; import { useAdminMembers } from '@/hooks/queries/admin'; function MemberPageContent() { const [selectedIds, setSelectedIds] = useState>(new Set()); const [searchValue, setSearchValue] = useState(''); - const [detailMember, setDetailMember] = useState(null); + const [detailMember, setDetailMember] = useState(null); const { ref: dragScrollRef, onMouseDown } = useDragScroll(); const { data: members = [] } = useAdminMembers(); const handleMemberAction = (m: Member) => { - setDetailMember(toMemberDetail(m)); + setDetailMember(m); }; const selectedMembers = members.filter((m) => selectedIds.has(m.id)); diff --git a/src/components/admin/member/modal/MemberDetailModal.tsx b/src/components/admin/member/modal/MemberDetailModal.tsx index 83fb87d4..ab7ad58f 100644 --- a/src/components/admin/member/modal/MemberDetailModal.tsx +++ b/src/components/admin/member/modal/MemberDetailModal.tsx @@ -25,12 +25,12 @@ import { getActivityStats, getFooterActions, } from '@/constants/admin/memberDetailModal.constants'; -import { MemberDetail } from '@/types/admin/member'; +import type { Member } from '@/types/admin/member'; interface MemberDetailModalProps { open: boolean; onOpenChange: (open: boolean) => void; - member: MemberDetail | null; + member: Member | null; onApprove?: () => void; onChangeToAdmin?: () => void; onResetPassword?: () => void; @@ -93,7 +93,7 @@ function MemberDetailModal({
{member.name} - {member.generation}기 + {parseInt(member.generation, 10)}기
@@ -140,22 +140,20 @@ function MemberDetailModal({ {/* Footer */}
- {footerActions - .filter(({ handler }) => handler !== undefined) - .map(({ label, title, handler }) => ( - - {label} - - } - > - 확인 - 취소 - - ))} + {footerActions.map(({ label, title, handler }) => ( + + {label} + + } + > + 확인 + 취소 + + ))} {onChangeGeneration && (
From 3c0f688dcabd5c0127b60caf79c9b68735c332e6 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sat, 28 Mar 2026 23:56:25 +0900 Subject: [PATCH 006/684] =?UTF-8?q?feat:=20=EB=A9=A4=EB=B2=84=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EB=B3=80=EA=B2=BD=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/member/MemberPageContent.tsx | 5 +++++ src/hooks/mutations/admin/index.ts | 1 + .../mutations/admin/useAdminMemberMutation.ts | 15 +++++++++++++++ src/hooks/queries/admin/useAdminMemberQuery.ts | 4 ++-- src/lib/apis/adminMember.ts | 4 +++- src/lib/apis/index.ts | 1 - src/types/admin/member.d.ts | 1 + src/utils/admin/memberMapper.ts | 1 + 8 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 src/hooks/mutations/admin/useAdminMemberMutation.ts diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index 8ef3e60f..194a756a 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -15,6 +15,7 @@ import { Card } from '@/components/ui'; import { useDragScroll } from '@/hooks'; import type { Member } from '@/types/admin/member'; import { useAdminMembers } from '@/hooks/queries/admin'; +import { useChangeToAdmin } from '@/hooks/mutations/admin'; function MemberPageContent() { const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -22,6 +23,7 @@ function MemberPageContent() { const [detailMember, setDetailMember] = useState(null); const { ref: dragScrollRef, onMouseDown } = useDragScroll(); const { data: members = [] } = useAdminMembers(); + const { mutate: changeToAdmin } = useChangeToAdmin(); const handleMemberAction = (m: Member) => { setDetailMember(m); @@ -118,6 +120,9 @@ function MemberPageContent() { if (!open) setDetailMember(null); }} member={detailMember} + onChangeToAdmin={ + detailMember ? () => changeToAdmin(detailMember.clubMemberId) : undefined + } />
); diff --git a/src/hooks/mutations/admin/index.ts b/src/hooks/mutations/admin/index.ts index e69de29b..2fe3533a 100644 --- a/src/hooks/mutations/admin/index.ts +++ b/src/hooks/mutations/admin/index.ts @@ -0,0 +1 @@ +export { useChangeToAdmin } from './useAdminMemberMutation'; diff --git a/src/hooks/mutations/admin/useAdminMemberMutation.ts b/src/hooks/mutations/admin/useAdminMemberMutation.ts new file mode 100644 index 00000000..8cf78037 --- /dev/null +++ b/src/hooks/mutations/admin/useAdminMemberMutation.ts @@ -0,0 +1,15 @@ +import { CLUB_ID } from '@/hooks/queries/admin/useAdminMemberQuery'; +import { adminMemberApi } from '@/lib/apis'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +export function useChangeToAdmin() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (clubMemberId: number) => + adminMemberApi.updateMemberRole(CLUB_ID, clubMemberId, 'ADMIN'), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'members', CLUB_ID] }); + }, + }); +} diff --git a/src/hooks/queries/admin/useAdminMemberQuery.ts b/src/hooks/queries/admin/useAdminMemberQuery.ts index 35baf9a8..f0fd4bce 100644 --- a/src/hooks/queries/admin/useAdminMemberQuery.ts +++ b/src/hooks/queries/admin/useAdminMemberQuery.ts @@ -1,10 +1,10 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { adminMemberApi } from '@/lib/apis/adminMember'; import { toMember } from '@/utils/admin/memberMapper'; // TODO: clubId store에서 가져오도로 변경 -const CLUB_ID = 'YUNJcjFKMO'; +export const CLUB_ID = 'YUNJcjFKMO'; export function useAdminMembers() { return useQuery({ diff --git a/src/lib/apis/adminMember.ts b/src/lib/apis/adminMember.ts index 58b5e9a7..ef9f26e1 100644 --- a/src/lib/apis/adminMember.ts +++ b/src/lib/apis/adminMember.ts @@ -1,8 +1,10 @@ import { apiClient } from '@/lib/apis/client'; -import type { ClubMember } from '@/types/admin/member'; +import type { ClubMember, ClubMemberRole } from '@/types/admin/member'; import type { ApiResponse } from '@/types/common'; export const adminMemberApi = { getMembers: (clubId: string) => apiClient.get>(`/api/v4/admin/clubs/${clubId}/members`), + updateMemberRole: (clubId: string, clubMemberId: number, memberRole: ClubMemberRole) => + apiClient.patch(`/api/v4/admin/clubs/${clubId}/members/${clubMemberId}/role`, { memberRole }), }; diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index e83a5ec0..cd8f8f58 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -1,3 +1,2 @@ export { apiClient } from './client'; -export { apiServer } from './server'; export { adminMemberApi } from './adminMember'; diff --git a/src/types/admin/member.d.ts b/src/types/admin/member.d.ts index 75ec8127..a7443103 100644 --- a/src/types/admin/member.d.ts +++ b/src/types/admin/member.d.ts @@ -2,6 +2,7 @@ export type MemberStatus = 'WAITING' | 'ACTIVE' | 'BANNED' | 'LEFT'; export interface Member { id: string; + clubMemberId: number; name: string; email: string; role: string; diff --git a/src/utils/admin/memberMapper.ts b/src/utils/admin/memberMapper.ts index 572f53ad..3ceb6ad6 100644 --- a/src/utils/admin/memberMapper.ts +++ b/src/utils/admin/memberMapper.ts @@ -9,6 +9,7 @@ const ROLE_MAP: Record = { export function toMember(cm: ClubMember): Member { return { id: String(cm.userId), + clubMemberId: cm.clubMemberId, name: cm.name, email: cm.email, role: '', From 6531f2d3f5e9aa436b5d90f2524d57028e9efbeb Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sat, 28 Mar 2026 23:59:00 +0900 Subject: [PATCH 007/684] =?UTF-8?q?fix:=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=ED=91=9C=EC=8B=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/member/MemberTable.tsx | 1 - src/constants/admin/memberTable.constants.ts | 86 +------------------- 2 files changed, 4 insertions(+), 83 deletions(-) diff --git a/src/components/admin/member/MemberTable.tsx b/src/components/admin/member/MemberTable.tsx index 4d489621..21ac6ee7 100644 --- a/src/components/admin/member/MemberTable.tsx +++ b/src/components/admin/member/MemberTable.tsx @@ -16,7 +16,6 @@ import { cn } from '@/lib/cn'; import type { Member } from '@/types/admin/member'; import { COLUMNS, - MOCK_MEMBERS, SORT_LABEL, STATUS_BAR_COLOR, STATUS_LEGEND, diff --git a/src/constants/admin/memberTable.constants.ts b/src/constants/admin/memberTable.constants.ts index 56122737..215f0aab 100644 --- a/src/constants/admin/memberTable.constants.ts +++ b/src/constants/admin/memberTable.constants.ts @@ -1,83 +1,5 @@ import type { Member, MemberStatus } from '@/types/admin/member'; -export const MOCK_MEMBERS: Member[] = [ - { - id: '1', - name: '김위드니', - email: 'weeth1@example.com', - role: '프론트엔드', - department: '컴퓨터공학과', - generation: '4, 3, 2, 1', - phone: '01000009999', - studentId: '202036123', - position: '사용자', - attendance: 12, - absence: 12, - penaltyCount: 0, - status: 'ACTIVE', - }, - { - id: '2', - name: '김위드니', - email: 'weeth2@example.com', - role: '프론트엔드', - department: '미디어커뮤니케이션학과', - generation: '8, 7, 6, 5, 4, 3, 2, 1', - phone: '01000009999', - studentId: '202036123', - position: '사용자', - attendance: 12, - absence: 12, - penaltyCount: 1, - status: 'WAITING', - }, - { - id: '3', - name: '김위드니', - email: 'weeth3@example.com', - role: '디자인', - department: '시각디자인학과', - generation: '3', - phone: '01000009999', - studentId: '202036123', - position: '관리자', - attendance: 12, - absence: 12, - penaltyCount: 0, - status: 'BANNED', - }, - { - id: '4', - name: '김위드니', - email: 'weeth4@example.com', - role: '백엔드', - department: '소프트웨어학과', - generation: '5, 4, 3', - phone: '01011112222', - studentId: '202112345', - position: '사용자', - attendance: 12, - absence: 12, - penaltyCount: 0, - status: 'ACTIVE', - }, - { - id: '5', - name: '김위드니', - email: 'weeth5@example.com', - role: '기획', - department: '경영학과', - generation: '6, 5', - phone: '01033334444', - studentId: '202298765', - position: '사용자', - attendance: 12, - absence: 12, - penaltyCount: 2, - status: 'WAITING', - }, -]; - export const STATUS_BAR_COLOR: Record = { ACTIVE: 'bg-brand-primary', WAITING: 'bg-state-caution', @@ -98,10 +20,10 @@ export const COLUMNS: { label: string; key: keyof Member }[] = [ ]; export const STATUS_LEGEND = [ - { label: 'ACTIVE', color: 'bg-brand-primary' }, - { label: 'WAITING', color: 'bg-state-caution' }, - { label: 'BANNED', color: 'bg-state-error' }, - { label: 'LEFT', color: 'bg-container-neutral-alternative' }, + { label: '활동중', color: 'bg-brand-primary' }, + { label: '경고', color: 'bg-state-caution' }, + { label: '강퇴', color: 'bg-state-error' }, + { label: '탈퇴', color: 'bg-button-neutral' }, ] as const; export type SortBy = 'generation' | 'name'; From b3d5940acfd76a0fb8e26c0a17e1964f219feccd Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 29 Mar 2026 00:19:38 +0900 Subject: [PATCH 008/684] =?UTF-8?q?fix:=20=EB=A9=A4=EB=B2=84=20user=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EB=B3=80=EA=B2=BD=EB=8F=84=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/member/MemberPageContent.tsx | 12 +++++++++--- src/hooks/mutations/admin/index.ts | 2 +- .../mutations/admin/useAdminMemberMutation.ts | 15 +++++++++++---- src/types/admin/member.d.ts | 1 + src/utils/admin/memberMapper.ts | 1 + 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index 194a756a..7a7275c6 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -15,7 +15,7 @@ import { Card } from '@/components/ui'; import { useDragScroll } from '@/hooks'; import type { Member } from '@/types/admin/member'; import { useAdminMembers } from '@/hooks/queries/admin'; -import { useChangeToAdmin } from '@/hooks/mutations/admin'; +import { useChangeMemberRole } from '@/hooks/mutations/admin'; function MemberPageContent() { const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -23,7 +23,7 @@ function MemberPageContent() { const [detailMember, setDetailMember] = useState(null); const { ref: dragScrollRef, onMouseDown } = useDragScroll(); const { data: members = [] } = useAdminMembers(); - const { mutate: changeToAdmin } = useChangeToAdmin(); + const { mutate: changeMemberRole } = useChangeMemberRole(); const handleMemberAction = (m: Member) => { setDetailMember(m); @@ -121,7 +121,13 @@ function MemberPageContent() { }} member={detailMember} onChangeToAdmin={ - detailMember ? () => changeToAdmin(detailMember.clubMemberId) : undefined + detailMember + ? () => + changeMemberRole({ + clubMemberId: detailMember.clubMemberId, + memberRole: detailMember.memberRole === 'ADMIN' ? 'USER' : 'ADMIN', + }) + : undefined } />
diff --git a/src/hooks/mutations/admin/index.ts b/src/hooks/mutations/admin/index.ts index 2fe3533a..67f6291e 100644 --- a/src/hooks/mutations/admin/index.ts +++ b/src/hooks/mutations/admin/index.ts @@ -1 +1 @@ -export { useChangeToAdmin } from './useAdminMemberMutation'; +export { useChangeMemberRole } from './useAdminMemberMutation'; diff --git a/src/hooks/mutations/admin/useAdminMemberMutation.ts b/src/hooks/mutations/admin/useAdminMemberMutation.ts index 8cf78037..5777e2fa 100644 --- a/src/hooks/mutations/admin/useAdminMemberMutation.ts +++ b/src/hooks/mutations/admin/useAdminMemberMutation.ts @@ -1,13 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + import { CLUB_ID } from '@/hooks/queries/admin/useAdminMemberQuery'; import { adminMemberApi } from '@/lib/apis'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ClubMemberRole } from '@/types/admin/member'; -export function useChangeToAdmin() { +export function useChangeMemberRole() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (clubMemberId: number) => - adminMemberApi.updateMemberRole(CLUB_ID, clubMemberId, 'ADMIN'), + mutationFn: ({ + clubMemberId, + memberRole, + }: { + clubMemberId: number; + memberRole: ClubMemberRole; + }) => adminMemberApi.updateMemberRole(CLUB_ID, clubMemberId, memberRole), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin', 'members', CLUB_ID] }); }, diff --git a/src/types/admin/member.d.ts b/src/types/admin/member.d.ts index a7443103..6bb6d50f 100644 --- a/src/types/admin/member.d.ts +++ b/src/types/admin/member.d.ts @@ -11,6 +11,7 @@ export interface Member { phone: string; studentId: string; position: string; + memberRole: ClubMemberRole; attendance: number; absence: number; penaltyCount: number; diff --git a/src/utils/admin/memberMapper.ts b/src/utils/admin/memberMapper.ts index 3ceb6ad6..f8cc7a0e 100644 --- a/src/utils/admin/memberMapper.ts +++ b/src/utils/admin/memberMapper.ts @@ -17,6 +17,7 @@ export function toMember(cm: ClubMember): Member { studentId: cm.studentId, phone: cm.tel, position: ROLE_MAP[cm.memberRole], + memberRole: cm.memberRole, generation: cm.cardinals.join(', '), attendance: cm.attendanceCount, absence: cm.absenceCount, From 314d1b19c1f4f05cb6bc954c41d8eca2badf51c1 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 29 Mar 2026 00:30:41 +0900 Subject: [PATCH 009/684] =?UTF-8?q?fix:=20=EB=AA=A8=EB=8B=AC=EA=B3=BC=20to?= =?UTF-8?q?pbar=20=EB=AA=A8=EB=91=90=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/member/MemberPageContent.tsx | 29 ++++++++++------ src/components/admin/member/MemberTopBar.tsx | 20 +++++------ .../admin/member/modal/MemberDetailModal.tsx | 6 ++-- .../admin/memberDetailModal.constants.ts | 17 +++++++--- src/constants/admin/memberTopBar.constants.ts | 34 +++++++++---------- .../mutations/admin/useAdminMemberMutation.ts | 32 +++++++++++++++-- 6 files changed, 87 insertions(+), 51 deletions(-) diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index 7a7275c6..5af1aee3 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -42,8 +42,9 @@ function MemberPageContent() { const selectedMembers = filteredMembers.filter((m) => selectedIds.has(m.id)); const selectedCount = selectedMembers.length; - const allUsers = selectedCount > 0 && selectedMembers.every((m) => m.position === '사용자'); - const allAdmins = selectedCount > 0 && selectedMembers.every((m) => m.position === '관리자'); + const allUsers = selectedCount > 0 && selectedMembers.every((m) => m.memberRole === 'USER'); + const allAdmins = selectedCount > 0 && selectedMembers.every((m) => m.memberRole === 'ADMIN'); + const targetRole = allUsers ? 'ADMIN' : allAdmins ? 'USER' : null; const handleClearSelection = () => setSelectedIds(new Set()); @@ -53,9 +54,16 @@ function MemberPageContent() { + selectedMembers.forEach((m) => + changeMemberRole({ clubMemberId: m.clubMemberId, memberRole: targetRole }), + ) + : undefined + } /> {/* Main content */} @@ -120,13 +128,14 @@ function MemberPageContent() { if (!open) setDetailMember(null); }} member={detailMember} - onChangeToAdmin={ + onChangeRole={ detailMember - ? () => - changeMemberRole({ - clubMemberId: detailMember.clubMemberId, - memberRole: detailMember.memberRole === 'ADMIN' ? 'USER' : 'ADMIN', - }) + ? () => { + const nextRole = detailMember.memberRole === 'ADMIN' ? 'USER' : 'ADMIN'; + const ROLE_LABEL = { USER: '사용자', ADMIN: '관리자', LEAD: '리더' } as const; + setDetailMember({ ...detailMember, memberRole: nextRole, position: ROLE_LABEL[nextRole] }); + changeMemberRole({ clubMemberId: detailMember.clubMemberId, memberRole: nextRole }); + } : undefined } /> diff --git a/src/components/admin/member/MemberTopBar.tsx b/src/components/admin/member/MemberTopBar.tsx index 051a2c0f..fdf43691 100644 --- a/src/components/admin/member/MemberTopBar.tsx +++ b/src/components/admin/member/MemberTopBar.tsx @@ -19,14 +19,14 @@ import { cn } from '@/lib/cn'; import { getTopBarActions } from '@/constants/admin/memberTopBar.constants'; import { useGenerationConfirm } from '@/hooks'; +import type { ClubMemberRole } from '@/types/admin/member'; + interface MemberTopBarProps extends React.HTMLAttributes { selectedCount: number; - canChangeToAdmin: boolean; - canChangeToUser: boolean; + targetRole: ClubMemberRole | null; onBack: () => void; onApprove?: () => void; - onChangeToAdmin?: () => void; - onChangeToUser?: () => void; + onChangeRole?: () => void; onResetPassword?: () => void; onBan?: () => void; onChangeGeneration?: (generation: number) => void; @@ -36,12 +36,10 @@ interface MemberTopBarProps extends React.HTMLAttributes { function MemberTopBar({ className, selectedCount, - canChangeToAdmin, - canChangeToUser, + targetRole, onBack, onApprove, - onChangeToAdmin, - onChangeToUser, + onChangeRole, onResetPassword, onBan, onChangeGeneration, @@ -60,11 +58,9 @@ function MemberTopBar({ const topBarActions = getTopBarActions({ selectedCount, - canChangeToAdmin, - canChangeToUser, + targetRole, onApprove, - onChangeToAdmin, - onChangeToUser, + onChangeRole, onResetPassword, onBan, }); diff --git a/src/components/admin/member/modal/MemberDetailModal.tsx b/src/components/admin/member/modal/MemberDetailModal.tsx index ab7ad58f..c552f0fa 100644 --- a/src/components/admin/member/modal/MemberDetailModal.tsx +++ b/src/components/admin/member/modal/MemberDetailModal.tsx @@ -32,7 +32,7 @@ interface MemberDetailModalProps { onOpenChange: (open: boolean) => void; member: Member | null; onApprove?: () => void; - onChangeToAdmin?: () => void; + onChangeRole?: () => void; onResetPassword?: () => void; onBan?: () => void; onChangeGeneration?: (generation: number) => void; @@ -43,7 +43,7 @@ function MemberDetailModal({ onOpenChange, member, onApprove, - onChangeToAdmin, + onChangeRole, onResetPassword, onBan, onChangeGeneration, @@ -63,7 +63,7 @@ function MemberDetailModal({ const personalInfo = getPersonalInfo(member); const activityInfo = getActivityInfo(member); const activityStats = getActivityStats(member); - const footerActions = getFooterActions({ onApprove, onChangeToAdmin, onResetPassword, onBan }); + const footerActions = getFooterActions({ memberRole: member.memberRole, onApprove, onChangeRole, onResetPassword, onBan }); return ( <> diff --git a/src/constants/admin/memberDetailModal.constants.ts b/src/constants/admin/memberDetailModal.constants.ts index 2ddb24f0..99f89154 100644 --- a/src/constants/admin/memberDetailModal.constants.ts +++ b/src/constants/admin/memberDetailModal.constants.ts @@ -41,26 +41,33 @@ export function getActivityStats(member: Member) { ]; } +import type { ClubMemberRole } from '@/types/admin/member'; + interface FooterActionHandlers { + memberRole: ClubMemberRole; onApprove?: () => void; - onChangeToAdmin?: () => void; + onChangeRole?: () => void; onResetPassword?: () => void; onBan?: () => void; } export function getFooterActions({ + memberRole, // onApprove, - onChangeToAdmin, + onChangeRole, // onResetPassword, onBan, }: FooterActionHandlers) { + const isAdmin = memberRole === 'ADMIN'; return [ // TODO: 가입 승인 api 열리면 열기 // { label: '가입 승인', title: '1명의 멤버 가입을 승인하시겠습니까?', handler: onApprove }, { - label: '관리자로 변경', - title: '1명의 멤버 역할을 관리자로\n변경하시겠습니까?', - handler: onChangeToAdmin, + label: isAdmin ? '사용자로 변경' : '관리자로 변경', + title: isAdmin + ? '1명의 멤버 역할을 사용자로\n변경하시겠습니까?' + : '1명의 멤버 역할을 관리자로\n변경하시겠습니까?', + handler: onChangeRole, }, // TODO: 비번 변경 api 열리면 열기 // { diff --git a/src/constants/admin/memberTopBar.constants.ts b/src/constants/admin/memberTopBar.constants.ts index 70149a52..efdb1b3f 100644 --- a/src/constants/admin/memberTopBar.constants.ts +++ b/src/constants/admin/memberTopBar.constants.ts @@ -1,24 +1,28 @@ +import type { ClubMemberRole } from '@/types/admin/member'; + interface TopBarActionParams { selectedCount: number; - canChangeToAdmin: boolean; - canChangeToUser: boolean; + targetRole: ClubMemberRole | null; // null = 혼합 선택 onApprove?: () => void; - onChangeToAdmin?: () => void; - onChangeToUser?: () => void; + onChangeRole?: () => void; onResetPassword?: () => void; onBan?: () => void; } export function getTopBarActions({ selectedCount, - canChangeToAdmin, - canChangeToUser, + targetRole, onApprove, - onChangeToAdmin, - onChangeToUser, + onChangeRole, onResetPassword, onBan, }: TopBarActionParams) { + const roleLabel = targetRole === 'ADMIN' ? '관리자로 변경' : '사용자로 변경'; + const roleTitle = + targetRole === 'ADMIN' + ? `${selectedCount}명의 멤버 역할을 관리자로\n변경하시겠습니까?` + : `${selectedCount}명의 멤버 역할을 사용자로\n변경하시겠습니까?`; + return [ { label: '가입 승인', @@ -27,16 +31,10 @@ export function getTopBarActions({ disabled: !onApprove, }, { - label: '관리자로 변경', - title: `${selectedCount}명의 멤버 역할을 관리자로\n변경하시겠습니까?`, - handler: onChangeToAdmin, - disabled: !canChangeToAdmin, - }, - { - label: '사용자로 변경', - title: `${selectedCount}명의 멤버 역할을 사용자로\n변경하시겠습니까?`, - handler: onChangeToUser, - disabled: !canChangeToUser, + label: roleLabel, + title: roleTitle, + handler: onChangeRole, + disabled: !onChangeRole || targetRole === null, }, { label: '비밀번호 초기화', diff --git a/src/hooks/mutations/admin/useAdminMemberMutation.ts b/src/hooks/mutations/admin/useAdminMemberMutation.ts index 5777e2fa..6d8de49f 100644 --- a/src/hooks/mutations/admin/useAdminMemberMutation.ts +++ b/src/hooks/mutations/admin/useAdminMemberMutation.ts @@ -2,10 +2,17 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { CLUB_ID } from '@/hooks/queries/admin/useAdminMemberQuery'; import { adminMemberApi } from '@/lib/apis'; -import type { ClubMemberRole } from '@/types/admin/member'; +import type { ClubMemberRole, Member } from '@/types/admin/member'; + +const ROLE_LABEL: Record = { + USER: '사용자', + ADMIN: '관리자', + LEAD: '리더', +}; export function useChangeMemberRole() { const queryClient = useQueryClient(); + const queryKey = ['admin', 'members', CLUB_ID]; return useMutation({ mutationFn: ({ @@ -15,8 +22,27 @@ export function useChangeMemberRole() { clubMemberId: number; memberRole: ClubMemberRole; }) => adminMemberApi.updateMemberRole(CLUB_ID, clubMemberId, memberRole), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin', 'members', CLUB_ID] }); + onMutate: async ({ clubMemberId, memberRole }) => { + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (old = []) => + old.map((m) => + m.clubMemberId === clubMemberId + ? { ...m, memberRole, position: ROLE_LABEL[memberRole] } + : m, + ), + ); + + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(queryKey, context.previous); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey }); }, }); } From c9fcba1f11dfb3081f231914bf33787194eccefd Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 29 Mar 2026 00:41:17 +0900 Subject: [PATCH 010/684] =?UTF-8?q?feat:=20=EB=A9=A4=EB=B2=84=20=EC=B6=94?= =?UTF-8?q?=EB=B0=A9=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/member/MemberPageContent.tsx | 4 ++- src/hooks/mutations/admin/index.ts | 2 +- .../mutations/admin/useAdminMemberMutation.ts | 29 +++++++++++++++++++ .../queries/admin/useAdminMemberQuery.ts | 2 +- src/lib/apis/adminMember.ts | 2 ++ 5 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index 5af1aee3..d934ab58 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -15,7 +15,7 @@ import { Card } from '@/components/ui'; import { useDragScroll } from '@/hooks'; import type { Member } from '@/types/admin/member'; import { useAdminMembers } from '@/hooks/queries/admin'; -import { useChangeMemberRole } from '@/hooks/mutations/admin'; +import { useBanMember, useChangeMemberRole } from '@/hooks/mutations/admin'; function MemberPageContent() { const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -24,6 +24,7 @@ function MemberPageContent() { const { ref: dragScrollRef, onMouseDown } = useDragScroll(); const { data: members = [] } = useAdminMembers(); const { mutate: changeMemberRole } = useChangeMemberRole(); + const { mutate: banMember } = useBanMember(); const handleMemberAction = (m: Member) => { setDetailMember(m); @@ -128,6 +129,7 @@ function MemberPageContent() { if (!open) setDetailMember(null); }} member={detailMember} + onBan={detailMember ? () => banMember(detailMember.clubMemberId) : undefined} onChangeRole={ detailMember ? () => { diff --git a/src/hooks/mutations/admin/index.ts b/src/hooks/mutations/admin/index.ts index 67f6291e..2a4c737e 100644 --- a/src/hooks/mutations/admin/index.ts +++ b/src/hooks/mutations/admin/index.ts @@ -1 +1 @@ -export { useChangeMemberRole } from './useAdminMemberMutation'; +export { useChangeMemberRole, useBanMember } from './useAdminMemberMutation'; diff --git a/src/hooks/mutations/admin/useAdminMemberMutation.ts b/src/hooks/mutations/admin/useAdminMemberMutation.ts index 6d8de49f..d2890d1b 100644 --- a/src/hooks/mutations/admin/useAdminMemberMutation.ts +++ b/src/hooks/mutations/admin/useAdminMemberMutation.ts @@ -10,6 +10,7 @@ const ROLE_LABEL: Record = { LEAD: '리더', }; +// 멤버 권한 변경 export function useChangeMemberRole() { const queryClient = useQueryClient(); const queryKey = ['admin', 'members', CLUB_ID]; @@ -46,3 +47,31 @@ export function useChangeMemberRole() { }, }); } + +//멤버 추방 +export function useBanMember() { + const queryClient = useQueryClient(); + const queryKey = ['admin', 'members', CLUB_ID]; + + return useMutation({ + mutationFn: (clubMemberId: number) => adminMemberApi.banMember(CLUB_ID, clubMemberId), + onMutate: async (clubMemberId) => { + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (old = []) => + old.map((m) => (m.clubMemberId === clubMemberId ? { ...m, status: 'BANNED' } : m)), + ); + + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(queryKey, context.previous); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); +} diff --git a/src/hooks/queries/admin/useAdminMemberQuery.ts b/src/hooks/queries/admin/useAdminMemberQuery.ts index f0fd4bce..8960e565 100644 --- a/src/hooks/queries/admin/useAdminMemberQuery.ts +++ b/src/hooks/queries/admin/useAdminMemberQuery.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { adminMemberApi } from '@/lib/apis/adminMember'; import { toMember } from '@/utils/admin/memberMapper'; diff --git a/src/lib/apis/adminMember.ts b/src/lib/apis/adminMember.ts index ef9f26e1..dc70a573 100644 --- a/src/lib/apis/adminMember.ts +++ b/src/lib/apis/adminMember.ts @@ -7,4 +7,6 @@ export const adminMemberApi = { apiClient.get>(`/api/v4/admin/clubs/${clubId}/members`), updateMemberRole: (clubId: string, clubMemberId: number, memberRole: ClubMemberRole) => apiClient.patch(`/api/v4/admin/clubs/${clubId}/members/${clubMemberId}/role`, { memberRole }), + banMember: (clubId: string, clubMemberId: number) => + apiClient.delete(`/api/v4/admin/clubs/${clubId}/members/${clubMemberId}/ban`), }; From 4e529b9fdb2b0fcef0ba84f0335e81a55b1ff5b2 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 29 Mar 2026 00:52:25 +0900 Subject: [PATCH 011/684] =?UTF-8?q?feat:=20=EB=A9=A4=EB=B2=84=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/member/MemberPageContent.tsx | 11 ++++++- .../admin/member/modal/MemberDetailModal.tsx | 4 ++- .../admin/memberDetailModal.constants.ts | 9 +++++- src/hooks/mutations/admin/index.ts | 2 +- .../mutations/admin/useAdminMemberMutation.ts | 30 ++++++++++++++++++- src/lib/apis/adminMember.ts | 2 ++ 6 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index d934ab58..7fef68a3 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -15,7 +15,7 @@ import { Card } from '@/components/ui'; import { useDragScroll } from '@/hooks'; import type { Member } from '@/types/admin/member'; import { useAdminMembers } from '@/hooks/queries/admin'; -import { useBanMember, useChangeMemberRole } from '@/hooks/mutations/admin'; +import { useBanMember, useChangeMemberRole, useRestoreMember } from '@/hooks/mutations/admin'; function MemberPageContent() { const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -25,6 +25,7 @@ function MemberPageContent() { const { data: members = [] } = useAdminMembers(); const { mutate: changeMemberRole } = useChangeMemberRole(); const { mutate: banMember } = useBanMember(); + const { mutate: restoreMember } = useRestoreMember(); const handleMemberAction = (m: Member) => { setDetailMember(m); @@ -130,6 +131,14 @@ function MemberPageContent() { }} member={detailMember} onBan={detailMember ? () => banMember(detailMember.clubMemberId) : undefined} + onRestore={ + detailMember + ? () => { + setDetailMember({ ...detailMember, status: 'ACTIVE' }); + restoreMember(detailMember.clubMemberId); + } + : undefined + } onChangeRole={ detailMember ? () => { diff --git a/src/components/admin/member/modal/MemberDetailModal.tsx b/src/components/admin/member/modal/MemberDetailModal.tsx index c552f0fa..6dbc0923 100644 --- a/src/components/admin/member/modal/MemberDetailModal.tsx +++ b/src/components/admin/member/modal/MemberDetailModal.tsx @@ -35,6 +35,7 @@ interface MemberDetailModalProps { onChangeRole?: () => void; onResetPassword?: () => void; onBan?: () => void; + onRestore?: () => void; onChangeGeneration?: (generation: number) => void; } @@ -46,6 +47,7 @@ function MemberDetailModal({ onChangeRole, onResetPassword, onBan, + onRestore, onChangeGeneration, }: MemberDetailModalProps) { const { @@ -63,7 +65,7 @@ function MemberDetailModal({ const personalInfo = getPersonalInfo(member); const activityInfo = getActivityInfo(member); const activityStats = getActivityStats(member); - const footerActions = getFooterActions({ memberRole: member.memberRole, onApprove, onChangeRole, onResetPassword, onBan }); + const footerActions = getFooterActions({ memberRole: member.memberRole, status: member.status, onApprove, onChangeRole, onResetPassword, onBan, onRestore }); return ( <> diff --git a/src/constants/admin/memberDetailModal.constants.ts b/src/constants/admin/memberDetailModal.constants.ts index 99f89154..1fa428e8 100644 --- a/src/constants/admin/memberDetailModal.constants.ts +++ b/src/constants/admin/memberDetailModal.constants.ts @@ -45,20 +45,25 @@ import type { ClubMemberRole } from '@/types/admin/member'; interface FooterActionHandlers { memberRole: ClubMemberRole; + status: MemberStatus; onApprove?: () => void; onChangeRole?: () => void; onResetPassword?: () => void; onBan?: () => void; + onRestore?: () => void; } export function getFooterActions({ memberRole, + status, // onApprove, onChangeRole, // onResetPassword, onBan, + onRestore, }: FooterActionHandlers) { const isAdmin = memberRole === 'ADMIN'; + const isBanned = status === 'BANNED'; return [ // TODO: 가입 승인 api 열리면 열기 // { label: '가입 승인', title: '1명의 멤버 가입을 승인하시겠습니까?', handler: onApprove }, @@ -75,6 +80,8 @@ export function getFooterActions({ // title: '1명의 멤버 비밀번호를 초기화\n시키시겠습니까?', // handler: onResetPassword, // }, - { label: '유저 추방', title: '1명의 멤버를 추방하시겠습니까?', handler: onBan }, + isBanned + ? { label: '멤버 복구', title: '1명의 멤버를 복구하시겠습니까?', handler: onRestore } + : { label: '유저 추방', title: '1명의 멤버를 추방하시겠습니까?', handler: onBan }, ]; } diff --git a/src/hooks/mutations/admin/index.ts b/src/hooks/mutations/admin/index.ts index 2a4c737e..2887614d 100644 --- a/src/hooks/mutations/admin/index.ts +++ b/src/hooks/mutations/admin/index.ts @@ -1 +1 @@ -export { useChangeMemberRole, useBanMember } from './useAdminMemberMutation'; +export { useChangeMemberRole, useBanMember, useRestoreMember } from './useAdminMemberMutation'; diff --git a/src/hooks/mutations/admin/useAdminMemberMutation.ts b/src/hooks/mutations/admin/useAdminMemberMutation.ts index d2890d1b..7251de48 100644 --- a/src/hooks/mutations/admin/useAdminMemberMutation.ts +++ b/src/hooks/mutations/admin/useAdminMemberMutation.ts @@ -48,7 +48,7 @@ export function useChangeMemberRole() { }); } -//멤버 추방 +// 멤버 추방 export function useBanMember() { const queryClient = useQueryClient(); const queryKey = ['admin', 'members', CLUB_ID]; @@ -75,3 +75,31 @@ export function useBanMember() { }, }); } + +// 추방 멤버 복구 +export function useRestoreMember() { + const queryClient = useQueryClient(); + const queryKey = ['admin', 'members', CLUB_ID]; + + return useMutation({ + mutationFn: (clubMemberId: number) => adminMemberApi.restoreMember(CLUB_ID, clubMemberId), + onMutate: async (clubMemberId) => { + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (old = []) => + old.map((m) => (m.clubMemberId === clubMemberId ? { ...m, status: 'ACTIVE' } : m)), + ); + + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(queryKey, context.previous); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); +} diff --git a/src/lib/apis/adminMember.ts b/src/lib/apis/adminMember.ts index dc70a573..0acd8b43 100644 --- a/src/lib/apis/adminMember.ts +++ b/src/lib/apis/adminMember.ts @@ -9,4 +9,6 @@ export const adminMemberApi = { apiClient.patch(`/api/v4/admin/clubs/${clubId}/members/${clubMemberId}/role`, { memberRole }), banMember: (clubId: string, clubMemberId: number) => apiClient.delete(`/api/v4/admin/clubs/${clubId}/members/${clubMemberId}/ban`), + restoreMember: (clubId: string, clubMemberId: number) => + apiClient.patch(`/api/v4/admin/clubs/${clubId}/members/${clubMemberId}/restore`), }; From f69afe9c148e4ea3e2b2d492b900ce06620a86e4 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 29 Mar 2026 00:59:55 +0900 Subject: [PATCH 012/684] =?UTF-8?q?feat:=20topbar=20=EB=A9=A4=EB=B2=84=20?= =?UTF-8?q?=EC=B6=94=EB=B0=A9=20=EB=B3=B5=EA=B5=AC=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/member/MemberPageContent.tsx | 4 ++++ src/components/admin/member/MemberTopBar.tsx | 6 +++++ src/constants/admin/memberTopBar.constants.ts | 23 ++++++++++++++----- src/hooks/mutations/admin/index.ts | 2 +- ...Mutation.ts => useAdminMemberMutations.ts} | 2 +- src/hooks/queries/admin/index.ts | 2 +- ...emberQuery.ts => useAdminMemberQueries.ts} | 0 src/hooks/queries/index.ts | 0 src/hooks/queries/useCommoneQUeries.ts | 0 9 files changed, 30 insertions(+), 9 deletions(-) rename src/hooks/mutations/admin/{useAdminMemberMutation.ts => useAdminMemberMutations.ts} (99%) rename src/hooks/queries/admin/{useAdminMemberQuery.ts => useAdminMemberQueries.ts} (100%) create mode 100644 src/hooks/queries/index.ts create mode 100644 src/hooks/queries/useCommoneQUeries.ts diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index 7fef68a3..6305df99 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -47,6 +47,7 @@ function MemberPageContent() { const allUsers = selectedCount > 0 && selectedMembers.every((m) => m.memberRole === 'USER'); const allAdmins = selectedCount > 0 && selectedMembers.every((m) => m.memberRole === 'ADMIN'); const targetRole = allUsers ? 'ADMIN' : allAdmins ? 'USER' : null; + const allBanned = selectedCount > 0 && selectedMembers.every((m) => m.status === 'BANNED'); const handleClearSelection = () => setSelectedIds(new Set()); @@ -57,6 +58,7 @@ function MemberPageContent() { className="sticky top-0 z-10 -mt-15" selectedCount={selectedCount} targetRole={targetRole} + allBanned={allBanned} onBack={handleClearSelection} onChangeRole={ targetRole @@ -66,6 +68,8 @@ function MemberPageContent() { ) : undefined } + onBan={() => selectedMembers.forEach((m) => banMember(m.clubMemberId))} + onRestore={() => selectedMembers.forEach((m) => restoreMember(m.clubMemberId))} /> {/* Main content */} diff --git a/src/components/admin/member/MemberTopBar.tsx b/src/components/admin/member/MemberTopBar.tsx index fdf43691..1e54a8c2 100644 --- a/src/components/admin/member/MemberTopBar.tsx +++ b/src/components/admin/member/MemberTopBar.tsx @@ -24,11 +24,13 @@ import type { ClubMemberRole } from '@/types/admin/member'; interface MemberTopBarProps extends React.HTMLAttributes { selectedCount: number; targetRole: ClubMemberRole | null; + allBanned: boolean; onBack: () => void; onApprove?: () => void; onChangeRole?: () => void; onResetPassword?: () => void; onBan?: () => void; + onRestore?: () => void; onChangeGeneration?: (generation: number) => void; ref?: React.Ref; } @@ -37,11 +39,13 @@ function MemberTopBar({ className, selectedCount, targetRole, + allBanned, onBack, onApprove, onChangeRole, onResetPassword, onBan, + onRestore, onChangeGeneration, ref, ...props @@ -59,10 +63,12 @@ function MemberTopBar({ const topBarActions = getTopBarActions({ selectedCount, targetRole, + allBanned, onApprove, onChangeRole, onResetPassword, onBan, + onRestore, }); return ( diff --git a/src/constants/admin/memberTopBar.constants.ts b/src/constants/admin/memberTopBar.constants.ts index efdb1b3f..3ca2a6c3 100644 --- a/src/constants/admin/memberTopBar.constants.ts +++ b/src/constants/admin/memberTopBar.constants.ts @@ -3,19 +3,23 @@ import type { ClubMemberRole } from '@/types/admin/member'; interface TopBarActionParams { selectedCount: number; targetRole: ClubMemberRole | null; // null = 혼합 선택 + allBanned: boolean; onApprove?: () => void; onChangeRole?: () => void; onResetPassword?: () => void; onBan?: () => void; + onRestore?: () => void; } export function getTopBarActions({ selectedCount, targetRole, + allBanned, onApprove, onChangeRole, onResetPassword, onBan, + onRestore, }: TopBarActionParams) { const roleLabel = targetRole === 'ADMIN' ? '관리자로 변경' : '사용자로 변경'; const roleTitle = @@ -42,11 +46,18 @@ export function getTopBarActions({ handler: onResetPassword, disabled: !onResetPassword, }, - { - label: '유저 추방', - title: `${selectedCount}명의 멤버를 추방하시겠습니까?`, - handler: onBan, - disabled: !onBan, - }, + allBanned + ? { + label: '멤버 복구', + title: `${selectedCount}명의 멤버를 복구하시겠습니까?`, + handler: onRestore, + disabled: !onRestore, + } + : { + label: '유저 추방', + title: `${selectedCount}명의 멤버를 추방하시겠습니까?`, + handler: onBan, + disabled: !onBan, + }, ]; } diff --git a/src/hooks/mutations/admin/index.ts b/src/hooks/mutations/admin/index.ts index 2887614d..57fb580a 100644 --- a/src/hooks/mutations/admin/index.ts +++ b/src/hooks/mutations/admin/index.ts @@ -1 +1 @@ -export { useChangeMemberRole, useBanMember, useRestoreMember } from './useAdminMemberMutation'; +export { useChangeMemberRole, useBanMember, useRestoreMember } from './useAdminMemberMutations'; diff --git a/src/hooks/mutations/admin/useAdminMemberMutation.ts b/src/hooks/mutations/admin/useAdminMemberMutations.ts similarity index 99% rename from src/hooks/mutations/admin/useAdminMemberMutation.ts rename to src/hooks/mutations/admin/useAdminMemberMutations.ts index 7251de48..bd2937e9 100644 --- a/src/hooks/mutations/admin/useAdminMemberMutation.ts +++ b/src/hooks/mutations/admin/useAdminMemberMutations.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { CLUB_ID } from '@/hooks/queries/admin/useAdminMemberQuery'; +import { CLUB_ID } from '@/hooks/queries/admin/useAdminMemberQueries'; import { adminMemberApi } from '@/lib/apis'; import type { ClubMemberRole, Member } from '@/types/admin/member'; diff --git a/src/hooks/queries/admin/index.ts b/src/hooks/queries/admin/index.ts index 2a7127fe..2155250b 100644 --- a/src/hooks/queries/admin/index.ts +++ b/src/hooks/queries/admin/index.ts @@ -1 +1 @@ -export { useAdminMembers } from './useAdminMemberQuery'; +export { useAdminMembers } from './useAdminMemberQueries'; diff --git a/src/hooks/queries/admin/useAdminMemberQuery.ts b/src/hooks/queries/admin/useAdminMemberQueries.ts similarity index 100% rename from src/hooks/queries/admin/useAdminMemberQuery.ts rename to src/hooks/queries/admin/useAdminMemberQueries.ts diff --git a/src/hooks/queries/index.ts b/src/hooks/queries/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/hooks/queries/useCommoneQUeries.ts b/src/hooks/queries/useCommoneQUeries.ts new file mode 100644 index 00000000..e69de29b From d4025265c4a8f80459cf626c6fdef158dd653827 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 29 Mar 2026 01:31:08 +0900 Subject: [PATCH 013/684] =?UTF-8?q?feat:=20=EC=A0=84=EC=B2=B4=20=EA=B8=B0?= =?UTF-8?q?=EC=88=98=20=EC=A1=B0=ED=9A=8C=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/member/MemberPageContent.tsx | 43 +++++++------------ src/hooks/queries/index.ts | 1 + src/hooks/queries/useCommoneQUeries.ts | 16 +++++++ src/lib/apis/cardinal.ts | 8 ++++ src/lib/apis/index.ts | 1 + src/types/admin/cardinal.ts | 9 ++++ 6 files changed, 51 insertions(+), 27 deletions(-) create mode 100644 src/lib/apis/cardinal.ts create mode 100644 src/types/admin/cardinal.ts diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index 6305df99..663e575b 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -15,6 +15,7 @@ import { Card } from '@/components/ui'; import { useDragScroll } from '@/hooks'; import type { Member } from '@/types/admin/member'; import { useAdminMembers } from '@/hooks/queries/admin'; +import { useCardinals } from '@/hooks/queries'; import { useBanMember, useChangeMemberRole, useRestoreMember } from '@/hooks/mutations/admin'; function MemberPageContent() { @@ -23,6 +24,7 @@ function MemberPageContent() { const [detailMember, setDetailMember] = useState(null); const { ref: dragScrollRef, onMouseDown } = useDragScroll(); const { data: members = [] } = useAdminMembers(); + const { data: cardinals = [] } = useCardinals(); const { mutate: changeMemberRole } = useChangeMemberRole(); const { mutate: banMember } = useBanMember(); const { mutate: restoreMember } = useRestoreMember(); @@ -88,32 +90,15 @@ function MemberPageContent() { - - {/* TODO: api 연결시 하드 코딩 제거 */} - - - - + {/* */} + {cardinals.map((c) => ( + + ))} {/* Member table */} @@ -148,7 +133,11 @@ function MemberPageContent() { ? () => { const nextRole = detailMember.memberRole === 'ADMIN' ? 'USER' : 'ADMIN'; const ROLE_LABEL = { USER: '사용자', ADMIN: '관리자', LEAD: '리더' } as const; - setDetailMember({ ...detailMember, memberRole: nextRole, position: ROLE_LABEL[nextRole] }); + setDetailMember({ + ...detailMember, + memberRole: nextRole, + position: ROLE_LABEL[nextRole], + }); changeMemberRole({ clubMemberId: detailMember.clubMemberId, memberRole: nextRole }); } : undefined diff --git a/src/hooks/queries/index.ts b/src/hooks/queries/index.ts index e69de29b..85c3883d 100644 --- a/src/hooks/queries/index.ts +++ b/src/hooks/queries/index.ts @@ -0,0 +1 @@ +export { useCardinals } from './useCommoneQUeries'; diff --git a/src/hooks/queries/useCommoneQUeries.ts b/src/hooks/queries/useCommoneQUeries.ts index e69de29b..4a35234b 100644 --- a/src/hooks/queries/useCommoneQUeries.ts +++ b/src/hooks/queries/useCommoneQUeries.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; + +import { cardinalApi } from '@/lib/apis'; +import { CLUB_ID } from '@/hooks/queries/admin/useAdminMemberQueries'; + +export function useCardinals() { + return useQuery({ + queryKey: ['cardinals', CLUB_ID], + queryFn: async () => { + const res = await cardinalApi.getCardinals(CLUB_ID); + return res.data.data; + }, + staleTime: 30 * 60 * 1000, + gcTime: 60 * 60 * 1000, + }); +} diff --git a/src/lib/apis/cardinal.ts b/src/lib/apis/cardinal.ts new file mode 100644 index 00000000..40b84f81 --- /dev/null +++ b/src/lib/apis/cardinal.ts @@ -0,0 +1,8 @@ +import { apiClient } from '@/lib/apis/client'; +import type { Cardinal } from '@/types/admin/cardinal'; +import type { ApiResponse } from '@/types/common'; + +export const cardinalApi = { + getCardinals: (clubId: string) => + apiClient.get>(`/api/v4/clubs/${clubId}/cardinals`), +}; diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index cd8f8f58..09b9a05c 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -1,2 +1,3 @@ export { apiClient } from './client'; export { adminMemberApi } from './adminMember'; +export { cardinalApi } from './cardinal'; diff --git a/src/types/admin/cardinal.ts b/src/types/admin/cardinal.ts new file mode 100644 index 00000000..12419331 --- /dev/null +++ b/src/types/admin/cardinal.ts @@ -0,0 +1,9 @@ +export interface Cardinal { + id: number; + cardinalNumber: number; + year: number; + semester: number; + status: 'IN_PROGRESS' | 'COMPLETED'; + createdAt: string; + modifiedAt: string; +} From f6d9d221a5d372183f055695234aa36b937e95fc Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 29 Mar 2026 01:39:34 +0900 Subject: [PATCH 014/684] =?UTF-8?q?feat:=20=EA=B8=B0=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/member/MemberPageContent.tsx | 9 +++++++-- src/hooks/mutations/admin/index.ts | 1 + .../mutations/admin/useAdminCardinalMutations.ts | 16 ++++++++++++++++ src/lib/apis/cardinal.ts | 4 +++- src/types/admin/cardinal.ts | 7 +++++++ 5 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 src/hooks/mutations/admin/useAdminCardinalMutations.ts diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index 663e575b..3c0405d5 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -16,7 +16,7 @@ import { useDragScroll } from '@/hooks'; import type { Member } from '@/types/admin/member'; import { useAdminMembers } from '@/hooks/queries/admin'; import { useCardinals } from '@/hooks/queries'; -import { useBanMember, useChangeMemberRole, useRestoreMember } from '@/hooks/mutations/admin'; +import { useBanMember, useChangeMemberRole, useCreateCardinal, useRestoreMember } from '@/hooks/mutations/admin'; function MemberPageContent() { const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -28,6 +28,7 @@ function MemberPageContent() { const { mutate: changeMemberRole } = useChangeMemberRole(); const { mutate: banMember } = useBanMember(); const { mutate: restoreMember } = useRestoreMember(); + const { mutate: createCardinal } = useCreateCardinal(); const handleMemberAction = (m: Member) => { setDetailMember(m); @@ -87,7 +88,11 @@ function MemberPageContent() { className="scrollbar-none flex cursor-grab gap-400 overflow-x-auto select-none active:cursor-grabbing" onMouseDown={onMouseDown} > - + + createCardinal({ cardinalNumber: generation, year, semester, inProgress: isCurrent }) + } + > {/* */} diff --git a/src/hooks/mutations/admin/index.ts b/src/hooks/mutations/admin/index.ts index 57fb580a..21ad6891 100644 --- a/src/hooks/mutations/admin/index.ts +++ b/src/hooks/mutations/admin/index.ts @@ -1 +1,2 @@ export { useChangeMemberRole, useBanMember, useRestoreMember } from './useAdminMemberMutations'; +export { useCreateCardinal } from './useAdminCardinalMutations'; diff --git a/src/hooks/mutations/admin/useAdminCardinalMutations.ts b/src/hooks/mutations/admin/useAdminCardinalMutations.ts new file mode 100644 index 00000000..ead8ceae --- /dev/null +++ b/src/hooks/mutations/admin/useAdminCardinalMutations.ts @@ -0,0 +1,16 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { CLUB_ID } from '@/hooks/queries/admin/useAdminMemberQueries'; +import { cardinalApi } from '@/lib/apis'; + +export function useCreateCardinal() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: { cardinalNumber: number; year: number; semester: number; inProgress: boolean }) => + cardinalApi.createCardinal(CLUB_ID, body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['cardinals', CLUB_ID] }); + }, + }); +} diff --git a/src/lib/apis/cardinal.ts b/src/lib/apis/cardinal.ts index 40b84f81..295a8386 100644 --- a/src/lib/apis/cardinal.ts +++ b/src/lib/apis/cardinal.ts @@ -1,8 +1,10 @@ import { apiClient } from '@/lib/apis/client'; -import type { Cardinal } from '@/types/admin/cardinal'; +import type { Cardinal, CreateCardinalBody } from '@/types/admin/cardinal'; import type { ApiResponse } from '@/types/common'; export const cardinalApi = { getCardinals: (clubId: string) => apiClient.get>(`/api/v4/clubs/${clubId}/cardinals`), + createCardinal: (clubId: string, body: CreateCardinalBody) => + apiClient.post(`/api/v4/admin/clubs/${clubId}/cardinals`, body), }; diff --git a/src/types/admin/cardinal.ts b/src/types/admin/cardinal.ts index 12419331..28f368d1 100644 --- a/src/types/admin/cardinal.ts +++ b/src/types/admin/cardinal.ts @@ -7,3 +7,10 @@ export interface Cardinal { createdAt: string; modifiedAt: string; } + +export interface CreateCardinalBody { + cardinalNumber: number; + year: number; + semester: number; + inProgress: boolean; +} From 13871cd3d775026a39208ea8ef1c49c39b6935c7 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 29 Mar 2026 01:43:44 +0900 Subject: [PATCH 015/684] =?UTF-8?q?feat:=20=EA=B8=B0=EC=88=98=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/member/MemberTopBar.tsx | 37 ++++++++++---------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/components/admin/member/MemberTopBar.tsx b/src/components/admin/member/MemberTopBar.tsx index 1e54a8c2..96c5919c 100644 --- a/src/components/admin/member/MemberTopBar.tsx +++ b/src/components/admin/member/MemberTopBar.tsx @@ -7,17 +7,17 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, - AlertDialogContent, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, + // AlertDialogContent, + // AlertDialogFooter, + // AlertDialogHeader, + // AlertDialogTitle, Button, Icon, } from '@/components/ui'; -import { ChangeGenerationModal } from '@/components/admin/member/modal/ChangeGenerationModal'; +// import { ChangeGenerationModal } from '@/components/admin/member/modal/ChangeGenerationModal'; import { cn } from '@/lib/cn'; import { getTopBarActions } from '@/constants/admin/memberTopBar.constants'; -import { useGenerationConfirm } from '@/hooks'; +// import { useGenerationConfirm } from '@/hooks'; import type { ClubMemberRole } from '@/types/admin/member'; @@ -46,17 +46,18 @@ function MemberTopBar({ onResetPassword, onBan, onRestore, - onChangeGeneration, + // onChangeGeneration, ref, ...props }: MemberTopBarProps) { - const { - genConfirmOpen, - setGenConfirmOpen, - pendingGeneration, - handleGenSubmit, - handleGenConfirm, - } = useGenerationConfirm(onChangeGeneration); + // TODO: 기수변경 추후 연결 + // const { + // genConfirmOpen, + // setGenConfirmOpen, + // pendingGeneration, + // handleGenSubmit, + // handleGenConfirm, + // } = useGenerationConfirm(onChangeGeneration); if (selectedCount === 0) return null; @@ -105,16 +106,16 @@ function MemberTopBar({ 취소 ))} - + {/* - + */} {/* Generation confirm alert */} - + {/* @@ -126,7 +127,7 @@ function MemberTopBar({ 취소 - + */} ); } From 9b0509ed124400341f2b7abde5ce5939023318b0 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 29 Mar 2026 01:50:15 +0900 Subject: [PATCH 016/684] =?UTF-8?q?fix:=20=EB=A9=A4=EB=B2=84=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B8=B0=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/member/MemberPageContent.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index 3c0405d5..0b507d57 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -40,7 +40,8 @@ function MemberPageContent() { (m) => m.name.toLowerCase().includes(query) || m.department.toLowerCase().includes(query) || - m.studentId.includes(query), + m.studentId.includes(query) || + m.generation.includes(query), ) : members; From ff9199c025239819c2c3afcf79c59836be873dee Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 30 Mar 2026 21:39:50 +0900 Subject: [PATCH 017/684] =?UTF-8?q?fix:=20=EC=9C=A0=EC=A0=80=20=EB=B0=B4?= =?UTF-8?q?=20api=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/member/MemberPageContent.tsx | 9 ++++++++- src/constants/admin/memberDetailModal.constants.ts | 2 +- src/constants/admin/memberTable.constants.ts | 5 ++--- src/constants/admin/memberTopBar.constants.ts | 2 +- src/hooks/mutations/admin/useAdminMemberMutations.ts | 2 +- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index 0b507d57..b112f9ab 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -125,7 +125,14 @@ function MemberPageContent() { if (!open) setDetailMember(null); }} member={detailMember} - onBan={detailMember ? () => banMember(detailMember.clubMemberId) : undefined} + onBan={ + detailMember + ? () => { + setDetailMember({ ...detailMember, status: 'BANNED' }); + banMember(detailMember.clubMemberId); + } + : undefined + } onRestore={ detailMember ? () => { diff --git a/src/constants/admin/memberDetailModal.constants.ts b/src/constants/admin/memberDetailModal.constants.ts index 1fa428e8..9fba5749 100644 --- a/src/constants/admin/memberDetailModal.constants.ts +++ b/src/constants/admin/memberDetailModal.constants.ts @@ -81,7 +81,7 @@ export function getFooterActions({ // handler: onResetPassword, // }, isBanned - ? { label: '멤버 복구', title: '1명의 멤버를 복구하시겠습니까?', handler: onRestore } + ? { label: '유저 복구', title: '1명의 멤버를 복구하시겠습니까?', handler: onRestore } : { label: '유저 추방', title: '1명의 멤버를 추방하시겠습니까?', handler: onBan }, ]; } diff --git a/src/constants/admin/memberTable.constants.ts b/src/constants/admin/memberTable.constants.ts index 215f0aab..2c2b7b97 100644 --- a/src/constants/admin/memberTable.constants.ts +++ b/src/constants/admin/memberTable.constants.ts @@ -21,9 +21,8 @@ export const COLUMNS: { label: string; key: keyof Member }[] = [ export const STATUS_LEGEND = [ { label: '활동중', color: 'bg-brand-primary' }, - { label: '경고', color: 'bg-state-caution' }, - { label: '강퇴', color: 'bg-state-error' }, - { label: '탈퇴', color: 'bg-button-neutral' }, + { label: '추방', color: 'bg-state-error' }, + { label: '탈퇴', color: 'bg-text-alternative' }, ] as const; export type SortBy = 'generation' | 'name'; diff --git a/src/constants/admin/memberTopBar.constants.ts b/src/constants/admin/memberTopBar.constants.ts index 3ca2a6c3..af3b199e 100644 --- a/src/constants/admin/memberTopBar.constants.ts +++ b/src/constants/admin/memberTopBar.constants.ts @@ -48,7 +48,7 @@ export function getTopBarActions({ }, allBanned ? { - label: '멤버 복구', + label: '유저 복구', title: `${selectedCount}명의 멤버를 복구하시겠습니까?`, handler: onRestore, disabled: !onRestore, diff --git a/src/hooks/mutations/admin/useAdminMemberMutations.ts b/src/hooks/mutations/admin/useAdminMemberMutations.ts index bd2937e9..b8a0d3e5 100644 --- a/src/hooks/mutations/admin/useAdminMemberMutations.ts +++ b/src/hooks/mutations/admin/useAdminMemberMutations.ts @@ -76,7 +76,7 @@ export function useBanMember() { }); } -// 추방 멤버 복구 +// 추방 유저 복구 export function useRestoreMember() { const queryClient = useQueryClient(); const queryKey = ['admin', 'members', CLUB_ID]; From a5db20a3ffdc5d7d17a34b7c1c229a37929cef65 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 5 Apr 2026 02:10:51 +0900 Subject: [PATCH 018/684] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/mypage/MyPageContent.tsx | 39 +++++++++++++++++-------- src/hooks/mypage/useMyMemberQuery.ts | 15 ++++++++++ src/lib/apis/index.ts | 1 + src/lib/apis/mypage.ts | 8 +++++ src/types/mypage.ts | 16 ++++++++++ 5 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 src/hooks/mypage/useMyMemberQuery.ts create mode 100644 src/lib/apis/mypage.ts diff --git a/src/components/mypage/MyPageContent.tsx b/src/components/mypage/MyPageContent.tsx index f81d6f0e..c8435c3a 100644 --- a/src/components/mypage/MyPageContent.tsx +++ b/src/components/mypage/MyPageContent.tsx @@ -9,14 +9,30 @@ import { ProfileSection } from './ProfileSection'; import { SupportListItem } from './SupportListItem'; import { ThemeToggle } from './ThemeToggle'; import { MyPageDropdownMenu } from './MyPageDropdownMenu'; -import { MOCK_AVAILABLE_CARDINALS, MOCK_CLUBS, MOCK_USER } from '@/constants/mock'; +import { MOCK_AVAILABLE_CARDINALS, MOCK_CLUBS } from '@/constants/mock'; import { ClubInfoCard } from './ClubInfoCard'; +import { useMyMemberQuery } from '@/hooks/mypage/useMyMemberQuery'; type MyPageContentProps = React.HTMLAttributes; function MyPageContent({ className, ...props }: MyPageContentProps) { - // TODO: API 연동 시 실제 데이터로 교체 - const user = MOCK_USER; + const { data: me, isLoading, isError } = useMyMemberQuery(); + + if (isLoading) { + return ( +
+

로딩 중...

+
+ ); + } + + if (isError || !me) { + return ( +
+

내 정보를 불러올 수 없습니다.

+
+ ); + } return (
{/* 프로필 */} - + {/* 개인정보 */}
diff --git a/src/hooks/mypage/useMyMemberQuery.ts b/src/hooks/mypage/useMyMemberQuery.ts new file mode 100644 index 00000000..4f9c3903 --- /dev/null +++ b/src/hooks/mypage/useMyMemberQuery.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; +import { mypageApi } from '@/lib/apis/mypage'; +import { useClubId } from '@/stores/useClubStore'; + +export function useMyMemberQuery() { + const clubId = useClubId(); + + return useQuery({ + queryKey: ['mypage', 'me', clubId], + queryFn: () => mypageApi.getMe(clubId!).then((res) => res.data.data), + enabled: !!clubId, + staleTime: 30 * 60 * 1000, + gcTime: 60 * 60 * 1000, + }); +} diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index f5a3cd9b..483522ca 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -7,3 +7,4 @@ export { fileApi } from './file'; export { homeApi } from './home'; export { attendanceApi } from './attendance'; export { universityApi } from './university'; +export { mypageApi } from './mypage'; diff --git a/src/lib/apis/mypage.ts b/src/lib/apis/mypage.ts new file mode 100644 index 00000000..0904129f --- /dev/null +++ b/src/lib/apis/mypage.ts @@ -0,0 +1,8 @@ +import { apiClient } from '@/lib/apis/client'; +import type { MyMember } from '@/types/mypage'; +import type { ApiResponse } from '@/types/common'; + +export const mypageApi = { + getMe: (clubId: string) => + apiClient.get>(`/clubs/${clubId}/members/me`), +}; diff --git a/src/types/mypage.ts b/src/types/mypage.ts index 66053c69..c8f6acda 100644 --- a/src/types/mypage.ts +++ b/src/types/mypage.ts @@ -1,6 +1,22 @@ export type MemberRole = 'USER' | 'ADMIN'; export type MemberStatus = 'ACTIVE' | 'INACTIVE' | 'BANNED'; +export interface MyMember { + userId: number; + clubMemberId: number; + name: string; + email: string; + tel: string; + school: string; + department: string; + studentId: string; + cardinals: number[]; + memberRole: MemberRole; + memberStatus: MemberStatus; + profileImageUrl: string; + bio: string; +} + export interface ClubDto { id: string; name: string; From a8aa800f20bc4a400f4dc4aec99e7f8a95f20e28 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 5 Apr 2026 02:56:33 +0900 Subject: [PATCH 019/684] =?UTF-8?q?feat:=20=ED=99=9C=EB=8F=99=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/mypage/MyPageContent.tsx | 11 ++++------- src/components/mypage/MyPageDropdownMenu.tsx | 4 ++-- src/hooks/queries/mypage/useMyClubsQuery.ts | 11 +++++++++++ src/lib/apis/mypage.ts | 3 ++- 4 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 src/hooks/queries/mypage/useMyClubsQuery.ts diff --git a/src/components/mypage/MyPageContent.tsx b/src/components/mypage/MyPageContent.tsx index c8435c3a..71494c63 100644 --- a/src/components/mypage/MyPageContent.tsx +++ b/src/components/mypage/MyPageContent.tsx @@ -9,14 +9,15 @@ import { ProfileSection } from './ProfileSection'; import { SupportListItem } from './SupportListItem'; import { ThemeToggle } from './ThemeToggle'; import { MyPageDropdownMenu } from './MyPageDropdownMenu'; -import { MOCK_AVAILABLE_CARDINALS, MOCK_CLUBS } from '@/constants/mock'; import { ClubInfoCard } from './ClubInfoCard'; import { useMyMemberQuery } from '@/hooks/mypage/useMyMemberQuery'; +import { useMyClubsQuery } from '@/hooks/queries/mypage/useMyClubsQuery'; type MyPageContentProps = React.HTMLAttributes; function MyPageContent({ className, ...props }: MyPageContentProps) { const { data: me, isLoading, isError } = useMyMemberQuery(); + const { data: clubs = [] } = useMyClubsQuery(); if (isLoading) { return ( @@ -90,12 +91,8 @@ function MyPageContent({ className, ...props }: MyPageContentProps) { {/* 활동정보 */}
- {MOCK_CLUBS.map((club) => ( - + {clubs.map((club) => ( + ))}
diff --git a/src/components/mypage/MyPageDropdownMenu.tsx b/src/components/mypage/MyPageDropdownMenu.tsx index 377e6921..3e2b98d9 100644 --- a/src/components/mypage/MyPageDropdownMenu.tsx +++ b/src/components/mypage/MyPageDropdownMenu.tsx @@ -50,8 +50,8 @@ function MyPageDropdownMenu() { open={withdrawOpen} onOpenChange={setWithdrawOpen} status="danger" - title={'동아리를 탈퇴하면\n남긴 추억이 모두 사라져요'} - description={'동아리에 게시한 게시글은 남아있지 않아요.\n버튼 클릭 시 바로 탈퇴돼요.'} + title={'서비스를 탈퇴하면\n모든 활동 기록이 사라져요'} + description={'가입한 동아리와 게시글이 모두 삭제돼요.\n버튼 클릭 시 바로 탈퇴돼요.'} > 탈퇴하기 취소 diff --git a/src/hooks/queries/mypage/useMyClubsQuery.ts b/src/hooks/queries/mypage/useMyClubsQuery.ts new file mode 100644 index 00000000..8a6bef27 --- /dev/null +++ b/src/hooks/queries/mypage/useMyClubsQuery.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { mypageApi } from '@/lib/apis/mypage'; + +export function useMyClubsQuery() { + return useQuery({ + queryKey: ['mypage', 'clubs'], + queryFn: () => mypageApi.getMyClubs().then((res) => res.data.data), + staleTime: 30 * 60 * 1000, + gcTime: 60 * 60 * 1000, + }); +} diff --git a/src/lib/apis/mypage.ts b/src/lib/apis/mypage.ts index 0904129f..b643831d 100644 --- a/src/lib/apis/mypage.ts +++ b/src/lib/apis/mypage.ts @@ -1,8 +1,9 @@ import { apiClient } from '@/lib/apis/client'; -import type { MyMember } from '@/types/mypage'; +import type { ClubDto, MyMember } from '@/types/mypage'; import type { ApiResponse } from '@/types/common'; export const mypageApi = { getMe: (clubId: string) => apiClient.get>(`/clubs/${clubId}/members/me`), + getMyClubs: () => apiClient.get>('/clubs'), }; From fb7c040c16bcc0ffa7c8c6d7ab3c2cb50daa91e8 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 5 Apr 2026 03:25:50 +0900 Subject: [PATCH 020/684] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/mypage/EditProfileContent.tsx | 184 ------------------ src/components/mypage/FormField.tsx | 6 +- .../mypage/edit/EditProfileContent.tsx | 164 ++++++++++++++++ .../mypage/edit/PersonalInfoFields.tsx | 57 ++++++ .../mypage/edit/ProfileImageEditor.tsx | 60 ++++++ .../mypage/edit/SchoolInfoFields.tsx | 55 ++++++ src/components/mypage/edit/index.ts | 4 + src/components/mypage/index.ts | 2 +- .../mypage/useUpdateProfileMutation.ts | 26 +++ src/lib/apis/mypage.ts | 22 +++ src/lib/schemas/editProfile.ts | 17 ++ 11 files changed, 410 insertions(+), 187 deletions(-) delete mode 100644 src/components/mypage/EditProfileContent.tsx create mode 100644 src/components/mypage/edit/EditProfileContent.tsx create mode 100644 src/components/mypage/edit/PersonalInfoFields.tsx create mode 100644 src/components/mypage/edit/ProfileImageEditor.tsx create mode 100644 src/components/mypage/edit/SchoolInfoFields.tsx create mode 100644 src/components/mypage/edit/index.ts create mode 100644 src/hooks/mutations/mypage/useUpdateProfileMutation.ts create mode 100644 src/lib/schemas/editProfile.ts diff --git a/src/components/mypage/EditProfileContent.tsx b/src/components/mypage/EditProfileContent.tsx deleted file mode 100644 index 698e727b..00000000 --- a/src/components/mypage/EditProfileContent.tsx +++ /dev/null @@ -1,184 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import Link from 'next/link'; - -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, - Icon, - Avatar, - AvatarFallback, - AvatarImage, - Button, - Input, -} from '@/components/ui'; - -import { EditIcon } from '@/assets/icons'; -import { cn } from '@/lib/cn'; - -import { FormField } from './FormField'; -import { SearchSelect } from './SearchSelect'; -import { MOCK_DEPARTMENTS, MOCK_UNIVERSITIES, MOCK_USER } from '@/constants/mock'; -import { formatPhone } from '@/utils/shared'; - -// TODO: API 연동 시 실제 데이터로 교체 - -type EditProfileContentProps = React.HTMLAttributes; - -function EditProfileContent({ className, ...props }: EditProfileContentProps) { - const [name, setName] = useState(MOCK_USER.name); - const [bio, setBio] = useState(MOCK_USER.bio); - const [phone, setPhone] = useState(MOCK_USER.phone); - const [email, setEmail] = useState(MOCK_USER.email); - const [university, setUniversity] = useState(MOCK_USER.university); - const [department, setDepartment] = useState(MOCK_USER.department); - const [studentId, setStudentId] = useState(MOCK_USER.studentId); - - const handlePhoneChange = (e: React.ChangeEvent) => { - setPhone(formatPhone(e.target.value)); - }; - - const handleSubmit = () => { - // TODO: API 연동 - console.log({ name, bio, phone, email, university, department, studentId }); - }; - - return ( -
- {/* PageNavigation */} -
- - - - - - My - - - - - - 개인정보 수정 - - - - -

개인정보 수정

-
- - {/* Body */} -
- {/* 아바타 + 편집 버튼 */} -
- - {MOCK_USER.profileImageUrl && ( - - )} - {name.charAt(0)} - - -
- - {/* 폼 */} -
- {/* 개인정보 필드 그룹 */} -
- - setName(e.target.value)} - placeholder="이름을 입력하세요" - className="rounded-lg" - /> - - - - setBio(e.target.value.slice(0, 30))} - placeholder="소개글을 입력하세요" - className="rounded-lg" - /> - - - - - - - - setEmail(e.target.value)} - type="email" - name="email" - autoComplete="email" - placeholder="이메일을 입력하세요" - className="rounded-lg" - /> - -
- - {/* 학교 정보 필드 그룹 */} -
- - - - - - - - - - setStudentId(e.target.value)} - placeholder="학번을 입력하세요" - inputMode="numeric" - className="rounded-lg" - /> - -
- - -
-
-
- ); -} - -export { EditProfileContent, type EditProfileContentProps }; diff --git a/src/components/mypage/FormField.tsx b/src/components/mypage/FormField.tsx index 60bdb3ef..78ff58b8 100644 --- a/src/components/mypage/FormField.tsx +++ b/src/components/mypage/FormField.tsx @@ -3,16 +3,18 @@ import { cn } from '@/lib/cn'; interface FormFieldProps { label: string; hint?: string; + error?: string; className?: string; children: React.ReactNode; } -function FormField({ label, hint, className, children }: FormFieldProps) { +function FormField({ label, hint, error, className, children }: FormFieldProps) { return (
{label} {children} - {hint && {hint}} + {error && {error}} + {!error && hint && {hint}}
); } diff --git a/src/components/mypage/edit/EditProfileContent.tsx b/src/components/mypage/edit/EditProfileContent.tsx new file mode 100644 index 00000000..d7c0b9cb --- /dev/null +++ b/src/components/mypage/edit/EditProfileContent.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useForm, useWatch } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, + Button, +} from '@/components/ui'; + +import { cn } from '@/lib/cn'; +import { editProfileSchema, type EditProfileFormData } from '@/lib/schemas/editProfile'; +import { useMyMemberQuery } from '@/hooks/mypage/useMyMemberQuery'; +import { useUpdateProfileMutation } from '@/hooks/mutations/mypage/useUpdateProfileMutation'; +import { toastSuccess, toastError } from '@/stores/useToastStore'; +import { formatPhone } from '@/utils/shared'; + +import { ProfileImageEditor } from './ProfileImageEditor'; +import { PersonalInfoFields } from './PersonalInfoFields'; +import { SchoolInfoFields } from './SchoolInfoFields'; + +type EditProfileContentProps = React.HTMLAttributes; + +function EditProfileContent({ className, ...props }: EditProfileContentProps) { + const router = useRouter(); + const { data: me } = useMyMemberQuery(); + const { mutate: updateProfile, isPending } = useUpdateProfileMutation(); + const selectedFileRef = useRef(null); + + const { + register, + handleSubmit, + setValue, + reset, + control, + formState: { errors }, + } = useForm({ + resolver: zodResolver(editProfileSchema), + defaultValues: { + name: '', + bio: '', + tel: '', + email: '', + school: '', + department: '', + studentId: '', + }, + }); + + useEffect(() => { + if (me) { + reset({ + name: me.name, + bio: me.bio ?? '', + tel: me.tel ? formatPhone(me.tel) : '', + email: me.email, + school: me.school, + department: me.department, + studentId: me.studentId, + }); + } + }, [me, reset]); + + const name = useWatch({ control, name: 'name' }); + + const onSubmit = (data: EditProfileFormData) => { + updateProfile( + { + user: { + name: data.name, + email: data.email, + studentId: data.studentId, + tel: data.tel?.replace(/-/g, '') ?? '', + school: data.school, + department: data.department, + }, + clubProfile: { + bio: data.bio ?? '', + }, + }, + { + onSuccess: () => { + toastSuccess('프로필이 수정되었습니다.'); + router.push('/mypage'); + }, + onError: () => { + toastError('프로필 수정에 실패했습니다.'); + }, + }, + ); + }; + + if (!me) { + return ( +
+

로딩 중...

+
+ ); + } + + return ( +
+
+ + + + + + My + + + + + + 개인정보 수정 + + + + +

개인정보 수정

+
+ +
+ { + selectedFileRef.current = file; + }} + /> + +
+ + + + +
+ +
+ ); +} + +export { EditProfileContent, type EditProfileContentProps }; diff --git a/src/components/mypage/edit/PersonalInfoFields.tsx b/src/components/mypage/edit/PersonalInfoFields.tsx new file mode 100644 index 00000000..7333d7f7 --- /dev/null +++ b/src/components/mypage/edit/PersonalInfoFields.tsx @@ -0,0 +1,57 @@ +import type { Control, FieldErrors, UseFormRegister, UseFormSetValue } from 'react-hook-form'; + +import { Input } from '@/components/ui'; +import { FormField } from '@/components/mypage/FormField'; +import { formatPhone } from '@/utils/shared'; +import type { EditProfileFormData } from '@/lib/schemas/editProfile'; + +interface PersonalInfoFieldsProps { + register: UseFormRegister; + errors: FieldErrors; + setValue: UseFormSetValue; +} + +function PersonalInfoFields({ register, errors, setValue }: PersonalInfoFieldsProps) { + const handlePhoneChange = (e: React.ChangeEvent) => { + setValue('tel', formatPhone(e.target.value), { shouldValidate: true }); + }; + + return ( +
+ + + + + + + + + + + + + + + +
+ ); +} + +export { PersonalInfoFields, type PersonalInfoFieldsProps }; diff --git a/src/components/mypage/edit/ProfileImageEditor.tsx b/src/components/mypage/edit/ProfileImageEditor.tsx new file mode 100644 index 00000000..fa6387e0 --- /dev/null +++ b/src/components/mypage/edit/ProfileImageEditor.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +import { Avatar, AvatarFallback, AvatarImage, Icon } from '@/components/ui'; +import { EditIcon } from '@/assets/icons'; + +interface ProfileImageEditorProps { + name: string; + profileImageUrl?: string; + onFileChange?: (file: File) => void; +} + +function ProfileImageEditor({ name, profileImageUrl, onFileChange }: ProfileImageEditorProps) { + const fileInputRef = useRef(null); + const [previewUrl, setPreviewUrl] = useState(null); + + const handleChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setPreviewUrl(URL.createObjectURL(file)); + onFileChange?.(file); + }; + + useEffect(() => { + return () => { + if (previewUrl) URL.revokeObjectURL(previewUrl); + }; + }, [previewUrl]); + + const displayUrl = previewUrl ?? profileImageUrl; + + return ( +
+ + {displayUrl && } + {name?.charAt(0)} + + + +
+ ); +} + +export { ProfileImageEditor, type ProfileImageEditorProps }; diff --git a/src/components/mypage/edit/SchoolInfoFields.tsx b/src/components/mypage/edit/SchoolInfoFields.tsx new file mode 100644 index 00000000..978b59d4 --- /dev/null +++ b/src/components/mypage/edit/SchoolInfoFields.tsx @@ -0,0 +1,55 @@ +'use client'; + +import type { Control, FieldErrors, UseFormRegister, UseFormSetValue } from 'react-hook-form'; +import { useWatch } from 'react-hook-form'; + +import { Input } from '@/components/ui'; +import { FormField } from '@/components/mypage/FormField'; +import { SearchSelect } from '@/components/mypage/SearchSelect'; +import { MOCK_DEPARTMENTS, MOCK_UNIVERSITIES } from '@/constants/mock'; +import type { EditProfileFormData } from '@/lib/schemas/editProfile'; + +interface SchoolInfoFieldsProps { + register: UseFormRegister; + control: Control; + errors: FieldErrors; + setValue: UseFormSetValue; +} + +function SchoolInfoFields({ register, control, errors, setValue }: SchoolInfoFieldsProps) { + const school = useWatch({ control, name: 'school' }); + const department = useWatch({ control, name: 'department' }); + + return ( +
+ + setValue('school', v, { shouldValidate: true })} + options={MOCK_UNIVERSITIES} + placeholder="학교 선택" + /> + + + + setValue('department', v, { shouldValidate: true })} + options={MOCK_DEPARTMENTS} + placeholder="학과 선택" + /> + + + + + +
+ ); +} + +export { SchoolInfoFields, type SchoolInfoFieldsProps }; diff --git a/src/components/mypage/edit/index.ts b/src/components/mypage/edit/index.ts new file mode 100644 index 00000000..f6e412ba --- /dev/null +++ b/src/components/mypage/edit/index.ts @@ -0,0 +1,4 @@ +export { EditProfileContent, type EditProfileContentProps } from './EditProfileContent'; +export { ProfileImageEditor, type ProfileImageEditorProps } from './ProfileImageEditor'; +export { PersonalInfoFields, type PersonalInfoFieldsProps } from './PersonalInfoFields'; +export { SchoolInfoFields, type SchoolInfoFieldsProps } from './SchoolInfoFields'; diff --git a/src/components/mypage/index.ts b/src/components/mypage/index.ts index 68256e8f..a7351dd9 100644 --- a/src/components/mypage/index.ts +++ b/src/components/mypage/index.ts @@ -5,7 +5,7 @@ export { InfoCard, type InfoCardProps, type InfoCardItem } from './InfoCard'; export { ThemeToggle, type ThemeToggleProps } from './ThemeToggle'; export { SupportListItem, type SupportListItemProps } from './SupportListItem'; export { MyPageDropdownMenu } from './MyPageDropdownMenu'; -export { EditProfileContent, type EditProfileContentProps } from './EditProfileContent'; +export { EditProfileContent, type EditProfileContentProps } from './edit'; export { FormField, type FormFieldProps } from './FormField'; export { SearchSelect, type SearchSelectProps } from './SearchSelect'; diff --git a/src/hooks/mutations/mypage/useUpdateProfileMutation.ts b/src/hooks/mutations/mypage/useUpdateProfileMutation.ts new file mode 100644 index 00000000..e8389555 --- /dev/null +++ b/src/hooks/mutations/mypage/useUpdateProfileMutation.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { mypageApi } from '@/lib/apis/mypage'; +import type { UpdateUserBody, UpdateClubProfileBody } from '@/lib/apis/mypage'; +import { useClubId } from '@/stores/useClubStore'; + +interface UpdateProfileParams { + user: UpdateUserBody; + clubProfile: UpdateClubProfileBody; +} + +export function useUpdateProfileMutation() { + const queryClient = useQueryClient(); + const clubId = useClubId(); + + return useMutation({ + mutationFn: async ({ user, clubProfile }: UpdateProfileParams) => { + await mypageApi.updateUser(user); + await mypageApi.updateClubProfile(clubProfile); + }, + onSuccess: () => { + if (clubId) { + queryClient.invalidateQueries({ queryKey: ['mypage', 'me', clubId] }); + } + }, + }); +} diff --git a/src/lib/apis/mypage.ts b/src/lib/apis/mypage.ts index b643831d..d7deecc6 100644 --- a/src/lib/apis/mypage.ts +++ b/src/lib/apis/mypage.ts @@ -2,8 +2,30 @@ import { apiClient } from '@/lib/apis/client'; import type { ClubDto, MyMember } from '@/types/mypage'; import type { ApiResponse } from '@/types/common'; +export interface UpdateUserBody { + name: string; + email: string; + studentId: string; + tel: string; + school: string; + department: string; +} + +export interface UpdateClubProfileBody { + profileImage?: { + fileName: string; + storageKey: string; + fileSize: number; + contentType: string; + } | null; + bio: string; +} + export const mypageApi = { getMe: (clubId: string) => apiClient.get>(`/clubs/${clubId}/members/me`), getMyClubs: () => apiClient.get>('/clubs'), + updateUser: (body: UpdateUserBody) => apiClient.patch('/users', body), + updateClubProfile: (body: UpdateClubProfileBody) => + apiClient.patch('/clubs/members/me', body), }; diff --git a/src/lib/schemas/editProfile.ts b/src/lib/schemas/editProfile.ts new file mode 100644 index 00000000..1b6c5f81 --- /dev/null +++ b/src/lib/schemas/editProfile.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export const editProfileSchema = z.object({ + name: z.string().min(1, '이름을 입력해주세요'), + bio: z.string().max(30, '30자 이내로 입력해주세요').optional().default(''), + tel: z + .string() + .optional() + .default('') + .refine((v) => !v || /^\d{3}-\d{3,4}-\d{4}$/.test(v), '올바른 전화번호 형식이 아닙니다'), + email: z.string().min(1, '이메일을 입력해주세요').email('올바른 이메일 형식이 아닙니다'), + school: z.string().min(1, '학교를 선택해주세요'), + department: z.string().min(1, '학과를 선택해주세요'), + studentId: z.string().min(1, '학번을 입력해주세요'), +}); + +export type EditProfileFormData = z.infer; From 5076fc4cbe06c176aa8bc9e922bd04a6f0918041 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 5 Apr 2026 03:30:23 +0900 Subject: [PATCH 021/684] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=EC=8B=9C=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=AF=B8=EB=A6=AC=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mypage/edit/EditProfileContent.tsx | 9 +++--- .../mypage/useUpdateProfileMutation.ts | 32 +++++++++++++++++-- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/components/mypage/edit/EditProfileContent.tsx b/src/components/mypage/edit/EditProfileContent.tsx index d7c0b9cb..c30e0ff2 100644 --- a/src/components/mypage/edit/EditProfileContent.tsx +++ b/src/components/mypage/edit/EditProfileContent.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef } from 'react'; +import { useEffect, useState } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useForm, useWatch } from 'react-hook-form'; @@ -33,7 +33,7 @@ function EditProfileContent({ className, ...props }: EditProfileContentProps) { const router = useRouter(); const { data: me } = useMyMemberQuery(); const { mutate: updateProfile, isPending } = useUpdateProfileMutation(); - const selectedFileRef = useRef(null); + const [selectedFile, setSelectedFile] = useState(null); const { register, @@ -85,6 +85,7 @@ function EditProfileContent({ className, ...props }: EditProfileContentProps) { clubProfile: { bio: data.bio ?? '', }, + profileImageFile: selectedFile, }, { onSuccess: () => { @@ -138,9 +139,7 @@ function EditProfileContent({ className, ...props }: EditProfileContentProps) { { - selectedFileRef.current = file; - }} + onFileChange={setSelectedFile} />
diff --git a/src/hooks/mutations/mypage/useUpdateProfileMutation.ts b/src/hooks/mutations/mypage/useUpdateProfileMutation.ts index e8389555..1c2e990c 100644 --- a/src/hooks/mutations/mypage/useUpdateProfileMutation.ts +++ b/src/hooks/mutations/mypage/useUpdateProfileMutation.ts @@ -1,11 +1,31 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { mypageApi } from '@/lib/apis/mypage'; +import { fileApi } from '@/lib/apis/file'; import type { UpdateUserBody, UpdateClubProfileBody } from '@/lib/apis/mypage'; import { useClubId } from '@/stores/useClubStore'; interface UpdateProfileParams { user: UpdateUserBody; - clubProfile: UpdateClubProfileBody; + clubProfile: Omit; + profileImageFile?: File | null; +} + +async function uploadProfileImage(file: File) { + const res = await fileApi.getPresignedUrls('CLUB_MEMBER_PROFILE', [file.name]); + const presigned = res.data.data[0]; + + await fetch(presigned.putUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': file.type }, + }); + + return { + fileName: file.name, + storageKey: presigned.storageKey, + fileSize: file.size, + contentType: file.type, + }; } export function useUpdateProfileMutation() { @@ -13,9 +33,15 @@ export function useUpdateProfileMutation() { const clubId = useClubId(); return useMutation({ - mutationFn: async ({ user, clubProfile }: UpdateProfileParams) => { + mutationFn: async ({ user, clubProfile, profileImageFile }: UpdateProfileParams) => { await mypageApi.updateUser(user); - await mypageApi.updateClubProfile(clubProfile); + + const profileImage = profileImageFile ? await uploadProfileImage(profileImageFile) : undefined; + + await mypageApi.updateClubProfile({ + bio: clubProfile.bio, + ...(profileImage && { profileImage }), + }); }, onSuccess: () => { if (clubId) { From 40824121631005963a916c4160acf35febbd3df5 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 5 Apr 2026 03:33:35 +0900 Subject: [PATCH 022/684] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=86=8C=EA=B0=9C=20=EC=88=98=EC=A0=95=EC=8B=9C=20null?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=80=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/mypage/edit/EditProfileContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/mypage/edit/EditProfileContent.tsx b/src/components/mypage/edit/EditProfileContent.tsx index c30e0ff2..aad88ff6 100644 --- a/src/components/mypage/edit/EditProfileContent.tsx +++ b/src/components/mypage/edit/EditProfileContent.tsx @@ -65,7 +65,7 @@ function EditProfileContent({ className, ...props }: EditProfileContentProps) { school: me.school, department: me.department, studentId: me.studentId, - }); + }, { keepDirtyValues: true }); } }, [me, reset]); From 7742c5cdf3db62acb82a3debff90b2592e15f5e5 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 5 Apr 2026 23:25:58 +0900 Subject: [PATCH 023/684] =?UTF-8?q?feat:=20=EC=B5=9C=EC=B4=88=20=ED=99=9C?= =?UTF-8?q?=EB=8F=99=20=EA=B8=B0=EC=88=98=20=EC=84=A4=EC=A0=95=20api=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/mypage/ClubInfoCard.tsx | 17 ++--------- src/components/mypage/MyPageContent.tsx | 2 +- src/components/mypage/MyPageDropdownMenu.tsx | 8 ++--- .../mypage/SetCardinalModal/index.tsx | 30 +++++++++++-------- .../SetCardinalModal/useCardinalModal.ts | 19 ++++++++++-- .../mypage/useInitCardinalsMutation.ts | 21 +++++++++++++ .../queries/admin/useAdminMemberQueries.ts | 11 +++---- src/hooks/queries/index.ts | 2 +- src/hooks/queries/useCardinals.ts | 19 ++++++++++++ src/hooks/queries/useCommoneQUeries.ts | 16 ---------- src/lib/apis/cardinal.ts | 2 +- src/lib/apis/mypage.ts | 2 ++ src/stores/useClubStore.ts | 2 +- src/types/admin/cardinal.ts | 4 --- 14 files changed, 92 insertions(+), 63 deletions(-) create mode 100644 src/hooks/mutations/mypage/useInitCardinalsMutation.ts create mode 100644 src/hooks/queries/useCardinals.ts delete mode 100644 src/hooks/queries/useCommoneQUeries.ts diff --git a/src/components/mypage/ClubInfoCard.tsx b/src/components/mypage/ClubInfoCard.tsx index 545b3423..ed00c60e 100644 --- a/src/components/mypage/ClubInfoCard.tsx +++ b/src/components/mypage/ClubInfoCard.tsx @@ -18,18 +18,11 @@ const SetCardinalModal = dynamic(() => interface ClubInfoCardProps extends React.HTMLAttributes { club: ClubDto; - /** 선택 가능한 기수 목록 (API 연동 전 mock) */ - availableCardinals?: number[]; } -function ClubInfoCard({ club, availableCardinals = [], className }: ClubInfoCardProps) { +function ClubInfoCard({ club, className }: ClubInfoCardProps) { const [modalOpen, setModalOpen] = useState(false); - const handleSave = (selected: number[]) => { - // TODO: API 연동 — 선택된 기수 저장 - console.log('저장할 기수:', selected); - }; - return ( <>
@@ -94,13 +87,7 @@ function ClubInfoCard({ club, availableCardinals = [], className }: ClubInfoCard
- + ); } diff --git a/src/components/mypage/MyPageContent.tsx b/src/components/mypage/MyPageContent.tsx index 71494c63..dcc3d6e1 100644 --- a/src/components/mypage/MyPageContent.tsx +++ b/src/components/mypage/MyPageContent.tsx @@ -92,7 +92,7 @@ function MyPageContent({ className, ...props }: MyPageContentProps) {
{clubs.map((club) => ( - + ))}
diff --git a/src/components/mypage/MyPageDropdownMenu.tsx b/src/components/mypage/MyPageDropdownMenu.tsx index 3e2b98d9..d8b756fd 100644 --- a/src/components/mypage/MyPageDropdownMenu.tsx +++ b/src/components/mypage/MyPageDropdownMenu.tsx @@ -38,15 +38,15 @@ function MyPageDropdownMenu() { setLogoutOpen(true)}>로그아웃 - + {/* setWithdrawOpen(true)}> 서비스 탈퇴 - + */} {/* TODO: "탈퇴하기"와 "로그아웃" 버튼에 onClick 핸들러 */} - 탈퇴하기 취소 - + */} void; club: ClubDto; - availableCardinals: number[]; - onSave: (selected: number[]) => void; + onSave?: (selected: number[]) => void; } -function SetCardinalModal({ - open, - onOpenChange, - club, - availableCardinals, - onSave, -}: SetCardinalModalProps) { - const { step, setStep, selected, handleOpenChange, handleClose, handleToggle, handleSave } = - useCardinalModal({ onOpenChange, onSave }); +function SetCardinalModal({ open, onOpenChange, club, onSave }: SetCardinalModalProps) { + const { data: cardinalsData = [] } = useCardinals(); + const availableCardinals = cardinalsData.map((c) => c.cardinalNumber); + + const { + step, + setStep, + selected, + isPending, + handleOpenChange, + handleClose, + handleToggle, + handleSave, + } = useCardinalModal({ onOpenChange, onSave }); const selectedArray = [...selected]; @@ -73,10 +78,11 @@ function SetCardinalModal({ title: '설정 내용을 확인해주세요', body: , footer: { - primaryLabel: '저장하기', + primaryLabel: isPending ? '저장 중...' : '저장하기', secondaryLabel: '이전', onPrimary: handleSave, onSecondary: () => setStep(2), + primaryDisabled: isPending, }, }, ]; diff --git a/src/components/mypage/SetCardinalModal/useCardinalModal.ts b/src/components/mypage/SetCardinalModal/useCardinalModal.ts index c42245d4..bc30bfc8 100644 --- a/src/components/mypage/SetCardinalModal/useCardinalModal.ts +++ b/src/components/mypage/SetCardinalModal/useCardinalModal.ts @@ -1,13 +1,16 @@ import { useState } from 'react'; +import { useInitCardinalsMutation } from '@/hooks/mutations/mypage/useInitCardinalsMutation'; +import { toastSuccess, toastError } from '@/stores/useToastStore'; interface UseCardinalModalProps { onOpenChange: (open: boolean) => void; - onSave: (selected: number[]) => void; + onSave?: (selected: number[]) => void; } function useCardinalModal({ onOpenChange, onSave }: UseCardinalModalProps) { const [step, setStep] = useState(1); const [selected, setSelected] = useState>(new Set()); + const { mutate: initCardinals, isPending } = useInitCardinalsMutation(); const handleOpenChange = (nextOpen: boolean) => { if (!nextOpen) { @@ -32,14 +35,24 @@ function useCardinalModal({ onOpenChange, onSave }: UseCardinalModalProps) { }; const handleSave = () => { - onSave([...selected]); - handleClose(); + const cardinals = [...selected]; + initCardinals(cardinals, { + onSuccess: () => { + toastSuccess('활동 기수가 설정되었습니다.'); + onSave?.(cardinals); + handleClose(); + }, + onError: () => { + toastError('활동 기수 설정에 실패했습니다.'); + }, + }); }; return { step, setStep, selected, + isPending, handleOpenChange, handleClose, handleToggle, diff --git a/src/hooks/mutations/mypage/useInitCardinalsMutation.ts b/src/hooks/mutations/mypage/useInitCardinalsMutation.ts new file mode 100644 index 00000000..a47c7bcf --- /dev/null +++ b/src/hooks/mutations/mypage/useInitCardinalsMutation.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { mypageApi } from '@/lib/apis/mypage'; +import { useClubId } from '@/stores/useClubStore'; + +export function useInitCardinalsMutation() { + const queryClient = useQueryClient(); + const clubId = useClubId(); + + return useMutation({ + mutationFn: (cardinals: number[]) => { + if (!clubId) throw new Error('clubId가 없습니다'); + return mypageApi.initCardinals(clubId, cardinals); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mypage', 'clubs'] }); + if (clubId) { + queryClient.invalidateQueries({ queryKey: ['mypage', 'me', clubId] }); + } + }, + }); +} diff --git a/src/hooks/queries/admin/useAdminMemberQueries.ts b/src/hooks/queries/admin/useAdminMemberQueries.ts index 8960e565..ae7e5703 100644 --- a/src/hooks/queries/admin/useAdminMemberQueries.ts +++ b/src/hooks/queries/admin/useAdminMemberQueries.ts @@ -2,17 +2,18 @@ import { useQuery } from '@tanstack/react-query'; import { adminMemberApi } from '@/lib/apis/adminMember'; import { toMember } from '@/utils/admin/memberMapper'; - -// TODO: clubId store에서 가져오도로 변경 -export const CLUB_ID = 'YUNJcjFKMO'; +import { useClubId } from '@/stores'; export function useAdminMembers() { + const clubId = useClubId(); + return useQuery({ - queryKey: ['admin', 'members', CLUB_ID], + queryKey: ['admin', 'members', clubId], queryFn: async () => { - const res = await adminMemberApi.getMembers(CLUB_ID); + const res = await adminMemberApi.getMembers(clubId!); return res.data.data.map(toMember); }, + enabled: !!clubId, staleTime: 30 * 60 * 1000, gcTime: 60 * 60 * 1000, }); diff --git a/src/hooks/queries/index.ts b/src/hooks/queries/index.ts index 85c3883d..24dee4b0 100644 --- a/src/hooks/queries/index.ts +++ b/src/hooks/queries/index.ts @@ -1 +1 @@ -export { useCardinals } from './useCommoneQUeries'; +export { useCardinals } from './useCardinals'; diff --git a/src/hooks/queries/useCardinals.ts b/src/hooks/queries/useCardinals.ts new file mode 100644 index 00000000..b0a1c621 --- /dev/null +++ b/src/hooks/queries/useCardinals.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; + +import { cardinalApi } from '@/lib/apis/cardinal'; +import { useClubId } from '@/stores'; + +export function useCardinals() { + const clubId = useClubId(); + + return useQuery({ + queryKey: ['cardinals', clubId], + queryFn: async () => { + const res = await cardinalApi.getCardinals(clubId!); + return res.data.data; + }, + enabled: !!clubId, + staleTime: 30 * 60 * 1000, + gcTime: 60 * 60 * 1000, + }); +} diff --git a/src/hooks/queries/useCommoneQUeries.ts b/src/hooks/queries/useCommoneQUeries.ts deleted file mode 100644 index 4a35234b..00000000 --- a/src/hooks/queries/useCommoneQUeries.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import { cardinalApi } from '@/lib/apis'; -import { CLUB_ID } from '@/hooks/queries/admin/useAdminMemberQueries'; - -export function useCardinals() { - return useQuery({ - queryKey: ['cardinals', CLUB_ID], - queryFn: async () => { - const res = await cardinalApi.getCardinals(CLUB_ID); - return res.data.data; - }, - staleTime: 30 * 60 * 1000, - gcTime: 60 * 60 * 1000, - }); -} diff --git a/src/lib/apis/cardinal.ts b/src/lib/apis/cardinal.ts index 295a8386..b14094d3 100644 --- a/src/lib/apis/cardinal.ts +++ b/src/lib/apis/cardinal.ts @@ -4,7 +4,7 @@ import type { ApiResponse } from '@/types/common'; export const cardinalApi = { getCardinals: (clubId: string) => - apiClient.get>(`/api/v4/clubs/${clubId}/cardinals`), + apiClient.get>(`/clubs/${clubId}/cardinals`), createCardinal: (clubId: string, body: CreateCardinalBody) => apiClient.post(`/api/v4/admin/clubs/${clubId}/cardinals`, body), }; diff --git a/src/lib/apis/mypage.ts b/src/lib/apis/mypage.ts index d7deecc6..2cfe9385 100644 --- a/src/lib/apis/mypage.ts +++ b/src/lib/apis/mypage.ts @@ -28,4 +28,6 @@ export const mypageApi = { updateUser: (body: UpdateUserBody) => apiClient.patch('/users', body), updateClubProfile: (body: UpdateClubProfileBody) => apiClient.patch('/clubs/members/me', body), + initCardinals: (clubId: string, cardinals: number[]) => + apiClient.post(`/clubs/${clubId}/members/me/cardinals`, { cardinals }), }; diff --git a/src/stores/useClubStore.ts b/src/stores/useClubStore.ts index 8e063f8a..b76cdff1 100644 --- a/src/stores/useClubStore.ts +++ b/src/stores/useClubStore.ts @@ -3,7 +3,7 @@ import { combine, devtools, persist } from 'zustand/middleware'; const initialState = { // TODO: 로그인 API 연결 후 제거 - clubId: 'YUNJcjFKMO' as string | null, + clubId: 'ZcsAEuGfHQ' as string | null, }; export type ClubState = typeof initialState; diff --git a/src/types/admin/cardinal.ts b/src/types/admin/cardinal.ts index 28f368d1..fe09624f 100644 --- a/src/types/admin/cardinal.ts +++ b/src/types/admin/cardinal.ts @@ -1,8 +1,6 @@ export interface Cardinal { id: number; cardinalNumber: number; - year: number; - semester: number; status: 'IN_PROGRESS' | 'COMPLETED'; createdAt: string; modifiedAt: string; @@ -10,7 +8,5 @@ export interface Cardinal { export interface CreateCardinalBody { cardinalNumber: number; - year: number; - semester: number; inProgress: boolean; } From ef796ac0dedb21258ed9cd21e1d34d79ae4346be Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 5 Apr 2026 23:37:26 +0900 Subject: [PATCH 024/684] =?UTF-8?q?fix:=20=ED=95=99=EB=B2=88=20=EB=B0=B0?= =?UTF-8?q?=EC=97=B4=EB=A1=9C=20=EC=B6=94=EC=B6=9C=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/mypage/MyPageContent.tsx | 2 +- .../mypage/edit/EditProfileContent.tsx | 23 ++++---- .../{ => queries}/mypage/useMyMemberQuery.ts | 0 src/hooks/queries/useCardinals.ts | 58 ++++++++++++++++++- 4 files changed, 71 insertions(+), 12 deletions(-) rename src/hooks/{ => queries}/mypage/useMyMemberQuery.ts (100%) diff --git a/src/components/mypage/MyPageContent.tsx b/src/components/mypage/MyPageContent.tsx index dcc3d6e1..e0f3e839 100644 --- a/src/components/mypage/MyPageContent.tsx +++ b/src/components/mypage/MyPageContent.tsx @@ -10,7 +10,7 @@ import { SupportListItem } from './SupportListItem'; import { ThemeToggle } from './ThemeToggle'; import { MyPageDropdownMenu } from './MyPageDropdownMenu'; import { ClubInfoCard } from './ClubInfoCard'; -import { useMyMemberQuery } from '@/hooks/mypage/useMyMemberQuery'; +import { useMyMemberQuery } from '@/hooks/queries/mypage/useMyMemberQuery'; import { useMyClubsQuery } from '@/hooks/queries/mypage/useMyClubsQuery'; type MyPageContentProps = React.HTMLAttributes; diff --git a/src/components/mypage/edit/EditProfileContent.tsx b/src/components/mypage/edit/EditProfileContent.tsx index aad88ff6..2ad480a4 100644 --- a/src/components/mypage/edit/EditProfileContent.tsx +++ b/src/components/mypage/edit/EditProfileContent.tsx @@ -18,7 +18,7 @@ import { import { cn } from '@/lib/cn'; import { editProfileSchema, type EditProfileFormData } from '@/lib/schemas/editProfile'; -import { useMyMemberQuery } from '@/hooks/mypage/useMyMemberQuery'; +import { useMyMemberQuery } from '@/hooks/queries/mypage/useMyMemberQuery'; import { useUpdateProfileMutation } from '@/hooks/mutations/mypage/useUpdateProfileMutation'; import { toastSuccess, toastError } from '@/stores/useToastStore'; import { formatPhone } from '@/utils/shared'; @@ -57,15 +57,18 @@ function EditProfileContent({ className, ...props }: EditProfileContentProps) { useEffect(() => { if (me) { - reset({ - name: me.name, - bio: me.bio ?? '', - tel: me.tel ? formatPhone(me.tel) : '', - email: me.email, - school: me.school, - department: me.department, - studentId: me.studentId, - }, { keepDirtyValues: true }); + reset( + { + name: me.name, + bio: me.bio ?? '', + tel: me.tel ? formatPhone(me.tel) : '', + email: me.email, + school: me.school, + department: me.department, + studentId: me.studentId, + }, + { keepDirtyValues: true }, + ); } }, [me, reset]); diff --git a/src/hooks/mypage/useMyMemberQuery.ts b/src/hooks/queries/mypage/useMyMemberQuery.ts similarity index 100% rename from src/hooks/mypage/useMyMemberQuery.ts rename to src/hooks/queries/mypage/useMyMemberQuery.ts diff --git a/src/hooks/queries/useCardinals.ts b/src/hooks/queries/useCardinals.ts index b0a1c621..b4b087dc 100644 --- a/src/hooks/queries/useCardinals.ts +++ b/src/hooks/queries/useCardinals.ts @@ -2,6 +2,60 @@ import { useQuery } from '@tanstack/react-query'; import { cardinalApi } from '@/lib/apis/cardinal'; import { useClubId } from '@/stores'; +import type { Cardinal } from '@/types/admin/cardinal'; + +// TODO: API에 데이터 채워지면 제거 +const MOCK_CARDINALS: Cardinal[] = [ + { + id: 1, + cardinalNumber: 1, + status: 'COMPLETED', + createdAt: '2024-03-01T00:00:00.000Z', + modifiedAt: '2024-03-01T00:00:00.000Z', + }, + { + id: 2, + cardinalNumber: 2, + status: 'COMPLETED', + createdAt: '2024-09-01T00:00:00.000Z', + modifiedAt: '2024-09-01T00:00:00.000Z', + }, + { + id: 3, + cardinalNumber: 3, + status: 'COMPLETED', + createdAt: '2025-03-01T00:00:00.000Z', + modifiedAt: '2025-03-01T00:00:00.000Z', + }, + { + id: 4, + cardinalNumber: 4, + status: 'COMPLETED', + createdAt: '2025-09-01T00:00:00.000Z', + modifiedAt: '2025-09-01T00:00:00.000Z', + }, + { + id: 5, + cardinalNumber: 5, + status: 'COMPLETED', + createdAt: '2026-03-01T00:00:00.000Z', + modifiedAt: '2026-03-01T00:00:00.000Z', + }, + { + id: 6, + cardinalNumber: 6, + status: 'COMPLETED', + createdAt: '2026-03-15T00:00:00.000Z', + modifiedAt: '2026-03-15T00:00:00.000Z', + }, + { + id: 7, + cardinalNumber: 7, + status: 'IN_PROGRESS', + createdAt: '2026-04-05T14:22:59.718Z', + modifiedAt: '2026-04-05T14:22:59.718Z', + }, +]; export function useCardinals() { const clubId = useClubId(); @@ -10,7 +64,9 @@ export function useCardinals() { queryKey: ['cardinals', clubId], queryFn: async () => { const res = await cardinalApi.getCardinals(clubId!); - return res.data.data; + const data = res.data.data; + // TODO: API에 데이터 채워지면 MOCK fallback 제거 + return data.length > 0 ? data : MOCK_CARDINALS; }, enabled: !!clubId, staleTime: 30 * 60 * 1000, From 9b9a881c5b9842f13451df27ae7fbab36a12b06b Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 5 Apr 2026 23:58:44 +0900 Subject: [PATCH 025/684] =?UTF-8?q?fix:=20zod=20=EB=A9=94=EC=84=B8?= =?UTF-8?q?=EC=A7=80=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/mypage/edit/EditProfileContent.tsx | 1 + src/components/mypage/edit/SchoolInfoFields.tsx | 4 ++++ src/lib/schemas/editProfile.ts | 11 ++++++----- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/mypage/edit/EditProfileContent.tsx b/src/components/mypage/edit/EditProfileContent.tsx index 2ad480a4..69085172 100644 --- a/src/components/mypage/edit/EditProfileContent.tsx +++ b/src/components/mypage/edit/EditProfileContent.tsx @@ -44,6 +44,7 @@ function EditProfileContent({ className, ...props }: EditProfileContentProps) { formState: { errors }, } = useForm({ resolver: zodResolver(editProfileSchema), + mode: 'onBlur', defaultValues: { name: '', bio: '', diff --git a/src/components/mypage/edit/SchoolInfoFields.tsx b/src/components/mypage/edit/SchoolInfoFields.tsx index 978b59d4..be1140cf 100644 --- a/src/components/mypage/edit/SchoolInfoFields.tsx +++ b/src/components/mypage/edit/SchoolInfoFields.tsx @@ -45,6 +45,10 @@ function SchoolInfoFields({ register, control, errors, setValue }: SchoolInfoFie {...register('studentId')} placeholder="학번을 입력하세요" inputMode="numeric" + onInput={(e) => { + const target = e.currentTarget; + target.value = target.value.replace(/\D/g, ''); + }} className="rounded-lg" /> diff --git a/src/lib/schemas/editProfile.ts b/src/lib/schemas/editProfile.ts index 1b6c5f81..6e28ef64 100644 --- a/src/lib/schemas/editProfile.ts +++ b/src/lib/schemas/editProfile.ts @@ -2,16 +2,17 @@ import { z } from 'zod'; export const editProfileSchema = z.object({ name: z.string().min(1, '이름을 입력해주세요'), - bio: z.string().max(30, '30자 이내로 입력해주세요').optional().default(''), + bio: z.string().max(30, '30자 이내로 입력해주세요'), tel: z .string() - .optional() - .default('') .refine((v) => !v || /^\d{3}-\d{3,4}-\d{4}$/.test(v), '올바른 전화번호 형식이 아닙니다'), - email: z.string().min(1, '이메일을 입력해주세요').email('올바른 이메일 형식이 아닙니다'), + email: z + .string() + .min(1, '이메일을 입력해주세요') + .refine((v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), '올바른 이메일 형식이 아닙니다'), school: z.string().min(1, '학교를 선택해주세요'), department: z.string().min(1, '학과를 선택해주세요'), - studentId: z.string().min(1, '학번을 입력해주세요'), + studentId: z.string().min(6, '올바른 학번을 입력해주세요'), }); export type EditProfileFormData = z.infer; From 4fe2587d4444145bfc80563d00817434291e135a Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Mon, 6 Apr 2026 18:24:51 +0900 Subject: [PATCH 026/684] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(private)/(main)/attendance/page.tsx | 31 ++++---- .../attendance/AttendanceContent.tsx | 72 ++++++++++++++----- src/constants/{ => attendance}/attendance.ts | 0 src/constants/attendance/error.ts | 11 +++ src/constants/attendance/index.ts | 2 + src/lib/apis/attendance.ts | 11 +++ src/types/attendance.ts | 1 + 7 files changed, 90 insertions(+), 38 deletions(-) rename src/constants/{ => attendance}/attendance.ts (100%) create mode 100644 src/constants/attendance/error.ts create mode 100644 src/constants/attendance/index.ts diff --git a/src/app/(private)/(main)/attendance/page.tsx b/src/app/(private)/(main)/attendance/page.tsx index 09050ad0..f0767b1d 100644 --- a/src/app/(private)/(main)/attendance/page.tsx +++ b/src/app/(private)/(main)/attendance/page.tsx @@ -1,25 +1,18 @@ import { AttendanceContent } from '@/components/attendance'; +import { attendanceServerApi } from '@/lib/apis/attendance'; import type { AttendanceData } from '@/types/attendance'; -// TODO: API 연동 시 실제 데이터로 교체 -function createMockAttendance(): AttendanceData { - const now = new Date(); - const start = now; - const end = new Date(now.getTime() + 10 * 60 * 1000); // 10분 후 +export default async function AttendancePage() { + // TODO: 하드코딩된 clubId 추후 동적으로 변경 + let attendance: AttendanceData | undefined; + let errorMessage: string | undefined; - return { - attendanceRate: 80, - title: '1주차 정기모임', - status: 'ATTEND', - code: 123456, - start: start.toISOString(), - end: end.toISOString(), - location: '공학관 401호', - }; -} + try { + const response = await attendanceServerApi.getAttendance('YUNJcjFKMO'); + attendance = response.data; + } catch { + errorMessage = '출석 정보를 불러오지 못했습니다.'; + } -export default function AttendancePage() { - // TODO: API 연동 시 실제 사용자 이름으로 교체 - const displayName = '사용자'; - return ; + return ; } diff --git a/src/components/attendance/AttendanceContent.tsx b/src/components/attendance/AttendanceContent.tsx index 5841db91..fb24888a 100644 --- a/src/components/attendance/AttendanceContent.tsx +++ b/src/components/attendance/AttendanceContent.tsx @@ -1,29 +1,54 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbPage, Card } from '@/components/ui'; import { AttendanceStatus } from '@/components/attendance/AttendanceStatus'; import { AttendanceTodayCard } from '@/components/attendance/AttendanceTodayCard'; +import { ATTENDANCE_ERROR_MESSAGE } from '@/constants/attendance'; +import { attendanceApi } from '@/lib/apis/attendance'; import { formatAttendanceDescription } from '@/lib/formatTime'; +import { toastError } from '@/stores/useToastStore'; +import { useUserName } from '@/stores/useUserStore'; import type { AttendanceData } from '@/types/attendance'; interface AttendanceContentProps { - name: string; - attendance: AttendanceData; + attendance?: AttendanceData; + errorMessage?: string; isAdmin?: boolean; } -function AttendanceContent({ name, attendance, isAdmin = false }: AttendanceContentProps) { +function AttendanceContent({ attendance, errorMessage, isAdmin = false }: AttendanceContentProps) { + const name = useUserName() ?? ''; const router = useRouter(); const [isChecked, setIsChecked] = useState(false); - const { attendanceRate, title, start, end, location } = attendance; + + useEffect(() => { + if (errorMessage) toastError(errorMessage); + }, [errorMessage]); + + const { + sessionId = null, + attendanceRate = 0, + title = null, + start = null, + end = null, + location = null, + } = attendance ?? {}; const description = formatAttendanceDescription(start ?? '', end ?? '', location ?? ''); - function handleAttendanceComplete(_code: string) { - // TODO: API 연결 시 출석 코드 검증 로직 추가 - setIsChecked(true); + async function handleAttendanceComplete(code: string) { + if (!sessionId) return; + + try { + // TODO: 하드코딩된 clubId 추후 동적으로 변경 + await attendanceApi.checkIn('YUNJcjFKMO', sessionId, Number(code)); + setIsChecked(true); + } catch (error) { + const errorCode = (error as { response?: { data?: { code?: number } } }).response?.data?.code; + toastError(errorCode ? ATTENDANCE_ERROR_MESSAGE[errorCode] : undefined); + } } return ( @@ -39,17 +64,26 @@ function AttendanceContent({ name, attendance, isAdmin = false }: AttendanceCont
- + {title ? ( + + ) : ( + + )} = { + [ATTENDANCE_ERROR_CODE.NOT_FOUND]: '출석 정보가 존재하지 않습니다.', + [ATTENDANCE_ERROR_CODE.CODE_MISMATCH]: '출석 코드가 일치하지 않습니다.', + [ATTENDANCE_ERROR_CODE.ALREADY_ATTENDED]: '이미 출석 처리된 세션입니다.', +}; diff --git a/src/constants/attendance/index.ts b/src/constants/attendance/index.ts new file mode 100644 index 00000000..cb89cb3c --- /dev/null +++ b/src/constants/attendance/index.ts @@ -0,0 +1,2 @@ +export { ATTENDANCE_STATUS_CONFIG } from './attendance'; +export { ATTENDANCE_ERROR_CODE, ATTENDANCE_ERROR_MESSAGE } from './error'; diff --git a/src/lib/apis/attendance.ts b/src/lib/apis/attendance.ts index 8dddff38..41e95e93 100644 --- a/src/lib/apis/attendance.ts +++ b/src/lib/apis/attendance.ts @@ -1,7 +1,18 @@ import { apiClient } from '@/lib/apis/client'; +import { apiServer } from '@/lib/apis/server'; import type { AttendanceResponse } from '@/types/attendance'; export const attendanceApi = { getAttendance: (clubId: string) => apiClient.get(`/clubs/${clubId}/attendances`), + + checkIn: (clubId: string, sessionId: number, code: number) => + apiClient.post(`/clubs/${clubId}/attendances/check-in`, { sessionId, code }), +}; + +export const attendanceServerApi = { + getAttendance: (clubId: string) => + apiServer.get(`/clubs/${clubId}/attendances`, { + cache: 'no-store', + }), }; diff --git a/src/types/attendance.ts b/src/types/attendance.ts index 883e6c45..47bcf3fa 100644 --- a/src/types/attendance.ts +++ b/src/types/attendance.ts @@ -3,6 +3,7 @@ import type { ApiResponse } from '@/types/common'; type AttendanceStatus = 'ATTEND' | 'ABSENT' | 'PENDING'; interface AttendanceData { + sessionId: number | null; attendanceRate: number; title: string | null; status: AttendanceStatus | null; From f876de62f2dfdc125e0538e3152d9cafdbb3d357 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Apr 2026 20:39:30 +0900 Subject: [PATCH 027/684] =?UTF-8?q?fix:=20mock=20data=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/queries/useCardinals.ts | 57 +------------------------------ 1 file changed, 1 insertion(+), 56 deletions(-) diff --git a/src/hooks/queries/useCardinals.ts b/src/hooks/queries/useCardinals.ts index b4b087dc..20aec91c 100644 --- a/src/hooks/queries/useCardinals.ts +++ b/src/hooks/queries/useCardinals.ts @@ -2,60 +2,6 @@ import { useQuery } from '@tanstack/react-query'; import { cardinalApi } from '@/lib/apis/cardinal'; import { useClubId } from '@/stores'; -import type { Cardinal } from '@/types/admin/cardinal'; - -// TODO: API에 데이터 채워지면 제거 -const MOCK_CARDINALS: Cardinal[] = [ - { - id: 1, - cardinalNumber: 1, - status: 'COMPLETED', - createdAt: '2024-03-01T00:00:00.000Z', - modifiedAt: '2024-03-01T00:00:00.000Z', - }, - { - id: 2, - cardinalNumber: 2, - status: 'COMPLETED', - createdAt: '2024-09-01T00:00:00.000Z', - modifiedAt: '2024-09-01T00:00:00.000Z', - }, - { - id: 3, - cardinalNumber: 3, - status: 'COMPLETED', - createdAt: '2025-03-01T00:00:00.000Z', - modifiedAt: '2025-03-01T00:00:00.000Z', - }, - { - id: 4, - cardinalNumber: 4, - status: 'COMPLETED', - createdAt: '2025-09-01T00:00:00.000Z', - modifiedAt: '2025-09-01T00:00:00.000Z', - }, - { - id: 5, - cardinalNumber: 5, - status: 'COMPLETED', - createdAt: '2026-03-01T00:00:00.000Z', - modifiedAt: '2026-03-01T00:00:00.000Z', - }, - { - id: 6, - cardinalNumber: 6, - status: 'COMPLETED', - createdAt: '2026-03-15T00:00:00.000Z', - modifiedAt: '2026-03-15T00:00:00.000Z', - }, - { - id: 7, - cardinalNumber: 7, - status: 'IN_PROGRESS', - createdAt: '2026-04-05T14:22:59.718Z', - modifiedAt: '2026-04-05T14:22:59.718Z', - }, -]; export function useCardinals() { const clubId = useClubId(); @@ -65,8 +11,7 @@ export function useCardinals() { queryFn: async () => { const res = await cardinalApi.getCardinals(clubId!); const data = res.data.data; - // TODO: API에 데이터 채워지면 MOCK fallback 제거 - return data.length > 0 ? data : MOCK_CARDINALS; + return data; }, enabled: !!clubId, staleTime: 30 * 60 * 1000, From a8041efb8e342b9df500931694916562b558ada7 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Apr 2026 20:59:13 +0900 Subject: [PATCH 028/684] =?UTF-8?q?fix:=20=EC=A0=84=ED=99=94=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=ED=95=84=EC=88=98=20=EC=98=B5=EC=85=98=EC=9D=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/mypage/edit/PersonalInfoFields.tsx | 2 +- src/lib/schemas/editProfile.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/mypage/edit/PersonalInfoFields.tsx b/src/components/mypage/edit/PersonalInfoFields.tsx index 7333d7f7..bcfacdc0 100644 --- a/src/components/mypage/edit/PersonalInfoFields.tsx +++ b/src/components/mypage/edit/PersonalInfoFields.tsx @@ -31,7 +31,7 @@ function PersonalInfoFields({ register, errors, setValue }: PersonalInfoFieldsPr /> - + !v || /^\d{3}-\d{3,4}-\d{4}$/.test(v), '올바른 전화번호 형식이 아닙니다'), + .min(1, '연락처를 입력해주세요') + .regex(/^\d{3}-\d{3,4}-\d{4}$/, '올바른 전화번호 형식이 아닙니다'), email: z .string() .min(1, '이메일을 입력해주세요') From 15d50823a2ed99838a70d2712e4cfe545c17b789 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Apr 2026 21:18:55 +0900 Subject: [PATCH 029/684] =?UTF-8?q?fix:=20=EB=B6=84=EB=A6=AC=EC=84=A0=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/mypage/MyPageDropdownMenu.tsx | 1 - src/components/mypage/edit/PersonalInfoFields.tsx | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/mypage/MyPageDropdownMenu.tsx b/src/components/mypage/MyPageDropdownMenu.tsx index d8b756fd..dcb76ad0 100644 --- a/src/components/mypage/MyPageDropdownMenu.tsx +++ b/src/components/mypage/MyPageDropdownMenu.tsx @@ -36,7 +36,6 @@ function MyPageDropdownMenu() { 개인정보 수정 - setLogoutOpen(true)}>로그아웃 {/* setWithdrawOpen(true)}> diff --git a/src/components/mypage/edit/PersonalInfoFields.tsx b/src/components/mypage/edit/PersonalInfoFields.tsx index bcfacdc0..63969f77 100644 --- a/src/components/mypage/edit/PersonalInfoFields.tsx +++ b/src/components/mypage/edit/PersonalInfoFields.tsx @@ -12,8 +12,11 @@ interface PersonalInfoFieldsProps { } function PersonalInfoFields({ register, errors, setValue }: PersonalInfoFieldsProps) { + const { onChange: telOnChange, ...telRest } = register('tel'); + const handlePhoneChange = (e: React.ChangeEvent) => { setValue('tel', formatPhone(e.target.value), { shouldValidate: true }); + telOnChange(e); }; return ( @@ -33,7 +36,7 @@ function PersonalInfoFields({ register, errors, setValue }: PersonalInfoFieldsPr Date: Mon, 6 Apr 2026 21:48:28 +0900 Subject: [PATCH 030/684] =?UTF-8?q?feat:=20=ED=95=99=EA=B5=90=20api=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/mypage/edit/SchoolInfoFields.tsx | 7 +++++-- src/hooks/queries/mypage/useSchoolsQuery.ts | 11 +++++++++++ src/stores/useClubStore.ts | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 src/hooks/queries/mypage/useSchoolsQuery.ts diff --git a/src/components/mypage/edit/SchoolInfoFields.tsx b/src/components/mypage/edit/SchoolInfoFields.tsx index be1140cf..7939363e 100644 --- a/src/components/mypage/edit/SchoolInfoFields.tsx +++ b/src/components/mypage/edit/SchoolInfoFields.tsx @@ -6,7 +6,8 @@ import { useWatch } from 'react-hook-form'; import { Input } from '@/components/ui'; import { FormField } from '@/components/mypage/FormField'; import { SearchSelect } from '@/components/mypage/SearchSelect'; -import { MOCK_DEPARTMENTS, MOCK_UNIVERSITIES } from '@/constants/mock'; +import { useSchoolsQuery } from '@/hooks/queries/mypage/useSchoolsQuery'; +import { MOCK_DEPARTMENTS } from '@/constants/mock'; import type { EditProfileFormData } from '@/lib/schemas/editProfile'; interface SchoolInfoFieldsProps { @@ -19,6 +20,8 @@ interface SchoolInfoFieldsProps { function SchoolInfoFields({ register, control, errors, setValue }: SchoolInfoFieldsProps) { const school = useWatch({ control, name: 'school' }); const department = useWatch({ control, name: 'department' }); + const { data: schools = [] } = useSchoolsQuery(); + const schoolNames = schools.map((s) => s.schoolName); return (
@@ -26,7 +29,7 @@ function SchoolInfoFields({ register, control, errors, setValue }: SchoolInfoFie setValue('school', v, { shouldValidate: true })} - options={MOCK_UNIVERSITIES} + options={schoolNames} placeholder="학교 선택" /> diff --git a/src/hooks/queries/mypage/useSchoolsQuery.ts b/src/hooks/queries/mypage/useSchoolsQuery.ts new file mode 100644 index 00000000..a1ce6e98 --- /dev/null +++ b/src/hooks/queries/mypage/useSchoolsQuery.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { universityApi } from '@/lib/apis/university'; + +export function useSchoolsQuery() { + return useQuery({ + queryKey: ['university', 'schools'], + queryFn: () => universityApi.getSchools().then((res) => res.data.data), + staleTime: 60 * 60 * 1000, + gcTime: 120 * 60 * 1000, + }); +} diff --git a/src/stores/useClubStore.ts b/src/stores/useClubStore.ts index b76cdff1..8e063f8a 100644 --- a/src/stores/useClubStore.ts +++ b/src/stores/useClubStore.ts @@ -3,7 +3,7 @@ import { combine, devtools, persist } from 'zustand/middleware'; const initialState = { // TODO: 로그인 API 연결 후 제거 - clubId: 'ZcsAEuGfHQ' as string | null, + clubId: 'YUNJcjFKMO' as string | null, }; export type ClubState = typeof initialState; From 00ed6881b69c2536c9aee217ab8946d36d8e9130 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Apr 2026 21:53:51 +0900 Subject: [PATCH 031/684] =?UTF-8?q?feat:=20=ED=95=99=EA=B3=BC=20api=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/mypage/edit/SchoolInfoFields.tsx | 6 ++++-- src/hooks/queries/mypage/useMajorsQuery.ts | 11 +++++++++++ src/lib/apis/university.ts | 14 +++++++++++++- 3 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 src/hooks/queries/mypage/useMajorsQuery.ts diff --git a/src/components/mypage/edit/SchoolInfoFields.tsx b/src/components/mypage/edit/SchoolInfoFields.tsx index 7939363e..edd77284 100644 --- a/src/components/mypage/edit/SchoolInfoFields.tsx +++ b/src/components/mypage/edit/SchoolInfoFields.tsx @@ -7,7 +7,7 @@ import { Input } from '@/components/ui'; import { FormField } from '@/components/mypage/FormField'; import { SearchSelect } from '@/components/mypage/SearchSelect'; import { useSchoolsQuery } from '@/hooks/queries/mypage/useSchoolsQuery'; -import { MOCK_DEPARTMENTS } from '@/constants/mock'; +import { useMajorsQuery } from '@/hooks/queries/mypage/useMajorsQuery'; import type { EditProfileFormData } from '@/lib/schemas/editProfile'; interface SchoolInfoFieldsProps { @@ -21,7 +21,9 @@ function SchoolInfoFields({ register, control, errors, setValue }: SchoolInfoFie const school = useWatch({ control, name: 'school' }); const department = useWatch({ control, name: 'department' }); const { data: schools = [] } = useSchoolsQuery(); + const { data: majors = [] } = useMajorsQuery(); const schoolNames = schools.map((s) => s.schoolName); + const majorNames = majors.map((m) => m.majorName); return (
@@ -38,7 +40,7 @@ function SchoolInfoFields({ register, control, errors, setValue }: SchoolInfoFie setValue('department', v, { shouldValidate: true })} - options={MOCK_DEPARTMENTS} + options={majorNames} placeholder="학과 선택" /> diff --git a/src/hooks/queries/mypage/useMajorsQuery.ts b/src/hooks/queries/mypage/useMajorsQuery.ts new file mode 100644 index 00000000..2c1b4733 --- /dev/null +++ b/src/hooks/queries/mypage/useMajorsQuery.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { universityApi } from '@/lib/apis/university'; + +export function useMajorsQuery() { + return useQuery({ + queryKey: ['university', 'majors'], + queryFn: () => universityApi.getMajors().then((res) => res.data.data), + staleTime: Infinity, + gcTime: 120 * 60 * 1000, + }); +} diff --git a/src/lib/apis/university.ts b/src/lib/apis/university.ts index 6e32cbec..d0e2f654 100644 --- a/src/lib/apis/university.ts +++ b/src/lib/apis/university.ts @@ -5,14 +5,26 @@ interface School { region: string; } +interface Major { + majorName: string; + category: string; +} + interface SchoolsResponse { code: number; message: string; data: School[]; } +interface MajorsResponse { + code: number; + message: string; + data: Major[]; +} + export const universityApi = { getSchools: () => apiClient.get('/university/schools'), + getMajors: () => apiClient.get('/university/majors'), }; -export type { School, SchoolsResponse }; +export type { School, Major, SchoolsResponse, MajorsResponse }; From a35436e3fcd535f0af8ca8cf5aebb8b3d88d199c Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Apr 2026 22:10:17 +0900 Subject: [PATCH 032/684] =?UTF-8?q?fix:=20=EC=BF=BC=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EC=B2=98=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/mypage/edit/EditProfileContent.tsx | 12 ++++++++++-- src/components/mypage/edit/PersonalInfoFields.tsx | 7 ++----- src/components/mypage/edit/ProfileImageEditor.tsx | 4 ++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/components/mypage/edit/EditProfileContent.tsx b/src/components/mypage/edit/EditProfileContent.tsx index 69085172..7785e247 100644 --- a/src/components/mypage/edit/EditProfileContent.tsx +++ b/src/components/mypage/edit/EditProfileContent.tsx @@ -31,7 +31,7 @@ type EditProfileContentProps = React.HTMLAttributes; function EditProfileContent({ className, ...props }: EditProfileContentProps) { const router = useRouter(); - const { data: me } = useMyMemberQuery(); + const { data: me, isLoading, isError } = useMyMemberQuery(); const { mutate: updateProfile, isPending } = useUpdateProfileMutation(); const [selectedFile, setSelectedFile] = useState(null); @@ -103,7 +103,7 @@ function EditProfileContent({ className, ...props }: EditProfileContentProps) { ); }; - if (!me) { + if (isLoading) { return (

로딩 중...

@@ -111,6 +111,14 @@ function EditProfileContent({ className, ...props }: EditProfileContentProps) { ); } + if (isError || !me) { + return ( +
+

내 정보를 불러올 수 없습니다.

+
+ ); + } + return (
) => { setValue('tel', formatPhone(e.target.value), { shouldValidate: true }); - telOnChange(e); }; return ( @@ -36,7 +33,7 @@ function PersonalInfoFields({ register, errors, setValue }: PersonalInfoFieldsPr ) => { const file = e.target.files?.[0]; if (!file) return; - + if (previewUrl) URL.revokeObjectURL(previewUrl); setPreviewUrl(URL.createObjectURL(file)); onFileChange?.(file); }; @@ -27,7 +27,7 @@ function ProfileImageEditor({ name, profileImageUrl, onFileChange }: ProfileImag return () => { if (previewUrl) URL.revokeObjectURL(previewUrl); }; - }, [previewUrl]); + }, []); const displayUrl = previewUrl ?? profileImageUrl; From 1e52b75cb98e9003f4c7cc6593c6fa65872352e1 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Apr 2026 22:15:18 +0900 Subject: [PATCH 033/684] =?UTF-8?q?fix:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=83=80=EC=9E=85=20null=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/mypage/edit/ProfileImageEditor.tsx | 1 + .../mutations/mypage/useUpdateProfileMutation.ts | 13 +++++++++++-- src/types/mypage.ts | 6 +++--- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/components/mypage/edit/ProfileImageEditor.tsx b/src/components/mypage/edit/ProfileImageEditor.tsx index 36b2282e..7a975943 100644 --- a/src/components/mypage/edit/ProfileImageEditor.tsx +++ b/src/components/mypage/edit/ProfileImageEditor.tsx @@ -27,6 +27,7 @@ function ProfileImageEditor({ name, profileImageUrl, onFileChange }: ProfileImag return () => { if (previewUrl) URL.revokeObjectURL(previewUrl); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const displayUrl = previewUrl ?? profileImageUrl; diff --git a/src/hooks/mutations/mypage/useUpdateProfileMutation.ts b/src/hooks/mutations/mypage/useUpdateProfileMutation.ts index 1c2e990c..a8e01838 100644 --- a/src/hooks/mutations/mypage/useUpdateProfileMutation.ts +++ b/src/hooks/mutations/mypage/useUpdateProfileMutation.ts @@ -13,13 +13,20 @@ interface UpdateProfileParams { async function uploadProfileImage(file: File) { const res = await fileApi.getPresignedUrls('CLUB_MEMBER_PROFILE', [file.name]); const presigned = res.data.data[0]; + if (!presigned) { + throw new Error('Presigned URL을 받지 못했습니다.'); + } - await fetch(presigned.putUrl, { + const uploadRes = await fetch(presigned.putUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type }, }); + if (!uploadRes.ok) { + throw new Error('프로필 이미지 업로드에 실패했습니다.'); + } + return { fileName: file.name, storageKey: presigned.storageKey, @@ -36,7 +43,9 @@ export function useUpdateProfileMutation() { mutationFn: async ({ user, clubProfile, profileImageFile }: UpdateProfileParams) => { await mypageApi.updateUser(user); - const profileImage = profileImageFile ? await uploadProfileImage(profileImageFile) : undefined; + const profileImage = profileImageFile + ? await uploadProfileImage(profileImageFile) + : undefined; await mypageApi.updateClubProfile({ bio: clubProfile.bio, diff --git a/src/types/mypage.ts b/src/types/mypage.ts index c8f6acda..6d9c822d 100644 --- a/src/types/mypage.ts +++ b/src/types/mypage.ts @@ -6,15 +6,15 @@ export interface MyMember { clubMemberId: number; name: string; email: string; - tel: string; + tel: string | null; school: string; department: string; studentId: string; cardinals: number[]; memberRole: MemberRole; memberStatus: MemberStatus; - profileImageUrl: string; - bio: string; + profileImageUrl: string | null; + bio: string | null; } export interface ClubDto { From 71f3bc146b3813bb0fea7af32a9c6721af477efd Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Apr 2026 22:25:25 +0900 Subject: [PATCH 034/684] =?UTF-8?q?fix:=20=ED=81=B4=EB=9F=BD=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EB=94=94=20=EB=B9=8C=EB=93=9C=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mypage/edit/EditProfileContent.tsx | 2 +- .../admin/useAdminMemberMutations.ts | 26 ++++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/components/mypage/edit/EditProfileContent.tsx b/src/components/mypage/edit/EditProfileContent.tsx index 7785e247..234d1c7b 100644 --- a/src/components/mypage/edit/EditProfileContent.tsx +++ b/src/components/mypage/edit/EditProfileContent.tsx @@ -150,7 +150,7 @@ function EditProfileContent({ className, ...props }: EditProfileContentProps) {
diff --git a/src/hooks/mutations/admin/useAdminMemberMutations.ts b/src/hooks/mutations/admin/useAdminMemberMutations.ts index b8a0d3e5..32c28ee1 100644 --- a/src/hooks/mutations/admin/useAdminMemberMutations.ts +++ b/src/hooks/mutations/admin/useAdminMemberMutations.ts @@ -1,8 +1,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { CLUB_ID } from '@/hooks/queries/admin/useAdminMemberQueries'; import { adminMemberApi } from '@/lib/apis'; import type { ClubMemberRole, Member } from '@/types/admin/member'; +import { useClubId } from '@/stores'; const ROLE_LABEL: Record = { USER: '사용자', @@ -13,7 +13,8 @@ const ROLE_LABEL: Record = { // 멤버 권한 변경 export function useChangeMemberRole() { const queryClient = useQueryClient(); - const queryKey = ['admin', 'members', CLUB_ID]; + const clubId = useClubId(); + const queryKey = ['admin', 'members', clubId]; return useMutation({ mutationFn: ({ @@ -22,7 +23,10 @@ export function useChangeMemberRole() { }: { clubMemberId: number; memberRole: ClubMemberRole; - }) => adminMemberApi.updateMemberRole(CLUB_ID, clubMemberId, memberRole), + }) => { + if (!clubId) throw new Error('clubId가 없습니다'); + return adminMemberApi.updateMemberRole(clubId, clubMemberId, memberRole); + }, onMutate: async ({ clubMemberId, memberRole }) => { await queryClient.cancelQueries({ queryKey }); const previous = queryClient.getQueryData(queryKey); @@ -51,10 +55,14 @@ export function useChangeMemberRole() { // 멤버 추방 export function useBanMember() { const queryClient = useQueryClient(); - const queryKey = ['admin', 'members', CLUB_ID]; + const clubId = useClubId(); + const queryKey = ['admin', 'members', clubId]; return useMutation({ - mutationFn: (clubMemberId: number) => adminMemberApi.banMember(CLUB_ID, clubMemberId), + mutationFn: (clubMemberId: number) => { + if (!clubId) throw new Error('clubId가 없습니다'); + return adminMemberApi.banMember(clubId, clubMemberId); + }, onMutate: async (clubMemberId) => { await queryClient.cancelQueries({ queryKey }); const previous = queryClient.getQueryData(queryKey); @@ -79,10 +87,14 @@ export function useBanMember() { // 추방 유저 복구 export function useRestoreMember() { const queryClient = useQueryClient(); - const queryKey = ['admin', 'members', CLUB_ID]; + const clubId = useClubId(); + const queryKey = ['admin', 'members', clubId]; return useMutation({ - mutationFn: (clubMemberId: number) => adminMemberApi.restoreMember(CLUB_ID, clubMemberId), + mutationFn: (clubMemberId: number) => { + if (!clubId) throw new Error('clubId가 없습니다'); + return adminMemberApi.restoreMember(clubId, clubMemberId); + }, onMutate: async (clubMemberId) => { await queryClient.cancelQueries({ queryKey }); const previous = queryClient.getQueryData(queryKey); From dbe025d5ba51d3ae883f0de3ccd86a4e839ac33a Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Apr 2026 22:37:14 +0900 Subject: [PATCH 035/684] =?UTF-8?q?fix:=20=EB=B9=8C=EB=93=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pnpm-lock.yaml | 36 +++++++++++++++++++ .../admin/member/MemberPageContent.tsx | 9 +++-- .../admin/member/modal/MemberDetailModal.tsx | 14 ++++++-- src/components/mypage/MyPageContent.tsx | 6 +++- .../admin/useAdminCardinalMutations.ts | 15 +++++--- src/lib/apis/mypage.ts | 6 ++-- 6 files changed, 72 insertions(+), 14 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce735fe2..29ac5f02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -741,89 +741,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -997,24 +1013,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.6': resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.6': resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.6': resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.6': resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} @@ -1912,24 +1932,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -2377,41 +2401,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -3782,24 +3814,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index b112f9ab..ec2f73d4 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -16,7 +16,12 @@ import { useDragScroll } from '@/hooks'; import type { Member } from '@/types/admin/member'; import { useAdminMembers } from '@/hooks/queries/admin'; import { useCardinals } from '@/hooks/queries'; -import { useBanMember, useChangeMemberRole, useCreateCardinal, useRestoreMember } from '@/hooks/mutations/admin'; +import { + useBanMember, + useChangeMemberRole, + useCreateCardinal, + useRestoreMember, +} from '@/hooks/mutations/admin'; function MemberPageContent() { const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -102,7 +107,7 @@ function MemberPageContent() { key={c.id} variant={c.status === 'IN_PROGRESS' ? 'active' : 'normal'} title={`${c.cardinalNumber}기`} - subtitle={`${c.year}년 ${c.semester}학기${c.status === 'IN_PROGRESS' ? ' (현재)' : ''}`} + subtitle={c.status === 'IN_PROGRESS' ? '현재' : undefined} /> ))}
diff --git a/src/components/admin/member/modal/MemberDetailModal.tsx b/src/components/admin/member/modal/MemberDetailModal.tsx index 6dbc0923..d516ce9e 100644 --- a/src/components/admin/member/modal/MemberDetailModal.tsx +++ b/src/components/admin/member/modal/MemberDetailModal.tsx @@ -65,7 +65,15 @@ function MemberDetailModal({ const personalInfo = getPersonalInfo(member); const activityInfo = getActivityInfo(member); const activityStats = getActivityStats(member); - const footerActions = getFooterActions({ memberRole: member.memberRole, status: member.status, onApprove, onChangeRole, onResetPassword, onBan, onRestore }); + const footerActions = getFooterActions({ + memberRole: member.memberRole, + status: member.status, + onApprove, + onChangeRole, + onResetPassword, + onBan, + onRestore, + }); return ( <> @@ -95,7 +103,9 @@ function MemberDetailModal({
{member.name} - {parseInt(member.generation, 10)}기 + + {parseInt(member.generation, 10)}기 +
diff --git a/src/components/mypage/MyPageContent.tsx b/src/components/mypage/MyPageContent.tsx index e0f3e839..3f4e215b 100644 --- a/src/components/mypage/MyPageContent.tsx +++ b/src/components/mypage/MyPageContent.tsx @@ -62,7 +62,11 @@ function MyPageContent({ className, ...props }: MyPageContentProps) { {/* Main Content */}
{/* 프로필 */} - + {/* 개인정보 */} diff --git a/src/hooks/mutations/admin/useAdminCardinalMutations.ts b/src/hooks/mutations/admin/useAdminCardinalMutations.ts index ead8ceae..ef486df7 100644 --- a/src/hooks/mutations/admin/useAdminCardinalMutations.ts +++ b/src/hooks/mutations/admin/useAdminCardinalMutations.ts @@ -1,16 +1,21 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { CLUB_ID } from '@/hooks/queries/admin/useAdminMemberQueries'; -import { cardinalApi } from '@/lib/apis'; +import { cardinalApi } from '@/lib/apis/cardinal'; +import { useClubId } from '@/stores'; export function useCreateCardinal() { + const clubId = useClubId(); const queryClient = useQueryClient(); return useMutation({ - mutationFn: (body: { cardinalNumber: number; year: number; semester: number; inProgress: boolean }) => - cardinalApi.createCardinal(CLUB_ID, body), + mutationFn: (body: { + cardinalNumber: number; + year: number; + semester: number; + inProgress: boolean; + }) => cardinalApi.createCardinal(clubId!, body), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['cardinals', CLUB_ID] }); + queryClient.invalidateQueries({ queryKey: ['cardinals', clubId] }); }, }); } diff --git a/src/lib/apis/mypage.ts b/src/lib/apis/mypage.ts index 2cfe9385..6c1537ce 100644 --- a/src/lib/apis/mypage.ts +++ b/src/lib/apis/mypage.ts @@ -22,12 +22,10 @@ export interface UpdateClubProfileBody { } export const mypageApi = { - getMe: (clubId: string) => - apiClient.get>(`/clubs/${clubId}/members/me`), + getMe: (clubId: string) => apiClient.get>(`/clubs/${clubId}/members/me`), getMyClubs: () => apiClient.get>('/clubs'), updateUser: (body: UpdateUserBody) => apiClient.patch('/users', body), - updateClubProfile: (body: UpdateClubProfileBody) => - apiClient.patch('/clubs/members/me', body), + updateClubProfile: (body: UpdateClubProfileBody) => apiClient.patch('/clubs/members/me', body), initCardinals: (clubId: string, cardinals: number[]) => apiClient.post(`/clubs/${clubId}/members/me/cardinals`, { cardinals }), }; From 053d6db38d3963558e9679de8333b96971b36a5e Mon Sep 17 00:00:00 2001 From: JIN921 Date: Mon, 6 Apr 2026 22:46:26 +0900 Subject: [PATCH 036/684] =?UTF-8?q?fix:=20barrel=20import=EC=97=90?= =?UTF-8?q?=EC=84=9C=20apiServer=20=EB=88=84=EC=B6=9C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useAdminMemberMutations에서 @/lib/apis barrel import 대신 @/lib/apis/adminMember 직접 import로 변경하여 클라이언트 번들에 next/headers가 포함되는 문제 해결 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/mutations/admin/useAdminMemberMutations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/mutations/admin/useAdminMemberMutations.ts b/src/hooks/mutations/admin/useAdminMemberMutations.ts index 32c28ee1..504bfa90 100644 --- a/src/hooks/mutations/admin/useAdminMemberMutations.ts +++ b/src/hooks/mutations/admin/useAdminMemberMutations.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { adminMemberApi } from '@/lib/apis'; +import { adminMemberApi } from '@/lib/apis/adminMember'; import type { ClubMemberRole, Member } from '@/types/admin/member'; import { useClubId } from '@/stores'; From 8c7527e04d312376c3ad33f8f0b4a4fc76817287 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Tue, 7 Apr 2026 16:47:52 +0900 Subject: [PATCH 037/684] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(main)/attendance/history/page.tsx | 68 +++++-------------- src/app/(private)/(main)/attendance/page.tsx | 2 +- .../attendance/AttendanceContent.tsx | 1 + .../attendance/AttendanceTodayCard.tsx | 4 +- src/lib/apis/attendance.server.ts | 14 ++++ src/lib/apis/attendance.ts | 11 +-- 6 files changed, 40 insertions(+), 60 deletions(-) create mode 100644 src/lib/apis/attendance.server.ts diff --git a/src/app/(private)/(main)/attendance/history/page.tsx b/src/app/(private)/(main)/attendance/history/page.tsx index 70903737..7f04d77a 100644 --- a/src/app/(private)/(main)/attendance/history/page.tsx +++ b/src/app/(private)/(main)/attendance/history/page.tsx @@ -1,55 +1,23 @@ import { AttendanceHistoryContent } from '@/components/attendance'; +import { attendanceServerApi } from '@/lib/apis/attendance.server'; import type { AttendanceSummary } from '@/types/attendance'; -// TODO: API 연동 시 실제 데이터로 교체 -const mockSummary: AttendanceSummary = { - total: 5, - attendanceCount: 3, - absenceCount: 2, - attendances: [ - { - id: 5, - status: 'ABSENT', - title: '5주차 정기모임', - start: '2026-02-23T10:00:00.000Z', - end: '2026-02-23T12:00:00.000Z', - location: '공학관 401호', - }, - { - id: 4, - status: 'ATTEND', - title: '4주차 정기모임', - start: '2026-02-16T10:00:00.000Z', - end: '2026-02-16T12:00:00.000Z', - location: '공학관 401호', - }, - { - id: 3, - status: 'ABSENT', - title: '3주차 정기모임', - start: '2026-02-09T10:00:00.000Z', - end: '2026-02-09T12:00:00.000Z', - location: '공학관 401호', - }, - { - id: 2, - status: 'ATTEND', - title: '2주차 정기모임', - start: '2026-02-02T10:00:00.000Z', - end: '2026-02-02T12:00:00.000Z', - location: '공학관 401호', - }, - { - id: 1, - status: 'ATTEND', - title: '1주차 정기모임', - start: '2026-01-26T10:00:00.000Z', - end: '2026-01-26T12:00:00.000Z', - location: '공학관 401호', - }, - ], -}; +export default async function AttendanceHistoryPage() { + // TODO: 하드코딩된 clubId 추후 동적으로 변경 + let summary: AttendanceSummary | undefined; -export default function AttendanceHistoryPage() { - return ; + try { + const response = await attendanceServerApi.getDetail('YUNJcjFKMO'); + summary = response.data; + } catch { + // 에러 시 빈 상태로 렌더링 + } + + return ( + + ); } diff --git a/src/app/(private)/(main)/attendance/page.tsx b/src/app/(private)/(main)/attendance/page.tsx index f0767b1d..ae360606 100644 --- a/src/app/(private)/(main)/attendance/page.tsx +++ b/src/app/(private)/(main)/attendance/page.tsx @@ -1,5 +1,5 @@ import { AttendanceContent } from '@/components/attendance'; -import { attendanceServerApi } from '@/lib/apis/attendance'; +import { attendanceServerApi } from '@/lib/apis/attendance.server'; import type { AttendanceData } from '@/types/attendance'; export default async function AttendancePage() { diff --git a/src/components/attendance/AttendanceContent.tsx b/src/components/attendance/AttendanceContent.tsx index fb24888a..5b8a8b7f 100644 --- a/src/components/attendance/AttendanceContent.tsx +++ b/src/components/attendance/AttendanceContent.tsx @@ -72,6 +72,7 @@ function AttendanceContent({ attendance, errorMessage, isAdmin = false }: Attend start={start ?? ''} endTime={end ?? ''} location={location ?? ''} + sessionId={sessionId} isAdmin={isAdmin} isChecked={isChecked} onAttendanceComplete={handleAttendanceComplete} diff --git a/src/components/attendance/AttendanceTodayCard.tsx b/src/components/attendance/AttendanceTodayCard.tsx index 84e33614..3a9fe0ce 100644 --- a/src/components/attendance/AttendanceTodayCard.tsx +++ b/src/components/attendance/AttendanceTodayCard.tsx @@ -17,6 +17,7 @@ interface AttendanceTodayCardProps { start: string; endTime: string; location: string; + sessionId?: number | null; isAdmin?: boolean; isChecked?: boolean; onAttendanceComplete?: (code: string) => void; @@ -41,6 +42,7 @@ function AttendanceTodayCard({ start, endTime, location, + sessionId, isAdmin = false, isChecked = false, onAttendanceComplete, @@ -66,7 +68,7 @@ function AttendanceTodayCard({ primaryButtonText={isChecked ? '출석 완료' : '출석하기'} onSecondaryClick={ isAdmin - ? () => router.push('/attendance/qr') + ? () => router.push(`/attendance/qr?sessionId=${sessionId}`) : () => toastError('관리자만 사용할 수 있는 기능입니다.') } secondaryButtonText="출석코드 확인" diff --git a/src/lib/apis/attendance.server.ts b/src/lib/apis/attendance.server.ts new file mode 100644 index 00000000..a5175deb --- /dev/null +++ b/src/lib/apis/attendance.server.ts @@ -0,0 +1,14 @@ +import { apiServer } from '@/lib/apis/server'; +import type { AttendanceResponse, AttendanceSummaryResponse } from '@/types/attendance'; + +export const attendanceServerApi = { + getAttendance: (clubId: string) => + apiServer.get(`/clubs/${clubId}/attendances`, { + cache: 'no-store', + }), + + getDetail: (clubId: string) => + apiServer.get(`/clubs/${clubId}/attendances/detail`, { + cache: 'no-store', + }), +}; diff --git a/src/lib/apis/attendance.ts b/src/lib/apis/attendance.ts index 41e95e93..6d7346ac 100644 --- a/src/lib/apis/attendance.ts +++ b/src/lib/apis/attendance.ts @@ -1,6 +1,5 @@ import { apiClient } from '@/lib/apis/client'; -import { apiServer } from '@/lib/apis/server'; -import type { AttendanceResponse } from '@/types/attendance'; +import type { AttendanceResponse, QRCodeResponse } from '@/types/attendance'; export const attendanceApi = { getAttendance: (clubId: string) => @@ -8,11 +7,7 @@ export const attendanceApi = { checkIn: (clubId: string, sessionId: number, code: number) => apiClient.post(`/clubs/${clubId}/attendances/check-in`, { sessionId, code }), -}; -export const attendanceServerApi = { - getAttendance: (clubId: string) => - apiServer.get(`/clubs/${clubId}/attendances`, { - cache: 'no-store', - }), + generateQR: (clubId: string, sessionId: number) => + apiClient.post(`/admin/clubs/${clubId}/attendances/${sessionId}/qr`), }; From cbb2014bd4b36fd5df83188962ac9b5ff1b18efd Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Tue, 7 Apr 2026 16:48:21 +0900 Subject: [PATCH 038/684] =?UTF-8?q?feat:=20qr=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 52 ++++++++++++++ .../(private)/(main)/attendance/qr/page.tsx | 25 ++++--- .../attendance/AttendanceQRContent.tsx | 68 ++++++++++++++----- src/hooks/useQRCode.ts | 18 +++++ src/types/attendance.ts | 10 +++ 6 files changed, 143 insertions(+), 31 deletions(-) create mode 100644 src/hooks/useQRCode.ts diff --git a/package.json b/package.json index 33aca7e7..b82e9641 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "lucide-react": "^0.468.0", "next": "16.1.6", "posthog-js": "^1.364.4", + "qr-code-styling": "^1.9.2", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce735fe2..04c428a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: posthog-js: specifier: ^1.364.4 version: 1.364.4 + qr-code-styling: + specifier: ^1.9.2 + version: 1.9.2 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -741,89 +744,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -997,24 +1016,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.6': resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.6': resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.6': resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.6': resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} @@ -1912,24 +1935,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -2377,41 +2404,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -3782,24 +3817,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -4307,6 +4346,13 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + qr-code-styling@1.9.2: + resolution: {integrity: sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==} + engines: {node: '>=18.18.0'} + + qrcode-generator@1.5.2: + resolution: {integrity: sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==} + query-selector-shadow-dom@1.0.1: resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} @@ -9466,6 +9512,12 @@ snapshots: pure-rand@7.0.1: {} + qr-code-styling@1.9.2: + dependencies: + qrcode-generator: 1.5.2 + + qrcode-generator@1.5.2: {} + query-selector-shadow-dom@1.0.1: {} queue-microtask@1.2.3: {} diff --git a/src/app/(private)/(main)/attendance/qr/page.tsx b/src/app/(private)/(main)/attendance/qr/page.tsx index 6c916fcd..c4a3803f 100644 --- a/src/app/(private)/(main)/attendance/qr/page.tsx +++ b/src/app/(private)/(main)/attendance/qr/page.tsx @@ -1,18 +1,17 @@ -import { AttendanceQRContent } from '@/components/attendance'; +import { redirect } from 'next/navigation'; -// TODO: API 연동 시 실제 데이터로 교체 -function createMockQRData() { - const now = new Date(); - const end = new Date(now.getTime() + 10 * 60 * 1000); // 10분 후 +import { AttendanceQRContent } from '@/components/attendance'; - return { - title: '1주차 정기모임', - code: '123456', - endTime: end.toISOString(), - }; +interface AttendanceQRPageProps { + searchParams: Promise<{ sessionId?: string }>; } -export default function AttendanceQRPage() { - const { title, code, endTime } = createMockQRData(); - return ; +export default async function AttendanceQRPage({ searchParams }: AttendanceQRPageProps) { + const { sessionId } = await searchParams; + + if (!sessionId) { + redirect('/attendance'); + } + + return ; } diff --git a/src/components/attendance/AttendanceQRContent.tsx b/src/components/attendance/AttendanceQRContent.tsx index 43789e8f..b4b7c897 100644 --- a/src/components/attendance/AttendanceQRContent.tsx +++ b/src/components/attendance/AttendanceQRContent.tsx @@ -1,9 +1,9 @@ 'use client'; -import Image from 'next/image'; +import { useEffect, useRef } from 'react'; import Link from 'next/link'; +import QRCodeStyling from 'qr-code-styling'; -import { AttendanceQRIcon } from '@/assets/icons'; import { Breadcrumb, BreadcrumbList, @@ -13,15 +13,37 @@ import { BreadcrumbSeparator, } from '@/components/ui'; import { useRemainingTime } from '@/hooks/useRemainingTime'; +import { useQRCode } from '@/hooks/useQRCode'; +import { useClubId } from '@/stores/useClubStore'; interface AttendanceQRContentProps { - title: string; - code: string; - endTime: string; + sessionId: number; } -function AttendanceQRContent({ title, code, endTime }: AttendanceQRContentProps) { - const { minutes, seconds, isExpired } = useRemainingTime(endTime); +function AttendanceQRContent({ sessionId }: AttendanceQRContentProps) { + const clubId = useClubId(); + const { data: qrData, isLoading } = useQRCode(clubId, sessionId); + const qrRef = useRef(null); + const qrCodeRef = useRef(null); + const { minutes, seconds, isExpired } = useRemainingTime(qrData?.expiredAt ?? ''); + + useEffect(() => { + if (!qrData || !qrRef.current) return; + + const checkInUrl = `${window.location.origin}/attendance?sessionId=${qrData.sessionId}&code=${qrData.code}`; + + if (!qrCodeRef.current) { + qrCodeRef.current = new QRCodeStyling({ + width: 256, + height: 256, + data: checkInUrl, + type: 'svg', + }); + qrCodeRef.current.append(qrRef.current); + } else { + qrCodeRef.current.update({ data: checkInUrl }); + } + }, [qrData]); return (
@@ -55,19 +77,29 @@ function AttendanceQRContent({ title, code, endTime }: AttendanceQRContentProps)
- QR 코드 - -
-
- 출석 가능 시간 - - {isExpired ? '마감' : `${minutes}:${seconds}`} - + {isLoading ? ( +
+

QR 코드 생성 중...

-

QR코드는 모바일만 제공하고 있어요.

-
+ ) : ( + <> +
+ +
+
+ 출석 가능 시간 + + {isExpired ? '마감' : `${minutes}:${seconds}`} + +
+

+ QR코드는 모바일만 제공하고 있어요. +

+
-

{code}

+

{qrData?.code}

+ + )}
diff --git a/src/hooks/useQRCode.ts b/src/hooks/useQRCode.ts new file mode 100644 index 00000000..c221b8cb --- /dev/null +++ b/src/hooks/useQRCode.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; + +import { attendanceApi } from '@/lib/apis/attendance'; + +function useQRCode(clubId: string | null, sessionId: number) { + return useQuery({ + queryKey: ['attendance', 'qr', sessionId], + queryFn: async () => { + const response = await attendanceApi.generateQR(clubId!, sessionId); + return response.data.data; + }, + enabled: !!clubId, + staleTime: 10 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); +} + +export { useQRCode }; diff --git a/src/types/attendance.ts b/src/types/attendance.ts index 47bcf3fa..a6423b6b 100644 --- a/src/types/attendance.ts +++ b/src/types/attendance.ts @@ -33,6 +33,14 @@ interface AttendanceSummary { type AttendanceSummaryResponse = ApiResponse; +interface QRCodeData { + sessionId: number; + code: number; + expiredAt: string; +} + +type QRCodeResponse = ApiResponse; + export type { AttendanceStatus, AttendanceData, @@ -40,4 +48,6 @@ export type { AttendanceRecord, AttendanceSummary, AttendanceSummaryResponse, + QRCodeData, + QRCodeResponse, }; From 2b9a5b0b3862a87222f098d0fe743d13fcdcafac Mon Sep 17 00:00:00 2001 From: JIN921 Date: Tue, 7 Apr 2026 23:00:07 +0900 Subject: [PATCH 039/684] =?UTF-8?q?feat:=20=EC=95=84=EC=9D=B4=EC=BD=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- src/assets/icons/admin/ic_admin_fileout.svg | 4 + src/assets/icons/admin/ic_admin_forum.svg | 3 + src/assets/icons/admin/ic_admin_light.svg | 3 + src/assets/icons/admin/ic_admin_setting.svg | 3 + src/assets/icons/admin/index.ts | 4 + src/assets/icons/index.ts | 2 + src/assets/icons/nav_toggle.svg | 5 + src/components/admin/layout/LNB.tsx | 243 ++++++++++-------- .../admin/layout/ThemeModeSelector.tsx | 79 ++++++ src/proxy.ts | 2 +- 11 files changed, 246 insertions(+), 105 deletions(-) create mode 100644 src/assets/icons/admin/ic_admin_fileout.svg create mode 100644 src/assets/icons/admin/ic_admin_forum.svg create mode 100644 src/assets/icons/admin/ic_admin_light.svg create mode 100644 src/assets/icons/admin/ic_admin_setting.svg create mode 100644 src/assets/icons/nav_toggle.svg create mode 100644 src/components/admin/layout/ThemeModeSelector.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bb5a248c..b84ef7f9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,8 @@ "Bash(ls -la \"D:\\\\project\\\\weeth-client\\\\src\\\\app\\\\\\(private\\)\\\\\\(main\\)\\\\mypage\\\\edit\")", "Bash(find D:projectweeth-clientsrccomponentsui -type f \\\\\\(-name *.tsx -o -name *.ts \\\\\\))", "Bash(find D:/project/weeth-client/src/constants -name *.ts)", - "Bash(grep -r \"POSTHOG\\\\|PostHog\" D:projectweeth-client/.env*)" + "Bash(grep -r \"POSTHOG\\\\|PostHog\" D:projectweeth-client/.env*)", + "Bash(find D:/project/weeth-client -type f -name *.css -o -name tailwind.config.* -o -name global.css)" ] } } diff --git a/src/assets/icons/admin/ic_admin_fileout.svg b/src/assets/icons/admin/ic_admin_fileout.svg new file mode 100644 index 00000000..907e2a35 --- /dev/null +++ b/src/assets/icons/admin/ic_admin_fileout.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/admin/ic_admin_forum.svg b/src/assets/icons/admin/ic_admin_forum.svg new file mode 100644 index 00000000..4f5862f3 --- /dev/null +++ b/src/assets/icons/admin/ic_admin_forum.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/admin/ic_admin_light.svg b/src/assets/icons/admin/ic_admin_light.svg new file mode 100644 index 00000000..ac4bb88a --- /dev/null +++ b/src/assets/icons/admin/ic_admin_light.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/admin/ic_admin_setting.svg b/src/assets/icons/admin/ic_admin_setting.svg new file mode 100644 index 00000000..0e89b250 --- /dev/null +++ b/src/assets/icons/admin/ic_admin_setting.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/admin/index.ts b/src/assets/icons/admin/index.ts index e7df19ee..6ebc8eea 100644 --- a/src/assets/icons/admin/index.ts +++ b/src/assets/icons/admin/index.ts @@ -9,3 +9,7 @@ export { default as AdminCheckboxIcon } from './ic_admin_checkbox.svg'; export { default as AdminUncheckboxIcon } from './ic_admin_uncheckbox.svg'; export { default as AdminUserIcon } from './ic_admin_user.svg'; export { default as AdminCloseIcon } from './ic_admin_close.svg'; +export { default as AdminSettinIcon } from './ic_admin_setting.svg'; +export { default as AdminFileoutIcon } from './ic_admin_fileout.svg'; +export { default as AdminLightIcon } from './ic_admin_light.svg'; +export { default as AdminForumIcon } from './ic_admin_forum.svg'; diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index e5df5a6c..0edac0be 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -48,3 +48,5 @@ export { default as TooltipIcon } from './tooltip.svg'; export { default as CopyIcon } from './copy.svg'; export { default as BasicAvatarIcon } from './basic_avatar.svg'; export { default as QuestionMarkIcon } from './question_mark.svg'; + +export { default as NavToggleIcon } from './nav_toggle.svg'; diff --git a/src/assets/icons/nav_toggle.svg b/src/assets/icons/nav_toggle.svg new file mode 100644 index 00000000..80e54fef --- /dev/null +++ b/src/assets/icons/nav_toggle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/admin/layout/LNB.tsx b/src/components/admin/layout/LNB.tsx index ce247be4..451d6fd7 100644 --- a/src/components/admin/layout/LNB.tsx +++ b/src/components/admin/layout/LNB.tsx @@ -1,136 +1,173 @@ 'use client'; import Link from 'next/link'; -import type { StaticImageData } from 'next/image'; import { usePathname } from 'next/navigation'; - -import logoIcon from '@/assets/icons/logo/logo_initial_Origin.svg'; -import userIcon from '@/assets/icons/admin/ic_admin_user.svg'; -import checkIcon from '@/assets/icons/admin/ic_admin_attendance.svg'; -//import penaltyIcon from '@/assets/icons/admin/ic_admin_penalty.svg'; -// import dueIcon from '@/assets/icons/admin/ic_admin_due.svg'; -import arrowIcon from '@/assets/icons/admin/ic_admin_service_transfer.svg'; -import manualIcon from '@/assets/icons/admin/ic_admin_manual.svg'; +import { + Calendar, + CheckCircle, + CircleUserRound, + ExternalLink, + FileText, + Info, + MessageSquare, + Users, +} from 'lucide-react'; import { cn } from '@/lib/cn'; +import { ThemeModeSelector } from '@/components/admin/layout/ThemeModeSelector'; + +const managementNavItems = [ + { id: 'member', icon: Users, label: '멤버 관리', path: '/admin/member' }, + { id: 'schedule', icon: Calendar, label: '일정 관리', path: '/admin/schedule' }, + { id: 'attendance', icon: CheckCircle, label: '출석 관리', path: '/admin/attendance' }, + { id: 'board', icon: MessageSquare, label: '게시판 관리', path: '/admin/board' }, +]; -const mainNavItems = [ - { id: 'member', icon: userIcon, label: '멤버 관리', path: '/admin/member' }, - { id: 'attendance', icon: checkIcon, label: '출석 관리', path: '/admin/attendance' }, - // { id: 'penalty', icon: penaltyIcon, label: '페널티 관리', path: '/admin/penalty' }, - // { id: 'dues', icon: dueIcon, label: '회비 관리', path: '/admin/dues' }, +const infoNavItems = [ + { id: 'club-info', icon: Info, label: '동아리 정보', path: '/admin/club-info' }, ]; const moveNavItems = [ - { id: 'service', icon: arrowIcon, label: '서비스로 이동', path: 'https://weeth.kr' }, + { id: 'service', icon: ExternalLink, label: 'Weeth로 이동', path: 'https://weeth.kr' }, { id: 'manual', - icon: manualIcon, + icon: FileText, label: '관리자 매뉴얼', path: 'https://weeth-develop-2.s3.ap-northeast-2.amazonaws.com/Weeth_%E1%84%80%E1%85%AA%E1%86%AB%E1%84%85%E1%85%B5%E1%84%8C%E1%85%A1_%E1%84%86%E1%85%A6%E1%84%82%E1%85%B2%E1%84%8B%E1%85%A5%E1%86%AF_v3.pdf', }, ]; -function NavIcon({ src, isActive }: { src: StaticImageData | string; isActive: boolean }) { - const url = typeof src === 'string' ? src : (src as StaticImageData).src; +const navItemClass = + 'typo-sub1 flex h-12 items-center gap-300 px-300 transition-colors text-text-alternative hover:bg-container-neutral-interaction'; + +interface NavSectionProps { + label?: string; + children: React.ReactNode; +} + +function NavSection({ label, children }: NavSectionProps) { return ( - +
+ {label && ( + {label} + )} + {children} +
); } -const navItemClass = - 'typo-sub1 flex h-12 items-center gap-300 px-300 transition-colors text-text-alternative hover:bg-container-neutral-interaction'; +interface InternalNavItemProps { + icon: React.ElementType; + label: string; + path: string; + isActive: boolean; +} + +function InternalNavItem({ icon: Icon, label, path, isActive }: InternalNavItemProps) { + return ( + + + {label} + + ); +} + +interface ExternalNavItemProps { + icon: React.ElementType; + label: string; + path: string; + openInWindow?: boolean; +} + +function ExternalNavItem({ icon: Icon, label, path, openInWindow }: ExternalNavItemProps) { + if (openInWindow) { + return ( + + ); + } + + return ( + + + {label} + + ); +} -export function LNB() { +function LNB() { const pathname = usePathname(); return ( ); } + +export { LNB }; diff --git a/src/components/admin/layout/ThemeModeSelector.tsx b/src/components/admin/layout/ThemeModeSelector.tsx new file mode 100644 index 00000000..56e35a17 --- /dev/null +++ b/src/components/admin/layout/ThemeModeSelector.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useState } from 'react'; +import { ChevronDown, Moon, Sun, SunMoon } from 'lucide-react'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui'; +import { cn } from '@/lib/cn'; +import { useThemeStore } from '@/stores/theme-store'; + +type ThemeMode = 'auto' | 'light' | 'dark'; + +const THEME_OPTIONS: { value: ThemeMode; label: string; icon: typeof Sun }[] = [ + { value: 'auto', label: '자동', icon: SunMoon }, + { value: 'light', label: '라이트', icon: Sun }, + { value: 'dark', label: '다크', icon: Moon }, +]; + +const TRIGGER_LABELS: Record = { + auto: '자동 모드', + light: '라이트 모드', + dark: '다크 모드', +}; + +function ThemeModeSelector() { + const setDark = useThemeStore((state) => state.setDark); + + const [mode, setMode] = useState(() => { + if (typeof window === 'undefined') return 'light'; + return useThemeStore.getState().isDark ? 'dark' : 'light'; + }); + + const handleSelect = (value: ThemeMode) => { + setMode(value); + + if (value === 'light') { + setDark(false); + } else if (value === 'dark') { + setDark(true); + } else { + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + setDark(prefersDark); + } + }; + + const currentOption = THEME_OPTIONS.find((o) => o.value === mode)!; + const TriggerIcon = currentOption.icon; + + return ( + + + + + + + {THEME_OPTIONS.map(({ value, label }) => ( + handleSelect(value)}> + {label} + + ))} + + + ); +} + +export { ThemeModeSelector }; diff --git a/src/proxy.ts b/src/proxy.ts index 52079d84..337e66ea 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -4,7 +4,7 @@ import { ACCESS_TOKEN_KEY } from '@/lib/apis/cookies'; const PUBLIC_PATHS = ['/', '/login', '/terms', '/landing']; // TODO: 런칭 후 PRE_LAUNCH 플래그 및 관련 분기 제거 -const PRE_LAUNCH = true; +const PRE_LAUNCH = false; export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; From 6abbc54c2c6d9976f0904a842eefe79285183e5a Mon Sep 17 00:00:00 2001 From: JIN921 Date: Tue, 7 Apr 2026 23:08:25 +0900 Subject: [PATCH 040/684] =?UTF-8?q?fix:=20=ED=94=BC=EA=B7=B8=EB=A7=88=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/icons/admin/ic_admin_calendar.svg | 3 ++ src/assets/icons/admin/index.ts | 3 +- src/components/admin/layout/LNB.tsx | 39 +++++++++----------- 3 files changed, 23 insertions(+), 22 deletions(-) create mode 100644 src/assets/icons/admin/ic_admin_calendar.svg diff --git a/src/assets/icons/admin/ic_admin_calendar.svg b/src/assets/icons/admin/ic_admin_calendar.svg new file mode 100644 index 00000000..4962aac5 --- /dev/null +++ b/src/assets/icons/admin/ic_admin_calendar.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/admin/index.ts b/src/assets/icons/admin/index.ts index 6ebc8eea..9a802e24 100644 --- a/src/assets/icons/admin/index.ts +++ b/src/assets/icons/admin/index.ts @@ -9,7 +9,8 @@ export { default as AdminCheckboxIcon } from './ic_admin_checkbox.svg'; export { default as AdminUncheckboxIcon } from './ic_admin_uncheckbox.svg'; export { default as AdminUserIcon } from './ic_admin_user.svg'; export { default as AdminCloseIcon } from './ic_admin_close.svg'; -export { default as AdminSettinIcon } from './ic_admin_setting.svg'; +export { default as AdminSettingIcon } from './ic_admin_setting.svg'; export { default as AdminFileoutIcon } from './ic_admin_fileout.svg'; export { default as AdminLightIcon } from './ic_admin_light.svg'; export { default as AdminForumIcon } from './ic_admin_forum.svg'; +export { default as AdminCalendarIcon } from './ic_admin_calendar.svg'; diff --git a/src/components/admin/layout/LNB.tsx b/src/components/admin/layout/LNB.tsx index 451d6fd7..2bc49b4f 100644 --- a/src/components/admin/layout/LNB.tsx +++ b/src/components/admin/layout/LNB.tsx @@ -3,35 +3,32 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { - Calendar, - CheckCircle, - CircleUserRound, - ExternalLink, - FileText, - Info, - MessageSquare, - Users, -} from 'lucide-react'; + AdminForumIcon, + AdminCalendarIcon, + AdminSettingIcon, + AdminFileoutIcon, +} from '@/assets/icons/admin'; +import { CheckRoundIcon, ExitToAppIcon, NavToggleIcon, PeopleIcon } from '@/assets/icons'; import { cn } from '@/lib/cn'; import { ThemeModeSelector } from '@/components/admin/layout/ThemeModeSelector'; const managementNavItems = [ - { id: 'member', icon: Users, label: '멤버 관리', path: '/admin/member' }, - { id: 'schedule', icon: Calendar, label: '일정 관리', path: '/admin/schedule' }, - { id: 'attendance', icon: CheckCircle, label: '출석 관리', path: '/admin/attendance' }, - { id: 'board', icon: MessageSquare, label: '게시판 관리', path: '/admin/board' }, + { id: 'member', icon: PeopleIcon, label: '멤버 관리', path: '/admin/member' }, + { id: 'schedule', icon: AdminCalendarIcon, label: '일정 관리', path: '/admin/schedule' }, + { id: 'attendance', icon: CheckRoundIcon, label: '출석 관리', path: '/admin/attendance' }, + { id: 'board', icon: AdminForumIcon, label: '게시판 관리', path: '/admin/board' }, ]; const infoNavItems = [ - { id: 'club-info', icon: Info, label: '동아리 정보', path: '/admin/club-info' }, + { id: 'club-info', icon: AdminSettingIcon, label: '동아리 정보', path: '/admin/club-info' }, ]; const moveNavItems = [ - { id: 'service', icon: ExternalLink, label: 'Weeth로 이동', path: 'https://weeth.kr' }, + { id: 'service', icon: ExitToAppIcon, label: 'Weeth로 이동', path: 'https://weeth.kr' }, { id: 'manual', - icon: FileText, + icon: AdminFileoutIcon, label: '관리자 매뉴얼', path: 'https://weeth-develop-2.s3.ap-northeast-2.amazonaws.com/Weeth_%E1%84%80%E1%85%AA%E1%86%AB%E1%84%85%E1%85%B5%E1%84%8C%E1%85%A1_%E1%84%86%E1%85%A6%E1%84%82%E1%85%B2%E1%84%8B%E1%85%A5%E1%86%AF_v3.pdf', }, @@ -69,7 +66,9 @@ function InternalNavItem({ icon: Icon, label, path, isActive }: InternalNavItemP href={path} className={cn(navItemClass, isActive && 'bg-container-neutral-interaction text-text-strong')} > - + {label} ); @@ -110,7 +109,7 @@ function LNB() { From 52794ecb5be8bdec9d7fe19dbe1e855850945eab Mon Sep 17 00:00:00 2001 From: JIN921 Date: Tue, 7 Apr 2026 23:20:42 +0900 Subject: [PATCH 041/684] =?UTF-8?q?fix:=20=EC=95=84=EC=9D=B4=EC=BD=98=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=AC=20=EB=B3=80=EA=B2=BD=20=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../icons/admin/ic_admin_attendance.svg | 6 ++-- src/assets/icons/admin/ic_admin_calendar.svg | 2 +- src/assets/icons/admin/ic_admin_due.svg | 4 +-- src/assets/icons/admin/ic_admin_fileout.svg | 4 +-- src/assets/icons/admin/ic_admin_forum.svg | 2 +- src/assets/icons/admin/ic_admin_light.svg | 2 +- src/assets/icons/admin/ic_admin_manual.svg | 6 ++-- src/assets/icons/admin/ic_admin_penalty.svg | 4 +-- .../icons/admin/ic_admin_service_transfer.svg | 4 +-- src/assets/icons/admin/ic_admin_setting.svg | 2 +- src/assets/icons/admin/ic_admin_user.svg | 4 +-- src/assets/icons/nav_toggle.svg | 6 ++-- src/components/admin/layout/LNB.tsx | 32 +++++++++---------- .../admin/layout/ThemeModeSelector.tsx | 2 +- 14 files changed, 39 insertions(+), 41 deletions(-) diff --git a/src/assets/icons/admin/ic_admin_attendance.svg b/src/assets/icons/admin/ic_admin_attendance.svg index 837e944e..87f99d3b 100644 --- a/src/assets/icons/admin/ic_admin_attendance.svg +++ b/src/assets/icons/admin/ic_admin_attendance.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/src/assets/icons/admin/ic_admin_calendar.svg b/src/assets/icons/admin/ic_admin_calendar.svg index 4962aac5..6af182a3 100644 --- a/src/assets/icons/admin/ic_admin_calendar.svg +++ b/src/assets/icons/admin/ic_admin_calendar.svg @@ -1,3 +1,3 @@ - + diff --git a/src/assets/icons/admin/ic_admin_due.svg b/src/assets/icons/admin/ic_admin_due.svg index 82bf7460..40ac4d2f 100644 --- a/src/assets/icons/admin/ic_admin_due.svg +++ b/src/assets/icons/admin/ic_admin_due.svg @@ -1,3 +1,3 @@ - - + + diff --git a/src/assets/icons/admin/ic_admin_fileout.svg b/src/assets/icons/admin/ic_admin_fileout.svg index 907e2a35..e0ba79bd 100644 --- a/src/assets/icons/admin/ic_admin_fileout.svg +++ b/src/assets/icons/admin/ic_admin_fileout.svg @@ -1,4 +1,4 @@ - - + + diff --git a/src/assets/icons/admin/ic_admin_forum.svg b/src/assets/icons/admin/ic_admin_forum.svg index 4f5862f3..69078333 100644 --- a/src/assets/icons/admin/ic_admin_forum.svg +++ b/src/assets/icons/admin/ic_admin_forum.svg @@ -1,3 +1,3 @@ - + diff --git a/src/assets/icons/admin/ic_admin_light.svg b/src/assets/icons/admin/ic_admin_light.svg index ac4bb88a..0a083e46 100644 --- a/src/assets/icons/admin/ic_admin_light.svg +++ b/src/assets/icons/admin/ic_admin_light.svg @@ -1,3 +1,3 @@ - + diff --git a/src/assets/icons/admin/ic_admin_manual.svg b/src/assets/icons/admin/ic_admin_manual.svg index a02c3432..5e2fd0be 100644 --- a/src/assets/icons/admin/ic_admin_manual.svg +++ b/src/assets/icons/admin/ic_admin_manual.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/src/assets/icons/admin/ic_admin_penalty.svg b/src/assets/icons/admin/ic_admin_penalty.svg index 265f55f3..69ccb9e5 100644 --- a/src/assets/icons/admin/ic_admin_penalty.svg +++ b/src/assets/icons/admin/ic_admin_penalty.svg @@ -1,3 +1,3 @@ - - + + diff --git a/src/assets/icons/admin/ic_admin_service_transfer.svg b/src/assets/icons/admin/ic_admin_service_transfer.svg index 137d4a4f..da7f8b42 100644 --- a/src/assets/icons/admin/ic_admin_service_transfer.svg +++ b/src/assets/icons/admin/ic_admin_service_transfer.svg @@ -1,3 +1,3 @@ - - + + diff --git a/src/assets/icons/admin/ic_admin_setting.svg b/src/assets/icons/admin/ic_admin_setting.svg index 0e89b250..09cd128d 100644 --- a/src/assets/icons/admin/ic_admin_setting.svg +++ b/src/assets/icons/admin/ic_admin_setting.svg @@ -1,3 +1,3 @@ - + diff --git a/src/assets/icons/admin/ic_admin_user.svg b/src/assets/icons/admin/ic_admin_user.svg index 7f44dd9a..2db41f01 100644 --- a/src/assets/icons/admin/ic_admin_user.svg +++ b/src/assets/icons/admin/ic_admin_user.svg @@ -1,3 +1,3 @@ - - + + diff --git a/src/assets/icons/nav_toggle.svg b/src/assets/icons/nav_toggle.svg index 80e54fef..56808d15 100644 --- a/src/assets/icons/nav_toggle.svg +++ b/src/assets/icons/nav_toggle.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/src/components/admin/layout/LNB.tsx b/src/components/admin/layout/LNB.tsx index 2bc49b4f..02dbb411 100644 --- a/src/components/admin/layout/LNB.tsx +++ b/src/components/admin/layout/LNB.tsx @@ -8,8 +8,9 @@ import { AdminSettingIcon, AdminFileoutIcon, } from '@/assets/icons/admin'; -import { CheckRoundIcon, ExitToAppIcon, NavToggleIcon, PeopleIcon } from '@/assets/icons'; +import { CheckRoundIcon, ExitIcon, NavToggleIcon, PeopleIcon } from '@/assets/icons'; +import { Avatar, Icon } from '@/components/ui'; import { cn } from '@/lib/cn'; import { ThemeModeSelector } from '@/components/admin/layout/ThemeModeSelector'; @@ -25,7 +26,7 @@ const infoNavItems = [ ]; const moveNavItems = [ - { id: 'service', icon: ExitToAppIcon, label: 'Weeth로 이동', path: 'https://weeth.kr' }, + { id: 'service', icon: ExitIcon, label: 'Weeth로 이동', path: 'https://weeth.kr' }, { id: 'manual', icon: AdminFileoutIcon, @@ -54,20 +55,22 @@ function NavSection({ label, children }: NavSectionProps) { } interface InternalNavItemProps { - icon: React.ElementType; + icon: typeof PeopleIcon; label: string; path: string; isActive: boolean; } -function InternalNavItem({ icon: Icon, label, path, isActive }: InternalNavItemProps) { +function InternalNavItem({ icon, label, path, isActive }: InternalNavItemProps) { return ( {label} @@ -75,20 +78,20 @@ function InternalNavItem({ icon: Icon, label, path, isActive }: InternalNavItemP } interface ExternalNavItemProps { - icon: React.ElementType; + icon: typeof PeopleIcon; label: string; path: string; openInWindow?: boolean; } -function ExternalNavItem({ icon: Icon, label, path, openInWindow }: ExternalNavItemProps) { +function ExternalNavItem({ icon, label, path, openInWindow }: ExternalNavItemProps) { if (openInWindow) { return ( ); @@ -96,7 +99,7 @@ function ExternalNavItem({ icon: Icon, label, path, openInWindow }: ExternalNavI return ( - + {label} ); @@ -109,13 +112,13 @@ function LNB() {
- + Weeth admin
{/* 동아리 정보 */}
- 가입대학교 + 가천대 검도부
@@ -156,13 +159,8 @@ function LNB() { openInWindow={id === 'manual'} /> ))} - - - {/* 모드 */} -
- 모드 -
+ ); } diff --git a/src/components/admin/layout/ThemeModeSelector.tsx b/src/components/admin/layout/ThemeModeSelector.tsx index 56e35a17..7f4f0a80 100644 --- a/src/components/admin/layout/ThemeModeSelector.tsx +++ b/src/components/admin/layout/ThemeModeSelector.tsx @@ -55,7 +55,7 @@ function ThemeModeSelector() { - + {THEME_OPTIONS.map(({ value, label }) => ( handleSelect(value)}> {label} diff --git a/src/hooks/queries/admin/useAdminClubQuery.ts b/src/hooks/queries/admin/useAdminClubQuery.ts new file mode 100644 index 00000000..e104e95d --- /dev/null +++ b/src/hooks/queries/admin/useAdminClubQuery.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; +import { adminClubApi } from '@/lib/apis/adminClub'; +import { useClubId } from '@/stores'; + +export function useAdminClubQuery() { + const clubId = useClubId(); + + return useQuery({ + queryKey: ['admin', 'club', clubId], + queryFn: () => adminClubApi.getDetail(clubId!).then((res) => res.data.data), + enabled: !!clubId, + staleTime: 30 * 60 * 1000, + gcTime: 60 * 60 * 1000, + }); +} diff --git a/src/lib/apis/adminClub.ts b/src/lib/apis/adminClub.ts new file mode 100644 index 00000000..793eebf9 --- /dev/null +++ b/src/lib/apis/adminClub.ts @@ -0,0 +1,8 @@ +import { apiClient } from '@/lib/apis/client'; +import type { Club } from '@/types/club'; +import type { ApiResponse } from '@/types/common'; + +export const adminClubApi = { + getDetail: (clubId: string) => + apiClient.get>(`/admin/clubs/${clubId}`), +}; diff --git a/src/types/club.ts b/src/types/club.ts index 2794b705..8e1c3aad 100644 --- a/src/types/club.ts +++ b/src/types/club.ts @@ -1,8 +1,12 @@ -// club 관련 타입 정의 - export interface Club { id: string; name: string; + code: string; + schoolName: string; description: string; - logoUrl?: string; + contactEmail: string; + contactPhoneNumber: string; + primaryContact: 'PHONE' | 'EMAIL'; + profileImageUrl: string; + backgroundImageUrl: string; } From b7b55ebc3f70972ddf5295f5b04f7678abe6212e Mon Sep 17 00:00:00 2001 From: JIN921 Date: Wed, 8 Apr 2026 22:16:25 +0900 Subject: [PATCH 043/684] =?UTF-8?q?fix:=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=8C=A8=EC=B9=AD=20=EC=97=90=EB=9F=AC=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EC=84=9C=EC=8A=A4=ED=8E=9C=EC=8A=A4=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/data-fetching.md | 52 +++++++++++++++++++ .claude/settings.local.json | 22 -------- src/app/(private)/(main)/mypage/error.tsx | 14 +++++ src/app/(private)/(main)/mypage/loading.tsx | 7 +++ src/components/mypage/MyPageContent.tsx | 18 +------ src/components/mypage/MyPageDropdownMenu.tsx | 2 +- .../mypage/edit/EditProfileContent.tsx | 18 +------ src/hooks/queries/mypage/useMyMemberQuery.ts | 9 ++-- 8 files changed, 81 insertions(+), 61 deletions(-) create mode 100644 src/app/(private)/(main)/mypage/error.tsx create mode 100644 src/app/(private)/(main)/mypage/loading.tsx diff --git a/.claude/rules/data-fetching.md b/.claude/rules/data-fetching.md index 661ce882..f22192bc 100644 --- a/.claude/rules/data-fetching.md +++ b/.claude/rules/data-fetching.md @@ -54,3 +54,55 @@ export const postApi = { ['posts', id] // single item ['attendance', { generationNumber }] // with filter ``` + +### React Query — Suspense Pattern (preferred) + +When the same query's loading/error handling is duplicated across 2+ components, use `useSuspenseQuery` + Next.js `loading.tsx` / `error.tsx` instead of manual `isLoading` / `isError` checks. + +```ts +// hooks/queries/mypage/useMyMemberQuery.ts +import { useSuspenseQuery, skipToken } from '@tanstack/react-query'; + +export function useMyMemberQuery() { + const clubId = useClubId(); + return useSuspenseQuery({ + queryKey: ['mypage', 'me', clubId], + queryFn: clubId + ? () => mypageApi.getMe(clubId).then((res) => res.data.data) + : skipToken, + }); +} +``` + +- `useSuspenseQuery`: `data` is always defined (no `isLoading` / `isError` / `!data` guards needed) +- `skipToken`: replaces `enabled` option (not supported by `useSuspenseQuery`) +- `loading.tsx`: placed in the route segment, handles Suspense fallback +- `error.tsx`: placed in the route segment, handles ErrorBoundary with `reset` prop + +```tsx +// app/(private)/(main)/mypage/loading.tsx +export default function Loading() { + return

로딩 중...

; +} + +// app/(private)/(main)/mypage/error.tsx +'use client'; +export default function Error({ reset }: { error: Error; reset: () => void }) { + return ; +} +``` + +**When to use:** Client components that fetch user-scoped data shown on page load (e.g., my profile, my clubs). +**When NOT to use:** Queries triggered by user interaction (e.g., search, infinite scroll) — use regular `useQuery` for those. + +### Import Rule — Barrel Export Caveat + +Client-side hooks must **NOT** import from `@/lib/apis` (barrel). The barrel re-exports `apiServer` which imports `next/headers`, causing build errors in client components. Always use direct imports: + +```ts +// Good +import { mypageApi } from '@/lib/apis/mypage'; + +// Bad — pulls in apiServer → next/headers +import { mypageApi } from '@/lib/apis'; +``` diff --git a/.claude/settings.local.json b/.claude/settings.local.json index fb4edf2f..2601f6a7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,29 +3,7 @@ "allow": [ "Bash(gh pr:*)", "Bash(git push:*)", -<<<<<<< HEAD - "mcp__figma__get_design_context", - "Bash(grep -E \"\\\\.tsx$\")", - "Bash(find \"D:\\\\project\\\\weeth-client\\\\src\\\\app\\\\\\(private\\)\" -name \"layout.tsx\" -type f)", - "Bash(find \"D:\\\\project\\\\weeth-client\\\\src\\\\app\\\\\\(private\\)\\\\\\(main\\)\" -name \"page.tsx\" -type f)", - "Bash(grep -r PageNavigation D:projectweeth-clientsrc --include=*.tsx --include=*.ts)", - "Bash(ls -la \"D:\\\\project\\\\weeth-client\\\\src\\\\app\\\\\\(private\\)\\\\\\(intro\\)\\\\home\")", - "Bash(find \"D:\\\\project\\\\weeth-client\\\\src\\\\app\\\\\\(private\\)\" -type f -name \"layout.tsx\")", - "mcp__figma__get_screenshot", - "Bash(find D:projectweeth-clientsrccomponentsmypage -type f -name *.tsx -o -name *.ts)", - "Bash(ls -la \"D:\\\\project\\\\weeth-client\\\\src\\\\app\\\\\\(private\\)\\\\\\(main\\)\\\\mypage\")", - "Bash(ls -la \"D:\\\\project\\\\weeth-client\\\\src\\\\app\\\\\\(private\\)\\\\\\(main\\)\\\\mypage\\\\edit\")", - "Bash(find D:projectweeth-clientsrccomponentsui -type f \\\\\\(-name *.tsx -o -name *.ts \\\\\\))", -<<<<<<< HEAD - "Bash(find D:/project/weeth-client/src/constants -name *.ts)" -======= - "Bash(ls \"D:/project/weeth-client/src/app/\\(public\\)/\\(landing\\)/\")", "Bash(git fetch:*)" ->>>>>>> feat/WTH-215-멤버-어드민-API-연결 -======= - "Bash(find D:/project/weeth-client/src/constants -name *.ts)", - "Bash(grep -r \"POSTHOG\\\\|PostHog\" D:projectweeth-client/.env*)" ->>>>>>> 70dadf52f3d996e43f43531b2e889a992079f87c ] } } diff --git a/src/app/(private)/(main)/mypage/error.tsx b/src/app/(private)/(main)/mypage/error.tsx new file mode 100644 index 00000000..bc1a31ce --- /dev/null +++ b/src/app/(private)/(main)/mypage/error.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { Button } from '@/components/ui'; + +export default function MyPageError({ reset }: { error: Error; reset: () => void }) { + return ( +
+

내 정보를 불러올 수 없습니다.

+ +
+ ); +} diff --git a/src/app/(private)/(main)/mypage/loading.tsx b/src/app/(private)/(main)/mypage/loading.tsx new file mode 100644 index 00000000..87bc22b9 --- /dev/null +++ b/src/app/(private)/(main)/mypage/loading.tsx @@ -0,0 +1,7 @@ +export default function MyPageLoading() { + return ( +
+

로딩 중...

+
+ ); +} diff --git a/src/components/mypage/MyPageContent.tsx b/src/components/mypage/MyPageContent.tsx index 3f4e215b..24c38a47 100644 --- a/src/components/mypage/MyPageContent.tsx +++ b/src/components/mypage/MyPageContent.tsx @@ -16,25 +16,9 @@ import { useMyClubsQuery } from '@/hooks/queries/mypage/useMyClubsQuery'; type MyPageContentProps = React.HTMLAttributes; function MyPageContent({ className, ...props }: MyPageContentProps) { - const { data: me, isLoading, isError } = useMyMemberQuery(); + const { data: me } = useMyMemberQuery(); const { data: clubs = [] } = useMyClubsQuery(); - if (isLoading) { - return ( -
-

로딩 중...

-
- ); - } - - if (isError || !me) { - return ( -
-

내 정보를 불러올 수 없습니다.

-
- ); - } - return (
; function EditProfileContent({ className, ...props }: EditProfileContentProps) { const router = useRouter(); - const { data: me, isLoading, isError } = useMyMemberQuery(); + const { data: me } = useMyMemberQuery(); const { mutate: updateProfile, isPending } = useUpdateProfileMutation(); const [selectedFile, setSelectedFile] = useState(null); @@ -103,22 +103,6 @@ function EditProfileContent({ className, ...props }: EditProfileContentProps) { ); }; - if (isLoading) { - return ( -
-

로딩 중...

-
- ); - } - - if (isError || !me) { - return ( -
-

내 정보를 불러올 수 없습니다.

-
- ); - } - return (
mypageApi.getMe(clubId!).then((res) => res.data.data), - enabled: !!clubId, + queryFn: clubId + ? () => mypageApi.getMe(clubId).then((res) => res.data.data) + : skipToken, staleTime: 30 * 60 * 1000, gcTime: 60 * 60 * 1000, }); From 67feedd3c13a2e1a4eaffaa178e20d64ab7dea63 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Wed, 8 Apr 2026 22:25:41 +0900 Subject: [PATCH 044/684] =?UTF-8?q?fix:=20=EC=9C=A0=EC=A0=80=20=ED=8F=BC?= =?UTF-8?q?=20=EC=B4=88=EA=B8=B0=20=EA=B0=92=20=EB=B9=84=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mypage/edit/EditProfileContent.tsx | 36 +++++-------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/src/components/mypage/edit/EditProfileContent.tsx b/src/components/mypage/edit/EditProfileContent.tsx index 981797e5..a69b1392 100644 --- a/src/components/mypage/edit/EditProfileContent.tsx +++ b/src/components/mypage/edit/EditProfileContent.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useForm, useWatch } from 'react-hook-form'; @@ -39,40 +39,22 @@ function EditProfileContent({ className, ...props }: EditProfileContentProps) { register, handleSubmit, setValue, - reset, control, formState: { errors }, } = useForm({ resolver: zodResolver(editProfileSchema), mode: 'onBlur', - defaultValues: { - name: '', - bio: '', - tel: '', - email: '', - school: '', - department: '', - studentId: '', + values: { + name: me.name, + bio: me.bio ?? '', + tel: me.tel ? formatPhone(me.tel) : '', + email: me.email, + school: me.school, + department: me.department, + studentId: me.studentId, }, }); - useEffect(() => { - if (me) { - reset( - { - name: me.name, - bio: me.bio ?? '', - tel: me.tel ? formatPhone(me.tel) : '', - email: me.email, - school: me.school, - department: me.department, - studentId: me.studentId, - }, - { keepDirtyValues: true }, - ); - } - }, [me, reset]); - const name = useWatch({ control, name: 'name' }); const onSubmit = (data: EditProfileFormData) => { From ab04c980d8d8b55d6c9ef63a5721f2dff09ff300 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Wed, 8 Apr 2026 22:28:17 +0900 Subject: [PATCH 045/684] =?UTF-8?q?fix:=20ref=EB=A1=9C=20url=20=EC=B6=94?= =?UTF-8?q?=EC=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mypage/edit/ProfileImageEditor.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/components/mypage/edit/ProfileImageEditor.tsx b/src/components/mypage/edit/ProfileImageEditor.tsx index 7a975943..e7e79815 100644 --- a/src/components/mypage/edit/ProfileImageEditor.tsx +++ b/src/components/mypage/edit/ProfileImageEditor.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { Avatar, AvatarFallback, AvatarImage, Icon } from '@/components/ui'; import { EditIcon } from '@/assets/icons'; @@ -13,23 +13,19 @@ interface ProfileImageEditorProps { function ProfileImageEditor({ name, profileImageUrl, onFileChange }: ProfileImageEditorProps) { const fileInputRef = useRef(null); + const previewUrlRef = useRef(null); const [previewUrl, setPreviewUrl] = useState(null); const handleChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; - if (previewUrl) URL.revokeObjectURL(previewUrl); - setPreviewUrl(URL.createObjectURL(file)); + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current); + const url = URL.createObjectURL(file); + previewUrlRef.current = url; + setPreviewUrl(url); onFileChange?.(file); }; - useEffect(() => { - return () => { - if (previewUrl) URL.revokeObjectURL(previewUrl); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const displayUrl = previewUrl ?? profileImageUrl; return ( From 5cc394378e7393c91c6add22b698b7eaa9d988b0 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Wed, 8 Apr 2026 22:44:11 +0900 Subject: [PATCH 046/684] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B3=91=EB=A0=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/mypage/edit/EditProfileContent.tsx | 2 +- .../mutations/{mypage => }/useUpdateProfileMutation.ts | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) rename src/hooks/mutations/{mypage => }/useUpdateProfileMutation.ts (90%) diff --git a/src/components/mypage/edit/EditProfileContent.tsx b/src/components/mypage/edit/EditProfileContent.tsx index a69b1392..6a341a32 100644 --- a/src/components/mypage/edit/EditProfileContent.tsx +++ b/src/components/mypage/edit/EditProfileContent.tsx @@ -19,7 +19,7 @@ import { import { cn } from '@/lib/cn'; import { editProfileSchema, type EditProfileFormData } from '@/lib/schemas/editProfile'; import { useMyMemberQuery } from '@/hooks/queries/mypage/useMyMemberQuery'; -import { useUpdateProfileMutation } from '@/hooks/mutations/mypage/useUpdateProfileMutation'; +import { useUpdateProfileMutation } from '@/hooks/mutations/useUpdateProfileMutation'; import { toastSuccess, toastError } from '@/stores/useToastStore'; import { formatPhone } from '@/utils/shared'; diff --git a/src/hooks/mutations/mypage/useUpdateProfileMutation.ts b/src/hooks/mutations/useUpdateProfileMutation.ts similarity index 90% rename from src/hooks/mutations/mypage/useUpdateProfileMutation.ts rename to src/hooks/mutations/useUpdateProfileMutation.ts index a8e01838..b02e6e1c 100644 --- a/src/hooks/mutations/mypage/useUpdateProfileMutation.ts +++ b/src/hooks/mutations/useUpdateProfileMutation.ts @@ -41,11 +41,10 @@ export function useUpdateProfileMutation() { return useMutation({ mutationFn: async ({ user, clubProfile, profileImageFile }: UpdateProfileParams) => { - await mypageApi.updateUser(user); - - const profileImage = profileImageFile - ? await uploadProfileImage(profileImageFile) - : undefined; + const [, profileImage] = await Promise.all([ + mypageApi.updateUser(user), + profileImageFile ? uploadProfileImage(profileImageFile) : undefined, + ]); await mypageApi.updateClubProfile({ bio: clubProfile.bio, From 208fefa17e6e5c8c2419d4579cfdb94e75aef5d5 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Wed, 8 Apr 2026 22:50:16 +0900 Subject: [PATCH 047/684] =?UTF-8?q?fix:=20=ED=8C=8C=EC=9D=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mypage/SetCardinalModal/index.tsx | 2 +- .../mutations/useUpdateProfileMutation.ts | 29 ++----------------- src/hooks/queries/index.ts | 2 +- .../{useCardinals.ts => useCardinalsQuery.ts} | 0 src/lib/apis/university.ts | 19 +++--------- src/lib/apis/upload.ts | 29 +++++++++++++++++++ 6 files changed, 37 insertions(+), 44 deletions(-) rename src/hooks/queries/{useCardinals.ts => useCardinalsQuery.ts} (100%) create mode 100644 src/lib/apis/upload.ts diff --git a/src/components/mypage/SetCardinalModal/index.tsx b/src/components/mypage/SetCardinalModal/index.tsx index c9bcd26a..0f3ad609 100644 --- a/src/components/mypage/SetCardinalModal/index.tsx +++ b/src/components/mypage/SetCardinalModal/index.tsx @@ -2,7 +2,7 @@ import { Dialog, DialogContent } from '@/components/ui'; import type { ClubDto } from '@/types/mypage'; -import { useCardinals } from '@/hooks/queries/useCardinals'; +import { useCardinals } from '@/hooks/queries/useCardinalsQuery'; import { useCardinalModal } from './useCardinalModal'; import { ModalHeader } from './components/ModalHeader'; import { ModalFooter } from './components/ModalFooter'; diff --git a/src/hooks/mutations/useUpdateProfileMutation.ts b/src/hooks/mutations/useUpdateProfileMutation.ts index b02e6e1c..cd89fc3f 100644 --- a/src/hooks/mutations/useUpdateProfileMutation.ts +++ b/src/hooks/mutations/useUpdateProfileMutation.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { mypageApi } from '@/lib/apis/mypage'; -import { fileApi } from '@/lib/apis/file'; +import { uploadFile } from '@/lib/apis/upload'; import type { UpdateUserBody, UpdateClubProfileBody } from '@/lib/apis/mypage'; import { useClubId } from '@/stores/useClubStore'; @@ -10,31 +10,6 @@ interface UpdateProfileParams { profileImageFile?: File | null; } -async function uploadProfileImage(file: File) { - const res = await fileApi.getPresignedUrls('CLUB_MEMBER_PROFILE', [file.name]); - const presigned = res.data.data[0]; - if (!presigned) { - throw new Error('Presigned URL을 받지 못했습니다.'); - } - - const uploadRes = await fetch(presigned.putUrl, { - method: 'PUT', - body: file, - headers: { 'Content-Type': file.type }, - }); - - if (!uploadRes.ok) { - throw new Error('프로필 이미지 업로드에 실패했습니다.'); - } - - return { - fileName: file.name, - storageKey: presigned.storageKey, - fileSize: file.size, - contentType: file.type, - }; -} - export function useUpdateProfileMutation() { const queryClient = useQueryClient(); const clubId = useClubId(); @@ -43,7 +18,7 @@ export function useUpdateProfileMutation() { mutationFn: async ({ user, clubProfile, profileImageFile }: UpdateProfileParams) => { const [, profileImage] = await Promise.all([ mypageApi.updateUser(user), - profileImageFile ? uploadProfileImage(profileImageFile) : undefined, + profileImageFile ? uploadFile(profileImageFile, 'CLUB_MEMBER_PROFILE') : undefined, ]); await mypageApi.updateClubProfile({ diff --git a/src/hooks/queries/index.ts b/src/hooks/queries/index.ts index 24dee4b0..1902c296 100644 --- a/src/hooks/queries/index.ts +++ b/src/hooks/queries/index.ts @@ -1 +1 @@ -export { useCardinals } from './useCardinals'; +export { useCardinals } from './useCardinalsQuery'; diff --git a/src/hooks/queries/useCardinals.ts b/src/hooks/queries/useCardinalsQuery.ts similarity index 100% rename from src/hooks/queries/useCardinals.ts rename to src/hooks/queries/useCardinalsQuery.ts diff --git a/src/lib/apis/university.ts b/src/lib/apis/university.ts index d0e2f654..a034fc21 100644 --- a/src/lib/apis/university.ts +++ b/src/lib/apis/university.ts @@ -1,3 +1,4 @@ +import { ApiResponse } from '@/types'; import { apiClient } from './client'; interface School { @@ -10,21 +11,9 @@ interface Major { category: string; } -interface SchoolsResponse { - code: number; - message: string; - data: School[]; -} - -interface MajorsResponse { - code: number; - message: string; - data: Major[]; -} - export const universityApi = { - getSchools: () => apiClient.get('/university/schools'), - getMajors: () => apiClient.get('/university/majors'), + getSchools: () => apiClient.get>('/university/schools'), + getMajors: () => apiClient.get>('/university/majors'), }; -export type { School, Major, SchoolsResponse, MajorsResponse }; +export type { School, Major }; diff --git a/src/lib/apis/upload.ts b/src/lib/apis/upload.ts new file mode 100644 index 00000000..eb8caffa --- /dev/null +++ b/src/lib/apis/upload.ts @@ -0,0 +1,29 @@ +import { fileApi } from '@/lib/apis/file'; +import type { OwnerType } from '@/lib/apis/file'; + +export interface UploadedFile { + fileName: string; + storageKey: string; + fileSize: number; + contentType: string; +} + +export async function uploadFile(file: File, ownerType: OwnerType): Promise { + const res = await fileApi.getPresignedUrls(ownerType, [file.name]); + const presigned = res.data.data[0]; + if (!presigned) throw new Error('Presigned URL을 받지 못했습니다.'); + + const uploadRes = await fetch(presigned.putUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': file.type }, + }); + if (!uploadRes.ok) throw new Error('파일 업로드에 실패했습니다.'); + + return { + fileName: file.name, + storageKey: presigned.storageKey, + fileSize: file.size, + contentType: file.type, + }; +} From b4ea5da34bf333a662172fa760097c45104be655 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Wed, 8 Apr 2026 23:25:19 +0900 Subject: [PATCH 048/684] =?UTF-8?q?style:=20=EB=8F=99=EC=95=84=EB=A6=AC=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=98=81=EC=97=AD=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/layout/LNB.tsx | 6 +++--- src/components/ui/avatar.tsx | 18 +++++++++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/components/admin/layout/LNB.tsx b/src/components/admin/layout/LNB.tsx index 5bdf87e1..27e52177 100644 --- a/src/components/admin/layout/LNB.tsx +++ b/src/components/admin/layout/LNB.tsx @@ -119,14 +119,14 @@ function LNB() {
{/* 동아리 정보 */} -
- +
+ {club?.profileImageUrl && } {club?.name?.charAt(0)}
{club?.schoolName} - {club?.name} + {club?.name}
diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx index e27e6ea2..fb564fbe 100644 --- a/src/components/ui/avatar.tsx +++ b/src/components/ui/avatar.tsx @@ -14,25 +14,33 @@ const avatarVariants = cva('group/avatar relative flex shrink-0 overflow-hidden size: { 128: 'size-32', 64: 'size-16', + 40: 'size-10', 24: 'size-6', }, + color: { + default: '', + primary: '', + secondary: '', + }, }, defaultVariants: { type: 'round', size: 64, + color: 'default', }, }); interface AvatarProps extends React.ComponentProps, VariantProps {} -function Avatar({ className, type, size, ...props }: AvatarProps) { +function Avatar({ className, type, size, color, ...props }: AvatarProps) { return ( ); @@ -56,10 +64,14 @@ function AvatarFallback({ Date: Wed, 8 Apr 2026 23:34:36 +0900 Subject: [PATCH 049/684] =?UTF-8?q?refactor:=20=EC=B6=9C=EC=84=9D=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=EC=9D=B4=20=EC=97=86=EB=8A=94=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=EC=B6=9C=EC=84=9D=20=EB=B2=84=ED=8A=BC=EC=9D=84=20?= =?UTF-8?q?disabled=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/AttendanceContent.tsx | 34 +++++++------------ .../attendance/AttendanceTodayCard.tsx | 4 +++ src/components/ui/card.tsx | 20 +++++++++-- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/components/attendance/AttendanceContent.tsx b/src/components/attendance/AttendanceContent.tsx index 5b8a8b7f..75026c2d 100644 --- a/src/components/attendance/AttendanceContent.tsx +++ b/src/components/attendance/AttendanceContent.tsx @@ -64,27 +64,19 @@ function AttendanceContent({ attendance, errorMessage, isAdmin = false }: Attend
- {title ? ( - - ) : ( - - )} + void; } @@ -45,6 +46,7 @@ function AttendanceTodayCard({ sessionId, isAdmin = false, isChecked = false, + disabled = false, onAttendanceComplete, }: AttendanceTodayCardProps) { const router = useRouter(); @@ -66,12 +68,14 @@ function AttendanceTodayCard({ showArrow={false} onPrimaryClick={isChecked ? () => setCompleteModalOpen(true) : () => setCodeModalOpen(true)} primaryButtonText={isChecked ? '출석 완료' : '출석하기'} + primaryButtonDisabled={disabled} onSecondaryClick={ isAdmin ? () => router.push(`/attendance/qr?sessionId=${sessionId}`) : () => toastError('관리자만 사용할 수 있는 기능입니다.') } secondaryButtonText="출석코드 확인" + secondaryButtonDisabled={disabled} > {isChecked && } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index b1933890..09e9430d 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -30,6 +30,8 @@ interface CardProps extends React.ComponentProps<'div'>, VariantProps void; secondaryButtonText?: string; onSecondaryClick?: () => void; + primaryButtonDisabled?: boolean; + secondaryButtonDisabled?: boolean; /** arrow 아이콘 표시 여부 (기본값: true) */ showArrow?: boolean; } @@ -45,6 +47,8 @@ function Card({ onPrimaryClick, secondaryButtonText = '출석코드 확인', onSecondaryClick, + primaryButtonDisabled, + secondaryButtonDisabled, showArrow = true, children, ...props @@ -94,12 +98,24 @@ function Card({
{onPrimaryClick && ( - )} {onSecondaryClick && ( - )} From 598843a95e5502b6622e0ab8d5982c1a309b361f Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Wed, 8 Apr 2026 23:48:44 +0900 Subject: [PATCH 050/684] =?UTF-8?q?refactor:=20isAdmin=EC=9D=84=20useUserR?= =?UTF-8?q?ole=EC=9C=BC=EB=A1=9C=20=ED=8C=90=EB=8B=A8=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/attendance/AttendanceContent.tsx | 7 ++++--- src/stores/useUserStore.ts | 4 ++-- src/types/home.ts | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/attendance/AttendanceContent.tsx b/src/components/attendance/AttendanceContent.tsx index 75026c2d..41d0787c 100644 --- a/src/components/attendance/AttendanceContent.tsx +++ b/src/components/attendance/AttendanceContent.tsx @@ -10,17 +10,18 @@ import { ATTENDANCE_ERROR_MESSAGE } from '@/constants/attendance'; import { attendanceApi } from '@/lib/apis/attendance'; import { formatAttendanceDescription } from '@/lib/formatTime'; import { toastError } from '@/stores/useToastStore'; -import { useUserName } from '@/stores/useUserStore'; +import { useUserName, useUserRole } from '@/stores/useUserStore'; import type { AttendanceData } from '@/types/attendance'; interface AttendanceContentProps { attendance?: AttendanceData; errorMessage?: string; - isAdmin?: boolean; } -function AttendanceContent({ attendance, errorMessage, isAdmin = false }: AttendanceContentProps) { +function AttendanceContent({ attendance, errorMessage }: AttendanceContentProps) { const name = useUserName() ?? ''; + const role = useUserRole(); + const isAdmin = role === 'LEAD' || role === 'ADMIN'; const router = useRouter(); const [isChecked, setIsChecked] = useState(false); diff --git a/src/stores/useUserStore.ts b/src/stores/useUserStore.ts index 8f392d18..f903d4f5 100644 --- a/src/stores/useUserStore.ts +++ b/src/stores/useUserStore.ts @@ -6,7 +6,7 @@ const initialState = { id: null as number | null, name: null as string | null, profileImageUrl: null as string | null, - role: null as 'LEAD' | 'USER' | null, + role: null as 'LEAD' | 'ADMIN' | 'USER' | null, }; export type UserState = typeof initialState; @@ -18,7 +18,7 @@ export const useUserStore = create( id: number; name: string; profileImageUrl: string | null; - role: 'LEAD' | 'USER'; + role: 'LEAD' | 'ADMIN' | 'USER'; }) => set(user, false, 'setUser'), reset: () => set(initialState, false, 'reset'), })), diff --git a/src/types/home.ts b/src/types/home.ts index 2157afdd..e55db293 100644 --- a/src/types/home.ts +++ b/src/types/home.ts @@ -1,6 +1,6 @@ import type { ApiResponse } from '@/types/common'; -type Role = 'LEAD' | 'USER'; +type Role = 'LEAD' | 'ADMIN' | 'USER'; type NullableImage = string | null; interface Identifiable { From 28f56539cbe87e777699fbfedb69f81435ffbc54 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Wed, 8 Apr 2026 23:51:46 +0900 Subject: [PATCH 051/684] =?UTF-8?q?fix:=20=EA=B5=AC=EB=B6=84=EC=82=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/layout/LNB.tsx | 245 ++++++++++++------ .../admin/layout/ThemeModeSelector.tsx | 49 +++- 2 files changed, 201 insertions(+), 93 deletions(-) diff --git a/src/components/admin/layout/LNB.tsx b/src/components/admin/layout/LNB.tsx index 27e52177..6254b5eb 100644 --- a/src/components/admin/layout/LNB.tsx +++ b/src/components/admin/layout/LNB.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useState } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { @@ -10,7 +11,16 @@ import { } from '@/assets/icons/admin'; import { CheckRoundIcon, ExitIcon, NavToggleIcon, PeopleIcon } from '@/assets/icons'; -import { Avatar, AvatarFallback, AvatarImage, Icon } from '@/components/ui'; +import { + Avatar, + AvatarFallback, + AvatarImage, + Icon, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui'; import { cn } from '@/lib/cn'; import { ThemeModeSelector } from '@/components/admin/layout/ThemeModeSelector'; import { useAdminClubQuery } from '@/hooks/queries/admin/useAdminClubQuery'; @@ -36,18 +46,16 @@ const moveNavItems = [ }, ]; -const navItemClass = - 'typo-sub1 flex h-12 items-center gap-300 px-300 transition-colors text-text-alternative hover:bg-container-neutral-interaction'; - interface NavSectionProps { label?: string; + collapsed?: boolean; children: React.ReactNode; } -function NavSection({ label, children }: NavSectionProps) { +function NavSection({ label, collapsed, children }: NavSectionProps) { return (
- {label && ( + {label && !collapsed && ( {label} )} {children} @@ -60,22 +68,38 @@ interface InternalNavItemProps { label: string; path: string; isActive: boolean; + collapsed?: boolean; } -function InternalNavItem({ icon, label, path, isActive }: InternalNavItemProps) { - return ( +function InternalNavItem({ icon, label, path, isActive, collapsed }: InternalNavItemProps) { + const content = ( - {label} + {!collapsed && {label}} ); + + if (collapsed) { + return ( + + {content} + {label} + + ); + } + + return content; } interface ExternalNavItemProps { @@ -83,93 +107,154 @@ interface ExternalNavItemProps { label: string; path: string; openInWindow?: boolean; + collapsed?: boolean; } -function ExternalNavItem({ icon, label, path, openInWindow }: ExternalNavItemProps) { +function ExternalNavItem({ icon, label, path, openInWindow, collapsed }: ExternalNavItemProps) { + const iconEl = ; + + const cls = cn( + 'flex h-12 items-center transition-colors text-text-alternative hover:bg-container-neutral-interaction', + collapsed ? 'justify-center px-200' : 'gap-300 px-300', + ); + + let el: React.ReactNode; + if (openInWindow) { - return ( + el = ( ); + } else { + el = ( + + {iconEl} + {!collapsed && {label}} + + ); } - return ( - - - {label} - - ); + if (collapsed) { + return ( + + {el} + {label} + + ); + } + + return el; } function LNB() { const pathname = usePathname(); const { data: club } = useAdminClubQuery(); + const [collapsed, setCollapsed] = useState(false); return ( - + + {/* 관리 메뉴 */} + + {managementNavItems.map(({ id, icon, label, path }) => ( + + ))} + + + {collapsed &&
} + + {/* 동아리 정보 메뉴 */} + + {infoNavItems.map(({ id, icon, label, path }) => ( + + ))} + + + {collapsed &&
} + + {/* 이동 */} + + {moveNavItems.map(({ id, icon, label, path }) => ( + + ))} + + {collapsed &&
} + + {/* 라이트 모드 */} + + + + ); } diff --git a/src/components/admin/layout/ThemeModeSelector.tsx b/src/components/admin/layout/ThemeModeSelector.tsx index 818887fa..846b859f 100644 --- a/src/components/admin/layout/ThemeModeSelector.tsx +++ b/src/components/admin/layout/ThemeModeSelector.tsx @@ -8,6 +8,9 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, } from '@/components/ui'; import { cn } from '@/lib/cn'; import { useThemeStore } from '@/stores/theme-store'; @@ -26,7 +29,11 @@ const TRIGGER_LABELS: Record = { dark: '다크 모드', }; -function ThemeModeSelector() { +interface ThemeModeSelectorProps { + collapsed?: boolean; +} + +function ThemeModeSelector({ collapsed }: ThemeModeSelectorProps) { const setDark = useThemeStore((state) => state.setDark); const [mode, setMode] = useState(() => { @@ -50,20 +57,36 @@ function ThemeModeSelector() { const currentOption = THEME_OPTIONS.find((o) => o.value === mode)!; const TriggerIcon = currentOption.icon; + const trigger = ( + + + + ); + return ( - - - + {collapsed ? ( + + {trigger} + {TRIGGER_LABELS[mode]} + + ) : ( + trigger + )} {THEME_OPTIONS.map(({ value, label }) => ( From 1ab5311a2880b89cb32f9c8e739781c1ab591f99 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Wed, 8 Apr 2026 23:59:12 +0900 Subject: [PATCH 052/684] =?UTF-8?q?style:=20QR=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/attendance/AttendanceQRContent.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/attendance/AttendanceQRContent.tsx b/src/components/attendance/AttendanceQRContent.tsx index b4b7c897..ef4bb0a5 100644 --- a/src/components/attendance/AttendanceQRContent.tsx +++ b/src/components/attendance/AttendanceQRContent.tsx @@ -38,6 +38,8 @@ function AttendanceQRContent({ sessionId }: AttendanceQRContentProps) { height: 256, data: checkInUrl, type: 'svg', + dotsOptions: { type: 'dots' }, + cornersSquareOptions: { type: 'extra-rounded' }, }); qrCodeRef.current.append(qrRef.current); } else { @@ -92,9 +94,7 @@ function AttendanceQRContent({ sessionId }: AttendanceQRContentProps) { {isExpired ? '마감' : `${minutes}:${seconds}`}
-

- QR코드는 모바일만 제공하고 있어요. -

+

QR코드는 모바일만 제공하고 있어요.

{qrData?.code}

From 0dafded6a941dfd585f21321ec2c64c2a894e673 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Thu, 9 Apr 2026 00:03:54 +0900 Subject: [PATCH 053/684] =?UTF-8?q?fix:=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/layout/CollapsedDivider.tsx | 11 + src/components/admin/layout/LNB.tsx | 193 ++---------------- src/components/admin/layout/LNBClubInfo.tsx | 35 ++++ src/components/admin/layout/LNBHeader.tsx | 35 ++++ src/components/admin/layout/NavItem.tsx | 85 ++++++++ src/components/admin/layout/NavSection.tsx | 18 ++ 6 files changed, 205 insertions(+), 172 deletions(-) create mode 100644 src/components/admin/layout/CollapsedDivider.tsx create mode 100644 src/components/admin/layout/LNBClubInfo.tsx create mode 100644 src/components/admin/layout/LNBHeader.tsx create mode 100644 src/components/admin/layout/NavItem.tsx create mode 100644 src/components/admin/layout/NavSection.tsx diff --git a/src/components/admin/layout/CollapsedDivider.tsx b/src/components/admin/layout/CollapsedDivider.tsx new file mode 100644 index 00000000..56d6661b --- /dev/null +++ b/src/components/admin/layout/CollapsedDivider.tsx @@ -0,0 +1,11 @@ +interface CollapsedDividerProps { + collapsed?: boolean; +} + +function CollapsedDivider({ collapsed }: CollapsedDividerProps) { + if (!collapsed) return null; + + return
; +} + +export { CollapsedDivider, type CollapsedDividerProps }; diff --git a/src/components/admin/layout/LNB.tsx b/src/components/admin/layout/LNB.tsx index 6254b5eb..5882d9e4 100644 --- a/src/components/admin/layout/LNB.tsx +++ b/src/components/admin/layout/LNB.tsx @@ -1,7 +1,6 @@ 'use client'; import { useState } from 'react'; -import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { AdminForumIcon, @@ -9,21 +8,16 @@ import { AdminSettingIcon, AdminFileoutIcon, } from '@/assets/icons/admin'; -import { CheckRoundIcon, ExitIcon, NavToggleIcon, PeopleIcon } from '@/assets/icons'; +import { CheckRoundIcon, ExitIcon, PeopleIcon } from '@/assets/icons'; -import { - Avatar, - AvatarFallback, - AvatarImage, - Icon, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui'; +import { TooltipProvider } from '@/components/ui'; import { cn } from '@/lib/cn'; +import { LNBHeader } from '@/components/admin/layout/LNBHeader'; +import { LNBClubInfo } from '@/components/admin/layout/LNBClubInfo'; +import { NavSection } from '@/components/admin/layout/NavSection'; +import { NavItem } from '@/components/admin/layout/NavItem'; +import { CollapsedDivider } from '@/components/admin/layout/CollapsedDivider'; import { ThemeModeSelector } from '@/components/admin/layout/ThemeModeSelector'; -import { useAdminClubQuery } from '@/hooks/queries/admin/useAdminClubQuery'; const managementNavItems = [ { id: 'member', icon: PeopleIcon, label: '멤버 관리', path: '/admin/member' }, @@ -37,123 +31,19 @@ const infoNavItems = [ ]; const moveNavItems = [ - { id: 'service', icon: ExitIcon, label: 'Weeth로 이동', path: 'https://weeth.kr' }, + { id: 'service', icon: ExitIcon, label: 'Weeth로 이동', path: 'https://weeth.kr', external: true }, { id: 'manual', icon: AdminFileoutIcon, label: '관리자 매뉴얼', path: 'https://weeth-develop-2.s3.ap-northeast-2.amazonaws.com/Weeth_%E1%84%80%E1%85%AA%E1%86%AB%E1%84%85%E1%85%B5%E1%84%8C%E1%85%A1_%E1%84%86%E1%85%A6%E1%84%82%E1%85%B2%E1%84%8B%E1%85%A5%E1%86%AF_v3.pdf', + external: true, + openInWindow: true, }, ]; -interface NavSectionProps { - label?: string; - collapsed?: boolean; - children: React.ReactNode; -} - -function NavSection({ label, collapsed, children }: NavSectionProps) { - return ( -
- {label && !collapsed && ( - {label} - )} - {children} -
- ); -} - -interface InternalNavItemProps { - icon: typeof PeopleIcon; - label: string; - path: string; - isActive: boolean; - collapsed?: boolean; -} - -function InternalNavItem({ icon, label, path, isActive, collapsed }: InternalNavItemProps) { - const content = ( - - - {!collapsed && {label}} - - ); - - if (collapsed) { - return ( - - {content} - {label} - - ); - } - - return content; -} - -interface ExternalNavItemProps { - icon: typeof PeopleIcon; - label: string; - path: string; - openInWindow?: boolean; - collapsed?: boolean; -} - -function ExternalNavItem({ icon, label, path, openInWindow, collapsed }: ExternalNavItemProps) { - const iconEl = ; - - const cls = cn( - 'flex h-12 items-center transition-colors text-text-alternative hover:bg-container-neutral-interaction', - collapsed ? 'justify-center px-200' : 'gap-300 px-300', - ); - - let el: React.ReactNode; - - if (openInWindow) { - el = ( - - ); - } else { - el = ( - - {iconEl} - {!collapsed && {label}} - - ); - } - - if (collapsed) { - return ( - - {el} - {label} - - ); - } - - return el; -} - function LNB() { const pathname = usePathname(); - const { data: club } = useAdminClubQuery(); const [collapsed, setCollapsed] = useState(false); return ( @@ -164,49 +54,12 @@ function LNB() { collapsed ? 'w-14' : 'w-56', )} > - {/* 헤더 */} -
- - - - - - {collapsed ? '사이드바 열기' : '사이드바 닫기'} - - - {!collapsed && Weeth admin} -
+ setCollapsed((prev) => !prev)} /> + - {/* 동아리 정보 */} -
- - {club?.profileImageUrl && } - {club?.name?.charAt(0)} - - {!collapsed && ( -
- {club?.schoolName} - {club?.name} -
- )} -
- - {/* 관리 메뉴 */} {managementNavItems.map(({ id, icon, label, path }) => ( - - {collapsed &&
} + - {/* 동아리 정보 메뉴 */} {infoNavItems.map(({ id, icon, label, path }) => ( - - {collapsed &&
} + - {/* 이동 */} - {moveNavItems.map(({ id, icon, label, path }) => ( - ( + ))} - - {collapsed &&
} - - {/* 라이트 모드 */} + diff --git a/src/components/admin/layout/LNBClubInfo.tsx b/src/components/admin/layout/LNBClubInfo.tsx new file mode 100644 index 00000000..3bc78a51 --- /dev/null +++ b/src/components/admin/layout/LNBClubInfo.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui'; +import { cn } from '@/lib/cn'; +import { useAdminClubQuery } from '@/hooks/queries/admin/useAdminClubQuery'; + +interface LNBClubInfoProps { + collapsed: boolean; +} + +function LNBClubInfo({ collapsed }: LNBClubInfoProps) { + const { data: club } = useAdminClubQuery(); + + return ( +
+ + {club?.profileImageUrl && } + {club?.name?.charAt(0)} + + {!collapsed && ( +
+ {club?.schoolName} + {club?.name} +
+ )} +
+ ); +} + +export { LNBClubInfo, type LNBClubInfoProps }; diff --git a/src/components/admin/layout/LNBHeader.tsx b/src/components/admin/layout/LNBHeader.tsx new file mode 100644 index 00000000..6ee28f54 --- /dev/null +++ b/src/components/admin/layout/LNBHeader.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { Icon, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'; +import { NavToggleIcon } from '@/assets/icons'; +import { cn } from '@/lib/cn'; + +interface LNBHeaderProps { + collapsed: boolean; + onToggle: () => void; +} + +function LNBHeader({ collapsed, onToggle }: LNBHeaderProps) { + return ( +
+ + + + + + {collapsed ? '사이드바 열기' : '사이드바 닫기'} + + + {!collapsed && Weeth admin} +
+ ); +} + +export { LNBHeader, type LNBHeaderProps }; diff --git a/src/components/admin/layout/NavItem.tsx b/src/components/admin/layout/NavItem.tsx new file mode 100644 index 00000000..327bc1cc --- /dev/null +++ b/src/components/admin/layout/NavItem.tsx @@ -0,0 +1,85 @@ +'use client'; + +import Link from 'next/link'; + +import { Icon, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'; +import { cn } from '@/lib/cn'; +import type { PeopleIcon } from '@/assets/icons'; + +const baseClass = + 'flex h-12 items-center transition-colors text-text-alternative hover:bg-container-neutral-interaction'; + +interface NavItemProps { + icon: typeof PeopleIcon; + label: string; + path: string; + isActive?: boolean; + collapsed?: boolean; + external?: boolean; + openInWindow?: boolean; +} + +function NavItem({ + icon, + label, + path, + isActive = false, + collapsed = false, + external = false, + openInWindow = false, +}: NavItemProps) { + const iconEl = ( + + ); + + const cls = cn( + baseClass, + collapsed ? 'justify-center px-200' : 'gap-300 px-300', + isActive && 'bg-container-neutral-interaction text-text-strong', + ); + + let el: React.ReactNode; + + if (openInWindow) { + el = ( + + ); + } else if (external) { + el = ( + + {iconEl} + {!collapsed && {label}} + + ); + } else { + el = ( + + {iconEl} + {!collapsed && {label}} + + ); + } + + if (collapsed) { + return ( + + {el} + {label} + + ); + } + + return el; +} + +export { NavItem, type NavItemProps }; diff --git a/src/components/admin/layout/NavSection.tsx b/src/components/admin/layout/NavSection.tsx new file mode 100644 index 00000000..22f024ab --- /dev/null +++ b/src/components/admin/layout/NavSection.tsx @@ -0,0 +1,18 @@ +interface NavSectionProps { + label?: string; + collapsed?: boolean; + children: React.ReactNode; +} + +function NavSection({ label, collapsed, children }: NavSectionProps) { + return ( +
+ {label && !collapsed && ( + {label} + )} + {children} +
+ ); +} + +export { NavSection, type NavSectionProps }; From 85f800b885ee6872d4bbb514a184f5df28caf81d Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Thu, 9 Apr 2026 13:56:00 +0900 Subject: [PATCH 054/684] =?UTF-8?q?feat:=20QR=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9D=B8=EC=8B=9D=EC=9D=84=20=ED=86=B5=ED=95=9C=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=20=EC=B2=B4=ED=81=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(private)/(main)/attendance/page.tsx | 17 ++- .../attendance/AttendanceCompleteModal.tsx | 13 ++- .../attendance/AttendanceContent.tsx | 109 +++++++++++------- .../attendance/AttendanceQRContent.tsx | 2 + src/hooks/useQRCheckIn.ts | 48 ++++++++ src/proxy.ts | 3 +- 6 files changed, 144 insertions(+), 48 deletions(-) create mode 100644 src/hooks/useQRCheckIn.ts diff --git a/src/app/(private)/(main)/attendance/page.tsx b/src/app/(private)/(main)/attendance/page.tsx index ae360606..db9178b6 100644 --- a/src/app/(private)/(main)/attendance/page.tsx +++ b/src/app/(private)/(main)/attendance/page.tsx @@ -2,7 +2,13 @@ import { AttendanceContent } from '@/components/attendance'; import { attendanceServerApi } from '@/lib/apis/attendance.server'; import type { AttendanceData } from '@/types/attendance'; -export default async function AttendancePage() { +interface AttendancePageProps { + searchParams: Promise<{ sessionId?: string; code?: string }>; +} + +export default async function AttendancePage({ searchParams }: AttendancePageProps) { + const { sessionId: qrSessionId, code: qrCode } = await searchParams; + // TODO: 하드코딩된 clubId 추후 동적으로 변경 let attendance: AttendanceData | undefined; let errorMessage: string | undefined; @@ -14,5 +20,12 @@ export default async function AttendancePage() { errorMessage = '출석 정보를 불러오지 못했습니다.'; } - return ; + return ( + + ); } diff --git a/src/components/attendance/AttendanceCompleteModal.tsx b/src/components/attendance/AttendanceCompleteModal.tsx index a9ffdc22..f5bbdaeb 100644 --- a/src/components/attendance/AttendanceCompleteModal.tsx +++ b/src/components/attendance/AttendanceCompleteModal.tsx @@ -15,9 +15,16 @@ import { interface AttendanceCompleteModalProps { open: boolean; onOpenChange: (open: boolean) => void; + title?: string; + description?: string; } -function AttendanceCompleteModal({ open, onOpenChange }: AttendanceCompleteModalProps) { +function AttendanceCompleteModal({ + open, + onOpenChange, + title = '이미 출석을 완료했네요!', + description = '오늘도 즐거운 활동을 이어가세요.', +}: AttendanceCompleteModalProps) { return ( 출석 완료
-

이미 출석을 완료했네요!

-

오늘도 즐거운 활동을 이어가세요.

+

{title}

+

{description}

diff --git a/src/components/attendance/AttendanceContent.tsx b/src/components/attendance/AttendanceContent.tsx index 41d0787c..9309daaa 100644 --- a/src/components/attendance/AttendanceContent.tsx +++ b/src/components/attendance/AttendanceContent.tsx @@ -4,11 +4,13 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbPage, Card } from '@/components/ui'; +import { AttendanceCompleteModal } from '@/components/attendance/AttendanceCompleteModal'; import { AttendanceStatus } from '@/components/attendance/AttendanceStatus'; import { AttendanceTodayCard } from '@/components/attendance/AttendanceTodayCard'; import { ATTENDANCE_ERROR_MESSAGE } from '@/constants/attendance'; import { attendanceApi } from '@/lib/apis/attendance'; import { formatAttendanceDescription } from '@/lib/formatTime'; +import { useQRCheckIn } from '@/hooks/useQRCheckIn'; import { toastError } from '@/stores/useToastStore'; import { useUserName, useUserRole } from '@/stores/useUserStore'; import type { AttendanceData } from '@/types/attendance'; @@ -16,14 +18,29 @@ import type { AttendanceData } from '@/types/attendance'; interface AttendanceContentProps { attendance?: AttendanceData; errorMessage?: string; + qrSessionId?: string; + qrCode?: string; } -function AttendanceContent({ attendance, errorMessage }: AttendanceContentProps) { +function AttendanceContent({ + attendance, + errorMessage, + qrSessionId, + qrCode, +}: AttendanceContentProps) { const name = useUserName() ?? ''; const role = useUserRole(); const isAdmin = role === 'LEAD' || role === 'ADMIN'; const router = useRouter(); - const [isChecked, setIsChecked] = useState(false); + const [isManualChecked, setIsManualChecked] = useState(false); + + const { + isChecked: isQRChecked, + completeModalOpen: qrCompleteModalOpen, + setCompleteModalOpen: setQrCompleteModalOpen, + } = useQRCheckIn({ qrSessionId, qrCode }); + + const isChecked = isManualChecked || isQRChecked; useEffect(() => { if (errorMessage) toastError(errorMessage); @@ -45,7 +62,7 @@ function AttendanceContent({ attendance, errorMessage }: AttendanceContentProps) try { // TODO: 하드코딩된 clubId 추후 동적으로 변경 await attendanceApi.checkIn('YUNJcjFKMO', sessionId, Number(code)); - setIsChecked(true); + setIsManualChecked(true); } catch (error) { const errorCode = (error as { response?: { data?: { code?: number } } }).response?.data?.code; toastError(errorCode ? ATTENDANCE_ERROR_MESSAGE[errorCode] : undefined); @@ -53,46 +70,54 @@ function AttendanceContent({ attendance, errorMessage }: AttendanceContentProps) } return ( -
- - - - 출석 - - - - - - -
- - - router.push('/attendance/history')} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - router.push('/attendance/history'); - } - }} - /> + <> +
+ + + + 출석 + + + + + + +
+ + + router.push('/attendance/history')} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + router.push('/attendance/history'); + } + }} + /> +
-
+ + + ); } diff --git a/src/components/attendance/AttendanceQRContent.tsx b/src/components/attendance/AttendanceQRContent.tsx index ef4bb0a5..5d36fecf 100644 --- a/src/components/attendance/AttendanceQRContent.tsx +++ b/src/components/attendance/AttendanceQRContent.tsx @@ -37,9 +37,11 @@ function AttendanceQRContent({ sessionId }: AttendanceQRContentProps) { width: 256, height: 256, data: checkInUrl, + qrOptions: { errorCorrectionLevel: 'L' }, type: 'svg', dotsOptions: { type: 'dots' }, cornersSquareOptions: { type: 'extra-rounded' }, + cornersDotOptions: { type: 'extra-rounded' }, }); qrCodeRef.current.append(qrRef.current); } else { diff --git a/src/hooks/useQRCheckIn.ts b/src/hooks/useQRCheckIn.ts new file mode 100644 index 00000000..4e45303f --- /dev/null +++ b/src/hooks/useQRCheckIn.ts @@ -0,0 +1,48 @@ +import { useEffect, useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; + +import { ATTENDANCE_ERROR_MESSAGE } from '@/constants/attendance'; +import { attendanceApi } from '@/lib/apis/attendance'; +import { toastError } from '@/stores/useToastStore'; + +interface UseQRCheckInParams { + qrSessionId?: string; + qrCode?: string; +} + +function useQRCheckIn({ qrSessionId, qrCode }: UseQRCheckInParams) { + const router = useRouter(); + const [isChecked, setIsChecked] = useState(false); + const [completeModalOpen, setCompleteModalOpen] = useState(false); + const hasCheckedIn = useRef(false); + + useEffect(() => { + if (!qrSessionId || !qrCode || hasCheckedIn.current) return; + hasCheckedIn.current = true; + + const checkIn = async () => { + try { + // TODO: 하드코딩된 clubId 추후 동적으로 변경 + await attendanceApi.checkIn('YUNJcjFKMO', Number(qrSessionId), Number(qrCode)); + setIsChecked(true); + setCompleteModalOpen(true); + } catch (error) { + const errorCode = (error as { response?: { data?: { code?: number } } }).response?.data + ?.code; + toastError(errorCode ? ATTENDANCE_ERROR_MESSAGE[errorCode] : undefined); + router.replace('/attendance'); + } + }; + + checkIn(); + }, [qrSessionId, qrCode, router]); + + function handleModalOpenChange(open: boolean) { + setCompleteModalOpen(open); + if (!open) router.replace('/attendance'); + } + + return { isChecked, completeModalOpen, setCompleteModalOpen: handleModalOpenChange }; +} + +export { useQRCheckIn }; diff --git a/src/proxy.ts b/src/proxy.ts index 52079d84..58c86b62 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -62,7 +62,8 @@ export function proxy(request: NextRequest) { return NextResponse.redirect(new URL(`/club/${clubId}`, request.url)); } const loginUrl = new URL('/login', request.url); - loginUrl.searchParams.set('redirect', pathname); + const redirect = request.nextUrl.search ? `${pathname}${request.nextUrl.search}` : pathname; + loginUrl.searchParams.set('redirect', redirect); return NextResponse.redirect(loginUrl); } From 7984bfe12844348ce344e96ec5e9d3e5c7a470b0 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Thu, 9 Apr 2026 14:34:15 +0900 Subject: [PATCH 055/684] =?UTF-8?q?feat:=20(main)=20=ED=95=98=EC=9C=84=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EA=B0=80=20=ED=99=88=EC=9D=84=20?= =?UTF-8?q?=EA=B1=B0=EC=B9=98=EC=A7=80=20=EC=95=8A=EC=95=84=EB=8F=84=20use?= =?UTF-8?q?r/club=20=EC=A0=95=EB=B3=B4=EC=97=90=20=EC=A0=91=EA=B7=BC=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=9C=20user-hydrator=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(private)/(main)/attendance/qr/page.tsx | 9 +++++ src/app/(private)/(main)/layout.tsx | 19 +++++++--- src/components/home/HomePageSections.tsx | 14 +------ src/lib/apis/home.server.ts | 9 +++++ src/providers/user-hydrator.tsx | 38 +++++++++++++++++++ src/stores/index.ts | 2 +- src/stores/useClubStore.ts | 9 +++-- 7 files changed, 78 insertions(+), 22 deletions(-) create mode 100644 src/lib/apis/home.server.ts create mode 100644 src/providers/user-hydrator.tsx diff --git a/src/app/(private)/(main)/attendance/qr/page.tsx b/src/app/(private)/(main)/attendance/qr/page.tsx index c4a3803f..568d3931 100644 --- a/src/app/(private)/(main)/attendance/qr/page.tsx +++ b/src/app/(private)/(main)/attendance/qr/page.tsx @@ -1,6 +1,7 @@ import { redirect } from 'next/navigation'; import { AttendanceQRContent } from '@/components/attendance'; +import { homeServerApi } from '@/lib/apis/home.server'; interface AttendanceQRPageProps { searchParams: Promise<{ sessionId?: string }>; @@ -13,5 +14,13 @@ export default async function AttendanceQRPage({ searchParams }: AttendanceQRPag redirect('/attendance'); } + // TODO: 하드코딩된 clubId 추후 동적으로 변경 + const { data } = await homeServerApi.getDashboard('YUNJcjFKMO'); + const role = data.myInfo.userInfo.role; + + if (role !== 'LEAD' && role !== 'ADMIN') { + redirect('/attendance'); + } + return ; } diff --git a/src/app/(private)/(main)/layout.tsx b/src/app/(private)/(main)/layout.tsx index c3716a34..2eb1169b 100644 --- a/src/app/(private)/(main)/layout.tsx +++ b/src/app/(private)/(main)/layout.tsx @@ -1,16 +1,25 @@ import type { ReactNode } from 'react'; import { Header } from '@/components/layout'; +import { homeServerApi } from '@/lib/apis/home.server'; +import { UserHydrator } from '@/providers/user-hydrator'; -export default function MainLayout({ +// TODO: 하드코딩된 clubId 추후 동적으로 변경 +export default async function MainLayout({ children, }: Readonly<{ children: ReactNode; }>) { + const { data } = await homeServerApi.getDashboard('YUNJcjFKMO'); + const { userInfo } = data.myInfo; + const { id: clubId, name: clubName } = data.club; + return ( -
-
- {children} -
+ +
+
+ {children} +
+
); } diff --git a/src/components/home/HomePageSections.tsx b/src/components/home/HomePageSections.tsx index 407d6990..87a3012a 100644 --- a/src/components/home/HomePageSections.tsx +++ b/src/components/home/HomePageSections.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Suspense, useEffect } from 'react'; +import { Suspense } from 'react'; import { BannerSkeleton, LeftContainerSkeleton, @@ -13,21 +13,9 @@ import { MainContainer, RightContainer, } from '@/components/home/DynamicSections'; -import { useHomeQuery } from '@/hooks/home'; import { Header } from '@/components/layout'; -import { useUserActions } from '@/stores'; export function HomePageSections() { - const { setUser } = useUserActions(); - const { data: myUserInfo } = useHomeQuery({ - select: (data) => data.myInfo.userInfo, - }); - - useEffect(() => { - if (!myUserInfo) return; - setUser(myUserInfo); - }, [myUserInfo, setUser]); - return ( <> }> diff --git a/src/lib/apis/home.server.ts b/src/lib/apis/home.server.ts new file mode 100644 index 00000000..de837178 --- /dev/null +++ b/src/lib/apis/home.server.ts @@ -0,0 +1,9 @@ +import { apiServer } from '@/lib/apis/server'; +import type { HomeDashboardResponse } from '@/types/home'; + +export const homeServerApi = { + getDashboard: (clubId: string) => + apiServer.get(`/clubs/${clubId}/dashboard/home`, { + cache: 'no-store', + }), +}; diff --git a/src/providers/user-hydrator.tsx b/src/providers/user-hydrator.tsx new file mode 100644 index 00000000..942a8b40 --- /dev/null +++ b/src/providers/user-hydrator.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { useEffect } from 'react'; + +import { useClubActions } from '@/stores/useClubStore'; +import { useUserActions } from '@/stores/useUserStore'; + +interface UserInfo { + id: number; + name: string; + profileImageUrl: string | null; + role: 'LEAD' | 'ADMIN' | 'USER'; +} + +interface ClubInfo { + clubId: string; + clubName: string; +} + +interface UserHydratorProps { + userInfo: UserInfo; + clubInfo: ClubInfo; + children: React.ReactNode; +} + +function UserHydrator({ userInfo, clubInfo, children }: UserHydratorProps) { + const { setUser } = useUserActions(); + const { setClub } = useClubActions(); + + useEffect(() => { + setUser(userInfo); + setClub(clubInfo); + }, [userInfo, clubInfo, setUser, setClub]); + + return children; +} + +export { UserHydrator, type UserHydratorProps }; diff --git a/src/stores/index.ts b/src/stores/index.ts index 3c9a4be4..3eb714c7 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -2,7 +2,7 @@ export { useThemeStore } from './theme-store'; export { useAuthStore, useAuthName, useAuthProfileImage, useAuthActions } from './useAuthStore'; -export { useClubStore, useClubId, useClubActions } from './useClubStore'; +export { useClubStore, useClubId, useClubName, useClubActions } from './useClubStore'; export { useUserStore, useUserId, diff --git a/src/stores/useClubStore.ts b/src/stores/useClubStore.ts index ccf42245..5ef4eb60 100644 --- a/src/stores/useClubStore.ts +++ b/src/stores/useClubStore.ts @@ -4,6 +4,7 @@ import { useShallow } from 'zustand/react/shallow'; const initialState = { clubId: null as string | null, + clubName: null as string | null, }; export type ClubState = typeof initialState; @@ -12,15 +13,17 @@ export const useClubStore = create( devtools( persist( combine(initialState, (set) => ({ - setClubId: (clubId: string) => set({ clubId }, false, 'setClubId'), + setClub: (club: { clubId: string; clubName: string }) => + set(club, false, 'setClub'), reset: () => set(initialState, false, 'reset'), })), { name: 'clubId' }, ), - { name: 'ClubIdStore' }, + { name: 'ClubStore' }, ), ); export const useClubId = () => useClubStore((store) => store.clubId); +export const useClubName = () => useClubStore((store) => store.clubName); export const useClubActions = () => - useClubStore(useShallow((store) => ({ setClubId: store.setClubId, reset: store.reset }))); + useClubStore(useShallow((store) => ({ setClub: store.setClub, reset: store.reset }))); From 28c45c44673e7cde0c39902c5d3d8f8f49d41505 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Thu, 9 Apr 2026 17:09:09 +0900 Subject: [PATCH 056/684] =?UTF-8?q?refactor:=20=EC=A4=91=EB=B3=B5=EB=90=9C?= =?UTF-8?q?=20user/club=20=ED=83=80=EC=9E=85=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/providers/user-hydrator.tsx | 16 +++------------- src/stores/useClubStore.ts | 5 +++-- src/stores/useUserStore.ts | 9 +++------ src/types/club.ts | 6 ++++++ src/types/home.ts | 33 ++++++++++----------------------- src/types/index.ts | 13 +++++++++++-- src/types/user.ts | 22 ++++++++++++++++++++++ 7 files changed, 58 insertions(+), 46 deletions(-) create mode 100644 src/types/user.ts diff --git a/src/providers/user-hydrator.tsx b/src/providers/user-hydrator.tsx index 942a8b40..83436581 100644 --- a/src/providers/user-hydrator.tsx +++ b/src/providers/user-hydrator.tsx @@ -4,22 +4,12 @@ import { useEffect } from 'react'; import { useClubActions } from '@/stores/useClubStore'; import { useUserActions } from '@/stores/useUserStore'; - -interface UserInfo { - id: number; - name: string; - profileImageUrl: string | null; - role: 'LEAD' | 'ADMIN' | 'USER'; -} - -interface ClubInfo { - clubId: string; - clubName: string; -} +import type { ClubIdentifier } from '@/types/club'; +import type { UserInfo } from '@/types/user'; interface UserHydratorProps { userInfo: UserInfo; - clubInfo: ClubInfo; + clubInfo: ClubIdentifier; children: React.ReactNode; } diff --git a/src/stores/useClubStore.ts b/src/stores/useClubStore.ts index 5ef4eb60..0bcec192 100644 --- a/src/stores/useClubStore.ts +++ b/src/stores/useClubStore.ts @@ -2,6 +2,8 @@ import { create } from 'zustand'; import { combine, devtools, persist } from 'zustand/middleware'; import { useShallow } from 'zustand/react/shallow'; +import type { ClubIdentifier } from '@/types/club'; + const initialState = { clubId: null as string | null, clubName: null as string | null, @@ -13,8 +15,7 @@ export const useClubStore = create( devtools( persist( combine(initialState, (set) => ({ - setClub: (club: { clubId: string; clubName: string }) => - set(club, false, 'setClub'), + setClub: (club: ClubIdentifier) => set(club, false, 'setClub'), reset: () => set(initialState, false, 'reset'), })), { name: 'clubId' }, diff --git a/src/stores/useUserStore.ts b/src/stores/useUserStore.ts index f903d4f5..175b2f00 100644 --- a/src/stores/useUserStore.ts +++ b/src/stores/useUserStore.ts @@ -2,6 +2,8 @@ import { create } from 'zustand'; import { combine, devtools } from 'zustand/middleware'; import { useShallow } from 'zustand/react/shallow'; +import type { UserInfo } from '@/types/user'; + const initialState = { id: null as number | null, name: null as string | null, @@ -14,12 +16,7 @@ export type UserState = typeof initialState; export const useUserStore = create( devtools( combine(initialState, (set) => ({ - setUser: (user: { - id: number; - name: string; - profileImageUrl: string | null; - role: 'LEAD' | 'ADMIN' | 'USER'; - }) => set(user, false, 'setUser'), + setUser: (user: UserInfo) => set(user, false, 'setUser'), reset: () => set(initialState, false, 'reset'), })), { name: 'UserStore' }, diff --git a/src/types/club.ts b/src/types/club.ts index 2794b705..1b567377 100644 --- a/src/types/club.ts +++ b/src/types/club.ts @@ -6,3 +6,9 @@ export interface Club { description: string; logoUrl?: string; } + +/** store hydration 등에 사용되는 최소 클럽 식별 정보 */ +export interface ClubIdentifier { + clubId: string; + clubName: string; +} diff --git a/src/types/home.ts b/src/types/home.ts index e55db293..9dde1504 100644 --- a/src/types/home.ts +++ b/src/types/home.ts @@ -1,23 +1,14 @@ import type { ApiResponse } from '@/types/common'; - -type Role = 'LEAD' | 'ADMIN' | 'USER'; -type NullableImage = string | null; - -interface Identifiable { - id: T; -} - -interface Named { - name: string; -} - -interface WithProfileImage { - profileImageUrl: NullableImage; -} - -interface WithRole { - role: Role; -} +import type { + Identifiable, + Named, + NullableImage, + Role, + UserInfo, + UserSummary, + WithProfileImage, + WithRole, +} from '@/types/user'; interface ClubInfo { id: string; @@ -30,10 +21,6 @@ interface ClubInfo { backgroundImageUrl: NullableImage; } -type UserSummary = Identifiable & Named & WithProfileImage & WithRole; - -type UserInfo = UserSummary; - interface MyInfo { userInfo: UserInfo; bio: string | null; diff --git a/src/types/index.ts b/src/types/index.ts index d9aa4b65..62a05762 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,7 +2,6 @@ export type { ApiResponse, MutationCallbacks } from './common'; export type { ClubInfo, - UserInfo, MyInfo, HomeDashboard, HomeDashboardResponse, @@ -14,4 +13,14 @@ export type { RecentPost, PageData, } from './home'; -export type { Club } from './club'; +export type { Club, ClubIdentifier } from './club'; +export type { + Role, + NullableImage, + Identifiable, + Named, + WithProfileImage, + WithRole, + UserSummary, + UserInfo, +} from './user'; diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 00000000..9ca64848 --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,22 @@ +export type Role = 'LEAD' | 'ADMIN' | 'USER'; +export type NullableImage = string | null; + +export interface Identifiable { + id: T; +} + +export interface Named { + name: string; +} + +export interface WithProfileImage { + profileImageUrl: NullableImage; +} + +export interface WithRole { + role: Role; +} + +export type UserSummary = Identifiable & Named & WithProfileImage & WithRole; + +export type UserInfo = UserSummary; From f6e19b6b4bb7a14e4a3bc20850d7a5166ac89e1c Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Thu, 9 Apr 2026 17:12:58 +0900 Subject: [PATCH 057/684] =?UTF-8?q?fix:=20=EC=B6=9C=EC=84=9D=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EA=B0=92=EC=9D=B4?= =?UTF-8?q?=20null=EC=9D=BC=20=EB=95=8C=200=EC=9C=BC=EB=A1=9C=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EB=90=98=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/attendance/AttendanceHistoryContent.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/attendance/AttendanceHistoryContent.tsx b/src/components/attendance/AttendanceHistoryContent.tsx index c33abf01..b35a87da 100644 --- a/src/components/attendance/AttendanceHistoryContent.tsx +++ b/src/components/attendance/AttendanceHistoryContent.tsx @@ -38,7 +38,7 @@ function toDisplayRecord(record: AttendanceSummary['attendances'][number]) { } function AttendanceHistoryContent({ summary }: AttendanceHistoryContentProps) { - const { total = 0, attendanceCount = 0, absenceCount = 0, attendances = [] } = summary; + const { total, attendanceCount, absenceCount, attendances = [] } = summary; const records = attendances.map(toDisplayRecord); return ( @@ -67,9 +67,9 @@ function AttendanceHistoryContent({ summary }: AttendanceHistoryContentProps) {
- - - + + +
From 2539bcf3fc6c34c6515343c746548089461bd191 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Thu, 9 Apr 2026 21:49:00 +0900 Subject: [PATCH 058/684] =?UTF-8?q?fix:=20=EC=A0=84=ED=99=94=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20register=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mypage/edit/EditProfileContent.tsx | 2 +- .../mypage/edit/PersonalInfoFields.tsx | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/mypage/edit/EditProfileContent.tsx b/src/components/mypage/edit/EditProfileContent.tsx index 6a341a32..47c47929 100644 --- a/src/components/mypage/edit/EditProfileContent.tsx +++ b/src/components/mypage/edit/EditProfileContent.tsx @@ -121,7 +121,7 @@ function EditProfileContent({ className, ...props }: EditProfileContentProps) { />
- + ; errors: FieldErrors; - setValue: UseFormSetValue; } -function PersonalInfoFields({ register, errors, setValue }: PersonalInfoFieldsProps) { - const handlePhoneChange = (e: React.ChangeEvent) => { - setValue('tel', formatPhone(e.target.value), { shouldValidate: true }); - }; +function PersonalInfoFields({ register, errors }: PersonalInfoFieldsProps) { + const telRegister = register('tel'); return (
@@ -33,8 +30,11 @@ function PersonalInfoFields({ register, errors, setValue }: PersonalInfoFieldsPr { + e.target.value = formatPhone(e.target.value); + telRegister.onChange(e); + }} placeholder="010-0000-0000" inputMode="numeric" className="rounded-lg" From 62e1317aacc272ee71bf6889baa2430b2af7009b Mon Sep 17 00:00:00 2001 From: JIN921 Date: Thu, 9 Apr 2026 23:21:06 +0900 Subject: [PATCH 059/684] =?UTF-8?q?fix:=20=ED=95=99=EA=B5=90=20api=20rsc?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(private)/(main)/mypage/edit/page.tsx | 13 +++++++-- .../mypage/edit/EditProfileContent.tsx | 9 ++++-- .../mypage/edit/SchoolInfoFields.tsx | 21 ++++++++------ src/hooks/queries/mypage/useMajorsQuery.ts | 11 -------- src/hooks/queries/mypage/useSchoolsQuery.ts | 11 -------- src/lib/apis/index.ts | 1 - src/lib/apis/university.server.ts | 28 +++++++++++++++++++ src/lib/apis/university.ts | 19 ------------- 8 files changed, 58 insertions(+), 55 deletions(-) delete mode 100644 src/hooks/queries/mypage/useMajorsQuery.ts delete mode 100644 src/hooks/queries/mypage/useSchoolsQuery.ts create mode 100644 src/lib/apis/university.server.ts delete mode 100644 src/lib/apis/university.ts diff --git a/src/app/(private)/(main)/mypage/edit/page.tsx b/src/app/(private)/(main)/mypage/edit/page.tsx index 32dc1bc4..410433d5 100644 --- a/src/app/(private)/(main)/mypage/edit/page.tsx +++ b/src/app/(private)/(main)/mypage/edit/page.tsx @@ -1,5 +1,14 @@ import { EditProfileContent } from '@/components/mypage'; +import { universityServerApi } from '@/lib/apis/university.server'; -export default function EditProfilePage() { - return ; +export default async function EditProfilePage() { + const [schoolsRes, majorsRes] = await Promise.all([ + universityServerApi.getSchools(), + universityServerApi.getMajors(), + ]); + + const schools = schoolsRes.data.map((s) => s.schoolName); + const majors = majorsRes.data.map((m) => m.majorName); + + return ; } diff --git a/src/components/mypage/edit/EditProfileContent.tsx b/src/components/mypage/edit/EditProfileContent.tsx index 47c47929..5370478a 100644 --- a/src/components/mypage/edit/EditProfileContent.tsx +++ b/src/components/mypage/edit/EditProfileContent.tsx @@ -27,9 +27,12 @@ import { ProfileImageEditor } from './ProfileImageEditor'; import { PersonalInfoFields } from './PersonalInfoFields'; import { SchoolInfoFields } from './SchoolInfoFields'; -type EditProfileContentProps = React.HTMLAttributes; +interface EditProfileContentProps extends React.HTMLAttributes { + schools: string[]; + majors: string[]; +} -function EditProfileContent({ className, ...props }: EditProfileContentProps) { +function EditProfileContent({ className, schools, majors, ...props }: EditProfileContentProps) { const router = useRouter(); const { data: me } = useMyMemberQuery(); const { mutate: updateProfile, isPending } = useUpdateProfileMutation(); @@ -127,6 +130,8 @@ function EditProfileContent({ className, ...props }: EditProfileContentProps) { control={control} errors={errors} setValue={setValue} + schools={schools} + majors={majors} /> ); } else if (external) { el = ( {iconEl} - {!collapsed && {label}} + {!collapsed && {label}} ); } else { el = ( {iconEl} - {!collapsed && {label}} + {!collapsed && {label}} ); } diff --git a/src/components/admin/layout/ThemeModeSelector.tsx b/src/components/admin/layout/ThemeModeSelector.tsx index 846b859f..ca5ccf73 100644 --- a/src/components/admin/layout/ThemeModeSelector.tsx +++ b/src/components/admin/layout/ThemeModeSelector.tsx @@ -61,7 +61,7 @@ function ThemeModeSelector({ collapsed }: ThemeModeSelectorProps) { ); } diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index ec2f73d4..0861ca9f 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -27,6 +27,7 @@ function MemberPageContent() { const [selectedIds, setSelectedIds] = useState>(new Set()); const [searchValue, setSearchValue] = useState(''); const [detailMember, setDetailMember] = useState(null); + const [selectedCardinal, setSelectedCardinal] = useState('all'); const { ref: dragScrollRef, onMouseDown } = useDragScroll(); const { data: members = [] } = useAdminMembers(); const { data: cardinals = [] } = useCardinals(); @@ -39,16 +40,26 @@ function MemberPageContent() { setDetailMember(m); }; + const cardinalFilteredMembers = + selectedCardinal === 'all' + ? members + : members.filter((m) => + m.generation + .split(',') + .map((g) => g.trim()) + .includes(String(selectedCardinal)), + ); + const query = searchValue.trim().toLowerCase(); const filteredMembers = query - ? members.filter( + ? cardinalFilteredMembers.filter( (m) => m.name.toLowerCase().includes(query) || m.department.toLowerCase().includes(query) || m.studentId.includes(query) || m.generation.includes(query), ) - : members; + : cardinalFilteredMembers; const selectedMembers = filteredMembers.filter((m) => selectedIds.has(m.id)); const selectedCount = selectedMembers.length; @@ -83,35 +94,39 @@ function MemberPageContent() { {/* Main content */}
- {/* Search bar */} - - - - - {/* Generation cards */} + {/* Generation pills */}
- - createCardinal({ cardinalNumber: generation, year, semester, inProgress: isCurrent }) - } - > - - - {/* */} + setSelectedCardinal('all')} + /> {cardinals.map((c) => ( setSelectedCardinal(c.cardinalNumber)} /> ))} + + createCardinal({ cardinalNumber: generation, inProgress: isCurrent }) + } + > + +
+ {/* Search bar */} + + + + {/* Member table */} 취소 ))} - {/* - - */} + +
- - {/* Generation confirm alert */} - {/* - - - - {selectedCount}명의 멤버를 {pendingGeneration}기로 변경하시겠습니까? - - - - 확인 - 취소 - - - */} ); } diff --git a/src/components/admin/member/modal/AddGenerationModal.tsx b/src/components/admin/member/modal/AddGenerationModal.tsx index 8abe03e2..0bcb6500 100644 --- a/src/components/admin/member/modal/AddGenerationModal.tsx +++ b/src/components/admin/member/modal/AddGenerationModal.tsx @@ -15,25 +15,16 @@ import { interface AddGenerationModalProps { children: ReactNode; - onSubmit?: (data: { - generation: number; - year: number; - semester: number; - isCurrent: boolean; - }) => void; + onSubmit?: (data: { generation: number; isCurrent: boolean }) => void; } function AddGenerationModal({ children, onSubmit }: AddGenerationModalProps) { const [open, setOpen] = useState(false); const [generation, setGeneration] = useState(''); - const [year, setYear] = useState(''); - const [semester, setSemester] = useState(''); const [isCurrent, setIsCurrent] = useState(false); const resetForm = () => { setGeneration(''); - setYear(''); - setSemester(''); setIsCurrent(false); }; @@ -42,14 +33,12 @@ function AddGenerationModal({ children, onSubmit }: AddGenerationModalProps) { if (!next) resetForm(); }; - const isValid = generation !== '' && year !== '' && semester !== ''; + const isValid = generation !== ''; const handleSubmit = () => { if (!isValid) return; onSubmit?.({ generation: Number(generation), - year: Number(year), - semester: Number(semester), isCurrent, }); handleOpenChange(false); @@ -86,47 +75,6 @@ function AddGenerationModal({ children, onSubmit }: AddGenerationModalProps) {
- - {/* 활동 시기 */} -
-

활동 시기

-
-
- { - const v = (e.target as HTMLInputElement).value; - if (v === '' || Number(v) > 0) setYear(v); - }} - className="pr-10" - placeholder=" " - /> - - 년 - -
-
- { - const v = (e.target as HTMLInputElement).value; - if (v === '' || Number(v) > 0) setSemester(v); - }} - className="pr-10" - placeholder=" " - /> - - 학기 - -
-
-
diff --git a/src/hooks/mutations/admin/useAdminCardinalMutations.ts b/src/hooks/mutations/admin/useAdminCardinalMutations.ts index ef486df7..c061bb89 100644 --- a/src/hooks/mutations/admin/useAdminCardinalMutations.ts +++ b/src/hooks/mutations/admin/useAdminCardinalMutations.ts @@ -8,12 +8,8 @@ export function useCreateCardinal() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (body: { - cardinalNumber: number; - year: number; - semester: number; - inProgress: boolean; - }) => cardinalApi.createCardinal(clubId!, body), + mutationFn: (body: { cardinalNumber: number; inProgress: boolean }) => + cardinalApi.createCardinal(clubId!, body), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['cardinals', clubId] }); }, From f5ab9a20fa19a0626ec561ea73c564dd361759c8 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 12 Apr 2026 04:06:49 +0900 Subject: [PATCH 070/684] =?UTF-8?q?fix:=20=EA=B8=B0=EC=88=98=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/index.ts | 4 + .../admin/member/MemberPageContent.tsx | 16 ++++ src/components/admin/member/MemberTopBar.tsx | 52 ++++------- src/components/admin/member/index.ts | 4 + .../member/modal/ChangeCardinalsModal.tsx | 92 +++++++++++++++++++ .../admin/member/modal/MemberDetailModal.tsx | 45 ++------- src/hooks/mutations/admin/index.ts | 7 +- .../admin/useAdminMemberMutations.ts | 25 +++++ src/lib/apis/adminMember.ts | 9 ++ 9 files changed, 180 insertions(+), 74 deletions(-) create mode 100644 src/components/admin/member/modal/ChangeCardinalsModal.tsx diff --git a/src/components/admin/index.ts b/src/components/admin/index.ts index 8db47f03..b0f48b95 100644 --- a/src/components/admin/index.ts +++ b/src/components/admin/index.ts @@ -4,6 +4,10 @@ export { AddGenerationModal, type AddGenerationModalProps, } from './member/modal/AddGenerationModal'; +export { + ChangeCardinalsModal, + type ChangeCardinalsModalProps, +} from './member/modal/ChangeCardinalsModal'; export { ChangeGenerationModal, type ChangeGenerationModalProps, diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index 0861ca9f..1eb2f2d3 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -18,6 +18,7 @@ import { useAdminMembers } from '@/hooks/queries/admin'; import { useCardinals } from '@/hooks/queries'; import { useBanMember, + useChangeMemberCardinals, useChangeMemberRole, useCreateCardinal, useRestoreMember, @@ -35,6 +36,7 @@ function MemberPageContent() { const { mutate: banMember } = useBanMember(); const { mutate: restoreMember } = useRestoreMember(); const { mutate: createCardinal } = useCreateCardinal(); + const { mutate: changeMemberCardinals } = useChangeMemberCardinals(); const handleMemberAction = (m: Member) => { setDetailMember(m); @@ -90,6 +92,11 @@ function MemberPageContent() { } onBan={() => selectedMembers.forEach((m) => banMember(m.clubMemberId))} onRestore={() => selectedMembers.forEach((m) => restoreMember(m.clubMemberId))} + onChangeCardinals={(cardinalIds) => + selectedMembers.forEach((m) => + changeMemberCardinals({ clubMemberId: m.clubMemberId, cardinalIds }), + ) + } /> {/* Main content */} @@ -175,6 +182,15 @@ function MemberPageContent() { } : undefined } + onChangeCardinals={ + detailMember + ? (cardinalIds) => + changeMemberCardinals({ + clubMemberId: detailMember.clubMemberId, + cardinalIds, + }) + : undefined + } />
); diff --git a/src/components/admin/member/MemberTopBar.tsx b/src/components/admin/member/MemberTopBar.tsx index 48d4af18..12bcd02c 100644 --- a/src/components/admin/member/MemberTopBar.tsx +++ b/src/components/admin/member/MemberTopBar.tsx @@ -3,21 +3,10 @@ import React from 'react'; import { ArrowLeftIcon } from '@/assets/icons'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - // AlertDialogContent, - // AlertDialogFooter, - // AlertDialogHeader, - // AlertDialogTitle, - Button, - Icon, -} from '@/components/ui'; -// import { ChangeGenerationModal } from '@/components/admin/member/modal/ChangeGenerationModal'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, Button, Icon } from '@/components/ui'; +import { ChangeCardinalsModal } from '@/components/admin/member/modal/ChangeCardinalsModal'; import { cn } from '@/lib/cn'; import { getTopBarActions } from '@/constants/admin/memberTopBar.constants'; -// import { useGenerationConfirm } from '@/hooks'; import type { ClubMemberRole } from '@/types/admin/member'; @@ -31,7 +20,7 @@ interface MemberTopBarProps extends React.HTMLAttributes { onResetPassword?: () => void; onBan?: () => void; onRestore?: () => void; - onChangeGeneration?: (generation: number) => void; + onChangeCardinals?: (cardinalIds: number[]) => void; ref?: React.Ref; } @@ -46,19 +35,10 @@ function MemberTopBar({ onResetPassword, onBan, onRestore, - // onChangeGeneration, + onChangeCardinals, ref, ...props }: MemberTopBarProps) { - // TODO: 기수변경 추후 연결 - // const { - // genConfirmOpen, - // setGenConfirmOpen, - // pendingGeneration, - // handleGenSubmit, - // handleGenConfirm, - // } = useGenerationConfirm(onChangeGeneration); - if (selectedCount === 0) return null; const topBarActions = getTopBarActions({ @@ -73,12 +53,11 @@ function MemberTopBar({ }); return ( - <> -
+
-
+ {onChangeCardinals && ( + + + + )}
- +
); } diff --git a/src/components/admin/member/index.ts b/src/components/admin/member/index.ts index 65e0377e..cf06f0be 100644 --- a/src/components/admin/member/index.ts +++ b/src/components/admin/member/index.ts @@ -1,4 +1,8 @@ export { AddGenerationButton, type AddGenerationButtonProps } from './AddGenerationButton'; +export { + ChangeCardinalsModal, + type ChangeCardinalsModalProps, +} from './modal/ChangeCardinalsModal'; export { ChangeGenerationModal, type ChangeGenerationModalProps, diff --git a/src/components/admin/member/modal/ChangeCardinalsModal.tsx b/src/components/admin/member/modal/ChangeCardinalsModal.tsx new file mode 100644 index 00000000..97338fb5 --- /dev/null +++ b/src/components/admin/member/modal/ChangeCardinalsModal.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { useEffect, useState, type ReactNode } from 'react'; + +import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'; +import { ModalFooter } from '@/components/mypage/SetCardinalModal/components/ModalFooter'; +import { ModalHeader } from '@/components/mypage/SetCardinalModal/components/ModalHeader'; +import { + CardinalTags, + Step2Select, +} from '@/components/mypage/SetCardinalModal/components/steps/Step2Select'; +import { useCardinals } from '@/hooks/queries'; + +interface ChangeCardinalsModalProps { + children: ReactNode; + overline?: string; + onSubmit?: (cardinalIds: number[]) => void; +} + +function ChangeCardinalsModal({ + children, + overline = '멤버 기수 변경', + onSubmit, +}: ChangeCardinalsModalProps) { + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const { data: cardinalsData = [] } = useCardinals(); + const availableCardinals = cardinalsData.map((c) => c.cardinalNumber); + + useEffect(() => { + if (!open) setSelected(new Set()); + }, [open]); + + const handleToggle = (n: number) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(n)) next.delete(n); + else next.add(n); + return next; + }); + }; + + const handleClose = () => setOpen(false); + + const selectedArray = [...selected].sort((a, b) => a - b); + + const handleSave = () => { + if (selected.size === 0) return; + onSubmit?.(selectedArray); + handleClose(); + }; + + return ( + + {children} + + + + + + {selected.size > 0 && ( +
+ 선택됨 + +
+ )} +
+
+
+ ); +} + +export { ChangeCardinalsModal, type ChangeCardinalsModalProps }; diff --git a/src/components/admin/member/modal/MemberDetailModal.tsx b/src/components/admin/member/modal/MemberDetailModal.tsx index d516ce9e..537da59d 100644 --- a/src/components/admin/member/modal/MemberDetailModal.tsx +++ b/src/components/admin/member/modal/MemberDetailModal.tsx @@ -1,20 +1,14 @@ 'use client'; -import { useGenerationConfirm } from '@/hooks'; - import { AlertDialog, AlertDialogAction, AlertDialogCancel, - AlertDialogContent, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, Button, Icon, } from '@/components/ui'; import { Dialog, DialogContent } from '@/components/ui/dialog'; -import { ChangeGenerationModal } from '@/components/admin/member/modal/ChangeGenerationModal'; +import { ChangeCardinalsModal } from '@/components/admin/member/modal/ChangeCardinalsModal'; import { cn } from '@/lib/cn'; import { AdminCloseIcon } from '@/assets/icons/admin'; import { @@ -36,7 +30,7 @@ interface MemberDetailModalProps { onResetPassword?: () => void; onBan?: () => void; onRestore?: () => void; - onChangeGeneration?: (generation: number) => void; + onChangeCardinals?: (cardinalIds: number[]) => void; } function MemberDetailModal({ @@ -48,16 +42,8 @@ function MemberDetailModal({ onResetPassword, onBan, onRestore, - onChangeGeneration, + onChangeCardinals, }: MemberDetailModalProps) { - const { - genConfirmOpen, - setGenConfirmOpen, - pendingGeneration, - handleGenSubmit, - handleGenConfirm, - } = useGenerationConfirm(onChangeGeneration); - if (!member) return null; const handleClose = () => onOpenChange(false); @@ -76,8 +62,7 @@ function MemberDetailModal({ }); return ( - <> - + 취소 ))} - {onChangeGeneration && ( - + {onChangeCardinals && ( + - + )}
@@ -181,22 +166,6 @@ function MemberDetailModal({
- - {/* Generation confirm alert */} - - - - - 1명의 멤버를 {pendingGeneration}기로 변경하시겠습니까? - - - - 확인 - 취소 - - - - ); } diff --git a/src/hooks/mutations/admin/index.ts b/src/hooks/mutations/admin/index.ts index 21ad6891..e90dbee8 100644 --- a/src/hooks/mutations/admin/index.ts +++ b/src/hooks/mutations/admin/index.ts @@ -1,2 +1,7 @@ -export { useChangeMemberRole, useBanMember, useRestoreMember } from './useAdminMemberMutations'; +export { + useChangeMemberRole, + useBanMember, + useRestoreMember, + useChangeMemberCardinals, +} from './useAdminMemberMutations'; export { useCreateCardinal } from './useAdminCardinalMutations'; diff --git a/src/hooks/mutations/admin/useAdminMemberMutations.ts b/src/hooks/mutations/admin/useAdminMemberMutations.ts index 504bfa90..4d423946 100644 --- a/src/hooks/mutations/admin/useAdminMemberMutations.ts +++ b/src/hooks/mutations/admin/useAdminMemberMutations.ts @@ -84,6 +84,31 @@ export function useBanMember() { }); } +// 멤버 기수 수정 +export function useChangeMemberCardinals() { + const queryClient = useQueryClient(); + const clubId = useClubId(); + const queryKey = ['admin', 'members', clubId]; + + return useMutation({ + mutationFn: ({ + clubMemberId, + cardinalIds, + force, + }: { + clubMemberId: number; + cardinalIds: number[]; + force?: boolean; + }) => { + if (!clubId) throw new Error('clubId가 없습니다'); + return adminMemberApi.updateMemberCardinals(clubId, clubMemberId, { cardinalIds, force }); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); +} + // 추방 유저 복구 export function useRestoreMember() { const queryClient = useQueryClient(); diff --git a/src/lib/apis/adminMember.ts b/src/lib/apis/adminMember.ts index c0b00cbd..7254ab5a 100644 --- a/src/lib/apis/adminMember.ts +++ b/src/lib/apis/adminMember.ts @@ -11,4 +11,13 @@ export const adminMemberApi = { apiClient.delete(`/admin/clubs/${clubId}/members/${clubMemberId}/ban`), restoreMember: (clubId: string, clubMemberId: number) => apiClient.patch(`/admin/clubs/${clubId}/members/${clubMemberId}/restore`), + updateMemberCardinals: ( + clubId: string, + clubMemberId: number, + body: { cardinalIds: number[]; force?: boolean }, + ) => + apiClient.patch(`/admin/clubs/${clubId}/members/${clubMemberId}/cardinals`, { + cardinalIds: body.cardinalIds, + force: body.force ?? false, + }), }; From 8ded216478abbb80a6ccfde2e3c3eadd42797c2c Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 12 Apr 2026 04:11:01 +0900 Subject: [PATCH 071/684] =?UTF-8?q?feat:=20=EA=B8=B0=EC=88=98=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=AA=A8=EB=8B=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/member/index.ts | 10 +- .../member/modal/ChangeCardinalsModal.tsx | 4 +- .../member/modal/ChangeGenerationModal.tsx | 121 ------------------ .../admin/member/modal/GenerationDropdown.tsx | 59 --------- .../components/ModalHeader.tsx | 8 +- 5 files changed, 7 insertions(+), 195 deletions(-) delete mode 100644 src/components/admin/member/modal/ChangeGenerationModal.tsx delete mode 100644 src/components/admin/member/modal/GenerationDropdown.tsx diff --git a/src/components/admin/member/index.ts b/src/components/admin/member/index.ts index cf06f0be..4a22434b 100644 --- a/src/components/admin/member/index.ts +++ b/src/components/admin/member/index.ts @@ -1,13 +1,5 @@ export { AddGenerationButton, type AddGenerationButtonProps } from './AddGenerationButton'; -export { - ChangeCardinalsModal, - type ChangeCardinalsModalProps, -} from './modal/ChangeCardinalsModal'; -export { - ChangeGenerationModal, - type ChangeGenerationModalProps, -} from './modal/ChangeGenerationModal'; -export { GenerationDropdown, type GenerationDropdownProps } from './modal/GenerationDropdown'; +export { ChangeCardinalsModal, type ChangeCardinalsModalProps } from './modal/ChangeCardinalsModal'; export { GenerationCard, generationCardVariants, type GenerationCardProps } from './GenerationCard'; export { MemberPageContent } from './MemberPageContent'; export { MemberSearchBar, type MemberSearchBarProps } from './MemberSearchBar'; diff --git a/src/components/admin/member/modal/ChangeCardinalsModal.tsx b/src/components/admin/member/modal/ChangeCardinalsModal.tsx index 97338fb5..159a07bf 100644 --- a/src/components/admin/member/modal/ChangeCardinalsModal.tsx +++ b/src/components/admin/member/modal/ChangeCardinalsModal.tsx @@ -56,11 +56,9 @@ function ChangeCardinalsModal({ void; -} - -function ChangeGenerationModal({ - children, - generations = [], - onSubmit, -}: ChangeGenerationModalProps) { - const [open, setOpen] = useState(false); - const [input, setInput] = useState(''); - const [selectedLabel, setSelectedLabel] = useState(DIRECT_INPUT_LABEL); - - const resetForm = () => { - setInput(''); - setSelectedLabel(DIRECT_INPUT_LABEL); - }; - - const handleOpenChange = (next: boolean) => { - setOpen(next); - if (!next) resetForm(); - }; - - const handleInputChange = (e: ChangeEvent) => { - const v = e.target.value; - if (v === '' || Number(v) > 0) { - setInput(v); - setSelectedLabel(DIRECT_INPUT_LABEL); - } - }; - - const handleSelectGeneration = (gen: number) => { - setInput(String(gen)); - setSelectedLabel(`${gen}기`); - }; - - const handleSelectDirect = () => { - setInput(''); - setSelectedLabel(DIRECT_INPUT_LABEL); - }; - - const isValid = input !== '' && Number(input) > 0; - const isNewGeneration = isValid && !generations.includes(Number(input)); - - const handleSubmit = () => { - if (!isValid) return; - onSubmit?.(Number(input)); - handleOpenChange(false); - }; - - return ( - - {children} - - - - -
- - -
- - {isNewGeneration && ( -

- *저장되지 않은 숫자는 새로운 기수로 추가됩니다. -

- )} -
- - -
- - -
-
-
-
- ); -} - -export { ChangeGenerationModal, type ChangeGenerationModalProps }; diff --git a/src/components/admin/member/modal/GenerationDropdown.tsx b/src/components/admin/member/modal/GenerationDropdown.tsx deleted file mode 100644 index 1e8a32b5..00000000 --- a/src/components/admin/member/modal/GenerationDropdown.tsx +++ /dev/null @@ -1,59 +0,0 @@ -'use client'; - -import { AdminMeatballIcon } from '@/assets/icons/admin'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - Icon, -} from '@/components/ui'; -import { cn } from '@/lib/cn'; - -interface GenerationDropdownProps { - generations: number[]; - selectedLabel: string; - onSelectGeneration: (gen: number) => void; - onSelectDirect: () => void; -} - -function GenerationDropdown({ - generations, - selectedLabel, - onSelectGeneration, - onSelectDirect, -}: GenerationDropdownProps) { - return ( - - - {selectedLabel} - - - - - - 직접 입력 - - {generations.map((gen) => ( - onSelectGeneration(gen)} - > - {gen}기 - - ))} - - - ); -} - -export { GenerationDropdown, type GenerationDropdownProps }; diff --git a/src/components/mypage/SetCardinalModal/components/ModalHeader.tsx b/src/components/mypage/SetCardinalModal/components/ModalHeader.tsx index a01d73db..134f6113 100644 --- a/src/components/mypage/SetCardinalModal/components/ModalHeader.tsx +++ b/src/components/mypage/SetCardinalModal/components/ModalHeader.tsx @@ -19,8 +19,8 @@ function StepIndicator({ current, total }: { current: number; total: number }) { } interface ModalHeaderProps { - step: number; - total: number; + step?: number; + total?: number; overline: string; title: string; onClose: () => void; @@ -30,7 +30,9 @@ function ModalHeader({ step, total, overline, title, onClose }: ModalHeaderProps return (
- + {step !== undefined && total !== undefined && ( + + )}

{overline}

{title}

From d0cf17712f637c7e78d126bfeadec1625a7ab451 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 12 Apr 2026 04:19:45 +0900 Subject: [PATCH 072/684] =?UTF-8?q?feat:=20=EA=B8=B0=EC=88=98=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=97=90=EB=9F=AC=20=ED=95=B8=EB=93=A4=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/index.ts | 4 - .../admin/member/MemberPageContent.tsx | 100 +++++++++++++++--- src/constants/errorCode.ts | 5 + 3 files changed, 89 insertions(+), 20 deletions(-) diff --git a/src/components/admin/index.ts b/src/components/admin/index.ts index b0f48b95..1c989bdc 100644 --- a/src/components/admin/index.ts +++ b/src/components/admin/index.ts @@ -8,10 +8,6 @@ export { ChangeCardinalsModal, type ChangeCardinalsModalProps, } from './member/modal/ChangeCardinalsModal'; -export { - ChangeGenerationModal, - type ChangeGenerationModalProps, -} from './member/modal/ChangeGenerationModal'; export { GenerationCard, generationCardVariants, diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index 1eb2f2d3..a1b827da 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import { isAxiosError } from 'axios'; import { AddGenerationButton, @@ -11,7 +12,8 @@ import { MemberTable, MemberTopBar, } from '@/components/admin'; -import { Card } from '@/components/ui'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, Card } from '@/components/ui'; +import { MEMBER_CARDINAL_ERROR_CODE } from '@/constants/errorCode'; import { useDragScroll } from '@/hooks'; import type { Member } from '@/types/admin/member'; import { useAdminMembers } from '@/hooks/queries/admin'; @@ -23,6 +25,12 @@ import { useCreateCardinal, useRestoreMember, } from '@/hooks/mutations/admin'; +import { toastError, toastSuccess } from '@/stores/useToastStore'; + +interface ForceConfirmState { + clubMemberIds: number[]; + cardinalIds: number[]; +} function MemberPageContent() { const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -36,7 +44,8 @@ function MemberPageContent() { const { mutate: banMember } = useBanMember(); const { mutate: restoreMember } = useRestoreMember(); const { mutate: createCardinal } = useCreateCardinal(); - const { mutate: changeMemberCardinals } = useChangeMemberCardinals(); + const { mutateAsync: changeMemberCardinalsAsync } = useChangeMemberCardinals(); + const [forceConfirm, setForceConfirm] = useState(null); const handleMemberAction = (m: Member) => { setDetailMember(m); @@ -73,6 +82,63 @@ function MemberPageContent() { const handleClearSelection = () => setSelectedIds(new Set()); + const submitCardinalsChange = async ( + clubMemberIds: number[], + cardinalIds: number[], + force = false, + ) => { + const results = await Promise.allSettled( + clubMemberIds.map((clubMemberId) => + changeMemberCardinalsAsync({ clubMemberId, cardinalIds, force }), + ), + ); + + const attendanceFailedIds: number[] = []; + let otherErrorCount = 0; + + results.forEach((result, idx) => { + if (result.status !== 'rejected') return; + const err = result.reason; + const code = isAxiosError(err) ? err.response?.data?.code : undefined; + if (code === MEMBER_CARDINAL_ERROR_CODE.REMOVAL_HAS_ATTENDANCE) { + attendanceFailedIds.push(clubMemberIds[idx]); + } else { + otherErrorCount += 1; + } + }); + + if (attendanceFailedIds.length > 0) { + setForceConfirm({ clubMemberIds: attendanceFailedIds, cardinalIds }); + return; + } + + if (otherErrorCount > 0) { + toastError('기수 변경에 실패했습니다.'); + return; + } + + toastSuccess('기수가 변경되었습니다.'); + }; + + const handleChangeCardinalsForDetail = (cardinalIds: number[]) => { + if (!detailMember) return; + submitCardinalsChange([detailMember.clubMemberId], cardinalIds); + }; + + const handleChangeCardinalsForBulk = (cardinalIds: number[]) => { + submitCardinalsChange( + selectedMembers.map((m) => m.clubMemberId), + cardinalIds, + ); + }; + + const handleForceConfirm = () => { + if (!forceConfirm) return; + const { clubMemberIds, cardinalIds } = forceConfirm; + setForceConfirm(null); + submitCardinalsChange(clubMemberIds, cardinalIds, true); + }; + return (
{/* Selection top bar */} @@ -92,11 +158,7 @@ function MemberPageContent() { } onBan={() => selectedMembers.forEach((m) => banMember(m.clubMemberId))} onRestore={() => selectedMembers.forEach((m) => restoreMember(m.clubMemberId))} - onChangeCardinals={(cardinalIds) => - selectedMembers.forEach((m) => - changeMemberCardinals({ clubMemberId: m.clubMemberId, cardinalIds }), - ) - } + onChangeCardinals={handleChangeCardinalsForBulk} /> {/* Main content */} @@ -182,16 +244,22 @@ function MemberPageContent() { } : undefined } - onChangeCardinals={ - detailMember - ? (cardinalIds) => - changeMemberCardinals({ - clubMemberId: detailMember.clubMemberId, - cardinalIds, - }) - : undefined - } + onChangeCardinals={detailMember ? handleChangeCardinalsForDetail : undefined} /> + + {/* 출석 기록이 있는 기수 삭제 확인 */} + { + if (!open) setForceConfirm(null); + }} + status="danger" + title={`출석 기록이 있는\n기수가 포함되어 있습니다.`} + description={'그래도 변경하시겠어요?\n출석/결석 기록도 함께 삭제됩니다.'} + > + 변경 + 취소 +
); } diff --git a/src/constants/errorCode.ts b/src/constants/errorCode.ts index ce0b1ab3..24023c18 100644 --- a/src/constants/errorCode.ts +++ b/src/constants/errorCode.ts @@ -3,3 +3,8 @@ export const CLUB_JOIN_ERROR_CODE = { ALREADY_JOINED: 21102, CLUB_MEMBER_LIMIT_EXCEEDED: 21110, } as const; + +export const MEMBER_CARDINAL_ERROR_CODE = { + /** 삭제하려는 기수에 출석/결석 기록이 존재 — force=true 재요청 필요 */ + REMOVAL_HAS_ATTENDANCE: 21118, +} as const; From 267a6676af1eb9b44618f251ae9e2a617fa60f99 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 12 Apr 2026 04:25:08 +0900 Subject: [PATCH 073/684] =?UTF-8?q?fix:=20=EA=B8=B0=EC=88=98=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=82=AC=ED=95=AD=20=EB=B0=94=EB=A1=9C=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/member/MemberPageContent.tsx | 32 ++++--------------- .../admin/useAdminMemberMutations.ts | 18 +++++++++++ 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index a1b827da..4c83223d 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -35,7 +35,7 @@ interface ForceConfirmState { function MemberPageContent() { const [selectedIds, setSelectedIds] = useState>(new Set()); const [searchValue, setSearchValue] = useState(''); - const [detailMember, setDetailMember] = useState(null); + const [detailMemberId, setDetailMemberId] = useState(null); const [selectedCardinal, setSelectedCardinal] = useState('all'); const { ref: dragScrollRef, onMouseDown } = useDragScroll(); const { data: members = [] } = useAdminMembers(); @@ -47,8 +47,10 @@ function MemberPageContent() { const { mutateAsync: changeMemberCardinalsAsync } = useChangeMemberCardinals(); const [forceConfirm, setForceConfirm] = useState(null); + const detailMember = detailMemberId ? (members.find((m) => m.id === detailMemberId) ?? null) : null; + const handleMemberAction = (m: Member) => { - setDetailMember(m); + setDetailMemberId(m.id); }; const cardinalFilteredMembers = @@ -211,35 +213,15 @@ function MemberPageContent() { { - if (!open) setDetailMember(null); + if (!open) setDetailMemberId(null); }} member={detailMember} - onBan={ - detailMember - ? () => { - setDetailMember({ ...detailMember, status: 'BANNED' }); - banMember(detailMember.clubMemberId); - } - : undefined - } - onRestore={ - detailMember - ? () => { - setDetailMember({ ...detailMember, status: 'ACTIVE' }); - restoreMember(detailMember.clubMemberId); - } - : undefined - } + onBan={detailMember ? () => banMember(detailMember.clubMemberId) : undefined} + onRestore={detailMember ? () => restoreMember(detailMember.clubMemberId) : undefined} onChangeRole={ detailMember ? () => { const nextRole = detailMember.memberRole === 'ADMIN' ? 'USER' : 'ADMIN'; - const ROLE_LABEL = { USER: '사용자', ADMIN: '관리자', LEAD: '리더' } as const; - setDetailMember({ - ...detailMember, - memberRole: nextRole, - position: ROLE_LABEL[nextRole], - }); changeMemberRole({ clubMemberId: detailMember.clubMemberId, memberRole: nextRole }); } : undefined diff --git a/src/hooks/mutations/admin/useAdminMemberMutations.ts b/src/hooks/mutations/admin/useAdminMemberMutations.ts index 4d423946..d3598b7b 100644 --- a/src/hooks/mutations/admin/useAdminMemberMutations.ts +++ b/src/hooks/mutations/admin/useAdminMemberMutations.ts @@ -103,6 +103,24 @@ export function useChangeMemberCardinals() { if (!clubId) throw new Error('clubId가 없습니다'); return adminMemberApi.updateMemberCardinals(clubId, clubMemberId, { cardinalIds, force }); }, + onMutate: async ({ clubMemberId, cardinalIds }) => { + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + const nextGeneration = [...cardinalIds].sort((a, b) => a - b).join(', '); + + queryClient.setQueryData(queryKey, (old = []) => + old.map((m) => + m.clubMemberId === clubMemberId ? { ...m, generation: nextGeneration } : m, + ), + ); + + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData(queryKey, context.previous); + } + }, onSettled: () => { queryClient.invalidateQueries({ queryKey }); }, From ba0251e012beb4f8148f86319f4860ab4f079e5e Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 12 Apr 2026 04:33:51 +0900 Subject: [PATCH 074/684] =?UTF-8?q?fix:=20=EA=B2=80=EC=83=89=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/modal/ChangeCardinalsModal.tsx | 15 ++++++++------- .../admin/memberDetailModal.constants.ts | 18 +++++++++--------- src/utils/admin/memberMapper.ts | 18 +++++++++--------- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/src/components/admin/member/modal/ChangeCardinalsModal.tsx b/src/components/admin/member/modal/ChangeCardinalsModal.tsx index 159a07bf..d85a28e1 100644 --- a/src/components/admin/member/modal/ChangeCardinalsModal.tsx +++ b/src/components/admin/member/modal/ChangeCardinalsModal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState, type ReactNode } from 'react'; +import { useState, type ReactNode } from 'react'; import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'; import { ModalFooter } from '@/components/mypage/SetCardinalModal/components/ModalFooter'; @@ -27,9 +27,12 @@ function ChangeCardinalsModal({ const { data: cardinalsData = [] } = useCardinals(); const availableCardinals = cardinalsData.map((c) => c.cardinalNumber); - useEffect(() => { - if (!open) setSelected(new Set()); - }, [open]); + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (!next) setSelected(new Set()); + }; + + const handleClose = () => handleOpenChange(false); const handleToggle = (n: number) => { setSelected((prev) => { @@ -40,8 +43,6 @@ function ChangeCardinalsModal({ }); }; - const handleClose = () => setOpen(false); - const selectedArray = [...selected].sort((a, b) => a - b); const handleSave = () => { @@ -51,7 +52,7 @@ function ChangeCardinalsModal({ }; return ( - + {children} = { - ACTIVE: 'ACTIVE', - WAITING: 'WAITING', - BANNED: 'BANNED', - LEFT: 'LEFT', + ACTIVE: '활동중', + WAITING: '가입 대기', + BANNED: '추방', + LEFT: '탈퇴', }; export const STATUS_DOT_COLOR: Record = { @@ -33,11 +33,11 @@ export function getActivityStats(member: Member) { return [ { label: '출석', value: member.attendance, color: 'text-text-strong' }, { label: '결석', value: member.absence, color: 'text-text-strong' }, - { - label: '패널티', - value: member.penaltyCount, - color: member.penaltyCount > 0 ? 'text-state-error' : 'text-text-strong', - }, + // { + // label: '패널티', + // value: member.penaltyCount, + // color: member.penaltyCount > 0 ? 'text-state-error' : 'text-text-strong', + // }, ]; } diff --git a/src/utils/admin/memberMapper.ts b/src/utils/admin/memberMapper.ts index f8cc7a0e..6cb590f6 100644 --- a/src/utils/admin/memberMapper.ts +++ b/src/utils/admin/memberMapper.ts @@ -10,18 +10,18 @@ export function toMember(cm: ClubMember): Member { return { id: String(cm.userId), clubMemberId: cm.clubMemberId, - name: cm.name, - email: cm.email, + name: cm.name ?? '', + email: cm.email ?? '', role: '', - department: cm.department, - studentId: cm.studentId, - phone: cm.tel, + department: cm.department ?? '', + studentId: cm.studentId ?? '', + phone: cm.tel ?? '', position: ROLE_MAP[cm.memberRole], memberRole: cm.memberRole, - generation: cm.cardinals.join(', '), - attendance: cm.attendanceCount, - absence: cm.absenceCount, - penaltyCount: cm.penaltyCount, + generation: cm.cardinals?.join(', ') ?? '', + attendance: cm.attendanceCount ?? 0, + absence: cm.absenceCount ?? 0, + penaltyCount: cm.penaltyCount ?? 0, status: cm.memberStatus, }; } From bc0767f12b1dc220c33b75e688b322126b455fbe Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 12 Apr 2026 04:52:01 +0900 Subject: [PATCH 075/684] =?UTF-8?q?fix:=20=EC=95=88=20=EC=93=B0=EB=8A=94?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/member/GenerationCard.tsx | 3 +- .../admin/member/MemberPageContent.tsx | 4 +- src/components/admin/member/MemberTopBar.tsx | 62 +++--- .../admin/member/modal/MemberDetailModal.tsx | 190 +++++++++--------- src/hooks/index.ts | 1 - src/hooks/useGenerationConfirm.ts | 27 --- 6 files changed, 125 insertions(+), 162 deletions(-) delete mode 100644 src/hooks/useGenerationConfirm.ts diff --git a/src/components/admin/member/GenerationCard.tsx b/src/components/admin/member/GenerationCard.tsx index 8e5b49f0..456d38ff 100644 --- a/src/components/admin/member/GenerationCard.tsx +++ b/src/components/admin/member/GenerationCard.tsx @@ -20,7 +20,8 @@ const generationCardVariants = cva( ); interface GenerationCardProps - extends React.ButtonHTMLAttributes, + extends + React.ButtonHTMLAttributes, VariantProps { title: string; ref?: React.Ref; diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index 4c83223d..0f9fce0d 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -47,7 +47,9 @@ function MemberPageContent() { const { mutateAsync: changeMemberCardinalsAsync } = useChangeMemberCardinals(); const [forceConfirm, setForceConfirm] = useState(null); - const detailMember = detailMemberId ? (members.find((m) => m.id === detailMemberId) ?? null) : null; + const detailMember = detailMemberId + ? (members.find((m) => m.id === detailMemberId) ?? null) + : null; const handleMemberAction = (m: Member) => { setDetailMemberId(m.id); diff --git a/src/components/admin/member/MemberTopBar.tsx b/src/components/admin/member/MemberTopBar.tsx index 12bcd02c..3476883c 100644 --- a/src/components/admin/member/MemberTopBar.tsx +++ b/src/components/admin/member/MemberTopBar.tsx @@ -58,41 +58,39 @@ function MemberTopBar({ className={cn('bg-container-primary flex h-15 items-center px-500', className)} {...props} > - + - - {selectedCount}명 선택됨 - + {selectedCount}명 선택됨 -
- {topBarActions.map(({ label, title, handler, disabled }) => ( - - {label} - - } - > - 확인 - 취소 - - ))} - - {onChangeCardinals && ( - - - - )} + } + > + 확인 + 취소 + + ))} + + {onChangeCardinals && ( + + + + )}
); diff --git a/src/components/admin/member/modal/MemberDetailModal.tsx b/src/components/admin/member/modal/MemberDetailModal.tsx index 537da59d..4c2c1844 100644 --- a/src/components/admin/member/modal/MemberDetailModal.tsx +++ b/src/components/admin/member/modal/MemberDetailModal.tsx @@ -1,12 +1,6 @@ 'use client'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - Button, - Icon, -} from '@/components/ui'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, Button, Icon } from '@/components/ui'; import { Dialog, DialogContent } from '@/components/ui/dialog'; import { ChangeCardinalsModal } from '@/components/admin/member/modal/ChangeCardinalsModal'; import { cn } from '@/lib/cn'; @@ -63,109 +57,105 @@ function MemberDetailModal({ return ( - - {/* Header */} -
-

멤버 관리 상세

- -
+ + {/* Header */} +
+

멤버 관리 상세

+ +
+ + {/* Body */} +
+ {/* 회원정보 */} +
+

회원정보

+ +
+ {member.name} + {parseInt(member.generation, 10)}기 +
- {/* Body */} -
- {/* 회원정보 */} -
-

회원정보

- -
- {member.name} - - {parseInt(member.generation, 10)}기 - -
- -
- - - {STATUS_LABEL[member.status]} - -
- -
- {personalInfo.map(({ label, value }) => ( -
- {label} - {value} -
- ))} -
+
+ + {STATUS_LABEL[member.status]}
- {/* 활동정보 */} -
-

활동정보

- -
- {activityInfo.map(({ label, value }) => ( -
- {label} - {value} -
- ))} -
- -
- {activityStats.map(({ label, value, color }) => ( -
- {label} - {value} -
- ))} -
+
+ {personalInfo.map(({ label, value }) => ( +
+ {label} + {value} +
+ ))}
- {/* Footer */} -
-
- {footerActions.map(({ label, title, handler }) => ( - - {label} - - } - > - 확인 - 취소 - + {/* 활동정보 */} +
+

활동정보

+ +
+ {activityInfo.map(({ label, value }) => ( +
+ {label} + {value} +
))} - {onChangeCardinals && ( - - - - )}
- +
+ {activityStats.map(({ label, value, color }) => ( +
+ {label} + {value} +
+ ))} +
+
+
+ + {/* Footer */} +
+
+ {footerActions.map(({ label, title, handler }) => ( + + {label} + + } + > + 확인 + 취소 + + ))} + {onChangeCardinals && ( + + + + )}
- -
+ + +
+ + ); } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 63130fae..da555a9a 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -2,7 +2,6 @@ export { useAutoScrollIntoView } from './useAutoScrollIntoView'; export { useClickOutside } from './useClickOutside'; export { useDragScroll } from './useDragScroll'; -export { useGenerationConfirm } from './useGenerationConfirm'; export { useFileAttach } from './board/useFileAttach'; export { useScrollIntoView } from './useScrollIntoView'; export { useRemainingTime } from './useRemainingTime'; diff --git a/src/hooks/useGenerationConfirm.ts b/src/hooks/useGenerationConfirm.ts deleted file mode 100644 index 615e600c..00000000 --- a/src/hooks/useGenerationConfirm.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useState } from 'react'; - -function useGenerationConfirm(onChangeGeneration?: (generation: number) => void) { - const [genConfirmOpen, setGenConfirmOpen] = useState(false); - const [pendingGeneration, setPendingGeneration] = useState(0); - - const handleGenSubmit = (generation: number) => { - setPendingGeneration(generation); - setGenConfirmOpen(true); - }; - - const handleGenConfirm = () => { - onChangeGeneration?.(pendingGeneration); - setGenConfirmOpen(false); - setPendingGeneration(0); - }; - - return { - genConfirmOpen, - setGenConfirmOpen, - pendingGeneration, - handleGenSubmit, - handleGenConfirm, - }; -} - -export { useGenerationConfirm }; From de19cde541de6fe1d7a9cfc32a83567bc5ec32fe Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 12 Apr 2026 05:07:00 +0900 Subject: [PATCH 076/684] =?UTF-8?q?fix:=20=EC=B6=94=EB=B0=A9,=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/member/MemberPageContent.tsx | 21 ++++++++++++++++--- src/components/admin/member/MemberTopBar.tsx | 6 +++--- .../admin/member/modal/AddGenerationModal.tsx | 2 +- src/constants/admin/memberTopBar.constants.ts | 8 +++---- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/components/admin/member/MemberPageContent.tsx b/src/components/admin/member/MemberPageContent.tsx index 0f9fce0d..e1376b1a 100644 --- a/src/components/admin/member/MemberPageContent.tsx +++ b/src/components/admin/member/MemberPageContent.tsx @@ -82,7 +82,14 @@ function MemberPageContent() { const allUsers = selectedCount > 0 && selectedMembers.every((m) => m.memberRole === 'USER'); const allAdmins = selectedCount > 0 && selectedMembers.every((m) => m.memberRole === 'ADMIN'); const targetRole = allUsers ? 'ADMIN' : allAdmins ? 'USER' : null; + const allBanned = selectedCount > 0 && selectedMembers.every((m) => m.status === 'BANNED'); + const noneBanned = selectedCount > 0 && selectedMembers.every((m) => m.status !== 'BANNED'); + const targetBanAction: 'ban' | 'restore' | null = allBanned + ? 'restore' + : noneBanned + ? 'ban' + : null; const handleClearSelection = () => setSelectedIds(new Set()); @@ -150,7 +157,7 @@ function MemberPageContent() { className="sticky top-0 z-10 -mt-15" selectedCount={selectedCount} targetRole={targetRole} - allBanned={allBanned} + targetBanAction={targetBanAction} onBack={handleClearSelection} onChangeRole={ targetRole @@ -160,8 +167,16 @@ function MemberPageContent() { ) : undefined } - onBan={() => selectedMembers.forEach((m) => banMember(m.clubMemberId))} - onRestore={() => selectedMembers.forEach((m) => restoreMember(m.clubMemberId))} + onBan={ + targetBanAction === 'ban' + ? () => selectedMembers.forEach((m) => banMember(m.clubMemberId)) + : undefined + } + onRestore={ + targetBanAction === 'restore' + ? () => selectedMembers.forEach((m) => restoreMember(m.clubMemberId)) + : undefined + } onChangeCardinals={handleChangeCardinalsForBulk} /> diff --git a/src/components/admin/member/MemberTopBar.tsx b/src/components/admin/member/MemberTopBar.tsx index 3476883c..90a34c78 100644 --- a/src/components/admin/member/MemberTopBar.tsx +++ b/src/components/admin/member/MemberTopBar.tsx @@ -13,7 +13,7 @@ import type { ClubMemberRole } from '@/types/admin/member'; interface MemberTopBarProps extends React.HTMLAttributes { selectedCount: number; targetRole: ClubMemberRole | null; - allBanned: boolean; + targetBanAction: 'ban' | 'restore' | null; onBack: () => void; onApprove?: () => void; onChangeRole?: () => void; @@ -28,7 +28,7 @@ function MemberTopBar({ className, selectedCount, targetRole, - allBanned, + targetBanAction, onBack, onApprove, onChangeRole, @@ -44,7 +44,7 @@ function MemberTopBar({ const topBarActions = getTopBarActions({ selectedCount, targetRole, - allBanned, + targetBanAction, onApprove, onChangeRole, onResetPassword, diff --git a/src/components/admin/member/modal/AddGenerationModal.tsx b/src/components/admin/member/modal/AddGenerationModal.tsx index 0bcb6500..178d9b61 100644 --- a/src/components/admin/member/modal/AddGenerationModal.tsx +++ b/src/components/admin/member/modal/AddGenerationModal.tsx @@ -78,7 +78,7 @@ function AddGenerationModal({ children, onSubmit }: AddGenerationModalProps) { -
+
diff --git a/src/components/auth/hub/ClubCreatingPage.tsx b/src/components/auth/hub/ClubCreatingPage.tsx index 9c6729de..34f2d80c 100644 --- a/src/components/auth/hub/ClubCreatingPage.tsx +++ b/src/components/auth/hub/ClubCreatingPage.tsx @@ -7,7 +7,7 @@ import { useRouter } from 'next/navigation'; import { ProgressBar } from '@/components/ui'; import { createClubAction } from '@/lib/actions/club'; import { useProgressAnimation } from '@/hooks'; -import { useCreateClubDraftStore } from '@/stores'; +import { useClubActions, useCreateClubDraftStore } from '@/stores'; import { toastError } from '@/stores/useToastStore'; import type { CreateClubDraftState } from '@/stores/useCreateClubDraftStore'; @@ -19,9 +19,11 @@ interface ClubCreatingPageProps { function ClubCreatingPage({ intent, onCancel }: ClubCreatingPageProps) { const router = useRouter(); const resetDraft = useCreateClubDraftStore((state) => state.reset); + const { setClub } = useClubActions(); const [apiDone, setApiDone] = useState(false); const apiCalledRef = useRef(false); const animationDoneRef = useRef(false); + const createdClubIdRef = useRef(null); const nextPath = intent === 'create' ? '/home?onboarding=club-created' : '/hub/welcome'; @@ -52,10 +54,14 @@ function ClubCreatingPage({ intent, onCancel }: ClubCreatingPageProps) { onCancel?.(); return; } + if (result.clubId) { + createdClubIdRef.current = result.clubId; + setClub(result.clubId, name); + } setApiDone(true); }, ); - }, [progress, onCancel]); + }, [progress, onCancel, setClub]); // API가 애니메이션 이후에 완료된 경우 즉시 navigate useEffect(() => { diff --git a/src/components/auth/hub/ClubJoiningPage.tsx b/src/components/auth/hub/ClubJoiningPage.tsx index 174fa320..9e66b8f9 100644 --- a/src/components/auth/hub/ClubJoiningPage.tsx +++ b/src/components/auth/hub/ClubJoiningPage.tsx @@ -9,8 +9,10 @@ import { useRouter } from 'next/navigation'; import { buttonVariants, ProgressBar } from '@/components/ui'; import { CLUB_JOIN_ERROR_CODE } from '@/constants/errorCode'; import { useProgressAnimation } from '@/hooks'; +import { setClubCookie } from '@/lib/actions/club'; import { clubApi } from '@/lib/apis/club'; import { cn } from '@/lib/cn'; +import { useClubActions } from '@/stores'; import { toastError } from '@/stores/useToastStore'; interface ClubJoiningPageProps { @@ -23,11 +25,17 @@ type ErrorState = { code: number; message: string } | null; function ClubJoiningPage({ clubName, clubId, code }: ClubJoiningPageProps) { const router = useRouter(); + const { setClub } = useClubActions(); const [apiDone, setApiDone] = useState(false); const [errorState, setErrorState] = useState(null); const apiCalledRef = useRef(false); const animationDoneRef = useRef(false); + const setClubInfo = async () => { + await setClubCookie(clubId, clubName); + setClub(clubId, clubName); + }; + const progress = useProgressAnimation({ duration: 3000, onComplete: () => { @@ -42,10 +50,11 @@ function ClubJoiningPage({ clubName, clubId, code }: ClubJoiningPageProps) { clubApi .join(clubId, code) - .then(() => { + .then(async () => { + await setClubInfo(); setApiDone(true); }) - .catch((error) => { + .catch(async (error) => { if (isAxiosError(error)) { const errorCode = error.response?.data?.code; if (errorCode === CLUB_JOIN_ERROR_CODE.INVALID_INVITE_LINK) { @@ -56,6 +65,8 @@ function ClubJoiningPage({ clubName, clubId, code }: ClubJoiningPageProps) { return; } if (errorCode === CLUB_JOIN_ERROR_CODE.ALREADY_JOINED) { + // 이미 가입된 경우에도 clubId를 세팅 + await setClubInfo(); setErrorState({ code: CLUB_JOIN_ERROR_CODE.ALREADY_JOINED, message: '이미 가입된 동아리입니다. 동아리로 이동할까요?', @@ -73,7 +84,8 @@ function ClubJoiningPage({ clubName, clubId, code }: ClubJoiningPageProps) { toastError(); router.replace('/hub'); }); - }, [clubId, code, router]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // API가 애니메이션 이후에 완료된 경우 즉시 navigate useEffect(() => { diff --git a/src/components/auth/hub/HubActionCard.tsx b/src/components/auth/hub/HubActionCard.tsx index 9e99fd3e..814107fb 100644 --- a/src/components/auth/hub/HubActionCard.tsx +++ b/src/components/auth/hub/HubActionCard.tsx @@ -2,6 +2,7 @@ import Image from 'next/image'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { Button, @@ -13,13 +14,17 @@ import { ItemTitle, } from '@/components/ui'; import { HUB_ACTION_CONFIG } from '@/constants/login/hub'; +import { setClubCookie } from '@/lib/actions/club'; import { cn } from '@/lib/cn'; +import { useClubActions } from '@/stores'; interface HubActionCardProps extends React.HTMLAttributes { variant: 'create' | 'join' | 'go'; href?: string; onAction?: () => void; isPrimary?: boolean; + clubId?: string; + clubName?: string; } function HubActionCard({ @@ -27,18 +32,32 @@ function HubActionCard({ href, onAction, isPrimary, + clubId, + clubName, className, ...props }: HubActionCardProps) { const config = HUB_ACTION_CONFIG[variant]; const isDisabled = !href && !onAction; + const router = useRouter(); + const { setClub } = useClubActions(); + + async function handleGoClick() { + if (variant === 'go' && clubId && clubName) { + await setClubCookie(clubId, clubName); + setClub(clubId, clubName); + } + if (href) router.push(href); + } + + const useClickHandler = variant === 'go' && clubId; const button = (
); diff --git a/src/lib/stripHtml.ts b/src/lib/stripHtml.ts new file mode 100644 index 00000000..96eca58e --- /dev/null +++ b/src/lib/stripHtml.ts @@ -0,0 +1,9 @@ +/** HTML 태그를 제거하고 plain text로 변환 */ +export function stripHtml(html: string): string { + return html + .replace(//gi, '\n') + .replace(/<\/(p|h[1-6]|li|div|blockquote)>/gi, '\n') + .replace(/<[^>]*>/g, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} From 6c02e56131a62cf499401d44717d2d31da4c9b12 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Sun, 12 Apr 2026 18:23:44 +0900 Subject: [PATCH 082/684] =?UTF-8?q?fix:=20clubId=20=ED=95=98=EB=93=9C?= =?UTF-8?q?=EC=BD=94=EB=94=A9=EB=90=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EC=97=90=20=EC=88=98=EC=A0=95=EC=82=AC=ED=95=AD=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(private)/(main)/board/(with-nav)/[id]/page.tsx | 9 +++++++-- src/app/(private)/(main)/board/(with-nav)/layout.tsx | 7 ++++++- src/app/(private)/(main)/board/edit/[id]/page.tsx | 9 ++++++--- src/components/board/CategorySelector.tsx | 5 +++-- src/hooks/board/useBoardQuery.ts | 3 ++- 5 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/app/(private)/(main)/board/(with-nav)/[id]/page.tsx b/src/app/(private)/(main)/board/(with-nav)/[id]/page.tsx index 0bfda59e..9b459d4c 100644 --- a/src/app/(private)/(main)/board/(with-nav)/[id]/page.tsx +++ b/src/app/(private)/(main)/board/(with-nav)/[id]/page.tsx @@ -1,4 +1,8 @@ +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; + import { boardServerApi } from '@/lib/apis/board.server'; +import { CLUB_ID_KEY } from '@/lib/apis/cookies'; import { PostDetailContent } from './PostDetailContent'; interface PostDetailPageProps { @@ -7,8 +11,9 @@ interface PostDetailPageProps { export default async function PostDetailPage({ params }: PostDetailPageProps) { const { id } = await params; - //TODO:"추후 하드코딩된 clubId 제거 예정 - const response = await boardServerApi.getPostById('YUNJcjFKMO', Number(id)); + const clubId = (await cookies()).get(CLUB_ID_KEY)?.value; + if (!clubId) redirect('/hub'); + const response = await boardServerApi.getPostById(clubId, Number(id)); return ; } diff --git a/src/app/(private)/(main)/board/(with-nav)/layout.tsx b/src/app/(private)/(main)/board/(with-nav)/layout.tsx index 711b3b41..e3b7a09b 100644 --- a/src/app/(private)/(main)/board/(with-nav)/layout.tsx +++ b/src/app/(private)/(main)/board/(with-nav)/layout.tsx @@ -1,5 +1,8 @@ import type { ReactNode } from 'react'; +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; import { boardServerApi } from '@/lib/apis/board.server'; +import { CLUB_ID_KEY } from '@/lib/apis/cookies'; import { toBoardNavItem } from '@/lib/board'; import { BOARD_TYPE_ORDER } from '@/constants/board/type'; import { BoardNavClient } from './BoardNavClient'; @@ -10,7 +13,9 @@ interface BoardLayoutProps { } export default async function BoardLayout({ children, footer }: BoardLayoutProps) { - const response = await boardServerApi.getBoards('YUNJcjFKMO'); + const clubId = (await cookies()).get(CLUB_ID_KEY)?.value; + if (!clubId) redirect('/hub'); + const response = await boardServerApi.getBoards(clubId); const boards = [...response.data].sort( (a, b) => (BOARD_TYPE_ORDER[a.type] ?? 99) - (BOARD_TYPE_ORDER[b.type] ?? 99), ); diff --git a/src/app/(private)/(main)/board/edit/[id]/page.tsx b/src/app/(private)/(main)/board/edit/[id]/page.tsx index 4e8fcf18..25417073 100644 --- a/src/app/(private)/(main)/board/edit/[id]/page.tsx +++ b/src/app/(private)/(main)/board/edit/[id]/page.tsx @@ -1,6 +1,8 @@ -import { notFound } from 'next/navigation'; +import { cookies } from 'next/headers'; +import { notFound, redirect } from 'next/navigation'; import { boardServerApi } from '@/lib/apis/board.server'; +import { CLUB_ID_KEY } from '@/lib/apis/cookies'; import { EditClientEditor } from './EditClientEditor'; interface PostEditPageProps { @@ -15,8 +17,9 @@ export default async function PostEditPage({ params }: PostEditPageProps) { notFound(); } - // TODO: 추후 하드코딩된 clubId 제거 예정 - const response = await boardServerApi.getPostById('YUNJcjFKMO', postId).catch(() => null); + const clubId = (await cookies()).get(CLUB_ID_KEY)?.value; + if (!clubId) redirect('/hub'); + const response = await boardServerApi.getPostById(clubId, postId).catch(() => null); if (!response?.data) { notFound(); diff --git a/src/components/board/CategorySelector.tsx b/src/components/board/CategorySelector.tsx index 3aa90e9d..4d3ebe60 100644 --- a/src/components/board/CategorySelector.tsx +++ b/src/components/board/CategorySelector.tsx @@ -14,6 +14,7 @@ import { Icon, } from '@/components/ui'; import { ChannelList } from '@/components/board/ChannelList'; +import { useClubName } from '@/stores'; import type { BoardNavItem } from '@/types/board'; interface CategorySelectorProps { @@ -30,9 +31,9 @@ interface CategorySelectorProps { function CategorySelector({ className, items, activeId, onItemSelect }: CategorySelectorProps) { const [open, setOpen] = useState(false); const activeItem = items.find((item) => item.id === activeId); + const clubName = useClubName(); - // TODO: 실제 게시판 이름은 API 연동 시 props로 전달 - const boardName = '가천대 검도부'; + const boardName = clubName ?? '게시판'; const channelName = activeItem?.label ?? ''; // 아이템 선택 후 드롭다운 닫기 diff --git a/src/hooks/board/useBoardQuery.ts b/src/hooks/board/useBoardQuery.ts index 4cb90856..684b0f8d 100644 --- a/src/hooks/board/useBoardQuery.ts +++ b/src/hooks/board/useBoardQuery.ts @@ -38,7 +38,8 @@ export function useBoardPosts(activeBoardId: number | null) { }, initialPageParam: 0, getNextPageParam: (lastPage) => { - const slice = lastPage.data.data; + const slice = lastPage.data?.data; + if (!slice) return undefined; return slice.last ? undefined : slice.number + 1; }, select: (data) => data.pages.flatMap((page) => page.data.data.content), From 4d063422a181684cd957483ce17e9a0845b5732a Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Sun, 12 Apr 2026 18:24:06 +0900 Subject: [PATCH 083/684] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EB=AF=B8=EC=99=84=EC=84=B1=20=EB=AA=A8=EB=8B=AC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=8B=A4=EC=9D=8C=EC=97=90=20=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20=EA=B2=8C=EC=8B=9C=ED=8C=90?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/home/useWritePost.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/home/useWritePost.ts b/src/hooks/home/useWritePost.ts index 4c62d5b9..cc558eff 100644 --- a/src/hooks/home/useWritePost.ts +++ b/src/hooks/home/useWritePost.ts @@ -25,7 +25,6 @@ export function useWritePost() { const handleSkipProfile = () => { setProfileModalOpen(false); - router.push('/board/write'); }; const isProfileIncomplete = !profileStatus?.cardinalAssigned || !profileStatus?.profileCompleted; From d21dd5673f1ba339521bdb0011a9f49737a72aa3 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Sun, 12 Apr 2026 18:50:18 +0900 Subject: [PATCH 084/684] =?UTF-8?q?fix:=20clubId=20=EB=AF=B8=EC=84=B8?= =?UTF-8?q?=ED=8C=85=20=EC=8B=9C=20/hub=20=EB=A6=AC=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EB=A0=89=ED=8A=B8=20=EB=8C=80=EC=8B=A0=20ClubGuard=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=B3=B5=EA=B5=AC=EC=97=90=20=EC=9C=84=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(main)/board/(with-nav)/[id]/page.tsx | 3 +-- .../(main)/board/(with-nav)/layout.tsx | 3 +-- .../(private)/(main)/board/edit/[id]/page.tsx | 4 +-- src/components/layout/ClubGuard.tsx | 27 +++++++++++-------- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/app/(private)/(main)/board/(with-nav)/[id]/page.tsx b/src/app/(private)/(main)/board/(with-nav)/[id]/page.tsx index 9b459d4c..874b3578 100644 --- a/src/app/(private)/(main)/board/(with-nav)/[id]/page.tsx +++ b/src/app/(private)/(main)/board/(with-nav)/[id]/page.tsx @@ -1,5 +1,4 @@ import { cookies } from 'next/headers'; -import { redirect } from 'next/navigation'; import { boardServerApi } from '@/lib/apis/board.server'; import { CLUB_ID_KEY } from '@/lib/apis/cookies'; @@ -12,7 +11,7 @@ interface PostDetailPageProps { export default async function PostDetailPage({ params }: PostDetailPageProps) { const { id } = await params; const clubId = (await cookies()).get(CLUB_ID_KEY)?.value; - if (!clubId) redirect('/hub'); + if (!clubId) return null; const response = await boardServerApi.getPostById(clubId, Number(id)); return ; diff --git a/src/app/(private)/(main)/board/(with-nav)/layout.tsx b/src/app/(private)/(main)/board/(with-nav)/layout.tsx index e3b7a09b..ea09b4f2 100644 --- a/src/app/(private)/(main)/board/(with-nav)/layout.tsx +++ b/src/app/(private)/(main)/board/(with-nav)/layout.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from 'react'; import { cookies } from 'next/headers'; -import { redirect } from 'next/navigation'; import { boardServerApi } from '@/lib/apis/board.server'; import { CLUB_ID_KEY } from '@/lib/apis/cookies'; import { toBoardNavItem } from '@/lib/board'; @@ -14,7 +13,7 @@ interface BoardLayoutProps { export default async function BoardLayout({ children, footer }: BoardLayoutProps) { const clubId = (await cookies()).get(CLUB_ID_KEY)?.value; - if (!clubId) redirect('/hub'); + if (!clubId) return null; const response = await boardServerApi.getBoards(clubId); const boards = [...response.data].sort( (a, b) => (BOARD_TYPE_ORDER[a.type] ?? 99) - (BOARD_TYPE_ORDER[b.type] ?? 99), diff --git a/src/app/(private)/(main)/board/edit/[id]/page.tsx b/src/app/(private)/(main)/board/edit/[id]/page.tsx index 25417073..a30c78c3 100644 --- a/src/app/(private)/(main)/board/edit/[id]/page.tsx +++ b/src/app/(private)/(main)/board/edit/[id]/page.tsx @@ -1,5 +1,5 @@ import { cookies } from 'next/headers'; -import { notFound, redirect } from 'next/navigation'; +import { notFound } from 'next/navigation'; import { boardServerApi } from '@/lib/apis/board.server'; import { CLUB_ID_KEY } from '@/lib/apis/cookies'; @@ -18,7 +18,7 @@ export default async function PostEditPage({ params }: PostEditPageProps) { } const clubId = (await cookies()).get(CLUB_ID_KEY)?.value; - if (!clubId) redirect('/hub'); + if (!clubId) return null; const response = await boardServerApi.getPostById(clubId, postId).catch(() => null); if (!response?.data) { diff --git a/src/components/layout/ClubGuard.tsx b/src/components/layout/ClubGuard.tsx index f58335d0..a9f414a7 100644 --- a/src/components/layout/ClubGuard.tsx +++ b/src/components/layout/ClubGuard.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useRef } from 'react'; +import { useRouter } from 'next/navigation'; import { setClubCookie } from '@/lib/actions/club'; import { apiClient } from '@/lib/apis/client'; @@ -10,16 +11,19 @@ interface ClubGuardProps { children: React.ReactNode; } -interface MembershipStatusResponse { - data: { - hasActiveClub: boolean; - activeClub: { id: string; name: string } | null; - }; +interface Club { + id: string; + name: string; +} + +interface MyClubsResponse { + data: Club[]; } function ClubGuard({ children }: ClubGuardProps) { const clubId = useClubId(); const { setClub } = useClubActions(); + const router = useRouter(); const fetchingRef = useRef(false); useEffect(() => { @@ -27,12 +31,13 @@ function ClubGuard({ children }: ClubGuardProps) { fetchingRef.current = true; apiClient - .get('/clubs/membership-status') + .get('/clubs') .then(async (res) => { - const activeClub = res.data?.data?.activeClub; - if (activeClub) { - await setClubCookie(activeClub.id, activeClub.name); - setClub(activeClub.id, activeClub.name); + const club = res.data?.data?.[0]; + if (club) { + await setClubCookie(club.id, club.name); + setClub(club.id, club.name); + router.refresh(); } }) .catch(() => { @@ -41,7 +46,7 @@ function ClubGuard({ children }: ClubGuardProps) { .finally(() => { fetchingRef.current = false; }); - }, [clubId, setClub]); + }, [clubId, setClub, router]); return <>{children}; } From 6c67f38fa005574d395f4a29e4802da10f99798a Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Sun, 12 Apr 2026 19:03:14 +0900 Subject: [PATCH 085/684] =?UTF-8?q?fix:=20prettier=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=ED=8C=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/home/UnreadNoticeBox.tsx | 4 +--- src/stores/useClubStore.ts | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/components/home/UnreadNoticeBox.tsx b/src/components/home/UnreadNoticeBox.tsx index bafa60ca..d41b33fe 100644 --- a/src/components/home/UnreadNoticeBox.tsx +++ b/src/components/home/UnreadNoticeBox.tsx @@ -33,9 +33,7 @@ export function UnreadNoticeBox() {

{data.title}

new
-

- {stripHtml(data.content)} -

+

{stripHtml(data.content)}

); diff --git a/src/stores/useClubStore.ts b/src/stores/useClubStore.ts index 9383aa6b..646aa0df 100644 --- a/src/stores/useClubStore.ts +++ b/src/stores/useClubStore.ts @@ -14,8 +14,7 @@ export const useClubStore = create( persist( combine(initialState, (set) => ({ setClubId: (clubId: string) => set({ clubId }, false, 'setClubId'), - setClub: (clubId: string, clubName: string) => - set({ clubId, clubName }, false, 'setClub'), + setClub: (clubId: string, clubName: string) => set({ clubId, clubName }, false, 'setClub'), reset: () => set(initialState, false, 'reset'), })), { name: 'clubId' }, From 31e744d0af5f5d7ff80af51c85eb208a1e8df9c6 Mon Sep 17 00:00:00 2001 From: Dahyeon Date: Sun, 12 Apr 2026 19:10:43 +0900 Subject: [PATCH 086/684] =?UTF-8?q?feat:=20=EB=8F=99=EC=95=84=EB=A6=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EC=A0=95=EB=B3=B4=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20component=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(private)/admin/club-info/page.tsx | 28 +++- src/app/globals.css | 5 + .../icons/admin/ic_admin_cloud_upload.svg | 3 + src/assets/icons/admin/index.ts | 1 + .../admin/club-info/AdminInfoCard.tsx | 27 +++ .../admin/club-info/ClubInfoPageContent.tsx | 156 ++++++++++++++++++ .../admin/club-info/ClubInfoTopBar.tsx | 38 +++++ .../admin/club-info/ImageUploadField.tsx | 96 +++++++++++ src/components/admin/club-info/index.ts | 10 ++ src/components/admin/index.ts | 10 ++ src/components/admin/layout/Header.tsx | 4 + src/components/mypage/SearchSelect.tsx | 12 +- 12 files changed, 386 insertions(+), 4 deletions(-) create mode 100644 src/assets/icons/admin/ic_admin_cloud_upload.svg create mode 100644 src/components/admin/club-info/AdminInfoCard.tsx create mode 100644 src/components/admin/club-info/ClubInfoPageContent.tsx create mode 100644 src/components/admin/club-info/ClubInfoTopBar.tsx create mode 100644 src/components/admin/club-info/ImageUploadField.tsx create mode 100644 src/components/admin/club-info/index.ts diff --git a/src/app/(private)/admin/club-info/page.tsx b/src/app/(private)/admin/club-info/page.tsx index 332d0448..13d96f8b 100644 --- a/src/app/(private)/admin/club-info/page.tsx +++ b/src/app/(private)/admin/club-info/page.tsx @@ -1,3 +1,27 @@ -export default function ClubInfoPage() { - return
ClubInfoPage
; +import { ClubInfoPageContent } from '@/components/admin'; +import { apiServer } from '@/lib/apis'; + +interface School { + schoolName: string; + region: string; +} + +export default async function ClubInfoPage() { + let schoolNames: string[] = []; + + try { + const json = await apiServer.get<{ data: School[] }>('/university/schools'); + const schools = json.data; + const counts = schools.reduce>((acc, s) => { + acc[s.schoolName] = (acc[s.schoolName] ?? 0) + 1; + return acc; + }, {}); + schoolNames = schools.map((s) => + counts[s.schoolName] > 1 ? `${s.schoolName}(${s.region})` : s.schoolName, + ); + } catch { + // 학교 목록 로드 실패 시 빈 배열 유지 + } + + return ; } diff --git a/src/app/globals.css b/src/app/globals.css index f21819a4..15190a21 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -161,6 +161,7 @@ --shadow-dialog: 0 10px 40px 0 rgba(0, 0, 0, 0.4); --shadow-sm: 0 1px 5px 0 rgba(17, 33, 49, 0.15); --shadow-lg: 0 10px 30px 0 rgba(17, 33, 49, 0.3); + --shadow-weeth: 0 0 5px 0 var(--brand-primary); --card: oklch(1 0 0); --card-foreground: oklch(0.145 0 0); @@ -372,6 +373,10 @@ --radius-sm: 8px; --radius-md: 12px; --radius-lg: 16px; + + --shadow-dialog: 0 10px 40px 0 rgba(0, 0, 0, 0.4); + --shadow-weeth: 0 0 5px 0 var(--brand-primary); + --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); diff --git a/src/assets/icons/admin/ic_admin_cloud_upload.svg b/src/assets/icons/admin/ic_admin_cloud_upload.svg new file mode 100644 index 00000000..69f433d0 --- /dev/null +++ b/src/assets/icons/admin/ic_admin_cloud_upload.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/admin/index.ts b/src/assets/icons/admin/index.ts index b5987668..e87b6a08 100644 --- a/src/assets/icons/admin/index.ts +++ b/src/assets/icons/admin/index.ts @@ -15,3 +15,4 @@ export { default as AdminFileoutIcon } from './ic_admin_fileout.svg'; export { default as AdminLightIcon } from './ic_admin_light.svg'; export { default as AdminForumIcon } from './ic_admin_forum.svg'; export { default as AdminCalendarIcon } from './ic_admin_calendar.svg'; +export { default as AdminCloudUploadIcon } from './ic_admin_cloud_upload.svg'; diff --git a/src/components/admin/club-info/AdminInfoCard.tsx b/src/components/admin/club-info/AdminInfoCard.tsx new file mode 100644 index 00000000..a7acdb0f --- /dev/null +++ b/src/components/admin/club-info/AdminInfoCard.tsx @@ -0,0 +1,27 @@ +import { cn } from '@/lib/cn'; + +interface AdminInfoCardProps extends React.HTMLAttributes { + title: string; +} + +function AdminInfoCard({ + title, + className, + children, + ...props +}: AdminInfoCardProps) { + return ( +
+

{title}

+ {children} +
+ ); +} + +export { AdminInfoCard, type AdminInfoCardProps }; diff --git a/src/components/admin/club-info/ClubInfoPageContent.tsx b/src/components/admin/club-info/ClubInfoPageContent.tsx new file mode 100644 index 00000000..e60d2f7e --- /dev/null +++ b/src/components/admin/club-info/ClubInfoPageContent.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useState } from 'react'; + +import { AdminInfoCard } from '@/components/admin/club-info/AdminInfoCard'; +import { ImageUploadField } from '@/components/admin/club-info/ImageUploadField'; +import { ClubInfoTopBar } from '@/components/admin/club-info/ClubInfoTopBar'; +import { SearchSelect } from '@/components/mypage'; +import { Button, Input } from '@/components/ui'; +import { cn } from '@/lib/cn'; + +function FieldBlock({ + label, + helper, + children, +}: { + label: string; + helper?: string; + children: React.ReactNode; +}) { + return ( +
+ {label} + {children} + {helper && {helper}} +
+ ); +} + +interface ClubInfoPageContentProps { + schoolNames: string[]; +} + +function ClubInfoPageContent({ schoolNames }: ClubInfoPageContentProps) { + const [isEditMode, setIsEditMode] = useState(false); + const [school, setSchool] = useState('가천대학교'); + const [primaryContact, setPrimaryContact] = useState<'phone' | 'email'>('phone'); + + return ( +
+ {isEditMode && ( + setIsEditMode(false)} /> + )} + +
+ +
+ + +
+
+ + +
+ + + + + + + + + + + +
+
+ + +
+ + + + + + + + + +
+ {(['phone', 'email'] as const).map((type) => ( +
+ + + {!isEditMode && ( +
+ +
+ )} +
+
+ ); +} + +export { ClubInfoPageContent, type ClubInfoPageContentProps }; diff --git a/src/components/admin/club-info/ClubInfoTopBar.tsx b/src/components/admin/club-info/ClubInfoTopBar.tsx new file mode 100644 index 00000000..b20c3e23 --- /dev/null +++ b/src/components/admin/club-info/ClubInfoTopBar.tsx @@ -0,0 +1,38 @@ +'use client'; + +import type { HTMLAttributes } from 'react'; + +import { ArrowLeftIcon } from '@/assets/icons'; +import { Button, Icon } from '@/components/ui'; +import { cn } from '@/lib/cn'; + +interface ClubInfoTopBarProps extends HTMLAttributes { + onBack: () => void; +} + +function ClubInfoTopBar({ className, onBack, ...props }: ClubInfoTopBarProps) { + return ( +
+ + + 수정 모드 + +
+ +
+
+ ); +} + +export { ClubInfoTopBar, type ClubInfoTopBarProps }; diff --git a/src/components/admin/club-info/ImageUploadField.tsx b/src/components/admin/club-info/ImageUploadField.tsx new file mode 100644 index 00000000..26929138 --- /dev/null +++ b/src/components/admin/club-info/ImageUploadField.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useRef, useState } from 'react'; + +import Image from 'next/image'; + +import { AdminCloudUploadIcon } from '@/assets/icons/admin'; +import { cn } from '@/lib/cn'; + +interface ImageUploadFieldProps extends React.HTMLAttributes { + label: string; + title: string; + description: string; + aspectRatio?: '1/1' | 'auto'; + onFileSelect?: (file: File) => void; +} + +function ImageUploadField({ + label, + title, + description, + aspectRatio = 'auto', + className, + onFileSelect, + ...props +}: ImageUploadFieldProps) { + const inputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + const handleClick = () => { + inputRef.current?.click(); + }; + + const handleChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + onFileSelect?.(file); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files?.[0]; + if (file && file.type.startsWith('image/')) { + onFileSelect?.(file); + } + }; + + return ( +
+ {label} + + +
+ ); +} + +export { ImageUploadField, type ImageUploadFieldProps }; diff --git a/src/components/admin/club-info/index.ts b/src/components/admin/club-info/index.ts new file mode 100644 index 00000000..2db6e317 --- /dev/null +++ b/src/components/admin/club-info/index.ts @@ -0,0 +1,10 @@ +export { AdminInfoCard, type AdminInfoCardProps } from './AdminInfoCard'; +export { + ClubInfoPageContent, + type ClubInfoPageContentProps, +} from './ClubInfoPageContent'; +export { ClubInfoTopBar, type ClubInfoTopBarProps } from './ClubInfoTopBar'; +export { + ImageUploadField, + type ImageUploadFieldProps, +} from './ImageUploadField'; diff --git a/src/components/admin/index.ts b/src/components/admin/index.ts index 8db47f03..532057c0 100644 --- a/src/components/admin/index.ts +++ b/src/components/admin/index.ts @@ -1,4 +1,14 @@ // admin components index file +export { AdminInfoCard, type AdminInfoCardProps } from './club-info/AdminInfoCard'; +export { + ClubInfoPageContent, + type ClubInfoPageContentProps, +} from './club-info/ClubInfoPageContent'; +export { ClubInfoTopBar, type ClubInfoTopBarProps } from './club-info/ClubInfoTopBar'; +export { + ImageUploadField, + type ImageUploadFieldProps, +} from './club-info/ImageUploadField'; export { AddGenerationButton, type AddGenerationButtonProps } from './member/AddGenerationButton'; export { AddGenerationModal, diff --git a/src/components/admin/layout/Header.tsx b/src/components/admin/layout/Header.tsx index d2c4b1ea..8edf8f47 100644 --- a/src/components/admin/layout/Header.tsx +++ b/src/components/admin/layout/Header.tsx @@ -8,6 +8,10 @@ const PAGE_METADATA: Record = { description: '가입 승인 등 멤버를 관리하는 페이지입니다. 정기모임을 모두 입력하신 후에 가입 승인을 해주시길 바랍니다.', }, + '/admin/club-info': { + title: '동아리 정보', + description: '부원에게 공유할 동아리의 프로필과 그 외의 정보를 수정하는 페이지입니다.', + }, '/admin/attendance': { title: '출석 관리', description: '기수를 선택하고, 해당 모임에 대한 출석을 수정하는 페이지입니다.', diff --git a/src/components/mypage/SearchSelect.tsx b/src/components/mypage/SearchSelect.tsx index 6cfc39f8..ef36c413 100644 --- a/src/components/mypage/SearchSelect.tsx +++ b/src/components/mypage/SearchSelect.tsx @@ -11,9 +11,17 @@ interface SearchSelectProps { options: string[]; placeholder?: string; className?: string; + inputClassName?: string; } -function SearchSelect({ value, onChange, options, placeholder, className }: SearchSelectProps) { +function SearchSelect({ + value, + onChange, + options, + placeholder, + className, + inputClassName, +}: SearchSelectProps) { const [query, setQuery] = useState(''); const [open, setOpen] = useState(false); @@ -37,7 +45,7 @@ function SearchSelect({ value, onChange, options, placeholder, className }: Sear onChange={(e) => setQuery(e.target.value)} onFocus={() => setOpen(true)} placeholder={open ? '검색...' : placeholder} - className="rounded-lg" + className={cn('rounded-lg', inputClassName)} /> {open && filtered.length > 0 && (
    From f29c53042908f364f66a2d9c86fefcaa14914dbe Mon Sep 17 00:00:00 2001 From: Dahyeon Date: Sun, 12 Apr 2026 19:45:16 +0900 Subject: [PATCH 087/684] =?UTF-8?q?style:=20=EA=B0=84=EA=B2=A9=20=EB=B0=8F?= =?UTF-8?q?=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/club-info/AdminInfoCard.tsx | 10 +++++++-- .../admin/club-info/ClubInfoPageContent.tsx | 22 ++++++++++--------- .../admin/club-info/ImageUploadField.tsx | 2 +- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/components/admin/club-info/AdminInfoCard.tsx b/src/components/admin/club-info/AdminInfoCard.tsx index a7acdb0f..97bdbb46 100644 --- a/src/components/admin/club-info/AdminInfoCard.tsx +++ b/src/components/admin/club-info/AdminInfoCard.tsx @@ -2,24 +2,30 @@ import { cn } from '@/lib/cn'; interface AdminInfoCardProps extends React.HTMLAttributes { title: string; + titleGapClassName?: string; + contentClassName?: string; } function AdminInfoCard({ title, className, + titleGapClassName, + contentClassName, children, ...props }: AdminInfoCardProps) { return (

    {title}

    - {children} +
    + {children} +
    ); } diff --git a/src/components/admin/club-info/ClubInfoPageContent.tsx b/src/components/admin/club-info/ClubInfoPageContent.tsx index e60d2f7e..1e9148d2 100644 --- a/src/components/admin/club-info/ClubInfoPageContent.tsx +++ b/src/components/admin/club-info/ClubInfoPageContent.tsx @@ -19,7 +19,7 @@ function FieldBlock({ children: React.ReactNode; }) { return ( -
    +
    {label} {children} {helper && {helper}} @@ -42,16 +42,18 @@ function ClubInfoPageContent({ schoolNames }: ClubInfoPageContentProps) { setIsEditMode(false)} /> )} -
    - -
    +
    + +
    - +
    @@ -84,19 +86,19 @@ function ClubInfoPageContent({ schoolNames }: ClubInfoPageContentProps) {
    - +
    @@ -104,7 +106,7 @@ function ClubInfoPageContent({ schoolNames }: ClubInfoPageContentProps) { diff --git a/src/components/admin/club-info/ImageUploadField.tsx b/src/components/admin/club-info/ImageUploadField.tsx index 26929138..cbd25fd9 100644 --- a/src/components/admin/club-info/ImageUploadField.tsx +++ b/src/components/admin/club-info/ImageUploadField.tsx @@ -58,7 +58,7 @@ function ImageUploadField({ }; return ( -
    +
    {label} + {formatYearMonth(year, month)} + +
    + ); +} + +export { MonthNavigator, type MonthNavigatorProps }; diff --git a/src/components/admin/schedule/ScheduleItem.tsx b/src/components/admin/schedule/ScheduleItem.tsx new file mode 100644 index 00000000..2aa5bbc8 --- /dev/null +++ b/src/components/admin/schedule/ScheduleItem.tsx @@ -0,0 +1,75 @@ +import { cn } from '@/lib/cn'; +import { Button } from '@/components/ui'; +import { ScheduleTag } from '@/components/admin/schedule/ScheduleTag'; +import type { Schedule } from '@/types/admin/schedule'; +import { + getDayLabel, + getDayOfMonth, + formatScheduleDateTime, +} from '@/utils/admin/scheduleUtils'; + +const SCHEDULE_TYPE_LABEL: Record = { + SESSION: '세션', + GENERAL: '일반 일정', +}; + +interface ScheduleItemProps extends React.HTMLAttributes { + schedule: Schedule; + isLast?: boolean; + onEdit?: () => void; + onDelete?: () => void; +} + +function ScheduleItem({ + className, + schedule, + isLast = false, + onEdit, + onDelete, + ...props +}: ScheduleItemProps) { + const day = getDayOfMonth(schedule.startDateTime); + const dayLabel = getDayLabel(schedule.startDateTime); + const dateTimeText = formatScheduleDateTime(schedule.startDateTime); + + return ( +
    + {/* Date column */} +
    + {day} + {dayLabel} +
    + + {/* Content column */} +
    + {schedule.title} +
    + {SCHEDULE_TYPE_LABEL[schedule.type]} + {dateTimeText} + {schedule.location && ( + {schedule.location} + )} +
    +
    + + {/* Action buttons */} +
    + + +
    +
    + ); +} + +export { ScheduleItem, type ScheduleItemProps }; diff --git a/src/components/admin/schedule/ScheduleList.tsx b/src/components/admin/schedule/ScheduleList.tsx new file mode 100644 index 00000000..1f9994a9 --- /dev/null +++ b/src/components/admin/schedule/ScheduleList.tsx @@ -0,0 +1,42 @@ +import { cn } from '@/lib/cn'; +import { ScheduleItem } from '@/components/admin/schedule/ScheduleItem'; +import type { Schedule } from '@/types/admin/schedule'; + +interface ScheduleListProps extends React.HTMLAttributes { + schedules: Schedule[]; + onEdit?: (schedule: Schedule) => void; + onDelete?: (schedule: Schedule) => void; +} + +function ScheduleList({ className, schedules, onEdit, onDelete, ...props }: ScheduleListProps) { + return ( +
    + {/* Table header */} +
    + 일정 내용 +
    + + {/* Schedule items */} + {schedules.length === 0 ? ( +
    + 일정이 없습니다. +
    + ) : ( + schedules.map((schedule, index) => ( + onEdit?.(schedule)} + onDelete={() => onDelete?.(schedule)} + /> + )) + )} +
    + ); +} + +export { ScheduleList, type ScheduleListProps }; diff --git a/src/components/admin/schedule/SchedulePageContent.tsx b/src/components/admin/schedule/SchedulePageContent.tsx new file mode 100644 index 00000000..d71a1326 --- /dev/null +++ b/src/components/admin/schedule/SchedulePageContent.tsx @@ -0,0 +1,201 @@ +'use client'; + +import { useState } from 'react'; +import Image from 'next/image'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + Button, + Card, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@/components/ui'; +import { Icon } from '@/components/ui'; +import { AdminCalendarIcon } from '@/assets/icons/admin'; +import { ArrowDownIcon, SearchIcon } from '@/assets/icons'; +import { MonthNavigator } from '@/components/admin/schedule/MonthNavigator'; +import { ScheduleList } from '@/components/admin/schedule/ScheduleList'; +import { useAdminSchedules } from '@/hooks/queries/admin/useAdminScheduleQueries'; +import { useDeleteSchedule } from '@/hooks/mutations/admin/useAdminScheduleMutations'; +import { useCardinals } from '@/hooks/queries'; +import type { Schedule } from '@/types/admin/schedule'; + +type ScheduleTab = 'all' | 'session'; + +function SchedulePageContent() { + const { data: cardinals = [] } = useCardinals(); + const [selectedCardinalId, setSelectedCardinalId] = useState(null); + const [currentYear, setCurrentYear] = useState(() => new Date().getFullYear()); + const [currentMonth, setCurrentMonth] = useState(() => new Date().getMonth() + 1); + const [searchValue, setSearchValue] = useState(''); + const [activeTab, setActiveTab] = useState('all'); + const [deleteTarget, setDeleteTarget] = useState(null); + + // 기수 선택이 없으면 첫 번째 기수 사용 + const activeCardinalId = selectedCardinalId ?? cardinals[0]?.id ?? null; + const activeCardinal = cardinals.find((c) => c.id === activeCardinalId); + + const { data: schedules = [] } = useAdminSchedules(activeCardinalId); + const { mutate: deleteSchedule } = useDeleteSchedule(); + + // 월 필터링 + const monthFiltered = schedules.filter((s) => { + const date = new Date(s.startDateTime); + return date.getFullYear() === currentYear && date.getMonth() + 1 === currentMonth; + }); + + // 탭 필터링 + const tabFiltered = + activeTab === 'session' + ? monthFiltered.filter((s) => s.type === 'SESSION') + : monthFiltered; + + // 검색 필터링 + const query = searchValue.trim().toLowerCase(); + const filteredSchedules = query + ? tabFiltered.filter( + (s) => + s.title.toLowerCase().includes(query) || + s.location.toLowerCase().includes(query), + ) + : tabFiltered; + + // 날짜순 정렬 + const sortedSchedules = [...filteredSchedules].sort( + (a, b) => new Date(a.startDateTime).getTime() - new Date(b.startDateTime).getTime(), + ); + + const handlePrevMonth = () => { + if (currentMonth === 1) { + setCurrentYear((y) => y - 1); + setCurrentMonth(12); + } else { + setCurrentMonth((m) => m - 1); + } + }; + + const handleNextMonth = () => { + if (currentMonth === 12) { + setCurrentYear((y) => y + 1); + setCurrentMonth(1); + } else { + setCurrentMonth((m) => m + 1); + } + }; + + const handleDelete = (schedule: Schedule) => { + setDeleteTarget(schedule); + }; + + const handleConfirmDelete = () => { + if (!deleteTarget) return; + deleteSchedule(deleteTarget.scheduleId); + setDeleteTarget(null); + }; + + return ( +
    + {/* Generation filter */} + + + + + + + {cardinals.map((c) => ( + setSelectedCardinalId(c.id)}> + {c.cardinalNumber}기 + + ))} + + + + + {/* Tabs */} + setActiveTab(v as ScheduleTab)} + className="gap-0" + > + + 전체 일정 + 세션 + + + + + {/* Month navigator */} + + + {/* Search bar + Create button */} +
    +
    + 검색 + setSearchValue(e.target.value)} + placeholder="Search for name" + className="bg-container-neutral-alternative typo-body1 placeholder:text-text-alternative h-12 w-full rounded-sm py-300 pr-300 pl-14 focus:outline-none" + /> +
    + +
    + + {/* Schedule list */} + +
    +
    +
    + + {/* Delete confirmation */} + { + if (!open) setDeleteTarget(null); + }} + status="danger" + title="일정을 삭제하시겠습니까?" + description="삭제된 일정은 복구할 수 없습니다." + > + 삭제 + 취소 + +
    + ); +} + +export { SchedulePageContent }; diff --git a/src/components/admin/schedule/ScheduleTag.tsx b/src/components/admin/schedule/ScheduleTag.tsx new file mode 100644 index 00000000..8dc0e694 --- /dev/null +++ b/src/components/admin/schedule/ScheduleTag.tsx @@ -0,0 +1,43 @@ +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/cn'; +import { Icon } from '@/components/ui'; +import { AdminCalendarIcon } from '@/assets/icons/admin'; +import { PinIcon } from '@/assets/icons'; + +const scheduleTagVariants = cva( + 'typo-caption1 inline-flex h-6 items-center gap-100 rounded-sm px-200 py-100 whitespace-nowrap', + { + variants: { + variant: { + type: 'bg-brand-primary/10 text-brand-primary', + info: 'bg-container-neutral-alternative text-text-alternative', + }, + }, + defaultVariants: { + variant: 'info', + }, + }, +); + +interface ScheduleTagProps + extends React.HTMLAttributes, + VariantProps { + icon?: 'calendar' | 'location'; +} + +function ScheduleTag({ className, variant, icon, children, ...props }: ScheduleTagProps) { + return ( + + {icon === 'calendar' && ( + + )} + {icon === 'location' && ( + + )} + {children} + + ); +} + +export { ScheduleTag, scheduleTagVariants, type ScheduleTagProps }; diff --git a/src/hooks/mutations/admin/index.ts b/src/hooks/mutations/admin/index.ts index 21ad6891..9fbaada6 100644 --- a/src/hooks/mutations/admin/index.ts +++ b/src/hooks/mutations/admin/index.ts @@ -1,2 +1,3 @@ export { useChangeMemberRole, useBanMember, useRestoreMember } from './useAdminMemberMutations'; export { useCreateCardinal } from './useAdminCardinalMutations'; +export { useDeleteSchedule } from './useAdminScheduleMutations'; diff --git a/src/hooks/mutations/admin/useAdminScheduleMutations.ts b/src/hooks/mutations/admin/useAdminScheduleMutations.ts new file mode 100644 index 00000000..dcf8178c --- /dev/null +++ b/src/hooks/mutations/admin/useAdminScheduleMutations.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { adminScheduleApi } from '@/lib/apis/adminSchedule'; +import { useClubId } from '@/stores'; +import { toastError, toastSuccess } from '@/stores/useToastStore'; + +export function useDeleteSchedule() { + const clubId = useClubId(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (scheduleId: number) => adminScheduleApi.deleteSchedule(clubId!, scheduleId), + onSuccess: () => { + toastSuccess('일정이 삭제되었습니다.'); + queryClient.invalidateQueries({ queryKey: ['admin', 'schedules', clubId] }); + }, + onError: () => { + toastError('일정 삭제에 실패했습니다.'); + }, + }); +} diff --git a/src/hooks/queries/admin/index.ts b/src/hooks/queries/admin/index.ts index 2155250b..bb1170d5 100644 --- a/src/hooks/queries/admin/index.ts +++ b/src/hooks/queries/admin/index.ts @@ -1 +1,2 @@ export { useAdminMembers } from './useAdminMemberQueries'; +export { useAdminSchedules } from './useAdminScheduleQueries'; diff --git a/src/hooks/queries/admin/useAdminScheduleQueries.ts b/src/hooks/queries/admin/useAdminScheduleQueries.ts new file mode 100644 index 00000000..98556391 --- /dev/null +++ b/src/hooks/queries/admin/useAdminScheduleQueries.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; + +import { adminScheduleApi } from '@/lib/apis/adminSchedule'; +import { useClubId } from '@/stores'; + +export function useAdminSchedules(cardinalId: number | null) { + const clubId = useClubId(); + + return useQuery({ + queryKey: ['admin', 'schedules', clubId, cardinalId], + queryFn: async () => { + const res = await adminScheduleApi.getSchedules(clubId!, cardinalId!); + return res.data.data; + }, + enabled: !!clubId && cardinalId !== null, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); +} diff --git a/src/lib/apis/adminSchedule.ts b/src/lib/apis/adminSchedule.ts new file mode 100644 index 00000000..e1490190 --- /dev/null +++ b/src/lib/apis/adminSchedule.ts @@ -0,0 +1,12 @@ +import { apiClient } from '@/lib/apis/client'; +import type { Schedule } from '@/types/admin/schedule'; +import type { ApiResponse } from '@/types/common'; + +export const adminScheduleApi = { + getSchedules: (clubId: string, cardinalId: number) => + apiClient.get>( + `/admin/clubs/${clubId}/cardinals/${cardinalId}/schedules`, + ), + deleteSchedule: (clubId: string, scheduleId: number) => + apiClient.delete(`/admin/clubs/${clubId}/schedules/${scheduleId}`), +}; diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 89037d11..665d07b0 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -13,3 +13,4 @@ export { adminMemberApi } from './adminMember'; export { cardinalApi } from './cardinal'; export { inquiryApi } from './inquiry'; export { adminClubApi } from './adminClub'; +export { adminScheduleApi } from './adminSchedule'; diff --git a/src/types/admin/schedule.d.ts b/src/types/admin/schedule.d.ts new file mode 100644 index 00000000..f5ce7191 --- /dev/null +++ b/src/types/admin/schedule.d.ts @@ -0,0 +1,15 @@ +export type ScheduleType = 'SESSION' | 'GENERAL'; + +export interface Schedule { + scheduleId: number; + title: string; + type: ScheduleType; + startDateTime: string; + endDateTime: string; + location: string; + cardinalNumber: number; +} + +export interface ScheduleListResponse { + schedules: Schedule[]; +} diff --git a/src/utils/admin/scheduleUtils.ts b/src/utils/admin/scheduleUtils.ts new file mode 100644 index 00000000..d37890ca --- /dev/null +++ b/src/utils/admin/scheduleUtils.ts @@ -0,0 +1,28 @@ +const DAY_LABELS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'] as const; + +export function getDayLabel(dateString: string): string { + const date = new Date(dateString); + return DAY_LABELS[date.getDay()]; +} + +export function getDayOfMonth(dateString: string): number { + return new Date(dateString).getDate(); +} + +export function formatScheduleDateTime(dateString: string): string { + const date = new Date(dateString); + const month = date.getMonth() + 1; + const day = date.getDate(); + const hours = date.getHours(); + const minutes = date.getMinutes(); + const period = hours < 12 ? '오전' : '오후'; + const displayHours = hours % 12 || 12; + const displayMinutes = minutes === 0 ? '' : `:${String(minutes).padStart(2, '0')}`; + + return `${month}월 ${day}일 ${period} ${displayHours}${displayMinutes}`; +} + +export function formatYearMonth(year: number, month: number): string { + const shortYear = String(year).slice(2); + return `${shortYear}년 ${month}월`; +} From 1336165249984eea87e06d917e1c61b9c5ecbb01 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 12 Apr 2026 17:30:21 +0900 Subject: [PATCH 090/684] =?UTF-8?q?style:=20=EA=B8=B0=EC=88=98=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=81=AC=EA=B8=B0=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/schedule/SchedulePageContent.tsx | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/components/admin/schedule/SchedulePageContent.tsx b/src/components/admin/schedule/SchedulePageContent.tsx index d71a1326..7885ec91 100644 --- a/src/components/admin/schedule/SchedulePageContent.tsx +++ b/src/components/admin/schedule/SchedulePageContent.tsx @@ -54,17 +54,13 @@ function SchedulePageContent() { // 탭 필터링 const tabFiltered = - activeTab === 'session' - ? monthFiltered.filter((s) => s.type === 'SESSION') - : monthFiltered; + activeTab === 'session' ? monthFiltered.filter((s) => s.type === 'SESSION') : monthFiltered; // 검색 필터링 const query = searchValue.trim().toLowerCase(); const filteredSchedules = query ? tabFiltered.filter( - (s) => - s.title.toLowerCase().includes(query) || - s.location.toLowerCase().includes(query), + (s) => s.title.toLowerCase().includes(query) || s.location.toLowerCase().includes(query), ) : tabFiltered; @@ -109,10 +105,12 @@ function SchedulePageContent() { @@ -173,10 +171,7 @@ function SchedulePageContent() {
    {/* Schedule list */} - + From 1d5994575cc2edbf2d3f700a90ab010fd57e4cf5 Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 12 Apr 2026 17:37:43 +0900 Subject: [PATCH 091/684] =?UTF-8?q?fix:=20=EC=95=84=EC=9D=B4=EC=BD=98=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/icons/admin/ic_admin_calendar_edit.svg | 3 +++ src/assets/icons/admin/ic_admin_square_left.svg | 4 ++++ src/assets/icons/admin/ic_admin_square_right.svg | 5 +++++ src/assets/icons/admin/index.ts | 4 ++++ src/components/admin/schedule/MonthNavigator.tsx | 6 +++--- src/components/admin/schedule/SchedulePageContent.tsx | 4 ++-- 6 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 src/assets/icons/admin/ic_admin_calendar_edit.svg create mode 100644 src/assets/icons/admin/ic_admin_square_left.svg create mode 100644 src/assets/icons/admin/ic_admin_square_right.svg diff --git a/src/assets/icons/admin/ic_admin_calendar_edit.svg b/src/assets/icons/admin/ic_admin_calendar_edit.svg new file mode 100644 index 00000000..c9459098 --- /dev/null +++ b/src/assets/icons/admin/ic_admin_calendar_edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/admin/ic_admin_square_left.svg b/src/assets/icons/admin/ic_admin_square_left.svg new file mode 100644 index 00000000..1bf3cefe --- /dev/null +++ b/src/assets/icons/admin/ic_admin_square_left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/admin/ic_admin_square_right.svg b/src/assets/icons/admin/ic_admin_square_right.svg new file mode 100644 index 00000000..96eac6d3 --- /dev/null +++ b/src/assets/icons/admin/ic_admin_square_right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/admin/index.ts b/src/assets/icons/admin/index.ts index b5987668..4626f52e 100644 --- a/src/assets/icons/admin/index.ts +++ b/src/assets/icons/admin/index.ts @@ -15,3 +15,7 @@ export { default as AdminFileoutIcon } from './ic_admin_fileout.svg'; export { default as AdminLightIcon } from './ic_admin_light.svg'; export { default as AdminForumIcon } from './ic_admin_forum.svg'; export { default as AdminCalendarIcon } from './ic_admin_calendar.svg'; + +export { default as AdminSquareLeftIcon } from './ic_admin_square_left.svg'; +export { default as AdminSquareRightIcon } from './ic_admin_square_right.svg'; +export { default as AdminCalendarEditIcon } from './ic_admin_calendar_edit.svg'; diff --git a/src/components/admin/schedule/MonthNavigator.tsx b/src/components/admin/schedule/MonthNavigator.tsx index 117c176a..b5d949e9 100644 --- a/src/components/admin/schedule/MonthNavigator.tsx +++ b/src/components/admin/schedule/MonthNavigator.tsx @@ -3,8 +3,8 @@ import Image from 'next/image'; import { cn } from '@/lib/cn'; -import { ArrowLeftIcon, ArrowRightIcon } from '@/assets/icons'; import { formatYearMonth } from '@/utils/admin/scheduleUtils'; +import { AdminSquareLeftIcon, AdminSquareRightIcon } from '@/assets/icons/admin'; interface MonthNavigatorProps extends React.HTMLAttributes { year: number; @@ -21,7 +21,7 @@ function MonthNavigator({ className, year, month, onPrev, onNext, ...props }: Mo onClick={onPrev} className="hover:bg-container-neutral-interaction flex size-5 cursor-pointer items-center justify-center rounded-sm" > - 이전 달 + 이전 달 {formatYearMonth(year, month)}
    ); diff --git a/src/components/admin/schedule/SchedulePageContent.tsx b/src/components/admin/schedule/SchedulePageContent.tsx index 7885ec91..fbae9d45 100644 --- a/src/components/admin/schedule/SchedulePageContent.tsx +++ b/src/components/admin/schedule/SchedulePageContent.tsx @@ -19,7 +19,7 @@ import { TabsTrigger, } from '@/components/ui'; import { Icon } from '@/components/ui'; -import { AdminCalendarIcon } from '@/assets/icons/admin'; +import { AdminCalendarEditIcon } from '@/assets/icons/admin'; import { ArrowDownIcon, SearchIcon } from '@/assets/icons'; import { MonthNavigator } from '@/components/admin/schedule/MonthNavigator'; import { ScheduleList } from '@/components/admin/schedule/ScheduleList'; @@ -165,7 +165,7 @@ function SchedulePageContent() { />
    From a78820aafe444ff7d9e24317fb309077f34f581f Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 12 Apr 2026 17:47:52 +0900 Subject: [PATCH 092/684] =?UTF-8?q?feat:=20=EB=AA=A9=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/schedule/ScheduleItem.tsx | 18 ++++------ .../admin/schedule/SchedulePageContent.tsx | 36 +++++++++++++++++-- src/components/ui/Button.tsx | 2 +- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/components/admin/schedule/ScheduleItem.tsx b/src/components/admin/schedule/ScheduleItem.tsx index 2aa5bbc8..3bdbce13 100644 --- a/src/components/admin/schedule/ScheduleItem.tsx +++ b/src/components/admin/schedule/ScheduleItem.tsx @@ -2,11 +2,7 @@ import { cn } from '@/lib/cn'; import { Button } from '@/components/ui'; import { ScheduleTag } from '@/components/admin/schedule/ScheduleTag'; import type { Schedule } from '@/types/admin/schedule'; -import { - getDayLabel, - getDayOfMonth, - formatScheduleDateTime, -} from '@/utils/admin/scheduleUtils'; +import { getDayLabel, getDayOfMonth, formatScheduleDateTime } from '@/utils/admin/scheduleUtils'; const SCHEDULE_TYPE_LABEL: Record = { SESSION: '세션', @@ -42,29 +38,27 @@ function ScheduleItem({ {...props} > {/* Date column */} -
    +
    {day} {dayLabel}
    {/* Content column */} -
    +
    {schedule.title}
    {SCHEDULE_TYPE_LABEL[schedule.type]} {dateTimeText} - {schedule.location && ( - {schedule.location} - )} + {schedule.location && {schedule.location}}
    {/* Action buttons */}
    - -
    diff --git a/src/components/admin/schedule/SchedulePageContent.tsx b/src/components/admin/schedule/SchedulePageContent.tsx index fbae9d45..b49fc143 100644 --- a/src/components/admin/schedule/SchedulePageContent.tsx +++ b/src/components/admin/schedule/SchedulePageContent.tsx @@ -23,13 +23,44 @@ import { AdminCalendarEditIcon } from '@/assets/icons/admin'; import { ArrowDownIcon, SearchIcon } from '@/assets/icons'; import { MonthNavigator } from '@/components/admin/schedule/MonthNavigator'; import { ScheduleList } from '@/components/admin/schedule/ScheduleList'; -import { useAdminSchedules } from '@/hooks/queries/admin/useAdminScheduleQueries'; +// TODO: API 연동 시 활성화 +// import { useAdminSchedules } from '@/hooks/queries/admin/useAdminScheduleQueries'; import { useDeleteSchedule } from '@/hooks/mutations/admin/useAdminScheduleMutations'; import { useCardinals } from '@/hooks/queries'; import type { Schedule } from '@/types/admin/schedule'; type ScheduleTab = 'all' | 'session'; +const MOCK_SCHEDULES: Schedule[] = [ + { + scheduleId: 1, + title: '7기 1차 정기모임', + type: 'SESSION', + startDateTime: '2026-04-12T20:00:00', + endDateTime: '2026-04-12T22:00:00', + location: '가천대 체육관', + cardinalNumber: 7, + }, + { + scheduleId: 2, + title: '7기 1차 정기모임', + type: 'GENERAL', + startDateTime: '2026-04-14T20:00:00', + endDateTime: '2026-04-14T22:00:00', + location: '가천대 체육관', + cardinalNumber: 7, + }, + { + scheduleId: 3, + title: '7기 1차 정기모임', + type: 'SESSION', + startDateTime: '2026-04-15T20:00:00', + endDateTime: '2026-04-15T22:00:00', + location: '종합경기장', + cardinalNumber: 7, + }, +]; + function SchedulePageContent() { const { data: cardinals = [] } = useCardinals(); const [selectedCardinalId, setSelectedCardinalId] = useState(null); @@ -43,7 +74,8 @@ function SchedulePageContent() { const activeCardinalId = selectedCardinalId ?? cardinals[0]?.id ?? null; const activeCardinal = cardinals.find((c) => c.id === activeCardinalId); - const { data: schedules = [] } = useAdminSchedules(activeCardinalId); + // TODO: API 연동 후 useAdminSchedules(activeCardinalId)로 교체 + const schedules = MOCK_SCHEDULES; const { mutate: deleteSchedule } = useDeleteSchedule(); // 월 필터링 diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 6e1ffe2b..550ec8ce 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -13,7 +13,7 @@ const buttonVariants = cva( tertiary: 'bg-transparent text-text-normal hover:bg-container-neutral-interaction active:bg-container-neutral-interaction disabled:text-text-disabled', danger: - 'bg-state-error text-text-strong hover:opacity-90 active:opacity-80 disabled:bg-button-neutral disabled:text-text-disabled', + 'bg-state-error text-text-inverse hover:opacity-90 active:opacity-80 disabled:bg-button-neutral disabled:text-text-disabled', kakao: 'bg-[var(--kakao-bg)] text-text-strong hover:opacity-90 active:opacity-80 disabled:bg-button-neutral disabled:text-text-disabled', apple: From 2b5212c28b50f32f3d4b99e00bea13a2886c784e Mon Sep 17 00:00:00 2001 From: JIN921 Date: Sun, 12 Apr 2026 18:47:23 +0900 Subject: [PATCH 093/684] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=AA=A8=EB=8B=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/index.ts | 6 + .../admin/schedule/CreateScheduleModal.tsx | 162 ++++++++++++++++++ .../admin/schedule/DateTimeInput.tsx | 89 ++++++++++ .../admin/schedule/ScheduleFormField.tsx | 20 +++ .../admin/schedule/SchedulePageContent.tsx | 24 +-- src/hooks/mutations/admin/index.ts | 2 +- .../admin/useAdminScheduleMutations.ts | 17 ++ src/lib/apis/adminSchedule.ts | 4 +- src/types/admin/schedule.d.ts | 9 +- 9 files changed, 319 insertions(+), 14 deletions(-) create mode 100644 src/components/admin/schedule/CreateScheduleModal.tsx create mode 100644 src/components/admin/schedule/DateTimeInput.tsx create mode 100644 src/components/admin/schedule/ScheduleFormField.tsx diff --git a/src/components/admin/index.ts b/src/components/admin/index.ts index 1e0d0ddb..5d0c0854 100644 --- a/src/components/admin/index.ts +++ b/src/components/admin/index.ts @@ -27,3 +27,9 @@ export { export { ScheduleItem, type ScheduleItemProps } from './schedule/ScheduleItem'; export { ScheduleList, type ScheduleListProps } from './schedule/ScheduleList'; export { SchedulePageContent } from './schedule/SchedulePageContent'; +export { + CreateScheduleModal, + type CreateScheduleModalProps, +} from './schedule/CreateScheduleModal'; +export { ScheduleFormField, type ScheduleFormFieldProps } from './schedule/ScheduleFormField'; +export { DateTimeInput, type DateTimeInputProps } from './schedule/DateTimeInput'; diff --git a/src/components/admin/schedule/CreateScheduleModal.tsx b/src/components/admin/schedule/CreateScheduleModal.tsx new file mode 100644 index 00000000..38fb76b2 --- /dev/null +++ b/src/components/admin/schedule/CreateScheduleModal.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useState } from 'react'; + +import { Button, Icon, Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { AdminCloseIcon } from '@/assets/icons/admin'; +import { ScheduleFormField } from '@/components/admin/schedule/ScheduleFormField'; +import { DateTimeInput } from '@/components/admin/schedule/DateTimeInput'; + +type ScheduleFormTab = 'GENERAL' | 'SESSION'; + +const TAB_TITLE: Record = { + GENERAL: '일반 일정 생성', + SESSION: '세션 생성', +}; + +function getDefaultDate(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +interface CreateScheduleModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + cardinalNumber: number | null; +} + +function CreateScheduleModal({ open, onOpenChange, cardinalNumber }: CreateScheduleModalProps) { + const [activeTab, setActiveTab] = useState('GENERAL'); + const [title, setTitle] = useState(''); + const [startDate, setStartDate] = useState(getDefaultDate); + const [startTime, setStartTime] = useState('00:00'); + const [endDate, setEndDate] = useState(getDefaultDate); + const [endTime, setEndTime] = useState('23:59'); + const [location, setLocation] = useState(''); + const [content, setContent] = useState(''); + + const resetForm = () => { + setTitle(''); + setStartDate(getDefaultDate()); + setStartTime('00:00'); + setEndDate(getDefaultDate()); + setEndTime('23:59'); + setLocation(''); + setContent(''); + }; + + const handleClose = () => { + onOpenChange(false); + resetForm(); + }; + + const handleSubmit = () => { + if (!title.trim() || cardinalNumber === null) return; + // TODO: API 연동 시 onSubmit 콜백으로 교체 + handleClose(); + }; + + const isValid = title.trim().length > 0 && cardinalNumber !== null; + + return ( + + + {/* Header with tabs */} +
    + setActiveTab(v as ScheduleFormTab)} + className="gap-0" + > + + 일반 일정 + 세션 + + + + +
    + + {/* Scrollable body */} +
    +

    {TAB_TITLE[activeTab]}

    + +
    + {/* Title */} + + setTitle(e.target.value)} + placeholder="예 : 중간고사 기간" + className="bg-container-neutral typo-body1 placeholder:text-text-alternative h-12 w-full rounded-sm px-400 py-300 focus:outline-none" + /> + + + {/* Start / End dates */} +
    + + +
    + + {/* Location */} + + setLocation(e.target.value)} + placeholder="장소를 입력해주세요." + className="bg-container-neutral typo-body1 placeholder:text-text-alternative h-12 w-full rounded-sm px-400 py-300 focus:outline-none" + /> + + + {/* Content */} + +