From 693b1a0b9ca44f62cc59a6bd9e94004b00957291 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Fri, 20 Mar 2026 13:06:47 +0900 Subject: [PATCH 01/20] =?UTF-8?q?feat:=20home,=20send=20svg=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/assets/icons/home.svg | 3 +++ src/assets/icons/index.ts | 2 ++ src/assets/icons/send.svg | 4 ++-- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 src/assets/icons/home.svg diff --git a/src/assets/icons/home.svg b/src/assets/icons/home.svg new file mode 100644 index 0000000..b1b07d6 --- /dev/null +++ b/src/assets/icons/home.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index 36b733c..0a2b31e 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -1,4 +1,5 @@ export { default as ArrowLeftIcon } from './arrow_left.svg'; +import { Home } from 'lucide-react'; export { default as ArrowRightIcon } from './arrow_right.svg'; export { default as ChatIcon } from './chat.svg'; export { default as BackIcon } from './back.svg'; @@ -31,3 +32,4 @@ export { default as FolderPlusIcon } from './folder_plus.svg'; export { default as SendIcon } from './send.svg'; export { default as DownloadIcon } from './download.svg'; export { default as FolderIcon } from './folder.svg'; +export { default as HomeIcon } from './home.svg'; diff --git a/src/assets/icons/send.svg b/src/assets/icons/send.svg index e23abc5..d9ca9b6 100644 --- a/src/assets/icons/send.svg +++ b/src/assets/icons/send.svg @@ -1,3 +1,3 @@ - - + + From 68f3a252fafa927a32fcd1bac7e13322f8710424 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Fri, 20 Mar 2026 13:29:33 +0900 Subject: [PATCH 02/20] =?UTF-8?q?refactor:=20lucide-react=20import=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20MoreHorizIcon=20svg=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/icons/index.ts | 3 +-- src/assets/icons/more-horiz.svg | 3 +++ src/assets/icons/send.svg | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 src/assets/icons/more-horiz.svg diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index 0a2b31e..73f2dac 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -1,5 +1,4 @@ export { default as ArrowLeftIcon } from './arrow_left.svg'; -import { Home } from 'lucide-react'; export { default as ArrowRightIcon } from './arrow_right.svg'; export { default as ChatIcon } from './chat.svg'; export { default as BackIcon } from './back.svg'; @@ -19,7 +18,6 @@ export { default as PaperclipIcon } from './paperclip.svg'; export { default as NewIcon } from './new.svg'; export { default as DeleteIcon } from './delete.svg'; export { default as PeopleIcon } from './people.svg'; - export { default as SearchIcon } from './search.svg'; export { default as MegaphoneDarkIcon } from './megaphone_dark.svg'; export { default as MegaphoneDarkActiveIcon } from './megaphone_dark_active.svg'; @@ -33,3 +31,4 @@ export { default as SendIcon } from './send.svg'; export { default as DownloadIcon } from './download.svg'; export { default as FolderIcon } from './folder.svg'; export { default as HomeIcon } from './home.svg'; +export { default as MoreHorizIcon } from './more-horiz.svg'; diff --git a/src/assets/icons/more-horiz.svg b/src/assets/icons/more-horiz.svg new file mode 100644 index 0000000..cabcd64 --- /dev/null +++ b/src/assets/icons/more-horiz.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/send.svg b/src/assets/icons/send.svg index d9ca9b6..5a00d74 100644 --- a/src/assets/icons/send.svg +++ b/src/assets/icons/send.svg @@ -1,3 +1,3 @@ - + From 58abfb6edfda0879ca71f1659c3ccaea88a237b7 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Fri, 20 Mar 2026 13:30:29 +0900 Subject: [PATCH 03/20] =?UTF-8?q?style:=20Header=20=EA=B2=8C=EC=8B=9C=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=B2=84=ED=8A=BC=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/Header.tsx | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 6251e67..9d91c27 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -4,15 +4,8 @@ import Image from 'next/image'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { usePathname } from 'next/navigation'; -import { Button } from '../ui'; -import { - MenuIcon, - EditIcon, - CheckRoundIcon, - ExitToAppIcon, - AvatarIcon, - LogoIcon, -} from '@/assets/icons'; +import { Button, Icon } from '../ui'; +import { MenuIcon, EditIcon, SendIcon, ExitToAppIcon, AvatarIcon, LogoIcon } from '@/assets/icons'; interface HeaderProps { isMain?: boolean; @@ -82,7 +75,7 @@ export default function Header({ isMain = true }: HeaderProps) { 작성 취소 @@ -106,7 +99,7 @@ export default function Header({ isMain = true }: HeaderProps) { className="typo-button1 text-text-strong gap-100" > - 관리자 서비스 + 관리자 More ); diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 911af9a..d34b218 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -2,18 +2,18 @@ import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; -import type { StaticImageData } from 'next/image'; +import { ArrowRightIcon } from '@/assets/icons'; import { cn } from '@/lib/cn'; import { Button } from '@/components/ui/Button'; -import arrowRightIcon from '@/assets/icons/arrow_right.svg'; +import { Icon } from '@/components/ui/Icon'; const cardVariants = cva('bg-container-neutral flex rounded-lg p-400 transition-colors', { variants: { variant: { default: 'flex-col gap-6 rounded-xl border py-6 shadow-sm', onlyText: - 'cursor-pointer items-center justify-between hover:bg-container-neutral-interaction', + 'cursor-pointer items-start justify-between hover:bg-container-neutral-interaction', buttonSet: 'flex-col gap-500', }, }, @@ -69,17 +69,7 @@ function Card({ )} {showArrow && ( - + )} ); @@ -98,17 +88,7 @@ function Card({ )} {showArrow && ( - + )} @@ -116,9 +96,11 @@ function Card({ - + {onSecondaryClick && ( + + )} ); From 5cf5666176a3a73e7877b81c7b583a45e9bca827 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Fri, 20 Mar 2026 14:04:55 +0900 Subject: [PATCH 06/20] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=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)/(main)/attendance/page.tsx | 17 ++ src/app/(public)/attendance/page.tsx | 241 ------------------ .../attendance/AttendanceContent.tsx | 71 ++++++ .../attendance/AttendanceStatus.tsx | 21 ++ .../attendance/AttendanceTodayCard.tsx | 24 ++ src/components/attendance/index.ts | 10 + 6 files changed, 143 insertions(+), 241 deletions(-) create mode 100644 src/app/(private)/(main)/attendance/page.tsx delete mode 100644 src/app/(public)/attendance/page.tsx create mode 100644 src/components/attendance/AttendanceContent.tsx create mode 100644 src/components/attendance/AttendanceStatus.tsx create mode 100644 src/components/attendance/AttendanceTodayCard.tsx create mode 100644 src/components/attendance/index.ts diff --git a/src/app/(private)/(main)/attendance/page.tsx b/src/app/(private)/(main)/attendance/page.tsx new file mode 100644 index 0000000..1ace951 --- /dev/null +++ b/src/app/(private)/(main)/attendance/page.tsx @@ -0,0 +1,17 @@ +import { AttendanceContent } from '@/components/attendance'; +import type { AttendanceData } from '@/types/attendance'; + +// TODO: API 연동 시 실제 데이터로 교체 +const mockAttendance: AttendanceData = { + attendanceRate: 80, + title: '1주차 정기모임', + status: 'ATTEND', + code: 1234, + start: '2026-03-20T04:53:06.913Z', + end: '2026-03-20T04:53:06.913Z', + location: '공학관 401호', +}; + +export default function AttendancePage() { + return ; +} diff --git a/src/app/(public)/attendance/page.tsx b/src/app/(public)/attendance/page.tsx deleted file mode 100644 index b1d6531..0000000 --- a/src/app/(public)/attendance/page.tsx +++ /dev/null @@ -1,241 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import Image from 'next/image'; -import { AttendanceProgressBar } from '@/components/attendance/AttendanceProgressBar'; -import { - Card, - Button, - Dialog, - DialogContent, - DialogHeader, - DialogBody, - DialogFooter, - DialogTrigger, - DialogClose, -} from '@/components/ui'; -import DummyImage from '@/assets/icons/dummy.svg'; - -export default function AttendancePage() { - const [currentPage, setCurrentPage] = useState(1); - const [qrModalOpen, setQrModalOpen] = useState(false); - - return ( -
- - - setQrModalOpen(true)} - onSecondaryClick={() => console.log('출석코드 확인')} - /> - - - - - -
-

- 운영진이 공유한 6자리 출석 코드를 입력하세요 -

-

- QR 스캔이 어려운 경우 코드를 직접 입력할 수 있어요 -

-
-
-
- {[1, 2, 3, 4, 5, 6].map((num) => ( -
- {num === 1 ? '2' : ''} -
- ))} -
-

출석 가능 시간 10:00

-
-
- - - - - -
-
- - - - {/* 모달 예시 버튼들 */} -
-

모달 예시

-
- {/* 모달 2: 간단한 모달 (title만) */} - - - - - - - -

출석이 완료되었습니다!

-
- - - - - -
-
- - {/* 모달 3: 페이지네이션 모달 */} - - - - - - - -
-
-

2024년 7월 1일 - 출석 완료

-
-
-

2024년 7월 8일 - 출석 완료

-
-
-

2024년 7월 15일 - 결석

-
-
-
- - - 1 / 3 - -
- } - > - - - - - - - - {/* 모달 4: 커스텀 헤더 (children 사용) */} - - - - - - -
-
- -
-

출석 성공

-
-
- -

출석이 성공적으로 처리되었습니다.

-
- - - - - -
-
- - {/* 모달 5: 온보딩 모달 (이미지 참조) */} - - - - - - - - {/* 이미지 영역 */} -
- 웹사이트 미리보기 -
- {/* 설정하러 가기 링크 */} -

설정하러 가기

-
- - - {currentPage} / 4 - -
- } - > - - - - - - -
- - - ); -} diff --git a/src/components/attendance/AttendanceContent.tsx b/src/components/attendance/AttendanceContent.tsx new file mode 100644 index 0000000..4902714 --- /dev/null +++ b/src/components/attendance/AttendanceContent.tsx @@ -0,0 +1,71 @@ +import Link from 'next/link'; + +import { HomeIcon } from '@/assets/icons'; +import { + Breadcrumb, + BreadcrumbList, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbSeparator, + BreadcrumbPage, + Card, + Icon, +} from '@/components/ui'; +import { AttendanceStatus } from '@/components/attendance/AttendanceStatus'; +import { AttendanceTodayCard } from '@/components/attendance/AttendanceTodayCard'; +import { formatTime } from '@/lib/formatTime'; +import type { AttendanceData } from '@/types/attendance'; + +function formatAttendanceDescription(start: string, end: string, location: string) { + const startDate = new Date(start); + const endDate = new Date(end); + + const year = startDate.getFullYear(); + const month = startDate.getMonth() + 1; + const day = startDate.getDate(); + + return `날짜 : ${year}년 ${month}월 ${day}일 (${formatTime(startDate)}~${formatTime(endDate)})\n장소 : ${location}`; +} + +interface AttendanceContentProps { + attendance: AttendanceData; +} + +function AttendanceContent({ attendance }: AttendanceContentProps) { + const { attendanceRate, title, start, end, location } = attendance; + const description = formatAttendanceDescription(start, end, location); + + return ( +
+ + + + + + + + + + + + 출석 + + + + + + +
+ + + +
+
+ ); +} + +export { AttendanceContent, type AttendanceContentProps }; diff --git a/src/components/attendance/AttendanceStatus.tsx b/src/components/attendance/AttendanceStatus.tsx new file mode 100644 index 0000000..c0af0c8 --- /dev/null +++ b/src/components/attendance/AttendanceStatus.tsx @@ -0,0 +1,21 @@ +import { cn } from '@/lib/cn'; +import { AttendanceProgressBar } from '@/components/attendance/AttendanceProgressBar'; + +interface AttendanceStatusProps extends React.HTMLAttributes { + attendanceRate: number; +} + +function AttendanceStatus({ attendanceRate, className, ...props }: AttendanceStatusProps) { + return ( +
+

+ 김위드님의 +
+ 출석률은 {attendanceRate}% +

+ +
+ ); +} + +export { AttendanceStatus, type AttendanceStatusProps }; diff --git a/src/components/attendance/AttendanceTodayCard.tsx b/src/components/attendance/AttendanceTodayCard.tsx new file mode 100644 index 0000000..61d14c8 --- /dev/null +++ b/src/components/attendance/AttendanceTodayCard.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { Card } from '@/components/ui'; + +interface AttendanceTodayCardProps { + overline: string; + title: string; + description: string; +} + +function AttendanceTodayCard({ overline, title, description }: AttendanceTodayCardProps) { + return ( + {}} + /> + ); +} + +export { AttendanceTodayCard, type AttendanceTodayCardProps }; diff --git a/src/components/attendance/index.ts b/src/components/attendance/index.ts new file mode 100644 index 0000000..e9f1a76 --- /dev/null +++ b/src/components/attendance/index.ts @@ -0,0 +1,10 @@ +export { AttendanceContent } from './AttendanceContent'; + +export { AttendanceProgressBar } from './AttendanceProgressBar'; +export type { AttendanceProgressBarProps } from './AttendanceProgressBar'; + +export { AttendanceTodayCard } from './AttendanceTodayCard'; +export type { AttendanceTodayCardProps } from './AttendanceTodayCard'; + +export { AttendanceStatus } from './AttendanceStatus'; +export type { AttendanceStatusProps } from './AttendanceStatus'; From fa48087b4f28caf364ed3261f5f60bd5ad53e563 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Fri, 20 Mar 2026 14:11:19 +0900 Subject: [PATCH 07/20] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=B6=9C=EC=84=9D=EC=BD=94=EB=93=9C=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/attendance/AttendanceContent.tsx | 4 +++- src/components/attendance/AttendanceTodayCard.tsx | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/attendance/AttendanceContent.tsx b/src/components/attendance/AttendanceContent.tsx index 4902714..117f3af 100644 --- a/src/components/attendance/AttendanceContent.tsx +++ b/src/components/attendance/AttendanceContent.tsx @@ -29,9 +29,10 @@ function formatAttendanceDescription(start: string, end: string, location: strin interface AttendanceContentProps { attendance: AttendanceData; + isAdmin?: boolean; } -function AttendanceContent({ attendance }: AttendanceContentProps) { +function AttendanceContent({ attendance, isAdmin = true }: AttendanceContentProps) { const { attendanceRate, title, start, end, location } = attendance; const description = formatAttendanceDescription(start, end, location); @@ -60,6 +61,7 @@ function AttendanceContent({ attendance }: AttendanceContentProps) { overline="오늘의 출석" title={title} description={description} + isAdmin={isAdmin} /> diff --git a/src/components/attendance/AttendanceTodayCard.tsx b/src/components/attendance/AttendanceTodayCard.tsx index 61d14c8..041a21d 100644 --- a/src/components/attendance/AttendanceTodayCard.tsx +++ b/src/components/attendance/AttendanceTodayCard.tsx @@ -6,9 +6,15 @@ interface AttendanceTodayCardProps { overline: string; title: string; description: string; + isAdmin?: boolean; } -function AttendanceTodayCard({ overline, title, description }: AttendanceTodayCardProps) { +function AttendanceTodayCard({ + overline, + title, + description, + isAdmin = false, +}: AttendanceTodayCardProps) { return ( {}} + onSecondaryClick={isAdmin ? () => {} : undefined} + secondaryButtonText="출석코드 확인" /> ); } From 605377d575fa9b0df469372cab2fcee3d6b14b37 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Fri, 20 Mar 2026 20:42:49 +0900 Subject: [PATCH 08/20] =?UTF-8?q?refactor:=20Dialog=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98/=ED=97=A4=EB=8D=94/=ED=91=B8=ED=84=B0=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=98=A4=EB=B2=84?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=20=EC=83=89=EC=83=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/dialog.tsx | 84 +++++++++++++----------------------- 1 file changed, 29 insertions(+), 55 deletions(-) diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 3968f22..1d593f5 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -3,12 +3,12 @@ import * as React from 'react'; import type { ReactNode } from 'react'; import { Dialog as DialogPrimitive } from 'radix-ui'; -import type { StaticImageData } from 'next/image'; +import { DeleteIcon } from '@/assets/icons'; import { cn } from '@/lib/cn'; import { Button } from '@/components/ui/Button'; import { Divider } from '@/components/ui/Divider'; -import deleteIcon from '@/assets/icons/delete.svg'; +import { Icon } from '@/components/ui/Icon'; function Dialog({ ...props }: React.ComponentProps) { return ; @@ -34,7 +34,7 @@ function DialogOverlay({ - - Close + )} @@ -88,6 +77,7 @@ function DialogContent({ } interface DialogHeaderProps extends React.HTMLAttributes { + icon?: ReactNode; overline?: string; title?: string; description?: string; @@ -97,6 +87,7 @@ interface DialogHeaderProps extends React.HTMLAttributes { } function DialogHeader({ + icon, overline, title, description, @@ -106,6 +97,12 @@ function DialogHeader({ className, ...props }: DialogHeaderProps) { + const closeButton = showClose && onClose && ( + + ); + // If children provided, use custom content mode if (children) { return ( @@ -116,20 +113,7 @@ function DialogHeader({ > Dialog
{children}
- {showClose && onClose && ( - - )} + {closeButton} ); } @@ -137,34 +121,24 @@ function DialogHeader({ return (
- {overline &&

{overline}

} - {title ? ( - -

{title}

-
- ) : ( - Dialog - )} - {description &&

{description}

} + {icon &&
{icon}
} +
+ {overline &&

{overline}

} + {title ? ( + +

{title}

+
+ ) : ( + Dialog + )} + {description &&

{description}

} +
- {showClose && onClose && ( - - )} + {closeButton}
); } @@ -201,10 +175,10 @@ function DialogFooter({ return (
{showDivider && } -
+
{children} {description && ( -

{description}

+

{description}

)} {showCloseButton && ( From 8a536de54de379104099fac43a69738a835530ed Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Fri, 20 Mar 2026 20:59:04 +0900 Subject: [PATCH 09/20] =?UTF-8?q?feat:=20=EC=8B=9C=EA=B0=84=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=ED=8C=85=20=EC=9C=A0=ED=8B=B8=20=EB=B0=8F=20=EC=B9=B4?= =?UTF-8?q?=EC=9A=B4=ED=8A=B8=EB=8B=A4=EC=9A=B4=20=ED=9B=85=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/hooks/index.ts | 1 + src/hooks/useRemainingTime.ts | 38 +++++++++++++++++++++++++++++++++++ src/lib/formatTime.ts | 29 +++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useRemainingTime.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts index eb0c3f6..848b41d 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -5,4 +5,5 @@ export { useDragScroll } from './useDragScroll'; export { useGenerationConfirm } from './useGenerationConfirm'; export { useFileAttach } from './useFileAttach'; export { useScrollIntoView } from './useScrollIntoView'; +export { useRemainingTime } from './useRemainingTime'; export { useScrollOnGrow } from './useScrollOnGrow'; diff --git a/src/hooks/useRemainingTime.ts b/src/hooks/useRemainingTime.ts new file mode 100644 index 0000000..6d269d2 --- /dev/null +++ b/src/hooks/useRemainingTime.ts @@ -0,0 +1,38 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +/** + * 종료 시각까지 남은 시간을 1초 간격으로 카운트다운하는 훅 + * + * @param endTime - ISO 8601 형식의 종료 시각 문자열 + * @returns minutes - 남은 분 (2자리, 예: "05") + * @returns seconds - 남은 초 (2자리, 예: "09") + * @returns isExpired - 시간 만료 여부 + */ +function useRemainingTime(endTime: string) { + // 초 단위 남은 시간 (초기값은 함수형 초기화로 한 번만 계산) + const [remaining, setRemaining] = useState(() => { + const diff = new Date(endTime).getTime() - Date.now(); + return Math.max(0, Math.floor(diff / 1000)); + }); + + // 1초마다 남은 시간 갱신, 만료 시 인터벌 자동 정리 + useEffect(() => { + const interval = setInterval(() => { + const diff = new Date(endTime).getTime() - Date.now(); + const seconds = Math.max(0, Math.floor(diff / 1000)); + setRemaining(seconds); + if (seconds <= 0) clearInterval(interval); + }, 1000); + + return () => clearInterval(interval); + }, [endTime]); + + const minutes = String(Math.floor(remaining / 60)).padStart(2, '0'); + const seconds = String(remaining % 60).padStart(2, '0'); + + return { minutes, seconds, isExpired: remaining <= 0 }; +} + +export { useRemainingTime }; diff --git a/src/lib/formatTime.ts b/src/lib/formatTime.ts index 787cf46..98d51f9 100644 --- a/src/lib/formatTime.ts +++ b/src/lib/formatTime.ts @@ -5,4 +5,31 @@ function formatTime(date: Date) { return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); } -export { formatTime }; +/** + * "2026년 3월 20일" 형식의 날짜 문자열로 변환 + */ +function formatKoreanDate(date: Date) { + return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일`; +} + +/** + * 출석 카드용 설명 문자열 생성 + * "날짜 : 2026년 3월 20일 (7:00 PM~9:00 PM)\n장소 : 동아리방" + */ +function formatAttendanceDescription(start: string, end: string, location: string) { + const startDate = new Date(start); + const endDate = new Date(end); + + return `날짜 : ${formatKoreanDate(startDate)} (${formatTime(startDate)}~${formatTime(endDate)})\n장소 : ${location}`; +} + +/** + * 출석 모달용 설명 문자열 생성 + * "2026년 3월 20일 · 동아리방" + */ +function formatModalDescription(start: string, location: string) { + const date = new Date(start); + return `${formatKoreanDate(date)} · ${location}`; +} + +export { formatTime, formatKoreanDate, formatAttendanceDescription, formatModalDescription }; From 49c2e5336447f47d9d0e39ab704959d3b16b1423 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Fri, 20 Mar 2026 20:59:21 +0900 Subject: [PATCH 10/20] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=85=EB=A0=A5=20=EB=AA=A8=EB=8B=AC=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 --- .../attendance/AttendanceCodeModal.tsx | 108 +++++++++++++++++ .../attendance/AttendanceContent.tsx | 16 +-- .../attendance/AttendanceTodayCard.tsx | 43 +++++-- src/components/attendance/InputOTP.tsx | 114 ++++++++++++++++++ src/components/attendance/index.ts | 6 + 5 files changed, 265 insertions(+), 22 deletions(-) create mode 100644 src/components/attendance/AttendanceCodeModal.tsx create mode 100644 src/components/attendance/InputOTP.tsx diff --git a/src/components/attendance/AttendanceCodeModal.tsx b/src/components/attendance/AttendanceCodeModal.tsx new file mode 100644 index 0000000..06882cf --- /dev/null +++ b/src/components/attendance/AttendanceCodeModal.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { useState } from 'react'; + +import { CheckRoundIcon } from '@/assets/icons'; +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogBody, + DialogFooter, + Icon, +} from '@/components/ui'; +import { InputOTP } from '@/components/attendance/InputOTP'; +import { useRemainingTime } from '@/hooks'; +import { formatModalDescription } from '@/lib/formatTime'; + +interface AttendanceCodeModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + start: string; + endTime: string; + location: string; +} + +function AttendanceCodeModal({ + open, + onOpenChange, + title, + start, + endTime, + location, +}: AttendanceCodeModalProps) { + const [code, setCode] = useState(''); + const { minutes, seconds, isExpired } = useRemainingTime(endTime); + const isComplete = code.length === 6; + const description = formatModalDescription(start, location); + + function handleOpenChange(nextOpen: boolean) { + if (!nextOpen) setCode(''); + onOpenChange(nextOpen); + } + + return ( + + + } + overline="QR 출석하기" + title={title} + description={description} + showClose + onClose={() => handleOpenChange(false)} + /> + + +
+
+

+ 운영진이 공유한 6자리 출석 코드를 입력하세요 +

+

+ QR 스캔이 어려운 경우 코드를 직접 입력할 수 있어요 +

+
+
+ + + + {!isExpired ? ( +

+ 출석 가능 시간{' '} + + {minutes}:{seconds} + +

+ ) : ( +

+ 출석 가능 시간이 만료되었습니다 +

+ )} +
+ + + + +
+
+ ); +} + +export { AttendanceCodeModal, type AttendanceCodeModalProps }; diff --git a/src/components/attendance/AttendanceContent.tsx b/src/components/attendance/AttendanceContent.tsx index 117f3af..0050636 100644 --- a/src/components/attendance/AttendanceContent.tsx +++ b/src/components/attendance/AttendanceContent.tsx @@ -13,20 +13,9 @@ import { } from '@/components/ui'; import { AttendanceStatus } from '@/components/attendance/AttendanceStatus'; import { AttendanceTodayCard } from '@/components/attendance/AttendanceTodayCard'; -import { formatTime } from '@/lib/formatTime'; +import { formatAttendanceDescription } from '@/lib/formatTime'; import type { AttendanceData } from '@/types/attendance'; -function formatAttendanceDescription(start: string, end: string, location: string) { - const startDate = new Date(start); - const endDate = new Date(end); - - const year = startDate.getFullYear(); - const month = startDate.getMonth() + 1; - const day = startDate.getDate(); - - return `날짜 : ${year}년 ${month}월 ${day}일 (${formatTime(startDate)}~${formatTime(endDate)})\n장소 : ${location}`; -} - interface AttendanceContentProps { attendance: AttendanceData; isAdmin?: boolean; @@ -61,6 +50,9 @@ function AttendanceContent({ attendance, isAdmin = true }: AttendanceContentProp overline="오늘의 출석" title={title} description={description} + start={start} + endTime={end} + location={location} isAdmin={isAdmin} /> diff --git a/src/components/attendance/AttendanceTodayCard.tsx b/src/components/attendance/AttendanceTodayCard.tsx index 041a21d..b659a61 100644 --- a/src/components/attendance/AttendanceTodayCard.tsx +++ b/src/components/attendance/AttendanceTodayCard.tsx @@ -1,11 +1,17 @@ 'use client'; +import { useState } from 'react'; + import { Card } from '@/components/ui'; +import { AttendanceCodeModal } from '@/components/attendance/AttendanceCodeModal'; interface AttendanceTodayCardProps { overline: string; title: string; description: string; + start: string; + endTime: string; + location: string; isAdmin?: boolean; } @@ -13,19 +19,36 @@ function AttendanceTodayCard({ overline, title, description, + start, + endTime, + location, isAdmin = false, }: AttendanceTodayCardProps) { + const [modalOpen, setModalOpen] = useState(false); + return ( - {}} - onSecondaryClick={isAdmin ? () => {} : undefined} - secondaryButtonText="출석코드 확인" - /> + <> + setModalOpen(true)} + // TODO: 관리자 출석코드 확인 기능 별도 브랜치에서 구현 예정 + onSecondaryClick={isAdmin ? () => {} : undefined} + secondaryButtonText="출석코드 확인" + /> + + + ); } diff --git a/src/components/attendance/InputOTP.tsx b/src/components/attendance/InputOTP.tsx new file mode 100644 index 0000000..d70b32a --- /dev/null +++ b/src/components/attendance/InputOTP.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useRef } from 'react'; + +import { cn } from '@/lib/cn'; + +/** 숫자가 아닌 문자 제거용 정규식 (모듈 레벨 호이스팅) */ +const NON_DIGIT = /\D/g; + +interface InputOTPProps { + /** 입력 칸 개수 (기본값: 6) */ + length?: number; + /** 현재 입력된 코드 문자열 */ + value: string; + /** 코드 변경 시 호출되는 콜백 */ + onChange: (value: string) => void; + className?: string; + ref?: React.Ref; +} + +/** + * OTP(일회용 코드) 입력 컴포넌트 + * + * - 각 칸에 숫자 1자리씩 입력 + * - 자동 포커스 이동 (입력 시 다음 칸, Backspace 시 이전 칸) + * - 화살표 키로 칸 이동 + * - 붙여넣기 지원 + */ +function InputOTP({ length = 6, value, onChange, className, ref }: InputOTPProps) { + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + + // 단일 패스로 digits 배열 생성 (split + concat + slice 대체) + const digits = Array.from({ length }, (_, i) => value[i] ?? ''); + + function focusAt(index: number) { + inputRefs.current[index]?.focus(); + } + + /** 특정 인덱스의 값을 교체한 새 코드를 onChange로 전달 */ + function updateDigitAt(index: number, digit: string) { + const next = digits.with(index, digit); + onChange(next.join('')); + } + + /** 숫자만 허용하고, 입력 후 다음 칸으로 포커스 이동 */ + function handleInputChange(index: number, inputValue: string) { + const digit = inputValue.replace(NON_DIGIT, '').slice(-1); + if (!digit) return; + + updateDigitAt(index, digit); + + if (index < length - 1) { + focusAt(index + 1); + } + } + + /** Backspace: 현재 칸 삭제 또는 이전 칸으로 이동, 화살표 키: 칸 이동 */ + function handleKeyDown(index: number, e: React.KeyboardEvent) { + if (e.key === 'Backspace') { + e.preventDefault(); + if (digits[index]) { + updateDigitAt(index, ''); + } else if (index > 0) { + updateDigitAt(index - 1, ''); + focusAt(index - 1); + } + return; + } + + if (e.key === 'ArrowLeft' && index > 0) { + focusAt(index - 1); + return; + } + + if (e.key === 'ArrowRight' && index < length - 1) { + focusAt(index + 1); + } + } + + /** 클립보드에서 숫자만 추출하여 한 번에 입력 */ + function handlePaste(e: React.ClipboardEvent) { + e.preventDefault(); + const pasted = e.clipboardData.getData('text').replace(NON_DIGIT, '').slice(0, length); + if (!pasted) return; + + onChange(pasted); + focusAt(Math.min(pasted.length, length - 1)); + } + + return ( +
+ {digits.map((digit, index) => ( +
+ { inputRefs.current[index] = el; }} + type="text" + inputMode="numeric" + pattern="[0-9]" + maxLength={1} + value={digit} + onChange={(e) => handleInputChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + onPaste={handlePaste} + onFocus={(e) => e.target.select()} + className="bg-container-neutral text-text-strong typo-body1 focus:border-brand-secondary flex w-full items-center self-stretch rounded-lg border-1 border-transparent px-200 py-300 text-center outline-none" + autoComplete="one-time-code" + /> +
+ ))} +
+ ); +} + +export { InputOTP, type InputOTPProps }; diff --git a/src/components/attendance/index.ts b/src/components/attendance/index.ts index e9f1a76..916c3c0 100644 --- a/src/components/attendance/index.ts +++ b/src/components/attendance/index.ts @@ -6,5 +6,11 @@ export type { AttendanceProgressBarProps } from './AttendanceProgressBar'; export { AttendanceTodayCard } from './AttendanceTodayCard'; export type { AttendanceTodayCardProps } from './AttendanceTodayCard'; +export { AttendanceCodeModal } from './AttendanceCodeModal'; +export type { AttendanceCodeModalProps } from './AttendanceCodeModal'; + +export { InputOTP } from './InputOTP'; +export type { InputOTPProps } from './InputOTP'; + export { AttendanceStatus } from './AttendanceStatus'; export type { AttendanceStatusProps } from './AttendanceStatus'; From f91c7ad5ae580a8f3a650179b326b507be555327 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Fri, 20 Mar 2026 20:59:37 +0900 Subject: [PATCH 11/20] =?UTF-8?q?chore:=20=EC=B6=9C=EC=84=9D=20mock=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A2=85=EB=A3=8C=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(private)/(main)/attendance/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/(private)/(main)/attendance/page.tsx b/src/app/(private)/(main)/attendance/page.tsx index 1ace951..b97fcec 100644 --- a/src/app/(private)/(main)/attendance/page.tsx +++ b/src/app/(private)/(main)/attendance/page.tsx @@ -8,7 +8,7 @@ const mockAttendance: AttendanceData = { status: 'ATTEND', code: 1234, start: '2026-03-20T04:53:06.913Z', - end: '2026-03-20T04:53:06.913Z', + end: '2026-03-21T15:00:00.000Z', location: '공학관 401호', }; From 64f815857a2573eab04bf6186b5c8694a3ab6aa9 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Fri, 20 Mar 2026 23:14:56 +0900 Subject: [PATCH 12/20] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=83=81=ED=83=9C=20UI=20=EB=B0=8F=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=20=EC=99=84=EB=A3=8C=20=EB=AA=A8=EB=8B=AC=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/assets/icons/complete.svg | 9 ++++ src/assets/icons/index.ts | 1 + .../attendance/AttendanceCodeModal.tsx | 1 + .../attendance/AttendanceCompleteModal.tsx | 52 +++++++++++++++++++ .../attendance/AttendanceTodayCard.tsx | 33 ++++++++++-- src/components/attendance/index.ts | 3 ++ src/components/ui/card.tsx | 38 +++++++------- 7 files changed, 114 insertions(+), 23 deletions(-) create mode 100644 src/assets/icons/complete.svg create mode 100644 src/components/attendance/AttendanceCompleteModal.tsx diff --git a/src/assets/icons/complete.svg b/src/assets/icons/complete.svg new file mode 100644 index 0000000..a05c5c0 --- /dev/null +++ b/src/assets/icons/complete.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index 73f2dac..34a8a57 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -32,3 +32,4 @@ export { default as DownloadIcon } from './download.svg'; export { default as FolderIcon } from './folder.svg'; export { default as HomeIcon } from './home.svg'; export { default as MoreHorizIcon } from './more-horiz.svg'; +export { default as CompleteIcon } from './complete.svg'; diff --git a/src/components/attendance/AttendanceCodeModal.tsx b/src/components/attendance/AttendanceCodeModal.tsx index 06882cf..713e39d 100644 --- a/src/components/attendance/AttendanceCodeModal.tsx +++ b/src/components/attendance/AttendanceCodeModal.tsx @@ -96,6 +96,7 @@ function AttendanceCodeModal({ size="lg" className="w-full" disabled={!isComplete || isExpired} + onClick={() => handleOpenChange(false)} > 출석 확인하기 diff --git a/src/components/attendance/AttendanceCompleteModal.tsx b/src/components/attendance/AttendanceCompleteModal.tsx new file mode 100644 index 0000000..a9ffdc2 --- /dev/null +++ b/src/components/attendance/AttendanceCompleteModal.tsx @@ -0,0 +1,52 @@ +'use client'; + +import Image from 'next/image'; + +import { CompleteIcon } from '@/assets/icons'; +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogBody, + DialogFooter, +} from '@/components/ui'; + +interface AttendanceCompleteModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function AttendanceCompleteModal({ open, onOpenChange }: AttendanceCompleteModalProps) { + return ( + + + onOpenChange(false)} /> + + + 출석 완료 +
+

이미 출석을 완료했네요!

+

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

+
+
+ + + + +
+
+ ); +} + +export { AttendanceCompleteModal, type AttendanceCompleteModalProps }; diff --git a/src/components/attendance/AttendanceTodayCard.tsx b/src/components/attendance/AttendanceTodayCard.tsx index b659a61..2c3734f 100644 --- a/src/components/attendance/AttendanceTodayCard.tsx +++ b/src/components/attendance/AttendanceTodayCard.tsx @@ -1,9 +1,12 @@ 'use client'; +import Image from 'next/image'; 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'; interface AttendanceTodayCardProps { overline: string; @@ -13,6 +16,19 @@ interface AttendanceTodayCardProps { endTime: string; location: string; isAdmin?: boolean; + isChecked?: boolean; +} + +function AttendanceCompleteBanner() { + return ( +
+ 출석 완료 +
+

출석이 완료되었어요!

+

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

+
+
+ ); } function AttendanceTodayCard({ @@ -23,8 +39,10 @@ function AttendanceTodayCard({ endTime, location, isAdmin = false, + isChecked = false, }: AttendanceTodayCardProps) { - const [modalOpen, setModalOpen] = useState(false); + const [codeModalOpen, setCodeModalOpen] = useState(false); + const [completeModalOpen, setCompleteModalOpen] = useState(false); return ( <> @@ -34,20 +52,25 @@ function AttendanceTodayCard({ title={title} description={description} showArrow={false} - onPrimaryClick={() => setModalOpen(true)} + onPrimaryClick={isChecked ? () => setCompleteModalOpen(true) : () => setCodeModalOpen(true)} + primaryButtonText="출석하기" // TODO: 관리자 출석코드 확인 기능 별도 브랜치에서 구현 예정 onSecondaryClick={isAdmin ? () => {} : undefined} secondaryButtonText="출석코드 확인" - /> + > + {isChecked && } +
+ + ); } diff --git a/src/components/attendance/index.ts b/src/components/attendance/index.ts index 916c3c0..e2ec980 100644 --- a/src/components/attendance/index.ts +++ b/src/components/attendance/index.ts @@ -9,6 +9,9 @@ export type { AttendanceTodayCardProps } from './AttendanceTodayCard'; export { AttendanceCodeModal } from './AttendanceCodeModal'; export type { AttendanceCodeModalProps } from './AttendanceCodeModal'; +export { AttendanceCompleteModal } from './AttendanceCompleteModal'; +export type { AttendanceCompleteModalProps } from './AttendanceCompleteModal'; + export { InputOTP } from './InputOTP'; export type { InputOTPProps } from './InputOTP'; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index d34b218..b193389 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -12,8 +12,7 @@ const cardVariants = cva('bg-container-neutral flex rounded-lg p-400 transition- variants: { variant: { default: 'flex-col gap-6 rounded-xl border py-6 shadow-sm', - onlyText: - 'cursor-pointer items-start justify-between hover:bg-container-neutral-interaction', + onlyText: 'cursor-pointer items-start justify-between hover:bg-container-neutral-interaction', buttonSet: 'flex-col gap-500', }, }, @@ -68,9 +67,7 @@ function Card({

{description}

)}
- {showArrow && ( - - )} + {showArrow && }
); } @@ -79,23 +76,28 @@ function Card({ if (variant === 'buttonSet' && title) { return (
-
-
- {overline &&

{overline}

} -

{title}

- {description && ( -

{description}

- )} +
+
+
+ {overline &&

{overline}

} +

{title}

+ {description && ( +

+ {description} +

+ )} +
+ {showArrow && }
- {showArrow && ( - - )} + {children}
- + {onPrimaryClick && ( + + )} {onSecondaryClick && ( diff --git a/src/components/attendance/AttendanceContent.tsx b/src/components/attendance/AttendanceContent.tsx index 0050636..87b6f7c 100644 --- a/src/components/attendance/AttendanceContent.tsx +++ b/src/components/attendance/AttendanceContent.tsx @@ -1,4 +1,7 @@ +'use client'; + import Link from 'next/link'; +import { useState } from 'react'; import { HomeIcon } from '@/assets/icons'; import { @@ -17,14 +20,22 @@ import { formatAttendanceDescription } from '@/lib/formatTime'; import type { AttendanceData } from '@/types/attendance'; interface AttendanceContentProps { + name: string; attendance: AttendanceData; isAdmin?: boolean; } -function AttendanceContent({ attendance, isAdmin = true }: AttendanceContentProps) { +function AttendanceContent({ name, attendance, isAdmin = false }: AttendanceContentProps) { + const [isChecked, setIsChecked] = useState(false); const { attendanceRate, title, start, end, location } = attendance; const description = formatAttendanceDescription(start, end, location); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function handleAttendanceComplete(code: string) { + // TODO: API 연결 시 출석 코드 검증 로직 추가 + setIsChecked(true); + } + return (
@@ -32,7 +43,7 @@ function AttendanceContent({ attendance, isAdmin = true }: AttendanceContentProp - + @@ -43,7 +54,7 @@ function AttendanceContent({ attendance, isAdmin = true }: AttendanceContentProp - +
diff --git a/src/components/attendance/AttendanceStatus.tsx b/src/components/attendance/AttendanceStatus.tsx index c0af0c8..8773b8e 100644 --- a/src/components/attendance/AttendanceStatus.tsx +++ b/src/components/attendance/AttendanceStatus.tsx @@ -2,14 +2,15 @@ import { cn } from '@/lib/cn'; import { AttendanceProgressBar } from '@/components/attendance/AttendanceProgressBar'; interface AttendanceStatusProps extends React.HTMLAttributes { + name: string; attendanceRate: number; } -function AttendanceStatus({ attendanceRate, className, ...props }: AttendanceStatusProps) { +function AttendanceStatus({ name, attendanceRate, className, ...props }: AttendanceStatusProps) { return (

- 김위드님의 + {name}님의
출석률은 {attendanceRate}%

diff --git a/src/components/attendance/AttendanceTodayCard.tsx b/src/components/attendance/AttendanceTodayCard.tsx index 2c3734f..e7dbfac 100644 --- a/src/components/attendance/AttendanceTodayCard.tsx +++ b/src/components/attendance/AttendanceTodayCard.tsx @@ -17,6 +17,7 @@ interface AttendanceTodayCardProps { location: string; isAdmin?: boolean; isChecked?: boolean; + onAttendanceComplete?: (code: string) => void; } function AttendanceCompleteBanner() { @@ -40,10 +41,16 @@ function AttendanceTodayCard({ location, isAdmin = false, isChecked = false, + onAttendanceComplete, }: AttendanceTodayCardProps) { const [codeModalOpen, setCodeModalOpen] = useState(false); const [completeModalOpen, setCompleteModalOpen] = useState(false); + function handleCodeConfirm(code: string) { + onAttendanceComplete?.(code); + setCompleteModalOpen(true); + } + return ( <> setCompleteModalOpen(true) : () => setCodeModalOpen(true)} - primaryButtonText="출석하기" + primaryButtonText={isChecked ? '출석 완료' : '출석하기'} // TODO: 관리자 출석코드 확인 기능 별도 브랜치에서 구현 예정 onSecondaryClick={isAdmin ? () => {} : undefined} secondaryButtonText="출석코드 확인" @@ -64,6 +71,7 @@ function AttendanceTodayCard({ Date: Sat, 21 Mar 2026 00:20:22 +0900 Subject: [PATCH 16/20] =?UTF-8?q?fix:=20DialogHeader=20=EB=8B=AB=EA=B8=B0?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20DialogPrimitive.Close=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/dialog.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 1d593f5..a0e3f4b 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -97,10 +97,12 @@ function DialogHeader({ className, ...props }: DialogHeaderProps) { - const closeButton = showClose && onClose && ( - + const closeButton = showClose && ( + + + ); // If children provided, use custom content mode From 808b0595802ef8edd6ada68fd0d5b993fcdcea2e Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Sat, 21 Mar 2026 00:20:40 +0900 Subject: [PATCH 17/20] =?UTF-8?q?fix:=20useRemainingTime=20=EC=9E=98?= =?UTF-8?q?=EB=AA=BB=EB=90=9C=20endTime=20=EC=9E=85=EB=A0=A5=20=EC=8B=9C?= =?UTF-8?q?=20NaN=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useRemainingTime.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/hooks/useRemainingTime.ts b/src/hooks/useRemainingTime.ts index 6d269d2..6fbfae7 100644 --- a/src/hooks/useRemainingTime.ts +++ b/src/hooks/useRemainingTime.ts @@ -10,18 +10,18 @@ import { useState, useEffect } from 'react'; * @returns seconds - 남은 초 (2자리, 예: "09") * @returns isExpired - 시간 만료 여부 */ +function getRemainingSeconds(endTime: string) { + 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(() => { - const diff = new Date(endTime).getTime() - Date.now(); - return Math.max(0, Math.floor(diff / 1000)); - }); + const [remaining, setRemaining] = useState(() => getRemainingSeconds(endTime)); - // 1초마다 남은 시간 갱신, 만료 시 인터벌 자동 정리 useEffect(() => { const interval = setInterval(() => { - const diff = new Date(endTime).getTime() - Date.now(); - const seconds = Math.max(0, Math.floor(diff / 1000)); + const seconds = getRemainingSeconds(endTime); setRemaining(seconds); if (seconds <= 0) clearInterval(interval); }, 1000); From aad545b710e900de535703b3c874d2376ffa5bb6 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Sat, 21 Mar 2026 00:21:01 +0900 Subject: [PATCH 18/20] =?UTF-8?q?fix:=20Header=20=EA=B2=8C=EC=8B=9C=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=95=84=EC=9D=B4=EC=BD=98=20alt=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/Header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 9d91c27..ff7a2ae 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -75,7 +75,7 @@ export default function Header({ isMain = true }: HeaderProps) { 작성 취소 From 6e5f6681679efc870f69b96b6b2817d5a3f69b2f Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Sun, 22 Mar 2026 17:17:25 +0900 Subject: [PATCH 19/20] =?UTF-8?q?refactor:=20=EA=B3=B5=ED=86=B5=20ApiRespo?= =?UTF-8?q?nse=20=ED=83=80=EC=9E=85=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20Att?= =?UTF-8?q?endanceResponse=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/attendance.ts | 8 +++----- src/types/common.ts | 6 ++++++ src/types/index.ts | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/types/attendance.ts b/src/types/attendance.ts index b37f9ef..7ae5534 100644 --- a/src/types/attendance.ts +++ b/src/types/attendance.ts @@ -1,3 +1,5 @@ +import type { ApiResponse } from '@/types/common'; + type AttendanceStatus = 'ATTEND' | 'ABSENT' | 'PENDING'; interface AttendanceData { @@ -10,10 +12,6 @@ interface AttendanceData { location: string; } -interface AttendanceResponse { - code: number; - message: string; - data: AttendanceData; -} +type AttendanceResponse = ApiResponse; export type { AttendanceStatus, AttendanceData, AttendanceResponse }; diff --git a/src/types/common.ts b/src/types/common.ts index 092ba4f..c67b5b8 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -1,3 +1,9 @@ +export interface ApiResponse { + code: number; + message: string; + data: T; +} + export type MutationCallbacks = { onSuccess?: () => void; onError?: (error: TError) => void; diff --git a/src/types/index.ts b/src/types/index.ts index e4cf5fe..c1116f4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,2 +1,2 @@ // types index file -export type { MutationCallbacks } from './common'; +export type { ApiResponse, MutationCallbacks } from './common'; From cf8632f65b946bc3137ab5f6425ad20242dbfc65 Mon Sep 17 00:00:00 2001 From: nabbang6 Date: Sun, 22 Mar 2026 17:19:25 +0900 Subject: [PATCH 20/20] =?UTF-8?q?refactor:=20BreadcrumbList=20=ED=99=88=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EA=B8=B0=EB=B3=B8=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=20=EB=B0=8F=20=EC=B6=9C=EC=84=9D=20=EB=AA=A9=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(private)/(main)/attendance/page.tsx | 27 ++++++++++++------- .../attendance/AttendanceContent.tsx | 24 ++--------------- src/components/ui/breadcrumb.tsx | 25 ++++++++++++++--- 3 files changed, 41 insertions(+), 35 deletions(-) diff --git a/src/app/(private)/(main)/attendance/page.tsx b/src/app/(private)/(main)/attendance/page.tsx index 09528e0..0d07340 100644 --- a/src/app/(private)/(main)/attendance/page.tsx +++ b/src/app/(private)/(main)/attendance/page.tsx @@ -2,17 +2,24 @@ import { AttendanceContent } from '@/components/attendance'; import type { AttendanceData } from '@/types/attendance'; // TODO: API 연동 시 실제 데이터로 교체 -const mockAttendance: AttendanceData = { - attendanceRate: 80, - title: '1주차 정기모임', - status: 'ATTEND', - code: '123456', - start: '2026-03-20T04:53:06.913Z', - end: '2026-03-21T15:00:00.000Z', - location: '공학관 401호', -}; +function createMockAttendance(): AttendanceData { + const now = new Date(); + const start = now; + const end = new Date(now.getTime() + 10 * 60 * 1000); // 10분 후 + + return { + attendanceRate: 80, + title: '1주차 정기모임', + status: 'ATTEND', + code: '123456', + start: start.toISOString(), + end: end.toISOString(), + location: '공학관 401호', + }; +} export default function AttendancePage() { // TODO: API 연동 시 실제 사용자 이름으로 교체 - return ; + const displayName = '사용자'; + return ; } diff --git a/src/components/attendance/AttendanceContent.tsx b/src/components/attendance/AttendanceContent.tsx index 87b6f7c..222cb98 100644 --- a/src/components/attendance/AttendanceContent.tsx +++ b/src/components/attendance/AttendanceContent.tsx @@ -1,19 +1,8 @@ 'use client'; -import Link from 'next/link'; import { useState } from 'react'; -import { HomeIcon } from '@/assets/icons'; -import { - Breadcrumb, - BreadcrumbList, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbSeparator, - BreadcrumbPage, - Card, - Icon, -} from '@/components/ui'; +import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbPage, Card } from '@/components/ui'; import { AttendanceStatus } from '@/components/attendance/AttendanceStatus'; import { AttendanceTodayCard } from '@/components/attendance/AttendanceTodayCard'; import { formatAttendanceDescription } from '@/lib/formatTime'; @@ -30,8 +19,7 @@ function AttendanceContent({ name, attendance, isAdmin = false }: AttendanceCont const { attendanceRate, title, start, end, location } = attendance; const description = formatAttendanceDescription(start, end, location); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - function handleAttendanceComplete(code: string) { + function handleAttendanceComplete(_code: string) { // TODO: API 연결 시 출석 코드 검증 로직 추가 setIsChecked(true); } @@ -40,14 +28,6 @@ function AttendanceContent({ name, attendance, isAdmin = false }: AttendanceCont
- - - - - - - - 출석 diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx index 5780e38..a199fd0 100644 --- a/src/components/ui/breadcrumb.tsx +++ b/src/components/ui/breadcrumb.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; +import Link from 'next/link'; import { Slot } from 'radix-ui'; -import { ArrowRightIcon, MoreHorizIcon } from '@/assets/icons'; +import { ArrowRightIcon, HomeIcon, MoreHorizIcon } from '@/assets/icons'; import { cn } from '@/lib/cn'; import { Icon } from '@/components/ui/Icon'; @@ -9,7 +10,11 @@ function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) { return