From 4fe2587d4444145bfc80563d00817434291e135a Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Mon, 6 Apr 2026 18:24:51 +0900 Subject: [PATCH 01/23] =?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 8c7527e04d312376c3ad33f8f0b4a4fc76817287 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Tue, 7 Apr 2026 16:47:52 +0900 Subject: [PATCH 02/23] =?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 03/23] =?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 15d7cbee30ce913fa62d80a36b47a865c761037e Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Wed, 8 Apr 2026 23:34:36 +0900 Subject: [PATCH 04/23] =?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 05/23] =?UTF-8?q?refactor:=20isAdmin=EC=9D=84=20useUserRol?= =?UTF-8?q?e=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 1ab5311a2880b89cb32f9c8e739781c1ab591f99 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Wed, 8 Apr 2026 23:59:12 +0900 Subject: [PATCH 06/23] =?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 85f800b885ee6872d4bbb514a184f5df28caf81d Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Thu, 9 Apr 2026 13:56:00 +0900 Subject: [PATCH 07/23] =?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 08/23] =?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 09/23] =?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 10/23] =?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 485126d7bc76db6f873155ce62254e714ceafd3c Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Sun, 12 Apr 2026 22:57:43 +0900 Subject: [PATCH 11/23] =?UTF-8?q?refactor:=20clubId=EB=A5=BC=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EA=B0=80=EC=A0=B8=EC=98=AC=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=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 --- .../(main)/attendance/history/page.tsx | 18 +++++++++++------ src/app/(private)/(main)/attendance/page.tsx | 20 +++++++++++++------ .../(private)/(main)/attendance/qr/page.tsx | 8 ++++++-- src/app/(private)/(main)/layout.tsx | 12 +++++++---- .../attendance/AttendanceContent.tsx | 7 ++++--- src/hooks/useQRCheckIn.ts | 9 +++++---- 6 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/app/(private)/(main)/attendance/history/page.tsx b/src/app/(private)/(main)/attendance/history/page.tsx index 7f04d77a..cc4fcb4c 100644 --- a/src/app/(private)/(main)/attendance/history/page.tsx +++ b/src/app/(private)/(main)/attendance/history/page.tsx @@ -1,16 +1,22 @@ +import { cookies } from 'next/headers'; + import { AttendanceHistoryContent } from '@/components/attendance'; +import { CLUB_ID_KEY } from '@/lib/apis/cookies'; import { attendanceServerApi } from '@/lib/apis/attendance.server'; import type { AttendanceSummary } from '@/types/attendance'; export default async function AttendanceHistoryPage() { - // TODO: 하드코딩된 clubId 추후 동적으로 변경 + const clubId = (await cookies()).get(CLUB_ID_KEY)?.value; + let summary: AttendanceSummary | undefined; - try { - const response = await attendanceServerApi.getDetail('YUNJcjFKMO'); - summary = response.data; - } catch { - // 에러 시 빈 상태로 렌더링 + if (clubId) { + try { + const response = await attendanceServerApi.getDetail(clubId); + summary = response.data; + } catch { + // 에러 시 빈 상태로 렌더링 + } } return ( diff --git a/src/app/(private)/(main)/attendance/page.tsx b/src/app/(private)/(main)/attendance/page.tsx index db9178b6..a40e4cc2 100644 --- a/src/app/(private)/(main)/attendance/page.tsx +++ b/src/app/(private)/(main)/attendance/page.tsx @@ -1,4 +1,7 @@ +import { cookies } from 'next/headers'; + import { AttendanceContent } from '@/components/attendance'; +import { CLUB_ID_KEY } from '@/lib/apis/cookies'; import { attendanceServerApi } from '@/lib/apis/attendance.server'; import type { AttendanceData } from '@/types/attendance'; @@ -9,15 +12,20 @@ interface AttendancePageProps { export default async function AttendancePage({ searchParams }: AttendancePageProps) { const { sessionId: qrSessionId, code: qrCode } = await searchParams; - // TODO: 하드코딩된 clubId 추후 동적으로 변경 + const clubId = (await cookies()).get(CLUB_ID_KEY)?.value; + let attendance: AttendanceData | undefined; let errorMessage: string | undefined; - try { - const response = await attendanceServerApi.getAttendance('YUNJcjFKMO'); - attendance = response.data; - } catch { - errorMessage = '출석 정보를 불러오지 못했습니다.'; + if (!clubId) { + errorMessage = '동아리 정보를 찾을 수 없습니다.'; + } else { + try { + const response = await attendanceServerApi.getAttendance(clubId); + attendance = response.data; + } catch { + errorMessage = '출석 정보를 불러오지 못했습니다.'; + } } return ( diff --git a/src/app/(private)/(main)/attendance/qr/page.tsx b/src/app/(private)/(main)/attendance/qr/page.tsx index 568d3931..64515439 100644 --- a/src/app/(private)/(main)/attendance/qr/page.tsx +++ b/src/app/(private)/(main)/attendance/qr/page.tsx @@ -1,6 +1,8 @@ +import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; import { AttendanceQRContent } from '@/components/attendance'; +import { CLUB_ID_KEY } from '@/lib/apis/cookies'; import { homeServerApi } from '@/lib/apis/home.server'; interface AttendanceQRPageProps { @@ -14,8 +16,10 @@ export default async function AttendanceQRPage({ searchParams }: AttendanceQRPag redirect('/attendance'); } - // TODO: 하드코딩된 clubId 추후 동적으로 변경 - const { data } = await homeServerApi.getDashboard('YUNJcjFKMO'); + const clubId = (await cookies()).get(CLUB_ID_KEY)?.value; + if (!clubId) redirect('/attendance'); + + const { data } = await homeServerApi.getDashboard(clubId); const role = data.myInfo.userInfo.role; if (role !== 'LEAD' && role !== 'ADMIN') { diff --git a/src/app/(private)/(main)/layout.tsx b/src/app/(private)/(main)/layout.tsx index 8d679e28..ff04d24f 100644 --- a/src/app/(private)/(main)/layout.tsx +++ b/src/app/(private)/(main)/layout.tsx @@ -1,22 +1,26 @@ import type { ReactNode } from 'react'; +import { cookies } from 'next/headers'; import { homeServerApi } from '@/lib/apis/home.server'; +import { CLUB_ID_KEY } from '@/lib/apis/cookies'; import { UserHydrator } from '@/providers/user-hydrator'; import { ClubGuard, Header } from '@/components/layout'; -// TODO: 하드코딩된 clubId 추후 동적으로 변경 export default async function MainLayout({ children, }: Readonly<{ children: ReactNode; }>) { - const { data } = await homeServerApi.getDashboard('YUNJcjFKMO'); + const clubId = (await cookies()).get(CLUB_ID_KEY)?.value; + if (!clubId) return null; + + const { data } = await homeServerApi.getDashboard(clubId); const { userInfo } = data.myInfo; - const { id: clubId, name: clubName } = data.club; + const { id: resolvedClubId, name: clubName } = data.club; return ( - +
{children} diff --git a/src/components/attendance/AttendanceContent.tsx b/src/components/attendance/AttendanceContent.tsx index 9309daaa..e362ef06 100644 --- a/src/components/attendance/AttendanceContent.tsx +++ b/src/components/attendance/AttendanceContent.tsx @@ -11,6 +11,7 @@ import { ATTENDANCE_ERROR_MESSAGE } from '@/constants/attendance'; import { attendanceApi } from '@/lib/apis/attendance'; import { formatAttendanceDescription } from '@/lib/formatTime'; import { useQRCheckIn } from '@/hooks/useQRCheckIn'; +import { useClubId } from '@/stores/useClubStore'; import { toastError } from '@/stores/useToastStore'; import { useUserName, useUserRole } from '@/stores/useUserStore'; import type { AttendanceData } from '@/types/attendance'; @@ -30,6 +31,7 @@ function AttendanceContent({ }: AttendanceContentProps) { const name = useUserName() ?? ''; const role = useUserRole(); + const clubId = useClubId(); const isAdmin = role === 'LEAD' || role === 'ADMIN'; const router = useRouter(); const [isManualChecked, setIsManualChecked] = useState(false); @@ -57,11 +59,10 @@ function AttendanceContent({ const description = formatAttendanceDescription(start ?? '', end ?? '', location ?? ''); async function handleAttendanceComplete(code: string) { - if (!sessionId) return; + if (!clubId || !sessionId) return; try { - // TODO: 하드코딩된 clubId 추후 동적으로 변경 - await attendanceApi.checkIn('YUNJcjFKMO', sessionId, Number(code)); + await attendanceApi.checkIn(clubId, sessionId, Number(code)); setIsManualChecked(true); } catch (error) { const errorCode = (error as { response?: { data?: { code?: number } } }).response?.data?.code; diff --git a/src/hooks/useQRCheckIn.ts b/src/hooks/useQRCheckIn.ts index 4e45303f..2f8e905f 100644 --- a/src/hooks/useQRCheckIn.ts +++ b/src/hooks/useQRCheckIn.ts @@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation'; import { ATTENDANCE_ERROR_MESSAGE } from '@/constants/attendance'; import { attendanceApi } from '@/lib/apis/attendance'; +import { useClubId } from '@/stores/useClubStore'; import { toastError } from '@/stores/useToastStore'; interface UseQRCheckInParams { @@ -12,18 +13,18 @@ interface UseQRCheckInParams { function useQRCheckIn({ qrSessionId, qrCode }: UseQRCheckInParams) { const router = useRouter(); + const clubId = useClubId(); const [isChecked, setIsChecked] = useState(false); const [completeModalOpen, setCompleteModalOpen] = useState(false); const hasCheckedIn = useRef(false); useEffect(() => { - if (!qrSessionId || !qrCode || hasCheckedIn.current) return; + if (!clubId || !qrSessionId || !qrCode || hasCheckedIn.current) return; hasCheckedIn.current = true; const checkIn = async () => { try { - // TODO: 하드코딩된 clubId 추후 동적으로 변경 - await attendanceApi.checkIn('YUNJcjFKMO', Number(qrSessionId), Number(qrCode)); + await attendanceApi.checkIn(clubId, Number(qrSessionId), Number(qrCode)); setIsChecked(true); setCompleteModalOpen(true); } catch (error) { @@ -35,7 +36,7 @@ function useQRCheckIn({ qrSessionId, qrCode }: UseQRCheckInParams) { }; checkIn(); - }, [qrSessionId, qrCode, router]); + }, [clubId, qrSessionId, qrCode, router]); function handleModalOpenChange(open: boolean) { setCompleteModalOpen(open); From 9281b0a8f97d1555668918771a58c8d5e9a933ee Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Sun, 12 Apr 2026 23:16:59 +0900 Subject: [PATCH 12/23] =?UTF-8?q?fix:=20=EC=A1=B0=ED=9A=8C=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EB=A5=BC=20=EC=97=90=EB=9F=AC=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=ED=91=9C=EC=8B=9C=ED=95=98=EA=B2=8C=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 --- .../(private)/(main)/attendance/history/page.tsx | 15 ++++++--------- .../attendance/AttendanceHistoryContent.tsx | 13 ++++++++++--- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/app/(private)/(main)/attendance/history/page.tsx b/src/app/(private)/(main)/attendance/history/page.tsx index cc4fcb4c..4f697374 100644 --- a/src/app/(private)/(main)/attendance/history/page.tsx +++ b/src/app/(private)/(main)/attendance/history/page.tsx @@ -9,21 +9,18 @@ export default async function AttendanceHistoryPage() { const clubId = (await cookies()).get(CLUB_ID_KEY)?.value; let summary: AttendanceSummary | undefined; + let errorMessage: string | undefined; - if (clubId) { + if (!clubId) { + errorMessage = '동아리 정보를 찾을 수 없습니다.'; + } else { try { const response = await attendanceServerApi.getDetail(clubId); summary = response.data; } catch { - // 에러 시 빈 상태로 렌더링 + errorMessage = '출석 기록을 불러오지 못했습니다.'; } } - return ( - - ); + return ; } diff --git a/src/components/attendance/AttendanceHistoryContent.tsx b/src/components/attendance/AttendanceHistoryContent.tsx index b35a87da..3a94510d 100644 --- a/src/components/attendance/AttendanceHistoryContent.tsx +++ b/src/components/attendance/AttendanceHistoryContent.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useEffect } from 'react'; import Link from 'next/link'; import { @@ -15,11 +16,13 @@ import { import { cn } from '@/lib/cn'; import { formatKoreanDate, formatTime } from '@/lib/formatTime'; import { ATTENDANCE_STATUS_CONFIG } from '@/constants/attendance'; +import { toastError } from '@/stores/useToastStore'; import type { AttendanceSummary } from '@/types/attendance'; import { StatBox } from './StatBox'; interface AttendanceHistoryContentProps { - summary: AttendanceSummary; + summary?: AttendanceSummary; + errorMessage?: string; } function toDisplayRecord(record: AttendanceSummary['attendances'][number]) { @@ -37,10 +40,14 @@ function toDisplayRecord(record: AttendanceSummary['attendances'][number]) { }; } -function AttendanceHistoryContent({ summary }: AttendanceHistoryContentProps) { - const { total, attendanceCount, absenceCount, attendances = [] } = summary; +function AttendanceHistoryContent({ summary, errorMessage }: AttendanceHistoryContentProps) { + const { total, attendanceCount, absenceCount, attendances = [] } = summary ?? {}; const records = attendances.map(toDisplayRecord); + useEffect(() => { + if (errorMessage) toastError(errorMessage); + }, [errorMessage]); + return (
From d6967b18a20d0a29125e4224e13848ffe7ff9e8d Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Sun, 12 Apr 2026 23:24:05 +0900 Subject: [PATCH 13/23] =?UTF-8?q?fix:=20check-in=20api=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81=20(sessionId)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(private)/(main)/attendance/qr/page.tsx | 7 ++++--- src/lib/apis/attendance.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/app/(private)/(main)/attendance/qr/page.tsx b/src/app/(private)/(main)/attendance/qr/page.tsx index 64515439..0f0d32ca 100644 --- a/src/app/(private)/(main)/attendance/qr/page.tsx +++ b/src/app/(private)/(main)/attendance/qr/page.tsx @@ -10,9 +10,10 @@ interface AttendanceQRPageProps { } export default async function AttendanceQRPage({ searchParams }: AttendanceQRPageProps) { - const { sessionId } = await searchParams; + const { sessionId: rawSessionId } = await searchParams; + const sessionId = Number(rawSessionId); - if (!sessionId) { + if (!Number.isInteger(sessionId) || sessionId <= 0) { redirect('/attendance'); } @@ -26,5 +27,5 @@ export default async function AttendanceQRPage({ searchParams }: AttendanceQRPag redirect('/attendance'); } - return ; + return ; } diff --git a/src/lib/apis/attendance.ts b/src/lib/apis/attendance.ts index 6d7346ac..26029d4a 100644 --- a/src/lib/apis/attendance.ts +++ b/src/lib/apis/attendance.ts @@ -6,7 +6,7 @@ export const attendanceApi = { apiClient.get(`/clubs/${clubId}/attendances`), checkIn: (clubId: string, sessionId: number, code: number) => - apiClient.post(`/clubs/${clubId}/attendances/check-in`, { sessionId, code }), + apiClient.post(`/clubs/${clubId}/attendances/sessions/${sessionId}/check-in`, { code }), generateQR: (clubId: string, sessionId: number) => apiClient.post(`/admin/clubs/${clubId}/attendances/${sessionId}/qr`), From e3202a519fb91aa6f11db02eb84eafedc18a2fb6 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Sun, 12 Apr 2026 23:46:34 +0900 Subject: [PATCH 14/23] =?UTF-8?q?refactor:=20QR=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=EC=9D=84=20useAttendanc?= =?UTF-8?q?eQR=20=ED=9B=85=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/AttendanceQRContent.tsx | 35 +++------------- src/hooks/useAttendanceQR.ts | 42 +++++++++++++++++++ src/hooks/useQRCode.ts | 6 +-- 3 files changed, 50 insertions(+), 33 deletions(-) create mode 100644 src/hooks/useAttendanceQR.ts diff --git a/src/components/attendance/AttendanceQRContent.tsx b/src/components/attendance/AttendanceQRContent.tsx index 5d36fecf..a669e385 100644 --- a/src/components/attendance/AttendanceQRContent.tsx +++ b/src/components/attendance/AttendanceQRContent.tsx @@ -1,8 +1,6 @@ 'use client'; -import { useEffect, useRef } from 'react'; import Link from 'next/link'; -import QRCodeStyling from 'qr-code-styling'; import { Breadcrumb, @@ -12,8 +10,7 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from '@/components/ui'; -import { useRemainingTime } from '@/hooks/useRemainingTime'; -import { useQRCode } from '@/hooks/useQRCode'; +import { useAttendanceQR } from '@/hooks/useAttendanceQR'; import { useClubId } from '@/stores/useClubStore'; interface AttendanceQRContentProps { @@ -22,32 +19,10 @@ interface AttendanceQRContentProps { 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, - qrOptions: { errorCorrectionLevel: 'L' }, - type: 'svg', - dotsOptions: { type: 'dots' }, - cornersSquareOptions: { type: 'extra-rounded' }, - cornersDotOptions: { type: 'extra-rounded' }, - }); - qrCodeRef.current.append(qrRef.current); - } else { - qrCodeRef.current.update({ data: checkInUrl }); - } - }, [qrData]); + const { qrRef, qrData, isLoading, minutes, seconds, isExpired } = useAttendanceQR( + clubId, + sessionId, + ); return (
diff --git a/src/hooks/useAttendanceQR.ts b/src/hooks/useAttendanceQR.ts new file mode 100644 index 00000000..36baa8dc --- /dev/null +++ b/src/hooks/useAttendanceQR.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef } from 'react'; +import QRCodeStyling from 'qr-code-styling'; + +import { useRemainingTime } from '@/hooks/useRemainingTime'; +import { useQRCode } from '@/hooks/useQRCode'; + +function useAttendanceQR(clubId: string | null, sessionId: number) { + const { data: qrData, isLoading, refetch } = useQRCode(clubId, sessionId); + const qrRef = useRef(null); + const qrCodeRef = useRef(null); + const { minutes, seconds, isExpired } = useRemainingTime(qrData?.expiredAt ?? ''); + + useEffect(() => { + if (isExpired && qrData) refetch(); + }, [isExpired, qrData, refetch]); + + 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, + qrOptions: { errorCorrectionLevel: 'L' }, + type: 'svg', + dotsOptions: { type: 'dots' }, + cornersSquareOptions: { type: 'extra-rounded' }, + cornersDotOptions: { type: 'extra-rounded' }, + }); + qrCodeRef.current.append(qrRef.current); + } else { + qrCodeRef.current.update({ data: checkInUrl }); + } + }, [qrData]); + + return { qrRef, qrData, isLoading, minutes, seconds, isExpired }; +} + +export { useAttendanceQR }; diff --git a/src/hooks/useQRCode.ts b/src/hooks/useQRCode.ts index c221b8cb..60f72ddb 100644 --- a/src/hooks/useQRCode.ts +++ b/src/hooks/useQRCode.ts @@ -4,14 +4,14 @@ import { attendanceApi } from '@/lib/apis/attendance'; function useQRCode(clubId: string | null, sessionId: number) { return useQuery({ - queryKey: ['attendance', 'qr', sessionId], + queryKey: ['attendance', 'qr', clubId, sessionId], queryFn: async () => { const response = await attendanceApi.generateQR(clubId!, sessionId); return response.data.data; }, enabled: !!clubId, - staleTime: 10 * 60 * 1000, - gcTime: 10 * 60 * 1000, + staleTime: 0, + gcTime: 0, }); } From 7c261be6a7e7fa8ebe128d8d95ecf963df02c0cd Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Sun, 12 Apr 2026 23:46:51 +0900 Subject: [PATCH 15/23] =?UTF-8?q?refactor:=20=EC=B6=9C=EC=84=9D=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EB=AA=A8=EB=8B=AC=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=A5=BC=20AttendanceContent=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/AttendanceContent.tsx | 16 ++++++----- .../attendance/AttendanceTodayCard.tsx | 28 ++++++++----------- src/hooks/useQRCheckIn.ts | 24 ++++++++-------- 3 files changed, 32 insertions(+), 36 deletions(-) diff --git a/src/components/attendance/AttendanceContent.tsx b/src/components/attendance/AttendanceContent.tsx index e362ef06..66bf9c83 100644 --- a/src/components/attendance/AttendanceContent.tsx +++ b/src/components/attendance/AttendanceContent.tsx @@ -35,12 +35,13 @@ function AttendanceContent({ const isAdmin = role === 'LEAD' || role === 'ADMIN'; const router = useRouter(); const [isManualChecked, setIsManualChecked] = useState(false); + const [completeModalOpen, setCompleteModalOpen] = useState(false); - const { - isChecked: isQRChecked, - completeModalOpen: qrCompleteModalOpen, - setCompleteModalOpen: setQrCompleteModalOpen, - } = useQRCheckIn({ qrSessionId, qrCode }); + const { isChecked: isQRChecked } = useQRCheckIn({ + qrSessionId, + qrCode, + onSuccess: () => setCompleteModalOpen(true), + }); const isChecked = isManualChecked || isQRChecked; @@ -64,6 +65,7 @@ function AttendanceContent({ try { await attendanceApi.checkIn(clubId, sessionId, Number(code)); setIsManualChecked(true); + setCompleteModalOpen(true); } catch (error) { const errorCode = (error as { response?: { data?: { code?: number } } }).response?.data?.code; toastError(errorCode ? ATTENDANCE_ERROR_MESSAGE[errorCode] : undefined); @@ -114,8 +116,8 @@ function AttendanceContent({
diff --git a/src/components/attendance/AttendanceTodayCard.tsx b/src/components/attendance/AttendanceTodayCard.tsx index 30657aa7..3fdc7cff 100644 --- a/src/components/attendance/AttendanceTodayCard.tsx +++ b/src/components/attendance/AttendanceTodayCard.tsx @@ -7,7 +7,6 @@ import { useState } from 'react'; import { CompleteIcon } from '@/assets/icons'; import { Card } from '@/components/ui'; import { AttendanceCodeModal } from '@/components/attendance/AttendanceCodeModal'; -import { AttendanceCompleteModal } from '@/components/attendance/AttendanceCompleteModal'; import { toastError } from '@/stores/useToastStore'; interface AttendanceTodayCardProps { @@ -51,11 +50,14 @@ function AttendanceTodayCard({ }: AttendanceTodayCardProps) { const router = useRouter(); const [codeModalOpen, setCodeModalOpen] = useState(false); - const [completeModalOpen, setCompleteModalOpen] = useState(false); - function handleCodeConfirm(code: string) { - onAttendanceComplete?.(code); - setCompleteModalOpen(true); + function handleSecondaryClick() { + if (!isAdmin) { + toastError('관리자만 사용할 수 있는 기능입니다.'); + return; + } + if (sessionId == null) return; + router.push(`/attendance/qr?sessionId=${sessionId}`); } return ( @@ -66,16 +68,12 @@ function AttendanceTodayCard({ title={title} description={description} showArrow={false} - onPrimaryClick={isChecked ? () => setCompleteModalOpen(true) : () => setCodeModalOpen(true)} + onPrimaryClick={() => setCodeModalOpen(true)} primaryButtonText={isChecked ? '출석 완료' : '출석하기'} - primaryButtonDisabled={disabled} - onSecondaryClick={ - isAdmin - ? () => router.push(`/attendance/qr?sessionId=${sessionId}`) - : () => toastError('관리자만 사용할 수 있는 기능입니다.') - } + primaryButtonDisabled={disabled || isChecked} + onSecondaryClick={handleSecondaryClick} secondaryButtonText="출석코드 확인" - secondaryButtonDisabled={disabled} + secondaryButtonDisabled={disabled || sessionId == null} > {isChecked && } @@ -83,14 +81,12 @@ function AttendanceTodayCard({ onAttendanceComplete?.(code)} title={title} start={start} endTime={endTime} location={location} /> - - ); } diff --git a/src/hooks/useQRCheckIn.ts b/src/hooks/useQRCheckIn.ts index 2f8e905f..1f5bc9c5 100644 --- a/src/hooks/useQRCheckIn.ts +++ b/src/hooks/useQRCheckIn.ts @@ -9,24 +9,27 @@ import { toastError } from '@/stores/useToastStore'; interface UseQRCheckInParams { qrSessionId?: string; qrCode?: string; + onSuccess?: () => void; } -function useQRCheckIn({ qrSessionId, qrCode }: UseQRCheckInParams) { +function useQRCheckIn({ qrSessionId, qrCode, onSuccess }: UseQRCheckInParams) { const router = useRouter(); const clubId = useClubId(); const [isChecked, setIsChecked] = useState(false); - const [completeModalOpen, setCompleteModalOpen] = useState(false); - const hasCheckedIn = useRef(false); + const checkedKey = useRef(null); useEffect(() => { - if (!clubId || !qrSessionId || !qrCode || hasCheckedIn.current) return; - hasCheckedIn.current = true; + if (!clubId || !qrSessionId || !qrCode) return; + + const key = `${qrSessionId}:${qrCode}`; + if (checkedKey.current === key) return; const checkIn = async () => { try { await attendanceApi.checkIn(clubId, Number(qrSessionId), Number(qrCode)); + checkedKey.current = key; setIsChecked(true); - setCompleteModalOpen(true); + onSuccess?.(); } catch (error) { const errorCode = (error as { response?: { data?: { code?: number } } }).response?.data ?.code; @@ -36,14 +39,9 @@ function useQRCheckIn({ qrSessionId, qrCode }: UseQRCheckInParams) { }; checkIn(); - }, [clubId, qrSessionId, qrCode, router]); - - function handleModalOpenChange(open: boolean) { - setCompleteModalOpen(open); - if (!open) router.replace('/attendance'); - } + }, [clubId, qrSessionId, qrCode, router, onSuccess]); - return { isChecked, completeModalOpen, setCompleteModalOpen: handleModalOpenChange }; + return { isChecked }; } export { useQRCheckIn }; From 91492f17624c0278d03419f34ccd5024a6791c6d Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Sun, 12 Apr 2026 23:47:06 +0900 Subject: [PATCH 16/23] =?UTF-8?q?refactor:=20UserHydrator=EB=A5=BC=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20hydration=20=EB=B0=A9=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/providers/user-hydrator.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/providers/user-hydrator.tsx b/src/providers/user-hydrator.tsx index 83436581..497dcf68 100644 --- a/src/providers/user-hydrator.tsx +++ b/src/providers/user-hydrator.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useEffect } from 'react'; +import { useRef } from 'react'; -import { useClubActions } from '@/stores/useClubStore'; -import { useUserActions } from '@/stores/useUserStore'; +import { useClubStore } from '@/stores/useClubStore'; +import { useUserStore } from '@/stores/useUserStore'; import type { ClubIdentifier } from '@/types/club'; import type { UserInfo } from '@/types/user'; @@ -14,13 +14,13 @@ interface UserHydratorProps { } function UserHydrator({ userInfo, clubInfo, children }: UserHydratorProps) { - const { setUser } = useUserActions(); - const { setClub } = useClubActions(); + const hydrated = useRef(false); - useEffect(() => { - setUser(userInfo); - setClub(clubInfo); - }, [userInfo, clubInfo, setUser, setClub]); + if (!hydrated.current) { + useUserStore.setState(userInfo, false, 'setUser'); + useClubStore.setState(clubInfo, false, 'setClub'); + hydrated.current = true; + } return children; } From e6c457706ab508293ba21c89e21b6a708e0dd73b Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Sun, 12 Apr 2026 23:47:25 +0900 Subject: [PATCH 17/23] =?UTF-8?q?fix:=20=ED=99=88=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=EC=BA=90=EC=8B=9C=20=EC=A0=84=EB=9E=B5?= =?UTF-8?q?=EC=9D=84=20ISR=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/apis/home.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/apis/home.server.ts b/src/lib/apis/home.server.ts index de837178..43045b2b 100644 --- a/src/lib/apis/home.server.ts +++ b/src/lib/apis/home.server.ts @@ -4,6 +4,6 @@ import type { HomeDashboardResponse } from '@/types/home'; export const homeServerApi = { getDashboard: (clubId: string) => apiServer.get(`/clubs/${clubId}/dashboard/home`, { - cache: 'no-store', + next: { revalidate: 300, tags: [`dashboard-${clubId}`] }, }), }; From 8da616a581e87b062a26548a8e9d540a45bb5825 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Mon, 13 Apr 2026 00:08:01 +0900 Subject: [PATCH 18/23] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20qr=20=EC=98=88=EC=8B=9C=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../icons/attendance/ic_attendance_qr.svg | 362 ------------------ 1 file changed, 362 deletions(-) delete mode 100644 src/assets/icons/attendance/ic_attendance_qr.svg diff --git a/src/assets/icons/attendance/ic_attendance_qr.svg b/src/assets/icons/attendance/ic_attendance_qr.svg deleted file mode 100644 index 303e5455..00000000 --- a/src/assets/icons/attendance/ic_attendance_qr.svg +++ /dev/null @@ -1,362 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 2f3f51804e648725c759bdbed4ba7b02462e2150 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Mon, 13 Apr 2026 00:12:45 +0900 Subject: [PATCH 19/23] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20import=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/stores/useClubStore.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/stores/useClubStore.ts b/src/stores/useClubStore.ts index bed89265..a7c3076e 100644 --- a/src/stores/useClubStore.ts +++ b/src/stores/useClubStore.ts @@ -2,8 +2,6 @@ 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, From 09c5ce6885838094db34413c00c21ddd2c4e16b5 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Mon, 13 Apr 2026 00:13:32 +0900 Subject: [PATCH 20/23] =?UTF-8?q?fix:=20useRemainingTime=EC=97=90=EC=84=9C?= =?UTF-8?q?=20endTime=EC=9D=B4=20=EC=97=86=EC=9C=BC=EB=A9=B4=20=ED=83=80?= =?UTF-8?q?=EC=9D=B4=EB=A8=B8=20=EC=9E=90=EC=B2=B4=EB=A5=BC=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=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/hooks/useRemainingTime.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/hooks/useRemainingTime.ts b/src/hooks/useRemainingTime.ts index 6fbfae77..c58d0f30 100644 --- a/src/hooks/useRemainingTime.ts +++ b/src/hooks/useRemainingTime.ts @@ -20,6 +20,8 @@ function useRemainingTime(endTime: string) { const [remaining, setRemaining] = useState(() => getRemainingSeconds(endTime)); useEffect(() => { + if (!endTime) return; + const interval = setInterval(() => { const seconds = getRemainingSeconds(endTime); setRemaining(seconds); From ce774c165e34d0ee1e168ed5e09f9e0cebba84ad Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Mon, 13 Apr 2026 00:14:15 +0900 Subject: [PATCH 21/23] =?UTF-8?q?fix:=20summary=EA=B0=80=20=EC=9E=88?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=EB=A7=8C=20=EB=82=B4=EC=9A=A9=EC=9D=84=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=ED=95=98=EA=B2=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/AttendanceHistoryContent.tsx | 74 ++++++++++--------- 1 file changed, 40 insertions(+), 34 deletions(-) diff --git a/src/components/attendance/AttendanceHistoryContent.tsx b/src/components/attendance/AttendanceHistoryContent.tsx index 3a94510d..1a333c97 100644 --- a/src/components/attendance/AttendanceHistoryContent.tsx +++ b/src/components/attendance/AttendanceHistoryContent.tsx @@ -71,45 +71,51 @@ function AttendanceHistoryContent({ summary, errorMessage }: AttendanceHistoryCo

출석 조회

-
-
-
- - - -
+ {summary ? ( +
+
+
+ + + +
- + -
- {records.length === 0 ? ( -

- 출석 기록이 없습니다. -

- ) : ( - records.map((record) => ( -
-
- - {record.statusLabel} - - {record.title} -
-
- 날짜 : {record.date} - 장소 : {record.location} +
+ {records.length === 0 ? ( +

+ 출석 기록이 없습니다. +

+ ) : ( + records.map((record) => ( +
+
+ + {record.statusLabel} + + {record.title} +
+
+ 날짜 : {record.date} + 장소 : {record.location} +
-
- )) - )} + )) + )} +
-
+ ) : ( +

+ 출석 정보를 불러올 수 없습니다. +

+ )}
); } From 9735cbc71100ee2da551eadb0f34ad3fe5d691fc Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Mon, 13 Apr 2026 00:15:44 +0900 Subject: [PATCH 22/23] =?UTF-8?q?chore:=20=EC=82=AC=EC=9A=A9=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20export=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/icons/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index 0edac0be..1d1bd7ec 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -35,7 +35,6 @@ export { default as HomeIcon } from './home.svg'; export { default as MoreHorizIcon } from './more-horiz.svg'; export { default as CompleteIcon } from './complete.svg'; -export { default as AttendanceQRIcon } from './attendance/ic_attendance_qr.svg'; export { default as InfoCircleIcon } from './info_circle.svg'; export { default as CautionIcon } from './caution.svg'; export { default as KakaoLogoIcon } from './kakao_logo.svg'; From 4bd7b89db5a7e93364d645ef993d513ef0bd3fee Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Mon, 13 Apr 2026 00:35:40 +0900 Subject: [PATCH 23/23] =?UTF-8?q?fix:=20endTime=EA=B0=80=20=EB=B9=84?= =?UTF-8?q?=EC=96=B4=EC=A7=88=20=EB=95=8C=20=EC=B9=B4=EC=9A=B4=ED=8A=B8?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=20=EC=83=81=ED=83=9C=EA=B0=80=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=EB=90=98=EC=A7=80=20=EC=95=8A=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/useRemainingTime.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/hooks/useRemainingTime.ts b/src/hooks/useRemainingTime.ts index c58d0f30..ad82c5ff 100644 --- a/src/hooks/useRemainingTime.ts +++ b/src/hooks/useRemainingTime.ts @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useSyncExternalStore } from 'react'; /** * 종료 시각까지 남은 시간을 1초 간격으로 카운트다운하는 훅 @@ -11,25 +11,26 @@ import { useState, useEffect } from 'react'; * @returns isExpired - 시간 만료 여부 */ function getRemainingSeconds(endTime: string) { + if (!endTime) return 0; const end = new Date(endTime).getTime(); if (Number.isNaN(end)) return 0; return Math.max(0, Math.floor((end - Date.now()) / 1000)); } function useRemainingTime(endTime: string) { - const [remaining, setRemaining] = useState(() => getRemainingSeconds(endTime)); - - useEffect(() => { - if (!endTime) return; + const subscribe = (onStoreChange: () => void) => { + if (!endTime || getRemainingSeconds(endTime) <= 0) return () => {}; const interval = setInterval(() => { - const seconds = getRemainingSeconds(endTime); - setRemaining(seconds); - if (seconds <= 0) clearInterval(interval); + onStoreChange(); + if (getRemainingSeconds(endTime) <= 0) clearInterval(interval); }, 1000); - return () => clearInterval(interval); - }, [endTime]); + }; + + const getSnapshot = () => getRemainingSeconds(endTime); + + const remaining = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); const minutes = String(Math.floor(remaining / 60)).padStart(2, '0'); const seconds = String(remaining % 60).padStart(2, '0');