From 8bdd615ec94ac1e5fa17d3dab587415cc0105590 Mon Sep 17 00:00:00 2001 From: wallace Date: Mon, 1 Dec 2025 09:56:04 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A9=94=EC=9D=BC=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=EC=A4=911?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/notices/[id]/page.tsx | 6 +- src/app/course-list/[id]/page.tsx | 343 ++++++++++++++++------ src/app/course-list/page.tsx | 339 +++++++++++++++------- src/app/lib/apiService.ts | 1 - src/app/notices/[id]/page.tsx | 277 ++++++++++++++++-- src/app/notices/page.tsx | 319 ++++++++++++++++----- src/app/page.tsx | 427 ++++++++++++++++++---------- src/app/resources/[id]/page.tsx | 289 ++++++++++++------- src/app/resources/page.tsx | 355 ++++++++++++++++------- 9 files changed, 1696 insertions(+), 660 deletions(-) diff --git a/src/app/admin/notices/[id]/page.tsx b/src/app/admin/notices/[id]/page.tsx index 25de7aa..bb67975 100644 --- a/src/app/admin/notices/[id]/page.tsx +++ b/src/app/admin/notices/[id]/page.tsx @@ -274,7 +274,8 @@ export default function AdminNoticeDetailPage() {

- {attachments.map((attachment, idx) => ( +
+ {attachments.map((attachment, idx) => (
- ))} + ))} +
)} diff --git a/src/app/course-list/[id]/page.tsx b/src/app/course-list/[id]/page.tsx index 21b936b..fd898ab 100644 --- a/src/app/course-list/[id]/page.tsx +++ b/src/app/course-list/[id]/page.tsx @@ -4,6 +4,10 @@ import { useEffect, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import Image from 'next/image'; import apiService from '../../lib/apiService'; +import BackCircleSvg from '../../svgs/backcirclesvg'; + +const imgPlay = '/imgs/play.svg'; +const imgMusicAudioPlay = '/imgs/music-audio-play.svg'; type Lesson = { id: string; @@ -39,23 +43,98 @@ export default function CourseDetailPage() { try { setLoading(true); setError(null); - const response = await apiService.getLecture(params.id as string); + + let data: any = null; + + // 먼저 getLecture 시도 + try { + const response = await apiService.getLecture(params.id as string); + data = response.data; + } catch (lectureErr) { + console.warn('getLecture 실패, getSubjects로 재시도:', lectureErr); + + // getLecture 실패 시 getSubjects로 폴백 + try { + const subjectsResponse = await apiService.getSubjects(); + let subjectsData: any[] = []; + + if (Array.isArray(subjectsResponse.data)) { + subjectsData = subjectsResponse.data; + } else if (subjectsResponse.data && typeof subjectsResponse.data === 'object') { + subjectsData = subjectsResponse.data.items || + subjectsResponse.data.courses || + subjectsResponse.data.data || + subjectsResponse.data.list || + subjectsResponse.data.subjects || + subjectsResponse.data.subjectList || + []; + } + + // ID로 과목 찾기 + data = subjectsData.find((s: any) => String(s.id || s.subjectId) === String(params.id)); + + if (!data) { + // getLectures로도 시도 + try { + const lecturesResponse = await apiService.getLectures(); + const lectures = Array.isArray(lecturesResponse.data) + ? lecturesResponse.data + : lecturesResponse.data?.items || lecturesResponse.data?.lectures || lecturesResponse.data?.data || []; + + data = lectures.find((l: any) => String(l.id || l.lectureId) === String(params.id)); + } catch (lecturesErr) { + console.error('getLectures 실패:', lecturesErr); + } + } + } catch (subjectsErr) { + console.error('getSubjects 실패:', subjectsErr); + } + } + + if (!data) { + throw new Error('강좌를 찾을 수 없습니다.'); + } + + // 썸네일 이미지 가져오기 + let thumbnail = '/imgs/talk.png'; + if (data.imageKey) { + try { + const imageUrl = await apiService.getFile(data.imageKey); + if (imageUrl) { + thumbnail = imageUrl; + } + } catch (imgErr) { + console.error('이미지 다운로드 실패:', imgErr); + } + } + + // 강좌의 차시(lessons) 정보 가져오기 + let lessons: any[] = []; + if (data.lessons && Array.isArray(data.lessons)) { + lessons = data.lessons; + } else if (data.lectureId) { + // lectureId가 있으면 해당 강좌의 차시 정보 가져오기 시도 + try { + const lectureResponse = await apiService.getLecture(data.lectureId); + if (lectureResponse.data?.lessons) { + lessons = lectureResponse.data.lessons; + } + } catch (err) { + console.warn('차시 정보 가져오기 실패:', err); + } + } // API 응답 데이터를 CourseDetail 타입으로 변환 - const data = response.data; - - // API 응답 구조에 맞게 데이터 매핑 - // 실제 API 응답 구조에 따라 조정 필요 const courseDetail: CourseDetail = { id: String(data.id || params.id), status: data.status || "수강 예정", - title: data.title || data.lectureName || '', + title: data.title || data.lectureName || data.subjectName || '', goal: data.objective || data.goal || '', method: data.method || '', - summary: data.summary || `VOD · 총 ${data.lessons?.length || 0}강`, + summary: data.summary || `VOD · 총 ${lessons.length || 0}강`, submitSummary: data.submitSummary || '', - thumbnail: data.thumbnail || data.imageKey || data.imageUrl || '/imgs/talk.png', - lessons: (data.lessons || []).map((lesson: any, index: number) => ({ + thumbnail: thumbnail, + lessons: lessons.map((lesson: any, index: number) => ({ id: String(lesson.id || lesson.lessonId || index + 1), title: `${index + 1}. ${lesson.title || lesson.lessonName || ''}`, duration: lesson.duration || '00:00', @@ -78,11 +157,19 @@ export default function CourseDetailPage() { if (loading) { return ( -
-
+
+
+

교육 과정 상세보기

-
+

로딩 중...

@@ -93,11 +180,19 @@ export default function CourseDetailPage() { if (error || !course) { return ( -
-
+
+
+

교육 과정 상세보기

-
+

{error || '강좌를 찾을 수 없습니다.'}

교육 과정 상세보기

-
-
- {/* 상단 소개 카드 */} -
-
- {course.title} + {/* 메인 콘텐츠 */} +
+ {/* 상단 정보 카드 */} +
+ {/* 이미지 컨테이너 */} +
+ {course.title} +
+ + {/* 교육과정 정보 */} +
+ {/* 제목 영역 */} +
+
+

{course.status}

+
+

{course.title}

-
-
- - {course.status} - -

{course.title}

-
-
-

- 학습 목표: {course.goal} -

-

- 학습 방법: {course.method} -

-
-
- {course.summary} - {course.submitSummary && {course.submitSummary}} + + {/* 학습 목표 및 방법 */} +
+

+ 학습 목표: + {` ${course.goal}`} +

+

+ 학습 방법: + {` ${course.method}`} +

+
+ + {/* 통계 정보 */} +
+
+ {/* VOD 정보 */} +
+
+ +
+

{course.summary}

+
+ + {/* 학습 제출 정보 */} + {course.submitSummary && ( +
+
+ +
+

{course.submitSummary}

+
+ )}
+
- {/* 차시 리스트 */} -
- {course.lessons.map((l) => { - const isSubmitted = l.state === "제출완료"; - const submitBtnStyle = - l.state === "제출완료" - ? "border border-transparent text-[#384fbf]" - : "border " + (l.action === "이어서 수강하기" || l.action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]"); - const rightBtnStyle = - l.action === "이어서 수강하기" - ? "bg-[#ecf0ff] text-[#384fbf]" - : l.action === "수강하기" - ? "bg-[#ecf0ff] text-[#384fbf]" - : "bg-[#f1f3f5] text-[#4c5561]"; - return ( -
-
-
-

{l.title}

-
-

{l.duration}

+ {/* 차시 리스트 */} +
+ {course.lessons.map((l) => { + const isSubmitted = l.state === "제출완료"; + const submitBtnBorder = isSubmitted + ? "border-transparent" + : (l.action === "이어서 수강하기" || l.action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]"); + const submitBtnText = isSubmitted ? "text-[#384fbf]" : (l.action === "이어서 수강하기" || l.action === "수강하기" ? "text-[#b1b8c0]" : "text-[#4c5561]"); + const rightBtnStyle = + l.action === "이어서 수강하기" || l.action === "수강하기" + ? "bg-[#ecf0ff] text-[#384fbf]" + : "bg-[#f1f3f5] text-[#4c5561]"; + + return ( +
+
+
+ {/* 차시 정보 */} +
+
+

{l.title}

+
+

{l.duration}

+
-
- - + + {/* 버튼 영역 */} +
+
+ {/* 학습 제출 버튼 */} + + + {/* 수강/복습 버튼 */} + +
- ); - })} -
+
+ ); + })}
diff --git a/src/app/course-list/page.tsx b/src/app/course-list/page.tsx index 8714b69..58bbb90 100644 --- a/src/app/course-list/page.tsx +++ b/src/app/course-list/page.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import ChevronDownSvg from '../svgs/chevrondownsvg'; +import apiService from '../lib/apiService'; // 피그마 선택 컴포넌트의 구조/스타일(타이포/여백/색상)을 반영한 리스트 UI // - 프로젝트는 Tailwind v4(@import "tailwindcss")를 사용하므로 클래스 그대로 적용 @@ -10,10 +11,21 @@ import ChevronDownSvg from '../svgs/chevrondownsvg'; // - 카드 구성: 섬네일(rounded 8px) + 상태 태그 + 제목 + 메타(재생 아이콘 + 텍스트) const imgPlay = '/imgs/play.svg'; // public/imgs/play.svg -const imgThumbA = '/imgs/thumb-a.png'; // public/imgs/thumb-a.png -const imgThumbB = '/imgs/thumb-b.png'; // public/imgs/thumb-b.png -const imgThumbC = '/imgs/thumb-c.png'; // public/imgs/thumb-c.png -const imgThumbD = '/imgs/thumb-d.png'; // public/imgs/thumb-d.png + +interface Subject { + id: string | number; + title: string; + imageKey?: string; + instructor?: string; +} + +interface Course { + id: string | number; + title: string; + image: string; + inProgress?: boolean; + meta?: string; +} function ColorfulTag({ text }: { text: string }) { return ( @@ -23,37 +35,114 @@ function ColorfulTag({ text }: { text: string }) { ); } -type Course = { id: string; title: string; image: string; inProgress?: boolean }; - export default function CourseListPage() { const ITEMS_PER_PAGE = 20; const [page, setPage] = useState(1); + const [subjects, setSubjects] = useState([]); + const [courses, setCourses] = useState([]); + const [loading, setLoading] = useState(true); + const [totalCount, setTotalCount] = useState(0); const router = useRouter(); - const base: Omit[] = [ - { title: '원자로 운전 및 계통', image: imgThumbA, inProgress: true }, - { title: '핵연료', image: imgThumbB }, - { title: '방사선 안전', image: imgThumbC }, - { title: '방사선 폐기물', image: imgThumbD }, - { title: '원자로 운전 및 계통', image: imgThumbA }, - { title: '핵연료', image: imgThumbB }, - { title: '방사선 안전', image: imgThumbC }, - ]; - const courses: Course[] = Array.from({ length: 28 }, (_, i) => { - const item = base[i % base.length]; - return { - id: `p${i + 1}`, - title: item.title, - image: item.image, - inProgress: item.inProgress ?? (i % 7 === 0), + // 과목 리스트 가져오기 + useEffect(() => { + let isMounted = true; + + async function fetchSubjects() { + try { + setLoading(true); + const response = await apiService.getSubjects(); + + if (response.status !== 200 || !response.data) { + return; + } + + // 응답 데이터 구조 확인 및 배열 추출 + let subjectsData: any[] = []; + let total = 0; + + if (Array.isArray(response.data)) { + subjectsData = response.data; + total = response.data.length; + } else if (response.data && typeof response.data === 'object') { + // 다양한 응답 구조 처리 + subjectsData = response.data.items || + response.data.courses || + response.data.data || + response.data.list || + response.data.subjects || + response.data.subjectList || + []; + + // 전체 개수는 total, totalCount, count 등의 필드에서 가져오거나 배열 길이 사용 + total = response.data.total !== undefined ? response.data.total : + response.data.totalCount !== undefined ? response.data.totalCount : + response.data.count !== undefined ? response.data.count : + subjectsData.length; + } + + if (!isMounted) return; + + setTotalCount(total); + setSubjects(subjectsData); + + // 각 과목의 이미지 다운로드 + const coursesWithImages = await Promise.all( + subjectsData.map(async (subject: Subject) => { + let imageUrl = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800'; + + if (subject.imageKey) { + try { + const fileUrl = await apiService.getFile(subject.imageKey); + if (fileUrl) { + imageUrl = fileUrl; + } + } catch (error) { + console.error(`이미지 다운로드 실패 (과목 ID: ${subject.id}):`, error); + } + } + + return { + id: subject.id, + title: subject.title || '', + image: imageUrl, + inProgress: false, // TODO: 수강 중 상태는 진행률 API에서 가져와야 함 + meta: subject.instructor ? `강사: ${subject.instructor}` : 'VOD • 온라인', + }; + }) + ); + + if (isMounted) { + setCourses(coursesWithImages); + } + } catch (error) { + console.error('과목 리스트 조회 오류:', error); + } finally { + if (isMounted) { + setLoading(false); + } + } + } + + fetchSubjects(); + + return () => { + isMounted = false; }; - }); - const totalPages = Math.ceil(courses.length / ITEMS_PER_PAGE); + }, []); + + const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE); const pagedCourses = useMemo( () => courses.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE), [courses, page] ); + // 페이지네이션: 10개씩 표시 + const pageGroup = Math.floor((page - 1) / 10); + const startPage = pageGroup * 10 + 1; + const endPage = Math.min(startPage + 9, totalPages); + const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i); + return (
{/* 상단 타이틀 영역 */} @@ -66,95 +155,135 @@ export default function CourseListPage() { {/* 상단 카운트/정렬 영역 */}

- 총 {courses.length}건 + 총 {totalCount}

- {/* 카드 그리드(고정 5열, gap 32px) */} -
-
- {pagedCourses.map((c) => ( -
router.push(`/course-list/${c.id}`)} - className="flex h-[260px] w-[249.6px] flex-col gap-[16px] rounded-[8px] bg-white cursor-pointer hover:shadow-lg transition-shadow" - > - {/* 섬네일 */} -
- -
- - {/* 본문 텍스트 블록 */} -
-
- {c.inProgress && } -

- {c.title} -

-
-
- -

- VOD · 총 6강 · 4시간 20분 -

-
-
-
- ))} + {loading ? ( +
+

로딩 중...

-
+ ) : ( + <> + {/* 카드 그리드(고정 5열, gap 32px) */} +
+
+ {pagedCourses.map((c) => ( +
router.push(`/course-list/${c.id}`)} + className="flex h-[260px] w-[249.6px] flex-col gap-[16px] rounded-[8px] bg-white cursor-pointer" + > + {/* 섬네일 */} +
+ {c.title} { + const t = e.currentTarget as HTMLImageElement; + if (!t.src.includes('picsum.photos')) t.src = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800'; + }} + /> +
- {/* 페이지네이션 (피그마 스타일 반영) */} -
- {/* Prev */} - + {/* 본문 텍스트 블록 */} +
+
+ {c.inProgress && } +

+ {c.title} +

+
+
+ +

+ {c.meta || 'VOD • 온라인'} +

+
+
+
+ ))} +
+
- {/* Numbers */} - {Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => { - const active = n === page; - return ( - - ); - })} + {/* 페이지네이션 - admin 페이지와 동일한 메커니즘 (10개씩 표시) */} + {totalCount > ITEMS_PER_PAGE && ( +
+ {/* First (맨 앞으로) */} + - {/* Next */} - -
+ {/* Prev */} + + + {/* Numbers */} + {visiblePages.map((n) => { + const active = n === page; + return ( + + ); + })} + + {/* Next */} + + + {/* Last (맨 뒤로) */} + +
+ )} + + )}
); } - - diff --git a/src/app/lib/apiService.ts b/src/app/lib/apiService.ts index 748aa27..4a6cf03 100644 --- a/src/app/lib/apiService.ts +++ b/src/app/lib/apiService.ts @@ -412,7 +412,6 @@ class ApiService { method: 'PATCH', }); } - /** * 대량 정지 (ID 배열) - 관리자 전용 */ diff --git a/src/app/notices/[id]/page.tsx b/src/app/notices/[id]/page.tsx index fcc25bc..c78e53c 100644 --- a/src/app/notices/[id]/page.tsx +++ b/src/app/notices/[id]/page.tsx @@ -1,17 +1,169 @@ -import Link from 'next/link'; -import { notFound } from 'next/navigation'; -import BackCircleSvg from '../../svgs/backcirclesvg'; -import { MOCK_NOTICES } from '../../admin/notices/mockData'; +'use client'; -export default async function NoticeDetailPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - const numericId = Number(id); - const item = MOCK_NOTICES.find((r) => r.id === numericId); - if (!item || !item.content) return notFound(); +import { useState, useEffect } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import BackCircleSvg from '../../svgs/backcirclesvg'; +import DownloadIcon from '../../svgs/downloadicon'; +import apiService from '../../lib/apiService'; +import type { Notice } from '../../admin/notices/mockData'; + +type Attachment = { + name: string; + size: string; + url?: string; + fileKey?: string; +}; + +export default function NoticeDetailPage() { + const params = useParams(); + const router = useRouter(); + const [notice, setNotice] = useState(null); + const [attachments, setAttachments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 날짜를 yyyy-mm-dd 형식으로 포맷팅 + const formatDate = (dateString: string): string => { + if (!dateString) return ''; + + try { + const date = new Date(dateString); + if (isNaN(date.getTime())) { + // 이미 yyyy-mm-dd 형식인 경우 그대로 반환 + if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { + return dateString; + } + return dateString; + } + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } catch { + return dateString; + } + }; + + useEffect(() => { + async function fetchNotice() { + if (!params?.id) return; + + try { + setLoading(true); + setError(null); + + const noticeId = Array.isArray(params.id) ? params.id[0] : params.id; + const response = await apiService.getNotice(noticeId); + const data = response.data; + + // API 응답 데이터를 Notice 형식으로 변환 + const transformedNotice: Notice = { + id: data.id || data.noticeId || Number(params.id), + title: data.title || '', + date: formatDate(data.date || data.createdAt || data.createdDate || new Date().toISOString().split('T')[0]), + views: data.views || data.viewCount || 0, + writer: data.writer || data.author || data.createdBy || '관리자', + content: data.content + ? (Array.isArray(data.content) + ? data.content + : typeof data.content === 'string' + ? data.content.split('\n').filter((line: string) => line.trim()) + : [String(data.content)]) + : [], + hasAttachment: data.hasAttachment || data.attachment || false, + }; + + // 첨부파일 정보 처리 + if (data.attachments && Array.isArray(data.attachments)) { + setAttachments(data.attachments.map((att: any) => ({ + name: att.name || att.fileName || att.filename || '', + size: att.size || att.fileSize || '', + url: att.url || att.downloadUrl, + fileKey: att.fileKey || att.key || att.fileId, + }))); + } else if (transformedNotice.hasAttachment && data.attachment) { + // 단일 첨부파일인 경우 + setAttachments([{ + name: data.attachment.name || data.attachment.fileName || data.attachment.filename || '첨부파일', + size: data.attachment.size || data.attachment.fileSize || '', + url: data.attachment.url || data.attachment.downloadUrl, + fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId, + }]); + } + + if (!transformedNotice.title) { + setError('공지사항을 찾을 수 없습니다.'); + return; + } + + setNotice(transformedNotice); + } catch (err) { + console.error('공지사항 조회 오류:', err); + setError('공지사항을 불러오는 중 오류가 발생했습니다.'); + } finally { + setLoading(false); + } + } + + fetchNotice(); + }, [params?.id]); + + const handleDownload = async (fileKey?: string, url?: string, fileName?: string) => { + if (url) { + // URL이 있으면 직접 다운로드 + const link = document.createElement('a'); + link.href = url; + link.download = fileName || 'download'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } else if (fileKey) { + // fileKey가 있으면 API를 통해 다운로드 + try { + const fileUrl = await apiService.getFile(fileKey); + if (fileUrl) { + const link = document.createElement('a'); + link.href = fileUrl; + link.download = fileName || 'download'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + } catch (error) { + console.error('파일 다운로드 오류:', error); + alert('파일 다운로드 중 오류가 발생했습니다.'); + } + } + }; + + if (loading) { + return ( +
+
+
+
+

로딩 중...

+
+
+
+
+ ); + } + + if (error || !notice) { + return ( +
+
+
+
+

{error || '공지사항을 찾을 수 없습니다.'}

+
+
+
+
+ ); + } return (
@@ -19,13 +171,14 @@ export default async function NoticeDetailPage({
{/* 상단 타이틀 */}
- router.back()} aria-label="뒤로 가기" - className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline" + className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer" > - +

공지사항 상세

@@ -37,17 +190,17 @@ export default async function NoticeDetailPage({ {/* 헤더 */}

- {item.title} + {notice.title}

작성자 - {item.writer} + {notice.writer} 게시일 - {item.date} + {notice.date} 조회수 - {item.views.toLocaleString()} + {notice.views.toLocaleString()}
@@ -55,14 +208,79 @@ export default async function NoticeDetailPage({
{/* 본문 */} -
-
- {item.content.map((p, idx) => ( -

- {p} -

- ))} +
+
+ {notice.content && notice.content.length > 0 ? ( + notice.content.map((p, idx) => ( +

+ {p} +

+ )) + ) : ( +

내용이 없습니다.

+ )}
+ + {/* 첨부파일 섹션 */} + {attachments.length > 0 && ( +
+
+ 첨부 파일 +
+
+ {attachments.map((attachment, idx) => ( +
+
+ + + + +
+
+

+ {attachment.name} +

+

+ {attachment.size} +

+
+ +
+ ))} +
+
+ )}
@@ -71,6 +289,3 @@ export default async function NoticeDetailPage({
); } - - - diff --git a/src/app/notices/page.tsx b/src/app/notices/page.tsx index 671b305..e72730f 100644 --- a/src/app/notices/page.tsx +++ b/src/app/notices/page.tsx @@ -1,14 +1,115 @@ 'use client'; +import { useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import PaperClipSvg from '../svgs/paperclipsvg'; -import { MOCK_NOTICES } from '../admin/notices/mockData'; +import ChevronDownSvg from '../svgs/chevrondownsvg'; +import apiService from '../lib/apiService'; +import type { Notice } from '../admin/notices/mockData'; export default function NoticesPage() { const router = useRouter(); - - // 날짜 내림차순 정렬 (최신 날짜가 먼저) - const rows = [...MOCK_NOTICES].sort((a, b) => b.date.localeCompare(a.date)); + const ITEMS_PER_PAGE = 10; + const [currentPage, setCurrentPage] = useState(1); + const [notices, setNotices] = useState([]); + const [loading, setLoading] = useState(true); + const [totalCount, setTotalCount] = useState(0); + + // 공지사항 리스트 가져오기 + useEffect(() => { + let isMounted = true; + + async function fetchNotices() { + try { + setLoading(true); + const response = await apiService.getNotices(); + + if (response.status !== 200 || !response.data) { + return; + } + + // API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태) + let noticesArray: any[] = []; + let total = 0; + + if (Array.isArray(response.data)) { + noticesArray = response.data; + total = response.data.length; + } else if (response.data && typeof response.data === 'object') { + noticesArray = response.data.items || response.data.notices || response.data.data || response.data.list || []; + total = response.data.total !== undefined ? response.data.total : + response.data.totalCount !== undefined ? response.data.totalCount : + response.data.count !== undefined ? response.data.count : + noticesArray.length; + } + + // 날짜를 yyyy-mm-dd 형식으로 포맷팅 + const formatDate = (dateString: string): string => { + if (!dateString) return ''; + + try { + const date = new Date(dateString); + if (isNaN(date.getTime())) { + // 이미 yyyy-mm-dd 형식인 경우 그대로 반환 + if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { + return dateString; + } + return dateString; + } + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } catch { + return dateString; + } + }; + + // API 응답 데이터를 Notice 형식으로 변환 + const transformedNotices: Notice[] = noticesArray.map((notice: any) => ({ + id: notice.id || notice.noticeId || 0, + title: notice.title || '', + date: formatDate(notice.date || notice.createdAt || notice.createdDate || new Date().toISOString().split('T')[0]), + views: notice.views || notice.viewCount || 0, + writer: notice.writer || notice.author || notice.createdBy || '관리자', + content: notice.content ? (Array.isArray(notice.content) ? notice.content : [notice.content]) : undefined, + hasAttachment: notice.hasAttachment || notice.attachment || false, + })); + + if (!isMounted) return; + + // 날짜 내림차순 정렬 (최신 날짜가 먼저) + const sortedNotices = [...transformedNotices].sort((a, b) => b.date.localeCompare(a.date)); + setNotices(sortedNotices); + setTotalCount(total || sortedNotices.length); + } catch (error) { + console.error('공지사항 리스트 조회 오류:', error); + } finally { + if (isMounted) { + setLoading(false); + } + } + } + + fetchNotices(); + + return () => { + isMounted = false; + }; + }, []); + + const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE); + const pagedNotices = useMemo( + () => notices.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE), + [notices, currentPage] + ); + + // 페이지네이션: 10개씩 표시 + const pageGroup = Math.floor((currentPage - 1) / 10); + const startPage = pageGroup * 10 + 1; + const endPage = Math.min(startPage + 9, totalPages); + const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i); return (
@@ -26,74 +127,162 @@ export default function NoticesPage() { {/* 총 건수 */}

- 총 {rows.length}건 + 총 {totalCount}

- {/* 표 */} -
- {/* 헤더 */} -
-
- 번호 -
-
- 제목 -
-
- 게시일 -
-
- 조회수 -
-
작성자
+ {loading ? ( +
+

로딩 중...

+ ) : ( + <> + {/* 표 */} +
+ {/* 헤더 */} +
+
+ 번호 +
+
+ 제목 +
+
+ 게시일 +
+
+ 조회수 +
+
작성자
+
- {/* 바디 */} -
- {rows.map((r, index) => { - // 번호는 정렬된 목록에서의 순서 - const noticeNumber = rows.length - index; - return ( -
router.push(`/notices/${r.id}`)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - router.push(`/notices/${r.id}`); - } - }} - className={[ - 'grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer', - ].join(' ')} - > -
- {noticeNumber} + {/* 바디 */} +
+ {pagedNotices.length === 0 ? ( +
+

공지사항이 없습니다.

-
- {r.title} - {r.hasAttachment && ( - - )} + ) : ( + pagedNotices.map((notice, index) => { + // 번호는 전체 목록에서의 순서 (최신이 1번) + const noticeNumber = totalCount - ((currentPage - 1) * ITEMS_PER_PAGE + index); + return ( +
router.push(`/notices/${notice.id}`)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + router.push(`/notices/${notice.id}`); + } + }} + className="grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer" + > +
+ {noticeNumber} +
+
+ {notice.title} + {notice.hasAttachment && ( + + )} +
+
+ {notice.date} +
+
+ {notice.views.toLocaleString()} +
+
{notice.writer}
+
+ ); + }) + )} +
+
+ + {/* 페이지네이션 - 10개 초과일 때만 표시 */} + {totalCount > ITEMS_PER_PAGE && ( +
+
+ {/* First (맨 앞으로) */} + + + {/* Prev */} + + + {/* Numbers */} + {visiblePages.map((n) => { + const active = n === currentPage; + return ( + + ); + })} + + {/* Next */} + + + {/* Last (맨 뒤로) */} +
-
- {r.date} -
-
- {r.views.toLocaleString()} -
-
{r.writer}
-
- ); - })} -
-
+
+ )} + + )}
diff --git a/src/app/page.tsx b/src/app/page.tsx index 22eea48..b444c81 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,93 +3,36 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; +import Link from 'next/link'; import MainLogoSvg from './svgs/mainlogosvg'; import apiService from './lib/apiService'; +import type { Notice } from './admin/notices/mockData'; + +interface Subject { + id: string | number; + title: string; + imageKey?: string; + instructor?: string; +} + +interface CourseCard { + id: string | number; + title: string; + meta: string; + image: string; +} export default function Home() { const router = useRouter(); const containerRef = useRef(null); const [currentIndex, setCurrentIndex] = useState(0); const [userName, setUserName] = useState(''); - - // 코스, 공지사항 더미 데이터 - const courseCards = useMemo( - () => - [ - { - id: 'c1', - title: '원자력 운영 기초', - meta: 'VOD • 초급 • 4시간 20분', - image: 'https://picsum.photos/seed/xrlms-c1/1200/800', - }, - { - id: 'c2', - title: '반도체', - meta: 'VOD • 중급 • 3시간 10분', - image: 'https://picsum.photos/seed/xrlms-c2/1200/800', - }, - { - id: 'c3', - title: '방사선 안전', - meta: 'VOD • 중급 • 4시간 20분', - image: 'https://picsum.photos/seed/xrlms-c3/1200/800', - }, - { - id: 'c4', - title: '방사선 폐기물', - meta: 'VOD • 중급 • 4시간 20분', - image: 'https://picsum.photos/seed/xrlms-c4/1200/800', - }, - { - id: 'c5', - title: '원자력 운전 개론', - meta: 'VOD • 초급 • 3시간 00분', - image: 'https://picsum.photos/seed/xrlms-c5/1200/800', - }, - { - id: 'c6', - title: '안전 표지와 표준', - meta: 'VOD • 초급 • 2시간 40분', - image: 'https://picsum.photos/seed/xrlms-c6/1200/800', - }, - { - id: 'c7', - title: '발전소 운영', - meta: 'VOD • 중급 • 4시간 20분', - image: 'https://picsum.photos/seed/xrlms-c7/1200/800', - }, - { - id: 'c8', - title: '방사선 안전 실습', - meta: 'VOD • 중급 • 3시간 30분', - image: 'https://picsum.photos/seed/xrlms-c8/1200/800', - }, - { - id: 'c9', - title: '실험실 안전', - meta: 'VOD • 초급 • 2시간 10분', - image: 'https://picsum.photos/seed/xrlms-c9/1200/800', - }, - { - id: 'c10', - title: '기초 장비 운용', - meta: 'VOD • 초급 • 2시간 50분', - image: 'https://picsum.photos/seed/xrlms-c10/1200/800', - }, - ] as Array<{ id: string; title: string; meta: string; image: string }>, - [] - ); - const noticeRows = useMemo( - () => - [ - { id: 5, title: '(공지)시스템 개선이 완료되었...', date: '2025-09-10', views: 1320, writer: '운영팀' }, - { id: 4, title: '(공지)서버 점검 안내(9/10 새벽)', date: '2025-09-10', views: 1210, writer: '운영팀' }, - { id: 3, title: '(공지)서비스 개선 안내', date: '2025-09-10', views: 1230, writer: '운영팀' }, - { id: 2, title: '(공지)시장점검 공지', date: '2025-09-10', views: 1320, writer: '관리자' }, - { id: 1, title: '뉴: 봉사시간 안내 및 한눈에 보는 현황 정리', date: '2025-08-28', views: 594, writer: '운영팀' }, - ], - [] - ); + const [subjects, setSubjects] = useState([]); + const [courseCards, setCourseCards] = useState([]); + const [loadingSubjects, setLoadingSubjects] = useState(true); + const [totalSubjectsCount, setTotalSubjectsCount] = useState(0); + const [notices, setNotices] = useState([]); + const [loadingNotices, setLoadingNotices] = useState(true); // NOTE: 실제 이미지 자산 연결 시 해당 src를 교체하세요. const slides = useMemo( @@ -167,6 +110,176 @@ export default function Home() { }; }, []); + // 과목 리스트 가져오기 + useEffect(() => { + let isMounted = true; + + async function fetchSubjects() { + try { + setLoadingSubjects(true); + const response = await apiService.getSubjects(); + + if (response.status !== 200 || !response.data) { + return; + } + + // 응답 데이터 구조 확인 및 배열 추출 + let subjectsData: any[] = []; + let totalCount = 0; + + if (Array.isArray(response.data)) { + subjectsData = response.data; + totalCount = response.data.length; + } else if (response.data && typeof response.data === 'object') { + // 다양한 응답 구조 처리 + subjectsData = response.data.items || + response.data.courses || + response.data.data || + response.data.list || + response.data.subjects || + response.data.subjectList || + []; + + // 전체 개수는 total, totalCount, count 등의 필드에서 가져오거나 배열 길이 사용 + totalCount = response.data.total !== undefined ? response.data.total : + response.data.totalCount !== undefined ? response.data.totalCount : + response.data.count !== undefined ? response.data.count : + subjectsData.length; + } + + console.log('과목 리스트 응답:', response.data); + console.log('추출된 과목 데이터:', subjectsData); + console.log('전체 과목 개수:', totalCount); + + if (!isMounted) return; + + // 전체 과목 개수 저장 + setTotalSubjectsCount(totalCount); + + // 상위 10개만 가져오기 + const top10Subjects = subjectsData.slice(0, 10); + setSubjects(top10Subjects); + + // 각 과목의 이미지 다운로드 + const courseCardsWithImages = await Promise.all( + top10Subjects.map(async (subject: Subject) => { + let imageUrl = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800'; + + if (subject.imageKey) { + try { + const fileUrl = await apiService.getFile(subject.imageKey); + if (fileUrl) { + imageUrl = fileUrl; + } + } catch (error) { + console.error(`이미지 다운로드 실패 (과목 ID: ${subject.id}):`, error); + } + } + + return { + id: subject.id, + title: subject.title || '', + meta: subject.instructor ? `강사: ${subject.instructor}` : 'VOD • 온라인', + image: imageUrl, + }; + }) + ); + + if (isMounted) { + setCourseCards(courseCardsWithImages); + } + } catch (error) { + console.error('과목 리스트 조회 오류:', error); + } finally { + if (isMounted) { + setLoadingSubjects(false); + } + } + } + + fetchSubjects(); + + return () => { + isMounted = false; + }; + }, []); + + // 공지사항 리스트 가져오기 + useEffect(() => { + let isMounted = true; + + async function fetchNotices() { + try { + setLoadingNotices(true); + const response = await apiService.getNotices(); + + if (response.status !== 200 || !response.data) { + return; + } + + // API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태) + let noticesArray: any[] = []; + if (Array.isArray(response.data)) { + noticesArray = response.data; + } else if (response.data && typeof response.data === 'object') { + noticesArray = response.data.items || response.data.notices || response.data.data || response.data.list || []; + } + + // 날짜를 yyyy-mm-dd 형식으로 포맷팅 + const formatDate = (dateString: string): string => { + if (!dateString) return ''; + + try { + const date = new Date(dateString); + if (isNaN(date.getTime())) { + // 이미 yyyy-mm-dd 형식인 경우 그대로 반환 + if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { + return dateString; + } + return dateString; + } + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } catch { + return dateString; + } + }; + + // API 응답 데이터를 Notice 형식으로 변환 + const transformedNotices: Notice[] = noticesArray.map((notice: any) => ({ + id: notice.id || notice.noticeId || 0, + title: notice.title || '', + date: formatDate(notice.date || notice.createdAt || notice.createdDate || new Date().toISOString().split('T')[0]), + views: notice.views || notice.viewCount || 0, + writer: notice.writer || notice.author || notice.createdBy || '관리자', + content: notice.content ? (Array.isArray(notice.content) ? notice.content : [notice.content]) : undefined, + hasAttachment: notice.hasAttachment || notice.attachment || false, + })); + + if (!isMounted) return; + + // 날짜 내림차순 정렬 (최신 날짜가 먼저) + const sortedNotices = [...transformedNotices].sort((a, b) => b.date.localeCompare(a.date)); + setNotices(sortedNotices); + } catch (error) { + console.error('공지사항 리스트 조회 오류:', error); + } finally { + if (isMounted) { + setLoadingNotices(false); + } + } + } + + fetchNotices(); + + return () => { + isMounted = false; + }; + }, []); + useEffect(() => { const containerEl = containerRef.current; if (!containerEl) return; @@ -321,11 +434,11 @@ export default function Home() {

교육 과정

- 총 28건 + 총 {totalSubjectsCount}
- 전체보기 @@ -339,42 +452,47 @@ export default function Home() { strokeLinejoin="round" /> - +
-
- {courseCards.map((c) => ( -
-
- {c.title} { - const t = e.currentTarget as HTMLImageElement; - if (!t.src.includes('picsum.photos')) t.src = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800'; - }} - /> -
-
- {c.id === 'c1' && ( - - 수강 중 - - )} -
- {c.title} -
-
- - - -

{c.meta}

+ {loadingSubjects ? ( +
+

로딩 중...

+
+ ) : ( +
+ {courseCards.map((c) => ( +
router.push(`/course-list/${c.id}`)} + className="flex flex-col gap-4 h-[260px] cursor-pointer" + > +
+ {c.title} { + const t = e.currentTarget as HTMLImageElement; + if (!t.src.includes('picsum.photos')) t.src = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800'; + }} + />
-
-
- ))} -
+
+
+ {c.title} +
+
+ + + +

{c.meta}

+
+
+ + ))} +
+ )} {/* 공지사항 */} @@ -383,11 +501,11 @@ export default function Home() {

공지사항

- 총 {noticeRows.length}건 + 총 {notices.length}
- 전체보기 @@ -401,38 +519,57 @@ export default function Home() { strokeLinejoin="round" /> - +
-
-
-
번호
-
제목
-
게시일
-
조회수
-
작성자
+ {loadingNotices ? ( +
+

로딩 중...

+ ) : ( +
+
+
번호
+
제목
+
게시일
+
조회수
+
작성자
+
-
- {noticeRows.map((r) => ( -
-
{r.id}
-
- {r.title} -
-
{r.date}
-
{r.views.toLocaleString()}
-
{r.writer}
-
- ))} +
+ {notices.map((notice, index) => { + // 번호는 정렬된 목록에서의 순서 (최신이 1번) + const noticeNumber = notices.length - index; + return ( +
router.push(`/notices/${notice.id}`)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + router.push(`/notices/${notice.id}`); + } + }} + className="grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer" + > +
{noticeNumber}
+
+ {notice.title} +
+
{notice.date}
+
{notice.views.toLocaleString()}
+
{notice.writer}
+
+ ); + })} +
-
+ )}
diff --git a/src/app/resources/[id]/page.tsx b/src/app/resources/[id]/page.tsx index 2070c95..784d361 100644 --- a/src/app/resources/[id]/page.tsx +++ b/src/app/resources/[id]/page.tsx @@ -1,92 +1,162 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; -import { notFound } from 'next/navigation'; import PaperClipSvg from '../../svgs/paperclipsvg'; import BackCircleSvg from '../../svgs/backcirclesvg'; +import DownloadIcon from '../../svgs/downloadicon'; +import apiService from '../../lib/apiService'; +import type { Resource } from '../../admin/resources/mockData'; -type ResourceRow = { - id: number; - title: string; - date: string; - views: number; - writer: string; - content: string[]; - attachments?: Array<{ name: string; size: string; url: string }>; +type Attachment = { + name: string; + size: string; + url?: string; + fileKey?: string; }; -const DATA: ResourceRow[] = [ - { - id: 6, - title: '방사선과 물질의 상호작용 관련 학습 자료', - date: '2025-06-28', - views: 1230, - writer: '강민재', - content: [ - '방사선(Radiation)이 물질 속을 지나갈 때 발생하는 다양한 상호작용(Interaction)의 기본적인 원리를 이해합니다.', - '상호작용의 원리는 방사선 측정, 방사선 이용(의료, 산업), 방사선 차폐 등 방사선 관련 분야의 기본이 됨을 인식합니다.', - '방사선의 종류(광자, 하전입자, 중성자 등) 및 에너지에 따라 물질과의 상호작용 형태가 어떻게 달라지는지 학습합니다.', - ], - attachments: [ - { - name: '[PPT] 방사선-물질 상호작용의 3가지 유형.pptx', - size: '796.35 KB', - url: '#', - }, - ], - }, - { - id: 5, - title: '감마선과 베타선의 특성 및 차이 분석 자료', - date: '2025-06-28', - views: 594, - writer: '강민재', - content: [ - '감마선과 베타선의 발생 원리, 물질과의 상호작용 차이를 비교합니다.', - '차폐 설계 시 고려해야 할 변수들을 사례와 함께 설명합니다.', - ], - }, - { - id: 4, - title: '방사선량 단위(Sv, Gy) 비교 및 계산 예제', - date: '2025-06-28', - views: 1230, - writer: '강민재', - content: ['방사선량 단위 변환 및 예제 계산을 통해 실무 감각을 익힙니다.'], - }, - { - id: 3, - title: '의료 영상 촬영 시 방사선 안전 수칙 가이드', - date: '2025-06-28', - views: 1230, - writer: '강민재', - content: ['촬영 환경에서의 방사선 안전 수칙을 체크리스트 형태로 정리합니다.'], - }, - { - id: 2, - title: 'X선 발생 원리 및 특성에 대한 이해 자료', - date: '2025-06-28', - views: 1230, - writer: '강민재', - content: ['X선의 발생 원리와 에너지 스펙트럼의 특성을 개관합니다.'], - }, - { - id: 1, - title: '방사선의 기초 개념과 물질과의 상호작용 정리 자료', - date: '2025-06-28', - views: 1230, - writer: '강민재', - content: ['방사선 기초 개념을 한눈에 정리한 입문용 자료입니다.'], - }, -]; +export default function ResourceDetailPage() { + const params = useParams(); + const router = useRouter(); + const [resource, setResource] = useState(null); + const [attachments, setAttachments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); -export default async function ResourceDetailPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - const numericId = Number(id); - const item = DATA.find((r) => r.id === numericId); - if (!item) return notFound(); + useEffect(() => { + async function fetchResource() { + if (!params?.id) return; + + try { + setLoading(true); + setError(null); + + const response = await apiService.getLibraryItem(params.id); + const data = response.data; + + // API 응답 데이터를 Resource 형식으로 변환 + const transformedResource: Resource = { + id: data.id || data.resourceId || Number(params.id), + title: data.title || '', + date: data.date || data.createdAt || data.createdDate || new Date().toISOString().split('T')[0], + views: data.views || data.viewCount || 0, + writer: data.writer || data.author || data.createdBy || '관리자', + content: data.content + ? (Array.isArray(data.content) + ? data.content + : typeof data.content === 'string' + ? data.content.split('\n').filter((line: string) => line.trim()) + : [String(data.content)]) + : [], + hasAttachment: data.hasAttachment || data.attachment || false, + }; + + // 첨부파일 정보 처리 + if (data.attachments && Array.isArray(data.attachments)) { + setAttachments(data.attachments.map((att: any) => ({ + name: att.name || att.fileName || att.filename || '', + size: att.size || att.fileSize || '', + url: att.url || att.downloadUrl, + fileKey: att.fileKey || att.key || att.fileId, + }))); + } else if (transformedResource.hasAttachment && data.attachment) { + // 단일 첨부파일인 경우 + setAttachments([{ + name: data.attachment.name || data.attachment.fileName || data.attachment.filename || '첨부파일', + size: data.attachment.size || data.attachment.fileSize || '', + url: data.attachment.url || data.attachment.downloadUrl, + fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId, + }]); + } + + if (!transformedResource.title) { + throw new Error('학습 자료를 찾을 수 없습니다.'); + } + + setResource(transformedResource); + } catch (err) { + console.error('학습 자료 조회 오류:', err); + setError('학습 자료를 불러오는 중 오류가 발생했습니다.'); + } finally { + setLoading(false); + } + } + + fetchResource(); + }, [params?.id]); + + const handleDownload = async (fileKey?: string, url?: string, fileName?: string) => { + if (url) { + // URL이 있으면 직접 다운로드 + const link = document.createElement('a'); + link.href = url; + link.download = fileName || 'download'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } else if (fileKey) { + // fileKey가 있으면 API를 통해 다운로드 + try { + const fileUrl = await apiService.getFile(fileKey); + if (fileUrl) { + const link = document.createElement('a'); + link.href = fileUrl; + link.download = fileName || 'download'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + } catch (err) { + console.error('파일 다운로드 실패:', err); + alert('파일 다운로드에 실패했습니다.'); + } + } + }; + + if (loading) { + return ( +
+
+
+
+

로딩 중...

+
+
+
+
+ ); + } + + if (error || !resource) { + return ( +
+
+
+
+ + + +

+ 학습 자료 상세 +

+
+
+

+ {error || '학습 자료를 찾을 수 없습니다.'} +

+
+
+
+
+ ); + } + + const item = resource; return (
@@ -119,7 +189,11 @@ export default async function ResourceDetailPage({ {item.writer} 게시일 - {item.date} + + {item.date.includes('T') + ? new Date(item.date).toISOString().split('T')[0] + : item.date} + 조회수 {item.views.toLocaleString()} @@ -132,21 +206,25 @@ export default async function ResourceDetailPage({ {/* 본문 */}
- {item.content.map((p, idx) => ( -

- {p} -

- ))} + {item.content && item.content.length > 0 ? ( + item.content.map((p, idx) => ( +

+ {p} +

+ )) + ) : ( +

내용이 없습니다.

+ )}
{/* 첨부 파일 */} - {item.attachments?.length ? ( + {attachments.length > 0 && (
첨부 파일
- {item.attachments.map((f, idx) => ( + {attachments.map((f, idx) => (
{f.name} - - {f.size} - + {f.size && ( + + {f.size} + + )}
- handleDownload(f.fileKey, f.url, f.name)} + className="h-8 px-4 rounded-[6px] border border-[#8C95A1] text-[13px] text-[#4C5561] inline-flex items-center gap-1 hover:bg-[#F9FAFB] cursor-pointer" > - - - + 다운로드 - +
))}
- ) : null} + )}
diff --git a/src/app/resources/page.tsx b/src/app/resources/page.tsx index 1ad76c1..6653a06 100644 --- a/src/app/resources/page.tsx +++ b/src/app/resources/page.tsx @@ -1,65 +1,97 @@ 'use client'; +import { useState, useEffect, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import PaperClipSvg from '../svgs/paperclipsvg'; - -type ResourceRow = { - id: number; - title: string; - date: string; - views: number; - writer: string; - hasAttachment?: boolean; -}; - -const rows: ResourceRow[] = [ - { - id: 6, - title: '방사선과 물질의 상호작용 관련 학습 자료', - date: '2025-06-28', - views: 1230, - writer: '강민재', - hasAttachment: true, - }, - { - id: 5, - title: '감마선과 베타선의 특성 및 차이 분석 자료', - date: '2025-06-28', - views: 594, - writer: '강민재', - }, - { - id: 4, - title: '방사선량 단위(Sv, Gy) 비교 및 계산 예제', - date: '2025-06-28', - views: 1230, - writer: '강민재', - }, - { - id: 3, - title: '의료 영상 촬영 시 방사선 안전 수칙 가이드', - date: '2025-06-28', - views: 1230, - writer: '강민재', - }, - { - id: 2, - title: 'X선 발생 원리 및 특성에 대한 이해 자료', - date: '2025-06-28', - views: 1230, - writer: '강민재', - }, - { - id: 1, - title: '방사선의 기초 개념과 물질과의 상호작용 정리 자료', - date: '2025-06-28', - views: 1230, - writer: '강민재', - }, -]; +import ChevronDownSvg from '../svgs/chevrondownsvg'; +import apiService from '../lib/apiService'; +import type { Resource } from '../admin/resources/mockData'; export default function ResourcesPage() { const router = useRouter(); + const [resources, setResources] = useState([]); + const [currentPage, setCurrentPage] = useState(1); + const [isLoading, setIsLoading] = useState(false); + + // 날짜를 yyyy-mm-dd 형식으로 포맷팅 + const formatDate = (dateString: string): string => { + if (!dateString) return ''; + + try { + const date = new Date(dateString); + if (isNaN(date.getTime())) { + // 이미 yyyy-mm-dd 형식인 경우 그대로 반환 + if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { + return dateString; + } + return dateString; + } + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } catch { + return dateString; + } + }; + + // API에서 학습 자료 목록 가져오기 + useEffect(() => { + async function fetchResources() { + try { + setIsLoading(true); + const response = await apiService.getLibrary(); + const data = response.data; + + // API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태) + let resourcesArray: any[] = []; + if (Array.isArray(data)) { + resourcesArray = data; + } else if (data && typeof data === 'object') { + resourcesArray = data.items || data.resources || data.data || data.list || []; + } + + // API 응답 데이터를 Resource 형식으로 변환 + const transformedResources: Resource[] = resourcesArray.map((resource: any) => ({ + id: resource.id || resource.resourceId || 0, + title: resource.title || '', + date: resource.date || resource.createdAt || resource.createdDate || new Date().toISOString().split('T')[0], + views: resource.views || resource.viewCount || 0, + writer: resource.writer || resource.author || resource.createdBy || '관리자', + content: resource.content ? (Array.isArray(resource.content) ? resource.content : [resource.content]) : undefined, + hasAttachment: resource.hasAttachment || resource.attachment || false, + })); + + setResources(transformedResources); + } catch (error) { + console.error('학습 자료 목록 조회 오류:', error); + // 에러 발생 시 빈 배열로 설정 + setResources([]); + } finally { + setIsLoading(false); + } + } + + fetchResources(); + }, []); + + const totalCount = useMemo(() => resources.length, [resources]); + + const ITEMS_PER_PAGE = 10; + const sortedResources = useMemo(() => { + return [...resources].sort((a, b) => { + // 생성일 내림차순 정렬 (최신 날짜가 먼저) + return b.date.localeCompare(a.date); + }); + }, [resources]); + + const totalPages = Math.ceil(sortedResources.length / ITEMS_PER_PAGE); + const paginatedResources = useMemo(() => { + const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; + const endIndex = startIndex + ITEMS_PER_PAGE; + return sortedResources.slice(startIndex, endIndex); + }, [sortedResources, currentPage]); return (
@@ -77,70 +109,173 @@ export default function ResourcesPage() { {/* 총 건수 */}

- 총 {rows.length}건 + 총 {totalCount}

- {/* 표 */} -
- {/* 헤더 */} -
-
- 번호 -
-
- 제목 -
-
- 게시일 -
-
- 조회수 -
-
등록자
+ {isLoading ? ( +
+

+ 로딩 중... +

- - {/* 바디 */} -
- {rows.map((r) => ( -
router.push(`/resources/${r.id}`)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - router.push(`/resources/${r.id}`); - } - }} - className={[ - 'grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer', - ].join(' ')} - > -
- {r.id} + ) : resources.length === 0 ? ( +
+

+ 등록된 학습 자료가 없습니다. +

+
+ ) : ( + <> + {/* 표 */} +
+ {/* 헤더 */} +
+
+ 번호
-
- {r.title} - {r.hasAttachment && ( - - )} +
+ 제목
-
- {r.date} +
+ 게시일
-
- {r.views.toLocaleString()} +
+ 조회수
-
{r.writer}
+
작성자
- ))} -
-
+ + {/* 바디 */} +
+ {paginatedResources.map((resource, index) => { + // 번호는 전체 목록에서의 순서 (정렬된 목록 기준) + const resourceNumber = sortedResources.length - (currentPage - 1) * ITEMS_PER_PAGE - index; + return ( +
router.push(`/resources/${resource.id}`)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + router.push(`/resources/${resource.id}`); + } + }} + className={[ + 'grid grid-cols-[80px_1fr_140px_120px_120px] h-[48px] text-[13px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer', + ].join(' ')} + > +
+ {resourceNumber} +
+
+ {resource.title} + {resource.hasAttachment && ( + + )} +
+
+ {formatDate(resource.date)} +
+
+ {resource.views.toLocaleString()} +
+
{resource.writer}
+
+ ); + })} +
+
+ + {/* 페이지네이션 - 10개 초과일 때만 표시 */} + {resources.length > ITEMS_PER_PAGE && ( +
+
+ {/* First (맨 앞으로) */} + + + {/* Prev */} + + + {/* Numbers */} + {(() => { + const pageGroup = Math.floor((currentPage - 1) / 10); + const startPage = pageGroup * 10 + 1; + const endPage = Math.min(startPage + 9, totalPages); + const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i); + + return visiblePages.map((n) => { + const active = n === currentPage; + return ( + + ); + }); + })()} + + {/* Next */} + + + {/* Last (맨 뒤로) */} + +
+
+ )} + + )}