메일페이지 수정중1

This commit is contained in:
2025-12-01 09:56:04 +09:00
parent 54d763e008
commit 8bdd615ec9
9 changed files with 1696 additions and 660 deletions

View File

@@ -274,7 +274,8 @@ export default function AdminNoticeDetailPage() {
</p> </p>
</div> </div>
</div> </div>
{attachments.map((attachment, idx) => ( <div className="pt-3">
{attachments.map((attachment, idx) => (
<div <div
key={idx} key={idx}
className="bg-white border border-[#DEE1E6] rounded-[6px] h-[64px] flex items-center gap-[12px] px-[17px] py-1 w-full" className="bg-white border border-[#DEE1E6] rounded-[6px] h-[64px] flex items-center gap-[12px] px-[17px] py-1 w-full"
@@ -301,7 +302,8 @@ export default function AdminNoticeDetailPage() {
</span> </span>
</button> </button>
</div> </div>
))} ))}
</div>
</div> </div>
</div> </div>
)} )}

View File

@@ -4,6 +4,10 @@ import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import Image from 'next/image'; import Image from 'next/image';
import apiService from '../../lib/apiService'; import apiService from '../../lib/apiService';
import BackCircleSvg from '../../svgs/backcirclesvg';
const imgPlay = '/imgs/play.svg';
const imgMusicAudioPlay = '/imgs/music-audio-play.svg';
type Lesson = { type Lesson = {
id: string; id: string;
@@ -39,23 +43,98 @@ export default function CourseDetailPage() {
try { try {
setLoading(true); setLoading(true);
setError(null); 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 타입으로 변환 // API 응답 데이터를 CourseDetail 타입으로 변환
const data = response.data;
// API 응답 구조에 맞게 데이터 매핑
// 실제 API 응답 구조에 따라 조정 필요
const courseDetail: CourseDetail = { const courseDetail: CourseDetail = {
id: String(data.id || params.id), id: String(data.id || params.id),
status: data.status || "수강 예정", status: data.status || "수강 예정",
title: data.title || data.lectureName || '', title: data.title || data.lectureName || data.subjectName || '',
goal: data.objective || data.goal || '', goal: data.objective || data.goal || '',
method: data.method || '', method: data.method || '',
summary: data.summary || `VOD · 총 ${data.lessons?.length || 0}`, summary: data.summary || `VOD · 총 ${lessons.length || 0}`,
submitSummary: data.submitSummary || '', submitSummary: data.submitSummary || '',
thumbnail: data.thumbnail || data.imageKey || data.imageUrl || '/imgs/talk.png', thumbnail: thumbnail,
lessons: (data.lessons || []).map((lesson: any, index: number) => ({ lessons: lessons.map((lesson: any, index: number) => ({
id: String(lesson.id || lesson.lessonId || index + 1), id: String(lesson.id || lesson.lessonId || index + 1),
title: `${index + 1}. ${lesson.title || lesson.lessonName || ''}`, title: `${index + 1}. ${lesson.title || lesson.lessonName || ''}`,
duration: lesson.duration || '00:00', duration: lesson.duration || '00:00',
@@ -78,11 +157,19 @@ export default function CourseDetailPage() {
if (loading) { if (loading) {
return ( return (
<main className="flex w-full flex-col"> <main className="flex w-full flex-col items-center">
<div className="flex h-[100px] items-center px-8"> <div className="flex h-[100px] w-full max-w-[1440px] items-center gap-[12px] px-8">
<button
type="button"
onClick={() => router.back()}
aria-label="뒤로 가기"
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer"
>
<BackCircleSvg width={32} height={32} />
</button>
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]"> </h1> <h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]"> </h1>
</div> </div>
<section className="px-8 pb-20"> <section className="w-full max-w-[1440px] px-8 pb-20">
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<p className="text-[16px] text-[#8c95a1]"> ...</p> <p className="text-[16px] text-[#8c95a1]"> ...</p>
</div> </div>
@@ -93,11 +180,19 @@ export default function CourseDetailPage() {
if (error || !course) { if (error || !course) {
return ( return (
<main className="flex w-full flex-col"> <main className="flex w-full flex-col items-center">
<div className="flex h-[100px] items-center px-8"> <div className="flex h-[100px] w-full max-w-[1440px] items-center gap-[12px] px-8">
<button
type="button"
onClick={() => router.back()}
aria-label="뒤로 가기"
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer"
>
<BackCircleSvg width={32} height={32} />
</button>
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]"> </h1> <h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]"> </h1>
</div> </div>
<section className="px-8 pb-20"> <section className="w-full max-w-[1440px] px-8 pb-20">
<div className="flex flex-col items-center justify-center py-20"> <div className="flex flex-col items-center justify-center py-20">
<p className="text-[16px] text-red-500 mb-4">{error || '강좌를 찾을 수 없습니다.'}</p> <p className="text-[16px] text-red-500 mb-4">{error || '강좌를 찾을 수 없습니다.'}</p>
<button <button
@@ -114,96 +209,160 @@ export default function CourseDetailPage() {
} }
return ( return (
<main className="flex w-full flex-col"> <main className="flex w-full flex-col items-center">
<div className="flex h-[100px] items-center px-8"> {/* 헤더 */}
<div className="flex h-[100px] w-full max-w-[1440px] items-center gap-[12px] px-8">
<button
type="button"
onClick={() => router.back()}
aria-label="뒤로 가기"
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer"
>
<BackCircleSvg width={32} height={32} />
</button>
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]"> </h1> <h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]"> </h1>
</div> </div>
<section className="px-8 pb-20"> {/* 메인 콘텐츠 */}
<div className="rounded-[8px] bg-white px-8 pb-20 pt-6"> <section className="w-full max-w-[1440px] px-8 pb-[80px] pt-[24px]">
{/* 상단 소개 카드 */} {/* 상단 정보 카드 */}
<div className="flex gap-6 rounded-[8px] bg-[#f8f9fa] p-6"> <div className="bg-[#f8f9fa] box-border flex gap-[24px] items-start p-[24px] rounded-[8px] w-full">
<div className="relative h-[159px] w-[292px] overflow-hidden rounded"> {/* 이미지 컨테이너 */}
<Image <div className="overflow-clip relative rounded-[4px] shrink-0 w-[220.5px] h-[159px]">
src={course.thumbnail} <Image
alt={course.title} src={course.thumbnail}
fill alt={course.title}
sizes="292px" fill
className="object-cover" sizes="220.5px"
unoptimized className="object-cover"
/> unoptimized
/>
</div>
{/* 교육과정 정보 */}
<div className="basis-0 flex flex-col gap-[12px] grow items-start min-h-px min-w-px relative shrink-0">
{/* 제목 영역 */}
<div className="flex gap-[8px] h-[27px] items-center w-full">
<div className="bg-[#e5f5ec] box-border flex h-[20px] items-center justify-center px-[4px] py-0 rounded-[4px] shrink-0">
<p className="font-['Pretendard:SemiBold',sans-serif] text-[#0c9d61] text-[13px] leading-[1.4]">{course.status}</p>
</div>
<h2 className="font-['Pretendard:SemiBold',sans-serif] text-[#333c47] text-[18px] leading-[1.5]">{course.title}</h2>
</div> </div>
<div className="flex-1">
<div className="flex h-[27px] items-center gap-2"> {/* 학습 목표 및 방법 */}
<span className="h-[20px] rounded-[4px] bg-[#e5f5ec] px-1.5 text-[13px] font-semibold leading-[1.4] text-[#0c9d61]"> <div className="flex flex-col gap-[4px] items-start w-full">
{course.status} <p className="font-['Pretendard:Regular',sans-serif] text-[#333c47] text-[15px] leading-[1.5] mb-0">
</span> <span className="font-['Pretendard:Medium',sans-serif]"> :</span>
<h2 className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">{course.title}</h2> <span>{` ${course.goal}`}</span>
</div> </p>
<div className="mt-3 space-y-1"> <p className="font-['Pretendard:Regular',sans-serif] text-[#333c47] text-[15px] leading-[1.5]">
<p className="text-[15px] leading-[1.5] text-[#333c47]"> <span className="font-['Pretendard:Medium',sans-serif]"> :</span>
<span className="font-medium"> :</span> {course.goal} <span>{` ${course.method}`}</span>
</p> </p>
<p className="text-[15px] leading-[1.5] text-[#333c47]"> </div>
<span className="font-medium"> :</span> {course.method}
</p> {/* 통계 정보 */}
</div> <div className="flex gap-[4px] items-center w-full">
<div className="mt-3 flex items-center gap-5 text-[13px] leading-[1.4] text-[#8c95a1]"> <div className="flex gap-[20px] items-center">
<span>{course.summary}</span> {/* VOD 정보 */}
{course.submitSummary && <span>{course.submitSummary}</span>} <div className="flex gap-[4px] items-center">
<div className="relative shrink-0 size-[16px]">
<img src={imgPlay} alt="" className="block max-w-none size-full" />
</div>
<p className="font-['Pretendard:Medium',sans-serif] text-[#8c95a1] text-[13px] leading-[1.4]">{course.summary}</p>
</div>
{/* 학습 제출 정보 */}
{course.submitSummary && (
<div className="flex gap-[4px] items-center">
<div className="relative shrink-0 size-[16px]">
<img src={imgMusicAudioPlay} alt="" className="block max-w-none size-full" />
</div>
<p className="font-['Pretendard:Medium',sans-serif] text-[#8c95a1] text-[13px] leading-[1.4]">{course.submitSummary}</p>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
</div>
{/* 차시 리스트 */} {/* 차시 리스트 */}
<div className="mt-6 space-y-2"> <div className="flex flex-col gap-[8px] items-start mt-[24px] w-full">
{course.lessons.map((l) => { {course.lessons.map((l) => {
const isSubmitted = l.state === "제출완료"; const isSubmitted = l.state === "제출완료";
const submitBtnStyle = const submitBtnBorder = isSubmitted
l.state === "제출완료" ? "border-transparent"
? "border border-transparent text-[#384fbf]" : (l.action === "이어서 수강하기" || l.action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]");
: "border " + (l.action === "이어서 수강하기" || l.action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]"); const submitBtnText = isSubmitted ? "text-[#384fbf]" : (l.action === "이어서 수강하기" || l.action === "수강하기" ? "text-[#b1b8c0]" : "text-[#4c5561]");
const rightBtnStyle = const rightBtnStyle =
l.action === "이어서 수강하기" l.action === "이어서 수강하기" || l.action === "수강하기"
? "bg-[#ecf0ff] text-[#384fbf]" ? "bg-[#ecf0ff] text-[#384fbf]"
: l.action === "수강하기" : "bg-[#f1f3f5] text-[#4c5561]";
? "bg-[#ecf0ff] text-[#384fbf]"
: "bg-[#f1f3f5] text-[#4c5561]"; return (
return ( <div key={l.id} className="bg-white border border-[#dee1e6] border-solid relative rounded-[8px] w-full">
<div key={l.id} className="rounded-[8px] border border-[#dee1e6] bg-white"> <div className="box-border flex gap-[16px] items-center overflow-clip px-[24px] py-[16px] rounded-[inherit] w-full">
<div className="flex items-center justify-between gap-4 rounded-[8px] px-6 py-4"> <div className="basis-0 flex grow h-[46px] items-center justify-between min-h-px min-w-px relative shrink-0">
<div className="min-w-0"> {/* 차시 정보 */}
<p className="text-[16px] font-semibold leading-[1.5] text-[#333c47]">{l.title}</p> <div className="basis-0 grow min-h-px min-w-px relative shrink-0">
<div className="mt-1 flex items-center gap-3"> <div className="flex flex-col gap-[4px] items-start w-full">
<p className="w-[40px] text-[13px] leading-[1.4] text-[#8c95a1]">{l.duration}</p> <p className="font-['Pretendard:SemiBold',sans-serif] text-[#333c47] text-[16px] leading-[1.5]">{l.title}</p>
<div className="flex gap-[12px] items-center w-full">
<p className="font-['Pretendard:Medium',sans-serif] text-[#8c95a1] text-[13px] leading-[1.4] w-[40px]">{l.duration}</p>
</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
<button {/* 버튼 영역 */}
type="button" <div className="relative shrink-0">
className={[ <div className="flex gap-[8px] items-center">
"h-8 rounded-[6px] px-4 text-[14px] font-medium leading-[1.5]", {/* 학습 제출 버튼 */}
"bg-white", <button
submitBtnStyle, type="button"
].join(" ")} className={[
> "bg-white box-border flex flex-col h-[32px] items-center justify-center px-[16px] py-[3px] rounded-[6px] shrink-0",
{isSubmitted ? "학습 제출 완료" : "학습 제출 하기"} "border border-solid",
</button> submitBtnBorder,
<button submitBtnText,
type="button" ].join(" ")}
className={[ >
"h-8 rounded-[6px] px-4 text-[14px] font-medium leading-[1.5]", {isSubmitted ? (
rightBtnStyle, <div className="flex gap-[4px] h-[18px] items-center">
].join(" ")} <div className="relative shrink-0 size-[12px]">
> <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
{l.action} <path d="M10 3L4.5 8.5L2 6" stroke="#384fbf" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</button> </svg>
</div>
<p className="font-['Pretendard:Medium',sans-serif] text-[#384fbf] text-[13px] leading-[1.4]"> </p>
</div>
) : (
<p className={[
"font-['Pretendard:Medium',sans-serif] text-[14px] leading-[1.5] text-center",
submitBtnText,
].join(" ")}> </p>
)}
</button>
{/* 수강/복습 버튼 */}
<button
type="button"
className={[
"box-border flex flex-col h-[32px] items-center justify-center px-[16px] py-[3px] rounded-[6px] shrink-0",
rightBtnStyle,
].join(" ")}
>
<p className={[
"font-['Pretendard:Medium',sans-serif] text-[14px] leading-[1.5] text-center",
l.action === "이어서 수강하기" || l.action === "수강하기" ? "text-[#384fbf]" : "text-[#4c5561]",
].join(" ")}>{l.action}</p>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
); </div>
})} );
</div> })}
</div> </div>
</section> </section>
</main> </main>

View File

@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import ChevronDownSvg from '../svgs/chevrondownsvg'; import ChevronDownSvg from '../svgs/chevrondownsvg';
import apiService from '../lib/apiService';
// 피그마 선택 컴포넌트의 구조/스타일(타이포/여백/색상)을 반영한 리스트 UI // 피그마 선택 컴포넌트의 구조/스타일(타이포/여백/색상)을 반영한 리스트 UI
// - 프로젝트는 Tailwind v4(@import "tailwindcss")를 사용하므로 클래스 그대로 적용 // - 프로젝트는 Tailwind v4(@import "tailwindcss")를 사용하므로 클래스 그대로 적용
@@ -10,10 +11,21 @@ import ChevronDownSvg from '../svgs/chevrondownsvg';
// - 카드 구성: 섬네일(rounded 8px) + 상태 태그 + 제목 + 메타(재생 아이콘 + 텍스트) // - 카드 구성: 섬네일(rounded 8px) + 상태 태그 + 제목 + 메타(재생 아이콘 + 텍스트)
const imgPlay = '/imgs/play.svg'; // public/imgs/play.svg 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 interface Subject {
const imgThumbC = '/imgs/thumb-c.png'; // public/imgs/thumb-c.png id: string | number;
const imgThumbD = '/imgs/thumb-d.png'; // public/imgs/thumb-d.png title: string;
imageKey?: string;
instructor?: string;
}
interface Course {
id: string | number;
title: string;
image: string;
inProgress?: boolean;
meta?: string;
}
function ColorfulTag({ text }: { text: string }) { function ColorfulTag({ text }: { text: string }) {
return ( return (
@@ -23,37 +35,114 @@ function ColorfulTag({ text }: { text: string }) {
); );
} }
type Course = { id: string; title: string; image: string; inProgress?: boolean };
export default function CourseListPage() { export default function CourseListPage() {
const ITEMS_PER_PAGE = 20; const ITEMS_PER_PAGE = 20;
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [subjects, setSubjects] = useState<Subject[]>([]);
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
const router = useRouter(); const router = useRouter();
const base: Omit<Course, 'id'>[] = [ // 과목 리스트 가져오기
{ title: '원자로 운전 및 계통', image: imgThumbA, inProgress: true }, useEffect(() => {
{ title: '핵연료', image: imgThumbB }, let isMounted = true;
{ title: '방사선 안전', image: imgThumbC },
{ title: '방사선 폐기물', image: imgThumbD }, async function fetchSubjects() {
{ title: '원자로 운전 및 계통', image: imgThumbA }, try {
{ title: '핵연료', image: imgThumbB }, setLoading(true);
{ title: '방사선 안전', image: imgThumbC }, const response = await apiService.getSubjects();
];
const courses: Course[] = Array.from({ length: 28 }, (_, i) => { if (response.status !== 200 || !response.data) {
const item = base[i % base.length]; return;
return { }
id: `p${i + 1}`,
title: item.title, // 응답 데이터 구조 확인 및 배열 추출
image: item.image, let subjectsData: any[] = [];
inProgress: item.inProgress ?? (i % 7 === 0), 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( const pagedCourses = useMemo(
() => courses.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE), () => courses.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE),
[courses, 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 ( return (
<main className="flex w-full flex-col items-center"> <main className="flex w-full flex-col items-center">
{/* 상단 타이틀 영역 */} {/* 상단 타이틀 영역 */}
@@ -66,95 +155,135 @@ export default function CourseListPage() {
{/* 상단 카운트/정렬 영역 */} {/* 상단 카운트/정렬 영역 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-[15px] font-medium leading-normal text-neutral-700"> <p className="text-[15px] font-medium leading-normal text-neutral-700">
<span className="text-primary">{courses.length}</span> <span className="text-primary">{totalCount}</span>
</p> </p>
<div className="h-[40px] w-[114px]" /> <div className="h-[40px] w-[114px]" />
</div> </div>
{/* 카드 그리드(고정 5열, gap 32px) */} {loading ? (
<div className="mt-4 flex flex-col items-center"> <div className="mt-4 flex items-center justify-center h-[400px]">
<div className="w-[1376px] grid grid-cols-5 gap-[32px]"> <p className="text-[16px] font-medium text-[#6C7682]"> ...</p>
{pagedCourses.map((c) => (
<article
key={c.id}
onClick={() => 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"
>
{/* 섬네일 */}
<div className="relative h-[166.4px] w-full overflow-clip rounded-[8px]">
<img
src={c.image}
alt=""
className="absolute inset-0 size-full object-cover"
/>
</div>
{/* 본문 텍스트 블록 */}
<div className="flex w-full flex-col gap-[4px]">
<div className="flex flex-col gap-[4px]">
{c.inProgress && <ColorfulTag text="수강 중" />}
<h2 className="text-[18px] font-semibold leading-normal text-neutral-700">
{c.title}
</h2>
</div>
<div className="flex items-center gap-[4px]">
<img src={imgPlay} alt="" className="size-[16px]" />
<p className="text-[13px] font-medium leading-[1.4] text-text-meta">
VOD · 6 · 4 20
</p>
</div>
</div>
</article>
))}
</div> </div>
</div> ) : (
<>
{/* 카드 그리드(고정 5열, gap 32px) */}
<div className="mt-4 flex flex-col items-center">
<div className="w-[1376px] grid grid-cols-5 gap-[32px]">
{pagedCourses.map((c) => (
<article
key={c.id}
onClick={() => router.push(`/course-list/${c.id}`)}
className="flex h-[260px] w-[249.6px] flex-col gap-[16px] rounded-[8px] bg-white cursor-pointer"
>
{/* 섬네일 */}
<div className="relative h-[166.4px] w-full overflow-clip rounded-[8px] flex items-center justify-center bg-[#F1F3F5] hover:shadow-lg transition-shadow">
<img
src={c.image}
alt={c.title}
className="h-full w-auto object-contain"
onError={(e) => {
const t = e.currentTarget as HTMLImageElement;
if (!t.src.includes('picsum.photos')) t.src = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800';
}}
/>
</div>
{/* 페이지네이션 (피그마 스타일 반영) */} {/* 본문 텍스트 블록 */}
<div className="mt-8 flex items-center justify-center gap-[8px]"> <div className="flex w-full flex-col gap-[4px]">
{/* Prev */} <div className="flex flex-col gap-[4px]">
<button {c.inProgress && <ColorfulTag text="수강 중" />}
type="button" <h2 className="text-[18px] font-semibold leading-normal text-neutral-700 truncate" title={c.title}>
onClick={() => setPage((p) => Math.max(1, p - 1))} {c.title}
aria-label="이전 페이지" </h2>
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40" </div>
disabled={page === 1} <div className="flex items-center gap-[4px]">
> <img src={imgPlay} alt="" className="size-[16px]" />
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" /> <p className="text-[13px] font-medium leading-[1.4] text-text-meta">
</button> {c.meta || 'VOD • 온라인'}
</p>
</div>
</div>
</article>
))}
</div>
</div>
{/* Numbers */} {/* 페이지네이션 - admin 페이지와 동일한 메커니즘 (10개씩 표시) */}
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => { {totalCount > ITEMS_PER_PAGE && (
const active = n === page; <div className="mt-8 flex items-center justify-center gap-[8px]">
return ( {/* First (맨 앞으로) */}
<button <button
key={n} type="button"
type="button" onClick={() => setPage(1)}
onClick={() => setPage(n)} aria-label="맨 앞 페이지"
aria-current={active ? 'page' : undefined} className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
className={[ disabled={page === 1}
'flex items-center justify-center rounded-[1000px] size-[32px]', >
active ? 'bg-bg-primary-light' : 'bg-white', <div className="relative flex items-center justify-center w-full h-full">
].join(' ')} <ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
> <ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
<span className="text-[16px] leading-[1.4] text-neutral-700">{n}</span> </div>
</button> </button>
);
})}
{/* Next */} {/* Prev */}
<button <button
type="button" type="button"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))} onClick={() => setPage((p) => Math.max(1, p - 1))}
aria-label="다음 페이지" aria-label="이전 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40" className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={page === totalPages} disabled={page === 1}
> >
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" /> <ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
</button> </button>
</div>
{/* Numbers */}
{visiblePages.map((n) => {
const active = n === page;
return (
<button
key={n}
type="button"
onClick={() => setPage(n)}
aria-current={active ? 'page' : undefined}
className={[
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
active ? 'bg-bg-primary-light' : 'bg-white',
].join(' ')}
>
<span className="text-[16px] leading-[1.4] text-neutral-700">{n}</span>
</button>
);
})}
{/* Next */}
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
aria-label="다음 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={page === totalPages}
>
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
</button>
{/* Last (맨 뒤로) */}
<button
type="button"
onClick={() => setPage(totalPages)}
aria-label="맨 뒤 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={page === totalPages}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
</div>
</button>
</div>
)}
</>
)}
</section> </section>
</main> </main>
); );
} }

View File

@@ -412,7 +412,6 @@ class ApiService {
method: 'PATCH', method: 'PATCH',
}); });
} }
/** /**
* 대량 정지 (ID 배열) - 관리자 전용 * 대량 정지 (ID 배열) - 관리자 전용
*/ */

View File

@@ -1,17 +1,169 @@
import Link from 'next/link'; 'use client';
import { notFound } from 'next/navigation';
import BackCircleSvg from '../../svgs/backcirclesvg';
import { MOCK_NOTICES } from '../../admin/notices/mockData';
export default async function NoticeDetailPage({ import { useState, useEffect } from 'react';
params, import { useRouter, useParams } from 'next/navigation';
}: { import BackCircleSvg from '../../svgs/backcirclesvg';
params: Promise<{ id: string }>; import DownloadIcon from '../../svgs/downloadicon';
}) { import apiService from '../../lib/apiService';
const { id } = await params; import type { Notice } from '../../admin/notices/mockData';
const numericId = Number(id);
const item = MOCK_NOTICES.find((r) => r.id === numericId); type Attachment = {
if (!item || !item.content) return notFound(); name: string;
size: string;
url?: string;
fileKey?: string;
};
export default function NoticeDetailPage() {
const params = useParams();
const router = useRouter();
const [notice, setNotice] = useState<Notice | null>(null);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="w-full bg-white">
<div className="flex justify-center">
<div className="w-full max-w-[1440px]">
<div className="h-[100px] flex items-center justify-center">
<p className="text-[16px] font-medium text-[#6C7682]"> ...</p>
</div>
</div>
</div>
</div>
);
}
if (error || !notice) {
return (
<div className="w-full bg-white">
<div className="flex justify-center">
<div className="w-full max-w-[1440px]">
<div className="h-[100px] flex items-center justify-center">
<p className="text-[16px] font-medium text-[#6C7682]">{error || '공지사항을 찾을 수 없습니다.'}</p>
</div>
</div>
</div>
</div>
);
}
return ( return (
<div className="w-full bg-white"> <div className="w-full bg-white">
@@ -19,13 +171,14 @@ export default async function NoticeDetailPage({
<div className="w-full max-w-[1440px]"> <div className="w-full max-w-[1440px]">
{/* 상단 타이틀 */} {/* 상단 타이틀 */}
<div className="h-[100px] flex items-center gap-3 px-8"> <div className="h-[100px] flex items-center gap-3 px-8">
<Link <button
href="/notices" type="button"
onClick={() => router.back()}
aria-label="뒤로 가기" 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"
> >
<BackCircleSvg width={32} height={32} /> <BackCircleSvg width={32} height={32} />
</Link> </button>
<h1 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]"> <h1 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">
</h1> </h1>
@@ -37,17 +190,17 @@ export default async function NoticeDetailPage({
{/* 헤더 */} {/* 헤더 */}
<div className="p-8"> <div className="p-8">
<h2 className="m-0 text-[20px] font-bold leading-normal text-[#333C47]"> <h2 className="m-0 text-[20px] font-bold leading-normal text-[#333C47]">
{item.title} {notice.title}
</h2> </h2>
<div className="mt-2 flex items-center gap-4 text-[13px] leading-[1.4]"> <div className="mt-2 flex items-center gap-4 text-[13px] leading-[1.4]">
<span className="text-[#8C95A1]"></span> <span className="text-[#8C95A1]"></span>
<span className="text-[#333C47]">{item.writer}</span> <span className="text-[#333C47]">{notice.writer}</span>
<span className="w-px h-4 bg-[#DEE1E6]" /> <span className="w-px h-4 bg-[#DEE1E6]" />
<span className="text-[#8C95A1]"></span> <span className="text-[#8C95A1]"></span>
<span className="text-[#333C47]">{item.date}</span> <span className="text-[#333C47]">{notice.date}</span>
<span className="w-px h-4 bg-[#DEE1E6]" /> <span className="w-px h-4 bg-[#DEE1E6]" />
<span className="text-[#8C95A1]"></span> <span className="text-[#8C95A1]"></span>
<span className="text-[#333C47]">{item.views.toLocaleString()}</span> <span className="text-[#333C47]">{notice.views.toLocaleString()}</span>
</div> </div>
</div> </div>
@@ -55,14 +208,79 @@ export default async function NoticeDetailPage({
<div className="h-px bg-[#DEE1E6] w-full" /> <div className="h-px bg-[#DEE1E6] w-full" />
{/* 본문 */} {/* 본문 */}
<div className="p-8"> <div className="p-8 flex flex-col gap-10">
<div className="text-[15px] leading-normal text-[#333C47] space-y-2"> <div className="text-[15px] leading-normal text-[#333C47]">
{item.content.map((p, idx) => ( {notice.content && notice.content.length > 0 ? (
<p key={idx} className="m-0"> notice.content.map((p, idx) => (
{p} <p key={idx} className="m-0 mb-2 last:mb-0">
</p> {p}
))} </p>
))
) : (
<p className="m-0"> .</p>
)}
</div> </div>
{/* 첨부파일 섹션 */}
{attachments.length > 0 && (
<div className="flex flex-col gap-8">
<div className="text-[15px] font-semibold leading-[1.5] text-[#6C7682]">
</div>
<div className="pt-3">
{attachments.map((attachment, idx) => (
<div
key={idx}
className="bg-white border border-[#DEE1E6] rounded-[6px] h-[64px] flex items-center gap-3 px-[17px]"
>
<div className="size-6 flex items-center justify-center">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-[#8C95A1]"
>
<path
d="M14 2H6C5.46957 2 4.96086 2.21071 4.58579 2.58579C4.21071 2.96086 4 3.46957 4 4V20C4 20.5304 4.21071 21.0391 4.58579 21.4142C4.96086 21.7893 5.46957 22 6 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V8L14 2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M14 2V8H20"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<div className="flex-1 flex items-center gap-2 min-w-0">
<p className="text-[15px] font-normal leading-[1.5] text-[#1B2027] truncate">
{attachment.name}
</p>
<p className="text-[13px] font-normal leading-[1.4] text-[#8C95A1] whitespace-nowrap">
{attachment.size}
</p>
</div>
<button
type="button"
onClick={() => handleDownload(attachment.fileKey, attachment.url, attachment.name)}
className="bg-white border border-[#8C95A1] rounded-[6px] h-8 px-4 flex items-center justify-center gap-1 hover:bg-[#F9FAFB] transition-colors"
>
<DownloadIcon width={16} height={16} className="text-[#4C5561]" />
<span className="text-[13px] font-medium leading-[1.4] text-[#4C5561]">
</span>
</button>
</div>
))}
</div>
</div>
)}
</div> </div>
</div> </div>
</section> </section>
@@ -71,6 +289,3 @@ export default async function NoticeDetailPage({
</div> </div>
); );
} }

View File

@@ -1,14 +1,115 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import PaperClipSvg from '../svgs/paperclipsvg'; 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() { export default function NoticesPage() {
const router = useRouter(); const router = useRouter();
const ITEMS_PER_PAGE = 10;
const [currentPage, setCurrentPage] = useState(1);
const [notices, setNotices] = useState<Notice[]>([]);
const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
// 날짜 내림차순 정렬 (최신 날짜가 먼저) // 공지사항 리스트 가져오기
const rows = [...MOCK_NOTICES].sort((a, b) => b.date.localeCompare(a.date)); 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 ( return (
<div className="w-full bg-white"> <div className="w-full bg-white">
@@ -26,74 +127,162 @@ export default function NoticesPage() {
{/* 총 건수 */} {/* 총 건수 */}
<div className="h-10 flex items-center"> <div className="h-10 flex items-center">
<p className="m-0 text-[15px] font-medium leading-[1.5] text-[#333C47]"> <p className="m-0 text-[15px] font-medium leading-[1.5] text-[#333C47]">
<span className="text-[#384FBF]">{rows.length}</span> <span className="text-[#384FBF]">{totalCount}</span>
</p> </p>
</div> </div>
{/* 표 */} {loading ? (
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white"> <div className="flex items-center justify-center h-[240px]">
{/* 헤더 */} <p className="text-[16px] font-medium text-[#6C7682]"> ...</p>
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
<div className="flex items-center justify-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
</div>
<div className="flex items-center px-4"></div>
</div> </div>
) : (
<>
{/* 표 */}
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
{/* 헤더 */}
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
<div className="flex items-center justify-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
</div>
<div className="flex items-center px-4"></div>
</div>
{/* 바디 */} {/* 바디 */}
<div> <div>
{rows.map((r, index) => { {pagedNotices.length === 0 ? (
// 번호는 정렬된 목록에서의 순서 <div className="flex items-center justify-center h-[240px]">
const noticeNumber = rows.length - index; <p className="text-[16px] font-medium text-[#6C7682]"> .</p>
return (
<div
key={r.id}
role="button"
tabIndex={0}
onClick={() => 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(' ')}
>
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">
{noticeNumber}
</div> </div>
<div ) : (
className="flex items-center gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis" pagedNotices.map((notice, index) => {
title={r.title} // 번호는 전체 목록에서의 순서 (최신이 1번)
> const noticeNumber = totalCount - ((currentPage - 1) * ITEMS_PER_PAGE + index);
{r.title} return (
{r.hasAttachment && ( <div
<PaperClipSvg width={16} height={16} className="shrink-0 text-[#8C95A1]" /> key={notice.id}
)} role="button"
tabIndex={0}
onClick={() => 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"
>
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">
{noticeNumber}
</div>
<div
className="flex items-center gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
title={notice.title}
>
{notice.title}
{notice.hasAttachment && (
<PaperClipSvg width={16} height={16} className="shrink-0 text-[#8C95A1]" />
)}
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
{notice.date}
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
{notice.views.toLocaleString()}
</div>
<div className="flex items-center px-4">{notice.writer}</div>
</div>
);
})
)}
</div>
</div>
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
{totalCount > ITEMS_PER_PAGE && (
<div className="pt-8 pb-[32px] flex items-center justify-center">
<div className="flex items-center justify-center gap-[8px]">
{/* First (맨 앞으로) */}
<button
type="button"
onClick={() => setCurrentPage(1)}
aria-label="맨 앞 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
</div>
</button>
{/* Prev */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
aria-label="이전 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
</button>
{/* Numbers */}
{visiblePages.map((n) => {
const active = n === currentPage;
return (
<button
key={n}
type="button"
onClick={() => setCurrentPage(n)}
aria-current={active ? 'page' : undefined}
className={[
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
active ? 'bg-bg-primary-light' : 'bg-white',
].join(' ')}
>
<span className="text-[16px] leading-[1.4] text-neutral-700">{n}</span>
</button>
);
})}
{/* Next */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
aria-label="다음 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
</button>
{/* Last (맨 뒤로) */}
<button
type="button"
onClick={() => setCurrentPage(totalPages)}
aria-label="맨 뒤 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
</div>
</button>
</div> </div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]"> </div>
{r.date} )}
</div> </>
<div className="flex items-center px-4 border-r border-[#DEE1E6]"> )}
{r.views.toLocaleString()}
</div>
<div className="flex items-center px-4">{r.writer}</div>
</div>
);
})}
</div>
</div>
</section> </section>
</div> </div>
</div> </div>

View File

@@ -3,93 +3,36 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link';
import MainLogoSvg from './svgs/mainlogosvg'; import MainLogoSvg from './svgs/mainlogosvg';
import apiService from './lib/apiService'; 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() { export default function Home() {
const router = useRouter(); const router = useRouter();
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [userName, setUserName] = useState<string>(''); const [userName, setUserName] = useState<string>('');
const [subjects, setSubjects] = useState<Subject[]>([]);
// 코스, 공지사항 더미 데이터 const [courseCards, setCourseCards] = useState<CourseCard[]>([]);
const courseCards = useMemo( const [loadingSubjects, setLoadingSubjects] = useState(true);
() => const [totalSubjectsCount, setTotalSubjectsCount] = useState(0);
[ const [notices, setNotices] = useState<Notice[]>([]);
{ const [loadingNotices, setLoadingNotices] = useState(true);
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: '운영팀' },
],
[]
);
// NOTE: 실제 이미지 자산 연결 시 해당 src를 교체하세요. // NOTE: 실제 이미지 자산 연결 시 해당 src를 교체하세요.
const slides = useMemo( 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(() => { useEffect(() => {
const containerEl = containerRef.current; const containerEl = containerRef.current;
if (!containerEl) return; if (!containerEl) return;
@@ -321,11 +434,11 @@ export default function Home() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]"> </h2> <h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]"> </h2>
<div className="text-[15px] font-medium leading-normal text-[#333C47]"> <div className="text-[15px] font-medium leading-normal text-[#333C47]">
<span className="text-[#384FBF]">28</span> <span className="text-[#384FBF]">{totalSubjectsCount}</span>
</div> </div>
</div> </div>
<a <Link
href="#" href="/course-list"
className="flex items-center gap-0.5 text-[14px] font-medium leading-normal text-[#6C7682] no-underline" className="flex items-center gap-0.5 text-[14px] font-medium leading-normal text-[#6C7682] no-underline"
> >
@@ -339,42 +452,47 @@ export default function Home() {
strokeLinejoin="round" strokeLinejoin="round"
/> />
</svg> </svg>
</a> </Link>
</div> </div>
<div className="grid grid-cols-5 gap-8"> {loadingSubjects ? (
{courseCards.map((c) => ( <div className="flex items-center justify-center h-[260px]">
<article key={c.id} className="flex flex-col gap-4 h-[260px]"> <p className="text-[16px] font-medium text-[#6C7682]"> ...</p>
<div className="h-[166.4px] overflow-hidden rounded-[8px]"> </div>
<img ) : (
alt={c.title} <div className="grid grid-cols-5 gap-8">
src={c.image} {courseCards.map((c) => (
className="w-full h-full object-cover block" <article
onError={(e) => { key={c.id}
const t = e.currentTarget as HTMLImageElement; onClick={() => router.push(`/course-list/${c.id}`)}
if (!t.src.includes('picsum.photos')) t.src = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800'; className="flex flex-col gap-4 h-[260px] cursor-pointer"
}} >
/> <div className="h-[166.4px] overflow-hidden rounded-[8px] flex items-center justify-center bg-[#F1F3F5] hover:shadow-lg transition-shadow">
</div> <img
<div className="flex flex-col gap-1"> alt={c.title}
{c.id === 'c1' && ( src={c.image}
<span className="inline-flex h-[20px] items-center justify-center px-1 bg-[#E5F5EC] rounded-[4px] text-[13px] font-semibold leading-[1.4] text-[#0C9D61]"> className="h-full w-auto object-contain block"
onError={(e) => {
</span> const t = e.currentTarget as HTMLImageElement;
)} if (!t.src.includes('picsum.photos')) t.src = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800';
<h5 className="m-0 text-[#333C47] font-semibold text-[18px] leading-normal truncate" title={c.title}> }}
{c.title} />
</h5>
<div className="flex items-center gap-1">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden className="text-[#8C95A1]">
<path d="M8 5v14l11-7z" fill="currentColor" />
</svg>
<p className="m-0 text-[#8C95A1] text-[13px] font-medium leading-[1.4]">{c.meta}</p>
</div> </div>
</div> <div className="flex flex-col gap-1">
</article> <h5 className="m-0 text-[#333C47] font-semibold text-[18px] leading-normal truncate" title={c.title}>
))} {c.title}
</div> </h5>
<div className="flex items-center gap-1">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden className="text-[#8C95A1]">
<path d="M8 5v14l11-7z" fill="currentColor" />
</svg>
<p className="m-0 text-[#8C95A1] text-[13px] font-medium leading-[1.4]">{c.meta}</p>
</div>
</div>
</article>
))}
</div>
)}
</section> </section>
{/* 공지사항 */} {/* 공지사항 */}
@@ -383,11 +501,11 @@ export default function Home() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]"></h2> <h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]"></h2>
<div className="text-[15px] font-medium leading-normal text-[#333C47]"> <div className="text-[15px] font-medium leading-normal text-[#333C47]">
<span className="text-[#384FBF]">{noticeRows.length}</span> <span className="text-[#384FBF]">{notices.length}</span>
</div> </div>
</div> </div>
<a <Link
href="#" href="/notices"
className="flex items-center gap-0.5 text-[14px] font-medium leading-normal text-[#6C7682] no-underline" className="flex items-center gap-0.5 text-[14px] font-medium leading-normal text-[#6C7682] no-underline"
> >
@@ -401,38 +519,57 @@ export default function Home() {
strokeLinejoin="round" strokeLinejoin="round"
/> />
</svg> </svg>
</a> </Link>
</div> </div>
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white"> {loadingNotices ? (
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]"> <div className="flex items-center justify-center h-[240px]">
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]"></div> <p className="text-[16px] font-medium text-[#6C7682]"> ...</p>
<div className="flex items-center px-4 border-r border-[#DEE1E6]"></div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]"></div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]"></div>
<div className="flex items-center px-4"></div>
</div> </div>
) : (
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]"></div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]"></div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]"></div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]"></div>
<div className="flex items-center px-4"></div>
</div>
<div> <div>
{noticeRows.map((r) => ( {notices.map((notice, index) => {
<div // 번호는 정렬된 목록에서의 순서 (최신이 1번)
key={r.id} const noticeNumber = notices.length - index;
className="grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6]" return (
> <div
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">{r.id}</div> key={notice.id}
<div role="button"
className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis" tabIndex={0}
title={r.title} onClick={() => router.push(`/notices/${notice.id}`)}
> onKeyDown={(e) => {
{r.title} if (e.key === 'Enter' || e.key === ' ') {
</div> e.preventDefault();
<div className="flex items-center px-4 border-r border-[#DEE1E6]">{r.date}</div> router.push(`/notices/${notice.id}`);
<div className="flex items-center px-4 border-r border-[#DEE1E6]">{r.views.toLocaleString()}</div> }
<div className="flex items-center px-4">{r.writer}</div> }}
</div> 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"
))} >
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">{noticeNumber}</div>
<div
className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
title={notice.title}
>
{notice.title}
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]">{notice.date}</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]">{notice.views.toLocaleString()}</div>
<div className="flex items-center px-4">{notice.writer}</div>
</div>
);
})}
</div>
</div> </div>
</div> )}
</section> </section>
</div> </div>
</div> </div>

View File

@@ -1,92 +1,162 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { notFound } from 'next/navigation';
import PaperClipSvg from '../../svgs/paperclipsvg'; import PaperClipSvg from '../../svgs/paperclipsvg';
import BackCircleSvg from '../../svgs/backcirclesvg'; import BackCircleSvg from '../../svgs/backcirclesvg';
import DownloadIcon from '../../svgs/downloadicon';
import apiService from '../../lib/apiService';
import type { Resource } from '../../admin/resources/mockData';
type ResourceRow = { type Attachment = {
id: number; name: string;
title: string; size: string;
date: string; url?: string;
views: number; fileKey?: string;
writer: string;
content: string[];
attachments?: Array<{ name: string; size: string; url: string }>;
}; };
const DATA: ResourceRow[] = [ export default function ResourceDetailPage() {
{ const params = useParams();
id: 6, const router = useRouter();
title: '방사선과 물질의 상호작용 관련 학습 자료', const [resource, setResource] = useState<Resource | null>(null);
date: '2025-06-28', const [attachments, setAttachments] = useState<Attachment[]>([]);
views: 1230, const [loading, setLoading] = useState(true);
writer: '강민재', const [error, setError] = useState<string | null>(null);
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 async function ResourceDetailPage({ useEffect(() => {
params, async function fetchResource() {
}: { if (!params?.id) return;
params: Promise<{ id: string }>;
}) { try {
const { id } = await params; setLoading(true);
const numericId = Number(id); setError(null);
const item = DATA.find((r) => r.id === numericId);
if (!item) return notFound(); 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 (
<div className="w-full bg-white">
<div className="flex justify-center">
<div className="w-full max-w-[1440px]">
<div className="h-[100px] flex items-center justify-center px-8">
<p className="text-[16px] font-medium text-[#333c47]"> ...</p>
</div>
</div>
</div>
</div>
);
}
if (error || !resource) {
return (
<div className="w-full bg-white">
<div className="flex justify-center">
<div className="w-full max-w-[1440px]">
<div className="h-[100px] flex items-center gap-3 px-8">
<Link
href="/resources"
aria-label="뒤로 가기"
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline"
>
<BackCircleSvg width={32} height={32} />
</Link>
<h1 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">
</h1>
</div>
<div className="flex-1 flex items-center justify-center px-8 pb-8">
<p className="text-[16px] font-medium text-[#333c47]">
{error || '학습 자료를 찾을 수 없습니다.'}
</p>
</div>
</div>
</div>
</div>
);
}
const item = resource;
return ( return (
<div className="w-full bg-white"> <div className="w-full bg-white">
@@ -119,7 +189,11 @@ export default async function ResourceDetailPage({
<span className="text-[#333C47]">{item.writer}</span> <span className="text-[#333C47]">{item.writer}</span>
<span className="w-px h-4 bg-[#DEE1E6]" /> <span className="w-px h-4 bg-[#DEE1E6]" />
<span className="text-[#8C95A1]"></span> <span className="text-[#8C95A1]"></span>
<span className="text-[#333C47]">{item.date}</span> <span className="text-[#333C47]">
{item.date.includes('T')
? new Date(item.date).toISOString().split('T')[0]
: item.date}
</span>
<span className="w-px h-4 bg-[#DEE1E6]" /> <span className="w-px h-4 bg-[#DEE1E6]" />
<span className="text-[#8C95A1]"></span> <span className="text-[#8C95A1]"></span>
<span className="text-[#333C47]">{item.views.toLocaleString()}</span> <span className="text-[#333C47]">{item.views.toLocaleString()}</span>
@@ -132,21 +206,25 @@ export default async function ResourceDetailPage({
{/* 본문 */} {/* 본문 */}
<div className="p-8"> <div className="p-8">
<div className="text-[15px] leading-normal text-[#333C47] space-y-2"> <div className="text-[15px] leading-normal text-[#333C47] space-y-2">
{item.content.map((p, idx) => ( {item.content && item.content.length > 0 ? (
<p key={idx} className="m-0"> item.content.map((p, idx) => (
{p} <p key={idx} className="m-0">
</p> {p}
))} </p>
))
) : (
<p className="m-0 text-[#8C95A1]"> .</p>
)}
</div> </div>
</div> </div>
{/* 첨부 파일 */} {/* 첨부 파일 */}
{item.attachments?.length ? ( {attachments.length > 0 && (
<div className="p-8 pt-0"> <div className="p-8 pt-0">
<div className="mb-2 text-[15px] font-semibold text-[#6C7682]"> <div className="mb-2 text-[15px] font-semibold text-[#6C7682]">
</div> </div>
{item.attachments.map((f, idx) => ( {attachments.map((f, idx) => (
<div <div
key={idx} key={idx}
className="bg-white border border-[#DEE1E6] h-[64px] rounded-[6px] flex items-center gap-3 px-[17px]" className="bg-white border border-[#DEE1E6] h-[64px] rounded-[6px] flex items-center gap-3 px-[17px]"
@@ -158,31 +236,24 @@ export default async function ResourceDetailPage({
<span className="text-[15px] text-[#1B2027] truncate"> <span className="text-[15px] text-[#1B2027] truncate">
{f.name} {f.name}
</span> </span>
<span className="text-[13px] text-[#8C95A1] whitespace-nowrap"> {f.size && (
{f.size} <span className="text-[13px] text-[#8C95A1] whitespace-nowrap">
</span> {f.size}
</span>
)}
</div> </div>
<a <button
href={f.url} type="button"
className="h-8 px-4 rounded-[6px] border border-[#8C95A1] text-[13px] text-[#4C5561] inline-flex items-center gap-1 hover:bg-[#F9FAFB] no-underline" onClick={() => handleDownload(f.fileKey, f.url, f.name)}
download 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"
> >
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden> <DownloadIcon width={16} height={16} className="text-[#4C5561]" />
<path
d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a> </button>
</div> </div>
))} ))}
</div> </div>
) : null} )}
</div> </div>
</section> </section>
</div> </div>

View File

@@ -1,65 +1,97 @@
'use client'; 'use client';
import { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import PaperClipSvg from '../svgs/paperclipsvg'; import PaperClipSvg from '../svgs/paperclipsvg';
import ChevronDownSvg from '../svgs/chevrondownsvg';
type ResourceRow = { import apiService from '../lib/apiService';
id: number; import type { Resource } from '../admin/resources/mockData';
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: '강민재',
},
];
export default function ResourcesPage() { export default function ResourcesPage() {
const router = useRouter(); const router = useRouter();
const [resources, setResources] = useState<Resource[]>([]);
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 ( return (
<div className="w-full bg-white"> <div className="w-full bg-white">
@@ -77,70 +109,173 @@ export default function ResourcesPage() {
{/* 총 건수 */} {/* 총 건수 */}
<div className="h-10 flex items-center"> <div className="h-10 flex items-center">
<p className="m-0 text-[15px] font-medium leading-[1.5] text-[#333C47]"> <p className="m-0 text-[15px] font-medium leading-[1.5] text-[#333C47]">
<span className="text-[#384FBF]">{rows.length}</span> <span className="text-[#384FBF]">{totalCount}</span>
</p> </p>
</div> </div>
{/* 표 */} {isLoading ? (
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white"> <div className="rounded-[8px] border border-[#DEE1E6] bg-white min-h-[400px] flex items-center justify-center">
{/* 헤더 */} <p className="text-[16px] font-medium leading-[1.5] text-[#333C47]">
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]"> ...
<div className="flex items-center justify-center px-4 border-r border-[#DEE1E6] whitespace-nowrap"> </p>
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
</div>
<div className="flex items-center px-4 whitespace-nowrap"></div>
</div> </div>
) : resources.length === 0 ? (
{/* 바디 */} <div className="rounded-[8px] border border-[#DEE1E6] bg-white min-h-[400px] flex items-center justify-center">
<div> <p className="text-[16px] font-medium leading-[1.5] text-[#333C47] text-center">
{rows.map((r) => ( .
<div </p>
key={r.id} </div>
role="button" ) : (
tabIndex={0} <>
onClick={() => router.push(`/resources/${r.id}`)} {/* 표 */}
onKeyDown={(e) => { <div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
if (e.key === 'Enter' || e.key === ' ') { {/* 헤더 */}
e.preventDefault(); <div className="grid grid-cols-[80px_1fr_140px_120px_120px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
router.push(`/resources/${r.id}`); <div className="flex items-center justify-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
}
}}
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(' ')}
>
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">
{r.id}
</div> </div>
<div <div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
className="flex items-center gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
title={r.title}
>
{r.title}
{r.hasAttachment && (
<PaperClipSvg width={16} height={16} className="shrink-0 text-[#8C95A1]" />
)}
</div> </div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]"> <div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
{r.date}
</div> </div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]"> <div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
{r.views.toLocaleString()}
</div> </div>
<div className="flex items-center px-4">{r.writer}</div> <div className="flex items-center px-4 whitespace-nowrap"></div>
</div> </div>
))}
</div> {/* 바디 */}
</div> <div>
{paginatedResources.map((resource, index) => {
// 번호는 전체 목록에서의 순서 (정렬된 목록 기준)
const resourceNumber = sortedResources.length - (currentPage - 1) * ITEMS_PER_PAGE - index;
return (
<div
key={resource.id}
role="button"
tabIndex={0}
onClick={() => 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(' ')}
>
<div className="flex items-center justify-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
{resourceNumber}
</div>
<div
className="flex items-center gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
title={resource.title}
>
{resource.title}
{resource.hasAttachment && (
<PaperClipSvg width={16} height={16} className="shrink-0 text-[#8C95A1]" />
)}
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
{formatDate(resource.date)}
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
{resource.views.toLocaleString()}
</div>
<div className="flex items-center px-4 whitespace-nowrap">{resource.writer}</div>
</div>
);
})}
</div>
</div>
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
{resources.length > ITEMS_PER_PAGE && (
<div className="pt-8 pb-[32px] flex items-center justify-center">
<div className="flex items-center justify-center gap-[8px]">
{/* First (맨 앞으로) */}
<button
type="button"
onClick={() => setCurrentPage(1)}
aria-label="맨 앞 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
</div>
</button>
{/* Prev */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
aria-label="이전 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === 1}
>
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
</button>
{/* 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 (
<button
key={n}
type="button"
onClick={() => setCurrentPage(n)}
aria-current={active ? 'page' : undefined}
className={[
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
active ? 'bg-[#ecf0ff]' : 'bg-white',
].join(' ')}
>
<span className="text-[16px] leading-[1.4] text-[#333c47]">{n}</span>
</button>
);
});
})()}
{/* Next */}
<button
type="button"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
aria-label="다음 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
</button>
{/* Last (맨 뒤로) */}
<button
type="button"
onClick={() => setCurrentPage(totalPages)}
aria-label="맨 뒤 페이지"
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
disabled={currentPage === totalPages}
>
<div className="relative flex items-center justify-center w-full h-full">
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
</div>
</button>
</div>
</div>
)}
</>
)}
</section> </section>
</div> </div>
</div> </div>