This commit is contained in:
mota
2025-11-18 09:09:09 +09:00
parent 4f7b98dffb
commit 4dc8304a1d
22 changed files with 1497 additions and 107 deletions

View File

@@ -2,6 +2,14 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: "https",
hostname: "picsum.photos",
},
],
},
};
export default nextConfig;

View File

@@ -7,7 +7,7 @@ import MainLogoSvg from "./svgs/mainlogosvg";
import ChevronDownSvg from "./svgs/chevrondownsvg";
const NAV_ITEMS = [
{ label: "교육 과정 목록", href: "/menu" },
{ label: "교육 과정 목록", href: "/course-list" },
{ label: "학습 자료실", href: "/resources" },
{ label: "공지사항", href: "/notices" },
];
@@ -52,14 +52,12 @@ export default function NavBar() {
</Link>
<nav className="flex h-full items-center">
{NAV_ITEMS.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={[
"px-4 py-2 text-[16px] font-semibold",
isActive ? "text-white" : "text-white/80 hover:text-white",
"px-4 py-2 text-[16px] font-semibold text-white",
].join(" ")}
>
{item.label}
@@ -69,7 +67,7 @@ export default function NavBar() {
</nav>
</div>
<div className="relative flex items-center gap-2">
<Link href="/my-courses" className="px-4 py-2 text-[16px] font-semibold text-white hover:text-white">
<Link href="/menu/courses" className="px-4 py-2 text-[16px] font-semibold text-white">
</Link>
<button

View File

@@ -6,7 +6,7 @@ export default function Footer() {
return (
<footer className="bg-[#f2f3f7] border-t border-[rgba(0,0,0,0.1)]">
<div className="flex justify-center">
<div className="w-full max-w-[1440px] px-[64px] py-[80px] flex gap-[32px]">
<div className="w-full max-w-[1440px] px-[1px] py-[40px] flex gap-[32px]">
<div className="flex flex-col items-center gap-[7px] w-[72px]">
<MainLogoSvg width={72} height={54} />
<div className="text-[16px] font-extrabold leading-[1.45] tracking-[-0.08px] text-black">

View File

@@ -0,0 +1,62 @@
'use client';
export default function CourseListPage() {
const courses = [
{
id: "p1",
title: "원자로 운전 및 계통",
description:
"원자로 운전 원리와 주요 계통의 구조 및 기능을 이해하고, 실제 운전 상황을 가상 환경에서 체험합니다.",
},
{
id: "p2",
title: "확률론",
description:
"확률과 통계의 핵심 개념을 직관적으로 이해하고 문제 해결력을 기릅니다.",
},
{
id: "p3",
title: "부서간 협업",
description:
"협업 절차, 기록 기준, 리스크 관리까지 실제 사례 기반으로 배우는 협업 가이드.",
},
{
id: "p4",
title: "방사선의 이해",
description:
"방사선 안전 기준과 보호 장비 선택법을 배우는 실무형 과정.",
},
];
return (
<main className="flex w-full flex-col">
<div className="flex h-[100px] items-center px-8">
<h1 className="text-[24px] font-bold leading-[1.5] text-white"> </h1>
</div>
<section className="px-8 pb-16">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{courses.map((c) => (
<article
key={c.id}
className="rounded-xl border border-[#ecf0ff] bg-white p-4 shadow-[0_2px_8px_rgba(0,0,0,0.02)]"
>
<h2 className="truncate text-[18px] font-bold leading-[1.5] text-[#1b2027]">{c.title}</h2>
<p className="mt-1 line-clamp-3 text-[14px] leading-[1.5] text-[#4c5561]">{c.description}</p>
<div className="mt-3">
<button
type="button"
className="h-9 rounded-md border border-[#dee1e6] px-3 text-[14px] font-medium leading-[1.5] text-[#4c5561] hover:bg-[#f9fafb]"
>
</button>
</div>
</article>
))}
</div>
</section>
</main>
);
}

View File

@@ -15,7 +15,7 @@ const learningItems: NavItem[] = [
const accountItems: NavItem[] = [
{ label: "내 정보 수정", href: "/menu/account" },
{ label: "로그아웃", href: "/logout" },
{ label: "로그아웃", href: "/login" },
];
export default function MenuSidebar() {

View File

@@ -2,6 +2,7 @@
import Image from "next/image";
import { useState } from "react";
import ChevronDownSvg from "../../svgs/chevrondownsvg";
type Lesson = {
id: string;
@@ -24,7 +25,7 @@ type Course = {
function ProgressBar({ value }: { value: number }) {
const pct = Math.max(0, Math.min(100, value));
return (
<div className="relative h-2 w-full overflow-hidden rounded-full bg-[#ecf0ff]">
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-[#ecf0ff]">
<div
className="h-full rounded-full bg-[#384fbf] transition-[width] duration-300 ease-out"
style={{ width: `${pct}%` }}
@@ -33,14 +34,36 @@ function ProgressBar({ value }: { value: number }) {
);
}
export default function CourseCard({ course }: { course: Course }) {
const [open, setOpen] = useState(false);
export default function CourseCard({ course, defaultOpen = false }: { course: Course; defaultOpen?: boolean }) {
const [open, setOpen] = useState(defaultOpen);
const totalMinutes = course.lessons.reduce((sum, l) => sum + (l.durationMin || 0), 0);
const totalHours = Math.floor(totalMinutes / 60);
const restMinutes = totalMinutes % 60;
const firstIncomplete = course.lessons.find((l) => !l.isCompleted)?.id;
const cardClassName = [
"rounded-xl border bg-white shadow-[0_2px_8px_rgba(0,0,0,0.02)]",
open ? "border-[#384fbf]" : "border-[#ecf0ff]",
].join(" ");
const formatDuration = (m: number) => {
const minutes = String(m).padStart(2, "0");
const seconds = String((m * 7) % 60).padStart(2, "0");
return `${minutes}:${seconds}`;
};
return (
<article className="rounded-xl border border-[#ecf0ff] bg-white shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<article className={cardClassName}>
<header className="flex items-center gap-4 p-4">
<div className="relative h-[76px] w-[120px] overflow-hidden rounded-md bg-[#f1f3f5]">
<Image src={course.thumbnail} alt="" fill sizes="120px" className="object-cover" />
<Image
src={`https://picsum.photos/seed/${encodeURIComponent(course.id)}/240/152`}
alt=""
fill
sizes="120px"
unoptimized
className="object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
@@ -52,32 +75,31 @@ export default function CourseCard({ course }: { course: Course }) {
</h2>
</div>
<p className="mt-1 line-clamp-2 text-[14px] leading-[1.5] text-[#4c5561]">{course.description}</p>
<div className="mt-2 flex items-center gap-3">
<div className="mt-2 flex items-center justify-between gap-3">
<p className="text-[13px] leading-[1.4] text-[#8c95a1]">
VOD · {course.lessons.length} · {totalHours} {restMinutes}
</p>
<p className="text-[13px] font-semibold leading-[1.4] text-[#384fbf]">
{course.progressPct}%
</p>
</div>
<div className="mt-2">
<ProgressBar value={course.progressPct} />
<span className="w-[72px] text-right text-[13px] font-medium leading-[1.4] text-[#6c7682]">
{course.progressPct}%
</span>
</div>
</div>
<div className="flex items-center gap-2 self-start">
<button
type="button"
className="rounded-md border border-[#dee1e6] px-3 py-2 text-[13px] font-semibold leading-[1.4] text-[#333c47] hover:bg-[#f9fafb]"
>
</button>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="flex h-8 w-8 items-center justify-center rounded-full border border-[#dee1e6] text-[#6c7682] hover:bg-[#f9fafb]"
className="flex h-8 w-8 items-center justify-center text-[#6c7682] cursor-pointer"
aria-label={open ? "접기" : "펼치기"}
>
<span
className={["inline-block transition-transform", open ? "rotate-180" : "rotate-0"].join(" ")}
>
</span>
<ChevronDownSvg
width={16}
height={16}
className={["transition-transform", open ? "rotate-180" : "rotate-0"].join(" ")}
/>
</button>
</div>
</header>
@@ -86,7 +108,7 @@ export default function CourseCard({ course }: { course: Course }) {
<div className="px-4 pb-4">
<ul className="flex flex-col gap-2">
{course.lessons.map((lesson, idx) => (
<li key={lesson.id} className="rounded-lg border border-[#ecf0ff]">
<li key={lesson.id} className="rounded-lg border border-[#ecf0ff] bg-white">
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
@@ -98,23 +120,41 @@ export default function CourseCard({ course }: { course: Course }) {
</p>
</div>
<div className="mt-2 flex items-center gap-3">
<ProgressBar value={lesson.progressPct} />
<span className="w-[80px] text-right text-[13px] leading-[1.4] text-[#8c95a1]">
{lesson.progressPct}% · {lesson.durationMin}
<span className="text-[13px] leading-[1.4] text-[#8c95a1]">
{formatDuration(lesson.durationMin)}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className={[
"rounded-md px-3 py-2 text-[14px] font-medium leading-[1.5]",
lesson.isCompleted
? "text-[#384fbf] border border-transparent bg-white"
: "text-[#4c5561] border border-[#8c95a1] bg-white",
].join(" ")}
>
{lesson.isCompleted ? "학습 제출 완료" : "학습 제출 하기"}
</button>
{lesson.isCompleted ? (
<span className="rounded bg-[#e5f5ec] px-2 py-1 text-[12px] font-semibold leading-[1.4] text-[#0c9d61]">
</span>
<button
type="button"
className="rounded-md border border-[#dee1e6] px-3 py-2 text-[14px] font-medium leading-[1.5] text-[#4c5561] hover:bg-[#f9fafb]"
>
</button>
) : (
<button
type="button"
className="rounded-md border border-[#dee1e6] px-3 py-2 text-[13px] font-semibold leading-[1.4] text-[#333c47] hover:bg-[#f9fafb]"
className={[
"rounded-md px-3 py-2 text-[14px] font-medium leading-[1.5]",
lesson.id === firstIncomplete
? "bg-[#ecf0ff] text-[#384fbf]"
: "border border-[#dee1e6] text-[#4c5561] hover:bg-[#f9fafb]",
].join(" ")}
>
{lesson.id === firstIncomplete ? "이어서 수강하기" : "수강하기"}
</button>
)}
</div>

View File

@@ -22,7 +22,7 @@ export default function CourseGridItem({
return (
<li
key={id}
className="group flex w-full flex-col gap-2"
className="flex w-full cursor-pointer flex-col gap-2"
>
<div className="relative aspect-[16/10] w-full overflow-hidden rounded-lg border border-[#ecf0ff] bg-[#f9fafb]">
<Image
@@ -30,7 +30,7 @@ export default function CourseGridItem({
alt=""
fill
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 25vw, 240px"
className="object-cover transition-transform duration-300 group-hover:scale-[1.02]"
className="object-cover"
priority={false}
/>
</div>

View File

@@ -0,0 +1,138 @@
'use client';
import Image from "next/image";
type Lesson = {
id: string;
title: string;
duration: string; // "12:46" 형식
state: "제출완료" | "제출대기";
action: "복습하기" | "이어서 수강하기" | "수강하기";
};
type CourseDetail = {
id: string;
status: "수강 중" | "수강 예정" | "수강 완료";
title: string;
goal: string;
method: string;
summary: string; // VOD · 총 n강 · n시간 n분
submitSummary: string; // 학습 제출 n/n
thumbnail: string;
lessons: Lesson[];
};
const MOCK_DETAIL: CourseDetail = {
id: "c1",
status: "수강 중",
title: "원자로 운전 및 계통",
goal:
"원자로 운전 원리와 주요 계통의 구조 및 기능을 이해하고, 실제 운전 상황을 가상 환경에서 체험하여 문제 해결 능력을 기른다.",
method:
"강좌 동영상을 통해 이론을 학습한 후, XR 실습을 통해 원자로 운전 및 계통 제어 과정을 체험한다. 이후 문제 풀이를 통해 학습 내용을 점검하며 평가를 받아 학습 성취도를 확인한다.",
summary: "VOD · 총 6강 · 4시간 20분",
submitSummary: "학습 제출 3/6",
thumbnail: "https://picsum.photos/seed/course-detail/584/318",
lessons: [
{ id: "l1", title: "1. 원자로 기초 및 핵분열 원리", duration: "12:46", state: "제출완료", action: "복습하기" },
{ id: "l2", title: "2. 제어봉 및 원자로 출력 제어", duration: "18:23", state: "제출완료", action: "복습하기" },
{ id: "l3", title: "3. 터빈-발전기 계통 및 전력 생산", duration: "13:47", state: "제출완료", action: "복습하기" },
{ id: "l4", title: "4. 보조 계통 및 안전 계통", duration: "13:47", state: "제출대기", action: "복습하기" },
{ id: "l5", title: "5. 원자로 냉각재 계통 (RCS) 및 열수력", duration: "08:11", state: "제출대기", action: "이어서 수강하기" },
{ id: "l6", title: "6. 원자로 시동, 운전 및 정지 절차", duration: "13:47", state: "제출대기", action: "수강하기" },
],
};
export default function CourseDetailPage() {
const c = MOCK_DETAIL;
return (
<main className="flex w-full flex-col">
<div className="flex h-[100px] items-center px-8">
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]"> </h1>
</div>
<section className="px-8 pb-20">
<div className="rounded-[8px] bg-white px-8 pb-20 pt-6">
{/* 상단 소개 카드 */}
<div className="flex gap-6 rounded-[8px] bg-[#f8f9fa] p-6">
<div className="relative h-[159px] w-[292px] overflow-hidden rounded">
<Image src={c.thumbnail} alt="" fill sizes="292px" className="object-cover" unoptimized />
</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]">
{c.status}
</span>
<h2 className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">{c.title}</h2>
</div>
<div className="mt-3 space-y-1">
<p className="text-[15px] leading-[1.5] text-[#333c47]">
<span className="font-medium"> :</span> {c.goal}
</p>
<p className="text-[15px] leading-[1.5] text-[#333c47]">
<span className="font-medium"> :</span> {c.method}
</p>
</div>
<div className="mt-3 flex items-center gap-5 text-[13px] leading-[1.4] text-[#8c95a1]">
<span>{c.summary}</span>
<span>{c.submitSummary}</span>
</div>
</div>
</div>
{/* 차시 리스트 */}
<div className="mt-6 space-y-2">
{c.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 (
<div key={l.id} className="rounded-[8px] border border-[#dee1e6] bg-white">
<div className="flex items-center justify-between gap-4 rounded-[8px] px-6 py-4">
<div className="min-w-0">
<p className="text-[16px] font-semibold leading-[1.5] text-[#333c47]">{l.title}</p>
<div className="mt-1 flex items-center gap-3">
<p className="w-[40px] text-[13px] leading-[1.4] text-[#8c95a1]">{l.duration}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className={[
"h-8 rounded-[6px] px-4 text-[14px] font-medium leading-[1.5]",
"bg-white",
submitBtnStyle,
].join(" ")}
>
{isSubmitted ? "학습 제출 완료" : "학습 제출 하기"}
</button>
<button
type="button"
className={[
"h-8 rounded-[6px] px-4 text-[14px] font-medium leading-[1.5]",
rightBtnStyle,
].join(" ")}
>
{l.action}
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
</section>
</main>
);
}

View File

@@ -9,7 +9,7 @@ type Lesson = {
id: string;
title: string;
durationMin: number;
progressPct: number; // 0~100
progressPct: number;
isCompleted: boolean;
};
@@ -23,41 +23,41 @@ type Course = {
lessons: Lesson[];
};
const MOCK_COURSES: Course[] = [
const COURSES: Course[] = [
{
id: "c1",
title: "원자재 출입 전",
title: "원자로 운전 및",
description:
"원자재 이송/보관기반으로 가이드하며, 일련 단계 별 강의에서 세부적인 작업 지침을 다룹니다.",
"원자로 운전 원리와 주요 계통의 구조 및이해하고, 일련 단계 가이드 기반의 현업에서 배워야 할 핵심을 익힙니다.",
thumbnail: "/imgs/talk.png",
status: "수강중",
progressPct: 80,
lessons: [
{ id: "c1l1", title: "1. 운반과방출 절차", durationMin: 12, progressPct: 100, isCompleted: true },
{ id: "c1l2", title: "2. 라벨링 원칙과 현장 케어", durationMin: 9, progressPct: 100, isCompleted: true },
{ id: "c1l3", title: "3. 배치/현황 기록 및 문서 관리", durationMin: 15, progressPct: 60, isCompleted: false },
{ id: "c1l4", title: "4. 보관 적재 기준 점검", durationMin: 8, progressPct: 0, isCompleted: false },
{ id: "c1l5", title: "5. 입고 검사 방법 (AQL) 및 유의점", durationMin: 11, progressPct: 0, isCompleted: false },
{ id: "c1l6", title: "6. 장비 사용, 손질필기", durationMin: 13, progressPct: 0, isCompleted: false },
{ id: "c1l1", title: "1. 원자로핵분열 원리", durationMin: 12, progressPct: 100, isCompleted: true },
{ id: "c1l2", title: "2. 제어봉 및 원자로 출력 제어", durationMin: 18, progressPct: 100, isCompleted: true },
{ id: "c1l3", title: "3. 터빈-발전기 계통 및 전력 생산", durationMin: 13, progressPct: 60, isCompleted: false },
{ id: "c1l4", title: "4. 보조 계통 및 안전 계통", durationMin: 13, progressPct: 0, isCompleted: false },
{ id: "c1l5", title: "5. 원자로 냉각재 계통 (RCS) 및 열수력", durationMin: 8, progressPct: 0, isCompleted: false },
{ id: "c1l6", title: "6. 원자로 시동, 운전절차", durationMin: 13, progressPct: 0, isCompleted: false },
],
},
{
id: "c2",
title: "학점과 평가",
title: "확률론",
description:
"학점과 평가 항목 기준을 가이드하며, 일과 관련된 기본 평가 체계와 피드백 처리 방법에 대해 배웁니다.",
"확률과 통계의 주요 개념을 가이드하며, 일련 단계 실습을 기반으로 문제 해결력을 기릅니다.",
thumbnail: "/imgs/talk.png",
status: "수강 완료",
progressPct: 100,
lessons: [
{ id: "c2l1", title: "평가 기준 이해", durationMin: 10, progressPct: 100, isCompleted: true },
{ id: "c2l1", title: "기초 개념", durationMin: 10, progressPct: 100, isCompleted: true },
],
},
{
id: "c3",
title: "부서간 협",
title: "부서간 협",
description:
"부서간 협업 절차와 기록 기준을 가이드하며, 일련 단계 별 협업에서 생길 수 있는 리스크 관리법을 다룹니다.",
"부서간 협업 절차와 기록 기준을 가이드하며, 협업 중 생길 수 있는 리스크 관리법을 다룹니다.",
thumbnail: "/imgs/talk.png",
status: "수강중",
progressPct: 60,
@@ -68,15 +68,13 @@ const MOCK_COURSES: Course[] = [
},
{
id: "c4",
title: "작업별 방사선의 이해",
title: "방사선의 이해",
description:
"작업별 방사선 안전 기준을 가이드하며, 일과 관련된 위험과 보호 장비 선택법을 배웁니다.",
"방사선 안전 기준을 가이드하며, 일과 관련된 위험과 보호 장비 선택법을 배웁니다.",
thumbnail: "/imgs/talk.png",
status: "수강 예정",
progressPct: 0,
lessons: [
{ id: "c4l1", title: "기초 이론", durationMin: 12, progressPct: 0, isCompleted: false },
],
lessons: [{ id: "c4l1", title: "기초 이론", durationMin: 12, progressPct: 0, isCompleted: false }],
},
];
@@ -87,16 +85,16 @@ export default function CoursesPage() {
const countsByStatus = useMemo(() => {
return {
전체: MOCK_COURSES.length,
"수강 예정": MOCK_COURSES.filter((c) => c.status === "수강 예정").length,
수강중: MOCK_COURSES.filter((c) => c.status === "수강중").length,
"수강 완료": MOCK_COURSES.filter((c) => c.status === "수강 완료").length,
전체: COURSES.length,
"수강 예정": COURSES.filter((c) => c.status === "수강 예정").length,
수강중: COURSES.filter((c) => c.status === "수강중").length,
"수강 완료": COURSES.filter((c) => c.status === "수강 완료").length,
};
}, []);
const filtered = useMemo(() => {
if (activeTab === "전체") return MOCK_COURSES;
return MOCK_COURSES.filter((c) => c.status === activeTab);
if (activeTab === "전체") return COURSES;
return COURSES.filter((c) => c.status === activeTab);
}, [activeTab]);
return (
@@ -176,4 +174,3 @@ export default function CoursesPage() {
);
}

View File

@@ -1,12 +1,86 @@
'use client';
import CourseGridItem, { CourseGridItemProps } from "./courses/CourseGridItem";
type CatalogCourse = CourseGridItemProps & {
category: string | undefined;
};
// 안정적인 랜덤 이미지를 위해 시드 기반 썸네일 풀 구성
const THUMBS = Array.from({ length: 16 }).map((_, i) => `https://picsum.photos/seed/xrlms-${i}/640/400`);
const CATALOG: CatalogCourse[] = Array.from({ length: 20 }).map((_, i) => ({
id: `cat-${i + 1}`,
title:
i % 5 === 0
? "방사선 안전"
: i % 5 === 1
? "원자재 운전 및 계동"
: i % 5 === 2
? "학점과 평가"
: i % 5 === 3
? "방사선 불가물"
: "현장 운전 및 계동",
category: i % 6 === 0 ? "추천" : undefined,
meta: "VOD · 총 6강 · 4시간 20분",
thumbnail: THUMBS[i % THUMBS.length],
isNew: i % 9 === 0,
}));
export default function MenuPage() {
return (
<main className="mx-auto w-full max-w-[1440px] px-8 py-8">
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]"> </h1>
<p className="mt-6 text-[16px] leading-[1.5] text-[#4c5561]">
.
</p>
</main>
);
return (
<main className="w-full">
<div className="flex h-[88px] items-center px-8">
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]"> </h1>
</div>
<div className="px-8 pb-16 pt-2">
<p className="mb-4 text-[13px] leading-[1.4] text-[#8c95a1]"> {CATALOG.length}</p>
<ul className="grid grid-cols-2 gap-x-5 gap-y-6 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{CATALOG.map((course) => (
<CourseGridItem
key={course.id}
id={course.id}
title={course.title}
category={course.category}
meta={course.meta}
thumbnail={course.thumbnail}
isNew={course.isNew}
/>
))}
</ul>
{/* pagination */}
<div className="mt-10 flex items-center justify-center gap-2">
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full text-[#8c95a1] hover:bg-[#f1f3f5]"
aria-label="이전 페이지"
>
&#x2039;
</button>
{[1, 2, 3].map((p) => (
<button
key={p}
type="button"
className={[
"flex h-8 w-8 items-center justify-center rounded-full text-[13px] leading-[1.4]",
p === 1 ? "bg-[#1f2b91] text-white" : "text-[#4c5561] hover:bg-[#f1f3f5]",
].join(" ")}
>
{p}
</button>
))}
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full text-[#8c95a1] hover:bg-[#f1f3f5]"
aria-label="다음 페이지"
>
&#x203A;
</button>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,23 @@
"use client";
import React from "react";
import FigmaCertificateContent from "./FigmaCertificateContent";
type Props = {
open: boolean;
onClose: () => void;
};
export default function CertificateModal({ open, onClose }: Props) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={onClose} aria-hidden="true" />
<div className="relative z-10 shadow-xl">
<FigmaCertificateContent onClose={onClose} />
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
"use client";
import React from "react";
import FigmaFeedbackContent from "./FigmaFeedbackContent";
type Props = {
open: boolean;
onClose: () => void;
learnerName?: string;
instructorName?: string;
scoreText?: string;
};
export default function FeedbackModal({
open,
onClose,
learnerName,
instructorName,
scoreText,
}: Props) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
aria-hidden={!open}
>
<div
className="absolute inset-0 bg-black/40"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative z-10 shadow-xl">
<FigmaFeedbackContent
onClose={onClose}
learnerName={learnerName}
instructorName={instructorName}
scoreText={scoreText}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,346 @@
/* eslint-disable @next/next/no-img-element */
"use client";
import React from "react";
type Props = {
onClose: () => void;
};
const imgImage1 = "http://localhost:3845/assets/89fda8e949171025b1232bae70fc9d442e4e70c8.png";
const imgContainer = "http://localhost:3845/assets/d04df6bb7fe1bd29946d04be9442029bca1503b0.png";
const img = "http://localhost:3845/assets/7adf9a5e43b6c9e5f9bee6adfee64e85eabac44a.svg";
const img1 = "http://localhost:3845/assets/9e3b52939dbaa99088659a82db437772ef1ad40e.svg";
export default function FigmaCertificateContent({ onClose }: Props) {
return (
<div
className="bg-white border border-[#dee1e6] border-solid box-border content-stretch flex flex-col items-center relative rounded-[12px] w-[720px] max-w-[95vw]"
data-name="Frame"
data-node-id="388:21934"
role="dialog"
aria-modal="true"
>
<div
className="box-border content-stretch flex gap-[10px] items-center overflow-clip p-[24px] relative shrink-0 w-full"
data-name="header"
data-node-id="388:21935"
>
<div
className="basis-0 flex flex-col font-['Pretendard:Bold',sans-serif] grow h-[32px] justify-center leading-[0] min-h-px min-w-px not-italic relative shrink-0 text-[#333c47] text-[20px]"
data-node-id="388:21937"
>
<p className="leading-[1.5]"></p>
</div>
<button
type="button"
aria-label="닫기"
onClick={onClose}
className="overflow-clip relative shrink-0 size-[24px]"
data-name="x-close-lg"
data-node-id="388:21938"
>
<div
className="absolute left-[calc(50%-0.02px)] size-[15.167px] top-[calc(50%-0.02px)] translate-x-[-50%] translate-y-[-50%]"
data-name="Icon (Stroke)"
data-node-id="I388:21938;1422:243"
>
<div
className="absolute inset-0"
style={{ "--fill-0": "rgba(51, 60, 71, 1)" } as React.CSSProperties}
>
<img alt="" className="block max-w-none size-full" src={img} />
</div>
</div>
</button>
</div>
<div
className="bg-white border-[#1f2b91] border-[6px] border-solid h-[714px] relative rounded-[12px] shrink-0 w-[506px]"
data-name="Container"
data-node-id="388:22388"
>
<div
className="absolute content-stretch flex flex-col gap-[8px] items-center justify-center left-[46px] not-italic text-center text-nowrap top-[92px] w-[418px] whitespace-pre"
data-node-id="388:22389"
>
<p
className="font-['Pretendard:Regular',sans-serif] leading-[1.4] relative shrink-0 text-[#6c7682] text-[13px]"
data-node-id="388:22390"
>
2025-N0055L3
</p>
<p
className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] relative shrink-0 text-[#333c47] text-[40px]"
data-node-id="388:22391"
>{`수 료 증`}</p>
</div>
<div
className="absolute content-stretch flex flex-col gap-[4px] items-start left-[46px] top-[216px] w-[418px]"
data-node-id="388:22392"
>
<div
className="content-stretch flex h-[24px] items-start relative shrink-0 w-full"
data-name="Container"
data-node-id="388:22393"
>
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#333c47] text-[15px] w-[100px] whitespace-pre-wrap">
{`소 속 :`}
</p>
<div className="h-[24px] relative shrink-0 w-[170.297px]" data-name="Paragraph">
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[24px] relative w-[170.297px]">
<p className="absolute font-['Pretendard:Medium',sans-serif] leading-[1.5] left-0 not-italic text-[#333c47] text-[15px] text-nowrap top-[-1px] whitespace-pre">
XR LMS
</p>
</div>
</div>
</div>
<div className="content-stretch flex h-[24px] items-start relative shrink-0 w-full" data-name="Container">
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#333c47] text-[15px] w-[100px] whitespace-pre-wrap">
{`성 명 :`}
</p>
<div className="h-[24px] relative shrink-0 w-[41.531px]" data-name="Paragraph">
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[24px] relative w-[41.531px]">
<p className="absolute font-['Pretendard:Medium',sans-serif] leading-[1.5] left-0 not-italic text-[#333c47] text-[15px] text-nowrap top-[-1px] whitespace-pre">
</p>
</div>
</div>
</div>
<div className="content-stretch flex h-[24px] items-start relative shrink-0 w-full" data-name="Container">
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#333c47] text-[15px] w-[100px]">
:
</p>
<div className="h-[24px] relative shrink-0 w-[78.281px]" data-name="Paragraph">
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[24px] relative w-[78.281px]">
<p className="absolute font-['Pretendard:Medium',sans-serif] leading-[1.5] left-0 not-italic text-[#333c47] text-[15px] text-nowrap top-[-1px] whitespace-pre">
1994-10-17
</p>
</div>
</div>
</div>
<div className="content-stretch flex h-[24px] items-start relative shrink-0 w-full" data-name="Container">
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#333c47] text-[15px] w-[100px]">
:
</p>
<div className="basis-0 grow h-[24px] min-h-px min-w-px relative shrink-0" data-name="Paragraph">
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[24px] relative w-full">
<p className="absolute font-['Pretendard:Medium',sans-serif] leading-[1.5] left-0 not-italic text-[#333c47] text-[15px] text-nowrap top-[-1px] whitespace-pre">
(2025) () 9
</p>
</div>
</div>
</div>
<div className="content-stretch flex h-[24px] items-start relative shrink-0 w-full" data-name="Container">
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#333c47] text-[15px] w-[100px]">
:
</p>
<div className="h-[24px] relative shrink-0 w-[229.328px]" data-name="Paragraph">
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[24px] relative w-[229.328px]">
<p className="absolute font-['Pretendard:Medium',sans-serif] leading-[1.5] left-0 not-italic text-[#333c47] text-[15px] text-nowrap top-[-1px] whitespace-pre">
2025-09-01 ~ 2025-09-30, 4
</p>
</div>
</div>
</div>
<div className="content-stretch flex h-[24px] items-start relative shrink-0 w-full" data-name="Container">
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#333c47] text-[15px] w-[100px]">
:
</p>
<div className="h-[24px] relative shrink-0 w-[84.219px]" data-name="Paragraph">
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[24px] relative w-[84.219px]">
<p className="absolute font-['Pretendard:Medium',sans-serif] leading-[1.5] left-0 not-italic text-[#333c47] text-[15px] text-nowrap top-[-1px] whitespace-pre">
2025-09-26
</p>
</div>
</div>
</div>
</div>
<div
className="absolute content-stretch flex flex-col gap-[24px] items-center left-[94.5px] top-[428px]"
data-node-id="388:22423"
>
<div className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#333c47] text-[16px] text-center text-nowrap whitespace-pre">
<p className="mb-0"> </p>
<p>
(2025) ()_9월
<br aria-hidden="true" />
.
</p>
</div>
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#4c5561] text-[16px] text-center text-nowrap whitespace-pre">
2025 10 21
</p>
<div className="content-stretch flex gap-[8px] items-center relative shrink-0" data-name="logo">
<div className="h-[36px] relative shrink-0 w-[46.703px]" data-name="image 1">
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<img alt="" className="absolute h-[390.71%] left-[-100%] max-w-none top-[-132.02%] w-[301.18%]" src={imgImage1} />
</div>
</div>
<div className="flex flex-col font-['Pretendard:ExtraBold',sans-serif] justify-center leading-[0] not-italic relative shrink-0 text-[#333c47] text-[24px] text-nowrap">
<p className="leading-[1.45] whitespace-pre">XR LMS</p>
</div>
</div>
</div>
<div className="absolute contents left-[26px] top-[662px]">
<div className="absolute flex h-[28px] items-center justify-center left-[26px] top-[662px] w-[40px]">
<div className="flex-none scale-y-[-100%]">
<div className="content-stretch flex flex-col gap-[4px] h-[28px] items-start relative w-[40px]">
<div className="h-[12px] relative shrink-0 w-[40px]">
<div className="content-stretch flex gap-[4px] h-[12px] items-start relative w-[40px]">
<div
className="relative shrink-0 size-[12px]"
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
>
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border size-[12px]" />
</div>
<div
className="relative shrink-0 size-[12px]"
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
>
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border size-[12px]" />
</div>
</div>
</div>
<div
className="basis-0 grow min-h-px min-w-px relative shrink-0 w-[12px]"
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
>
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-full w-[12px]" />
</div>
</div>
</div>
</div>
<div className="absolute flex h-[28px] items-center justify-center left-[444px] top-[662px] w-[40px]">
<div className="flex-none scale-y-[-100%]">
<div className="content-stretch flex flex-col gap-[4px] h-[28px] items-end relative w-[40px]">
<div className="h-[12px] relative shrink-0 w-[28px]">
<div className="content-stretch flex gap-[4px] h-[12px] items-start relative w-[28px]">
<div
className="relative shrink-0 size-[12px]"
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
>
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border size-[12px]" />
</div>
<div
className="basis-0 grow h-[12px] min-h-px min-w-px relative shrink-0"
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
>
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[12px] w-full" />
</div>
</div>
</div>
<div
className="basis-0 grow min-h-px min-w-px relative shrink-0 w-[12px]"
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
>
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-full w-[12px]" />
</div>
</div>
</div>
</div>
<div className="absolute h-[2px] left-[86px] top-[690px] w-[338px]">
<img alt="" className="absolute inset-0 max-w-none object-50%-50% object-cover pointer-events-none size-full" src={imgContainer} />
</div>
</div>
<div className="absolute contents left-[24px] top-[22px]">
<div className="absolute content-stretch flex flex-col gap-[4px] h-[28px] items-start left-[24px] top-[24px] w-[40px]">
<div className="h-[12px] relative shrink-0 w-[40px]">
<div className="content-stretch flex gap-[4px] h-[12px] items-start relative w-[40px]">
<div
className="relative shrink-0 size-[12px]"
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
>
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border size-[12px]" />
</div>
<div
className="relative shrink-0 size-[12px]"
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
>
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border size-[12px]" />
</div>
</div>
</div>
<div
className="basis-0 grow min-h-px min-w-px relative shrink-0 w-[12px]"
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
>
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-full w-[12px]" />
</div>
</div>
<div className="absolute content-stretch flex flex-col gap-[4px] h-[28px] items-end left-[442px] top-[24px] w-[40px]">
<div className="h-[12px] relative shrink-0 w-[28px]">
<div className="content-stretch flex gap-[4px] h-[12px] items-start relative w-[28px]">
<div
className="relative shrink-0 size-[12px]"
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
>
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border size-[12px]" />
</div>
<div
className="basis-0 grow h-[12px] min-h-px min-w-px relative shrink-0"
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
>
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[12px] w-full" />
</div>
</div>
</div>
<div
className="basis-0 grow min-h-px min-w-px relative shrink-0 w-[12px]"
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
>
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-full w-[12px]" />
</div>
</div>
<div className="absolute h-[2px] left-[84px] top-[22px] w-[338px]">
<img alt="" className="absolute inset-0 max-w-none object-50%-50% object-cover pointer-events-none size-full" src={imgContainer} />
</div>
</div>
</div>
<div
className="box-border content-stretch flex flex-col gap-[32px] h-[96px] items-center p-[24px] relative shrink-0 w-full"
data-node-id="388:22061"
>
<div
className="basis-0 content-stretch flex gap-[12px] grow items-start justify-center min-h-px min-w-px overflow-clip relative shrink-0 w-full"
data-name="Actions"
data-node-id="388:22062"
>
<button
type="button"
className="bg-white border border-[#8c95a1] border-solid box-border content-stretch flex gap-[4px] h-[48px] items-center justify-center px-[16px] py-[3px] relative rounded-[10px] shrink-0 w-[136px]"
>
<div className="relative shrink-0 size-[20px]" data-name="Interface, Essential/download-arrow">
<div className="absolute inset-[-0.02%]">
<img alt="" className="block max-w-none size-full" src={img1} />
</div>
</div>
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#4c5561] text-[16px] text-center text-nowrap whitespace-pre">
</p>
</button>
<button
type="button"
className="bg-[#ecf0ff] box-border content-stretch flex gap-[4px] h-[48px] items-center justify-center px-[16px] py-[8px] relative rounded-[10px] shrink-0 w-[136px]"
>
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#384fbf] text-[16px] text-center text-nowrap whitespace-pre">
</p>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,122 @@
/* eslint-disable @next/next/no-img-element */
"use client";
import React from "react";
type Props = {
onClose: () => void;
learnerName?: string;
instructorName?: string;
scoreText?: string;
};
const img = "http://localhost:3845/assets/7adf9a5e43b6c9e5f9bee6adfee64e85eabac44a.svg";
const img1 = "http://localhost:3845/assets/498f1d9877c6da3dadf581f98114a7f15bfc6769.svg";
export default function FigmaFeedbackContent({
onClose,
learnerName = "{학습자명}",
instructorName = "이공필",
scoreText = "{점수}",
}: Props) {
return (
<div
className="bg-white border border-[#dee1e6] border-solid box-border content-stretch flex flex-col items-center relative rounded-[12px] w-[640px] max-w-[90vw]"
data-name="Frame"
data-node-id="270:9120"
role="dialog"
aria-modal="true"
>
<div
className="box-border content-stretch flex gap-[10px] items-center overflow-clip p-[24px] relative shrink-0 w-full"
data-name="header"
data-node-id="270:9121"
>
<div
className="basis-0 flex flex-col font-['Pretendard:Bold',sans-serif] grow h-[32px] justify-center leading-[0] min-h-px min-w-px not-italic relative shrink-0 text-[#333c47] text-[20px]"
data-node-id="270:9123"
>
<p className="leading-[1.5]">{`${learnerName}님의 피드백`}</p>
</div>
<button
type="button"
aria-label="닫기"
onClick={onClose}
className="overflow-clip relative shrink-0 size-[24px]"
data-name="x-close-lg"
data-node-id="270:9124"
>
<div
className="absolute left-[calc(50%-0.02px)] size-[15.167px] top-[calc(50%-0.02px)] translate-x-[-50%] translate-y-[-50%]"
data-name="Icon (Stroke)"
data-node-id="I270:9124;1422:243"
>
<div
className="absolute inset-0"
style={{ "--fill-0": "rgba(51, 60, 71, 1)" } as React.CSSProperties}
>
<img alt="" className="block max-w-none size-full" src={img} />
</div>
</div>
</button>
</div>
<div
className="box-border content-stretch flex flex-col gap-[16px] items-center px-[24px] py-0 relative shrink-0 w-full"
data-node-id="270:9126"
>
<div
className="bg-gray-50 border border-[#dee1e6] border-solid box-border content-stretch flex flex-col gap-[8px] items-start justify-center p-[24px] relative rounded-[16px] shrink-0 w-full"
data-node-id="270:9127"
>
<div
className="content-stretch flex gap-[10px] items-center justify-center relative shrink-0"
data-node-id="270:9128"
>
<p
className="font-['Pretendard:Bold',sans-serif] h-[23px] leading-[1.5] not-italic relative shrink-0 text-[#4c5561] text-[15px] w-full"
data-node-id="270:9129"
>
: {instructorName}
</p>
</div>
<p
className="font-['Pretendard:Regular',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#4c5561] text-[15px] whitespace-pre-wrap"
data-node-id="270:9130"
>
{`- ${learnerName}님, 총점 ${scoreText}점으로 합격하셨습니다. 학습 흐름이 안정적이며 이해도가 좋습니다.`}
</p>
</div>
</div>
<div
className="box-border content-stretch flex flex-col gap-[32px] h-[96px] items-center p-[24px] relative shrink-0 w-full"
data-node-id="270:9131"
>
<div
className="basis-0 content-stretch flex gap-[12px] grow items-start justify-center min-h-px min-w-px overflow-clip relative shrink-0 w-full"
data-name="Actions"
data-node-id="270:9132"
>
<button
type="button"
onClick={onClose}
className="bg-[#1f2b91] box-border content-stretch flex flex-col h-[48px] items-center justify-center px-[16px] py-0 relative rounded-[10px] shrink-0 w-[284px] cursor-pointer"
data-name="Primary"
data-node-id="270:9134"
>
<div className="h-0 relative shrink-0 w-[82px]" data-name="Min Width" data-node-id="I270:9134;4356:4636">
<img alt="" className="block max-w-none size-full" src={img1} />
</div>
<p
className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[16px] text-center text-nowrap text-white whitespace-pre"
data-node-id="I270:9134;4356:4637"
>
</p>
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,3 +1,9 @@
"use client";
import { useState } from "react";
import FeedbackModal from "./FeedbackModal";
import CertificateModal from "./CertificateModal";
type ResultRow = {
programTitle: string;
courseTitle: string;
@@ -11,14 +17,14 @@ type ResultRow = {
const mockResults: ResultRow[] = [
{
programTitle: "XR 안전 기본 과정",
courseTitle: "{강좌명}",
courseTitle: "기본 안전 교육",
completedAt: "2025-09-10",
score: "-",
instructor: "이공필",
},
{
programTitle: "건설 현장 안전 실무",
courseTitle: "{강좌명}",
courseTitle: "비계 설치 점검",
completedAt: "2025-09-10",
score: "70 / 100",
instructor: "이공필",
@@ -27,7 +33,7 @@ const mockResults: ResultRow[] = [
},
{
programTitle: "전기 설비 위험성 평가",
courseTitle: "{강좌명}",
courseTitle: "분전반 작업 안전",
completedAt: "2025-09-10",
score: "70 / 100",
instructor: "이공필",
@@ -37,6 +43,19 @@ const mockResults: ResultRow[] = [
];
export default function ResultsPage() {
const [isFeedbackOpen, setIsFeedbackOpen] = useState(false);
const [selected, setSelected] = useState<ResultRow | null>(null);
const [isCertOpen, setIsCertOpen] = useState(false);
function openFeedback(row: ResultRow) {
setSelected(row);
setIsFeedbackOpen(true);
}
function openCertificate(row: ResultRow) {
setSelected(row);
setIsCertOpen(true);
}
return (
<main className="flex w-full flex-col">
<div className="flex h-[100px] items-center px-8">
@@ -71,7 +90,7 @@ export default function ResultsPage() {
{mockResults.map((row, idx) => (
<tr
key={`${row.programTitle}-${idx}`}
className={idx === 1 ? "h-12 bg-[rgba(236,240,255,0.5)]" : "h-12"}
className="h-12"
>
<td className="border-t border-r border-[#dee1e6] px-4 text-[15px] leading-[1.5] text-[#1b2027]">
{row.programTitle}
@@ -90,18 +109,26 @@ export default function ResultsPage() {
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-center text-[15px] leading-[1.5] text-[#1b2027]">
{row.feedbackLink ? (
<a href={row.feedbackLink} className="text-[12px] text-blue-500 underline underline-offset-[3px]">
<button
type="button"
onClick={() => openFeedback(row)}
className="text-[12px] text-blue-500 underline underline-offset-[3px] cursor-pointer"
>
</a>
</button>
) : (
"-"
)}
</td>
<td className="border-t border-[#dee1e6] px-4 text-center text-[15px] leading-[1.5] text-[#1b2027]">
{row.certificateLink ? (
<a href={row.certificateLink} className="text-[12px] text-blue-500 underline underline-offset-[3px]">
<button
type="button"
onClick={() => openCertificate(row)}
className="text-[12px] text-blue-500 underline underline-offset-[3px] cursor-pointer"
>
</a>
</button>
) : (
"-"
)}
@@ -113,6 +140,17 @@ export default function ResultsPage() {
</div>
</div>
</section>
<FeedbackModal
open={isFeedbackOpen}
onClose={() => setIsFeedbackOpen(false)}
learnerName="홍길동"
instructorName={selected?.instructor}
scoreText={selected?.score?.split(" / ")[0] ?? "0"}
/>
<CertificateModal
open={isCertOpen}
onClose={() => setIsCertOpen(false)}
/>
</main>
);
}

View File

@@ -0,0 +1,109 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import BackCircleSvg from '../../svgs/backcirclesvg';
type NoticeItem = {
id: number;
title: string;
date: string;
views: number;
writer: string;
content: string[];
};
const DATA: NoticeItem[] = [
{
id: 2,
title: '공지사항 제목이 노출돼요',
date: '2025-09-10',
views: 1230,
writer: '문지호',
content: [
'사이트 이용 관련 주요 변경 사항을 안내드립니다.',
'변경되는 내용은 공지일자로부터 즉시 적용됩니다.',
],
},
{
id: 1,
title: '📢 방사선학 온라인 강의 수강 안내 및 필수 공지',
date: '2025-06-28',
views: 594,
writer: '문지호',
content: [
'온라인 강의 수강 방법과 필수 확인 사항을 안내드립니다.',
'수강 기간 및 출석, 과제 제출 관련 정책을 반드시 확인해 주세요.',
],
},
];
export default async function NoticeDetailPage({
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();
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="/notices"
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>
{/* 카드 */}
<section className="px-8 pb-8">
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
{/* 헤더 */}
<div className="p-8">
<h2 className="m-0 text-[20px] font-bold leading-normal text-[#333C47]">
{item.title}
</h2>
<div className="mt-2 flex items-center gap-4 text-[13px] leading-[1.4]">
<span className="text-[#8C95A1]"></span>
<span className="text-[#333C47]">{item.writer}</span>
<span className="w-px h-4 bg-[#DEE1E6]" />
<span className="text-[#8C95A1]"></span>
<span className="text-[#333C47]">{item.date}</span>
<span className="w-px h-4 bg-[#DEE1E6]" />
<span className="text-[#8C95A1]"></span>
<span className="text-[#333C47]">{item.views.toLocaleString()}</span>
</div>
</div>
{/* 구분선 */}
<div className="h-px bg-[#DEE1E6] w-full" />
{/* 본문 */}
<div className="p-8">
<div className="text-[15px] leading-normal text-[#333C47] space-y-2">
{item.content.map((p, idx) => (
<p key={idx} className="m-0">
{p}
</p>
))}
</div>
</div>
</div>
</section>
</div>
</div>
</div>
);
}

124
src/app/notices/page.tsx Normal file
View File

@@ -0,0 +1,124 @@
'use client';
import { useRouter } from 'next/navigation';
import PaperClipSvg from '../svgs/paperclipsvg';
type NoticeRow = {
id: number;
title: string;
date: string;
views: number;
writer: string;
hasAttachment?: boolean;
};
const rows: NoticeRow[] = [
{
id: 2,
title: '공지사항 제목이 노출돼요',
date: '2025-09-10',
views: 1230,
writer: '문지호',
},
{
id: 1,
title: '📢 방사선학 온라인 강의 수강 안내 및 필수 공지',
date: '2025-06-28',
views: 594,
writer: '문지호',
hasAttachment: true,
},
];
export default function NoticesPage() {
const router = useRouter();
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 px-8">
<h1 className="m-0 text-[24px] font-bold leading-[1.5] text-[#1B2027]">
</h1>
</div>
{/* 본문 영역 */}
<section className="px-8 pb-8">
{/* 총 건수 */}
<div className="h-10 flex items-center">
<p className="m-0 text-[15px] font-medium leading-[1.5] text-[#333C47]">
<span className="text-[#384FBF]">{rows.length}</span>
</p>
</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>
{rows.map((r) => (
<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]">
{r.id}
</div>
<div
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 className="flex items-center px-4 border-r border-[#DEE1E6]">
{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>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,194 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import PaperClipSvg from '../../svgs/paperclipsvg';
import BackCircleSvg from '../../svgs/backcirclesvg';
type ResourceRow = {
id: number;
title: string;
date: string;
views: number;
writer: string;
content: string[];
attachments?: Array<{ name: string; size: string; url: 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 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();
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>
{/* 카드 */}
<section className="px-8 pb-8">
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
{/* 헤더 */}
<div className="p-8">
<h2 className="m-0 text-[20px] font-bold leading-normal text-[#333C47]">
{item.title}
</h2>
<div className="mt-2 flex items-center gap-4 text-[13px] leading-[1.4]">
<span className="text-[#8C95A1]"></span>
<span className="text-[#333C47]">{item.writer}</span>
<span className="w-px h-4 bg-[#DEE1E6]" />
<span className="text-[#8C95A1]"></span>
<span className="text-[#333C47]">{item.date}</span>
<span className="w-px h-4 bg-[#DEE1E6]" />
<span className="text-[#8C95A1]"></span>
<span className="text-[#333C47]">{item.views.toLocaleString()}</span>
</div>
</div>
{/* 구분선 */}
<div className="h-px bg-[#DEE1E6] w-full" />
{/* 본문 */}
<div className="p-8">
<div className="text-[15px] leading-normal text-[#333C47] space-y-2">
{item.content.map((p, idx) => (
<p key={idx} className="m-0">
{p}
</p>
))}
</div>
</div>
{/* 첨부 파일 */}
{item.attachments?.length ? (
<div className="p-8 pt-0">
<div className="mb-2 text-[15px] font-semibold text-[#6C7682]">
</div>
{item.attachments.map((f, idx) => (
<div
key={idx}
className="bg-white border border-[#DEE1E6] h-[64px] rounded-[6px] flex items-center gap-3 px-[17px]"
>
<div className="text-[#8C95A1]">
<PaperClipSvg width={20} height={20} />
</div>
<div className="flex-1 flex items-center gap-2 min-w-0">
<span className="text-[15px] text-[#1B2027] truncate">
{f.name}
</span>
<span className="text-[13px] text-[#8C95A1] whitespace-nowrap">
{f.size}
</span>
</div>
<a
href={f.url}
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"
download
>
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden>
<path
d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
</div>
))}
</div>
) : null}
</div>
</section>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,8 @@
'use client';
import { useRouter } from 'next/navigation';
import PaperClipSvg from '../svgs/paperclipsvg';
type ResourceRow = {
id: number;
title: string;
@@ -56,6 +59,8 @@ const rows: ResourceRow[] = [
];
export default function ResourcesPage() {
const router = useRouter();
return (
<div className="w-full bg-white">
<div className="flex justify-center">
@@ -80,19 +85,19 @@ export default function ResourcesPage() {
<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]">
<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]">
<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 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 className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
</div>
<div className="flex items-center px-4"></div>
<div className="flex items-center px-4 whitespace-nowrap"></div>
</div>
{/* 바디 */}
@@ -100,9 +105,17 @@ export default function ResourcesPage() {
{rows.map((r) => (
<div
key={r.id}
role="button"
tabIndex={0}
onClick={() => 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]',
r.id === rows[0].id ? 'bg-[rgba(236,240,255,0.5)]' : '',
'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]">
@@ -114,22 +127,7 @@ export default function ResourcesPage() {
>
{r.title}
{r.hasAttachment && (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
aria-hidden
className="shrink-0 text-[#8C95A1]"
>
<path
d="M21 8l-9.5 9.5a4 4 0 01-5.657-5.657L14 4.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<PaperClipSvg width={16} height={16} className="shrink-0 text-[#8C95A1]" />
)}
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]">

View File

@@ -0,0 +1,46 @@
export default function BackCircleSvg(
{
width = 32,
height = 32,
className = '',
}: { width?: number | string; height?: number | string; className?: string }
): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
viewBox="0 0 32 32"
fill="none"
className={className}
aria-hidden
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16 4C22.628 4 28 9.372 28 16C28 22.628 22.628 28 16 28C9.372 28 4 22.628 4 16C4 9.372 9.372 4 16 4Z"
fill="#8C95A1"
stroke="#8C95A1"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10.666 15.9999H21.3327"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M14.666 20L10.666 16L14.666 12"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@@ -19,7 +19,7 @@ export default function ChevronDownSvg(
fillRule="evenodd"
clipRule="evenodd"
d="M18.7071 8.29277C19.0976 8.6833 19.0976 9.31646 18.7071 9.70698L12.7071 15.707C12.3166 16.0975 11.6834 16.0975 11.2929 15.707L5.29289 9.70699C4.90237 9.31646 4.90237 8.6833 5.29289 8.29277C5.68342 7.90225 6.31658 7.90225 6.70711 8.29277L12 13.5857L17.2929 8.29277C17.6834 7.90225 18.3166 7.90225 18.7071 8.29277Z"
fill="white"
fill="currentColor"
/>
</svg>
);

View File

@@ -0,0 +1,29 @@
export default function PaperClipSvg(
{
width = 16,
height = 16,
className = '',
}: { width?: number | string; height?: number | string; className?: string }
): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
viewBox="0 0 16 16"
fill="none"
className={className}
aria-hidden
>
<path
d="M8.9721 6.2539L5.2839 9.94144C4.85621 10.3693 4.85621 11.0629 5.2839 11.4908V11.4908C5.71179 11.9184 6.40532 11.9184 6.83321 11.4908L12.5863 5.73769C12.9972 5.3268 13.2281 4.76949 13.2281 4.18838C13.2281 3.60726 12.9972 3.04995 12.5863 2.63906V2.63906C12.1755 2.22817 11.6183 1.99731 11.0373 1.99731C10.4563 1.99731 9.89908 2.22817 9.48831 2.63906L3.73525 8.39279C3.1188 9.00896 2.77246 9.84483 2.77246 10.7164C2.77246 11.588 3.1188 12.4239 3.73525 13.0401V13.0401C4.35151 13.6563 5.18735 14.0026 6.05889 14.0026C6.93042 14.0026 7.76626 13.6563 8.38252 13.0401L12.5843 8.83831"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}