diff --git a/docs/FRONTEND_ARCHITECTURE.md b/docs/FRONTEND_ARCHITECTURE.md index 3b95e35..dc3d4d4 100644 --- a/docs/FRONTEND_ARCHITECTURE.md +++ b/docs/FRONTEND_ARCHITECTURE.md @@ -49,6 +49,7 @@ src/ - 학생 전용 route - 선생님 전용 route - 내부 사용자 공통 도서관 route +- `학생 관리` route는 선생님 전용 route로 분리하고, 학생 권한 내비게이션에는 노출하지 않는다. 예시: diff --git a/docs/USER_ROLES.md b/docs/USER_ROLES.md index 269e2a0..b92663e 100644 --- a/docs/USER_ROLES.md +++ b/docs/USER_ROLES.md @@ -32,6 +32,12 @@ - 레주메북 변환을 실행한다. - 내부 도서관을 열람하고 관리한다. +### 선생님 전용 화면 + +- `학생 관리` 화면은 선생님 권한에서만 제공한다. +- 학생 사용자는 `학생 관리` 메뉴와 반별 학생 제출 현황 모달에 접근하지 않는다. +- 프론트엔드 내비게이션에서 학생 역할에는 `홈`, `이력서 관리`, `도서관`만 노출하고, 선생님 역할에는 `전공 관리`, `학생 관리`, `도서관`을 노출한다. + ## 로그인한 내부 사용자 학생/선생님 등 서비스에 로그인한 사용자는 내부 레주메북 도서관을 볼 수 있습니다. diff --git a/src/app/(public)/resume-books/[bookId]/page.module.css b/src/app/(public)/resume-books/[bookId]/page.module.css index d476ced..d250d61 100644 --- a/src/app/(public)/resume-books/[bookId]/page.module.css +++ b/src/app/(public)/resume-books/[bookId]/page.module.css @@ -223,182 +223,6 @@ line-height: var(--repo-font-title-tiny-line-height); } -.sheet { - width: 423px; - height: 599px; - overflow: hidden; - padding: 20px; - color: var(--repo-text-black); - background: var(--repo-gray-50); -} - -.sheetHeader { - display: grid; - grid-template-columns: 50px minmax(0, 1fr) 47px; - gap: 13px; - align-items: start; -} - -.profileImage { - width: 47px; - height: 47px; - border-radius: var(--repo-radius-pill); - background: - radial-gradient(circle at 58% 42%, #ffffff 0 8px, #d7d7d7 9px 10px, transparent 11px), - radial-gradient(circle at 37% 41%, #f7f7f7 0 12px, #d8d8d8 13px 15px, transparent 16px), - radial-gradient(circle at 40% 38%, #323232 0 2px, transparent 3px), - radial-gradient(circle at 61% 37%, #323232 0 2px, transparent 3px), - linear-gradient(135deg, #e8e4de, #c8c1b7 46%, #f1eee9); -} - -.identity { - display: grid; - gap: 5px; - min-width: 0; -} - -.nameRow { - display: flex; - align-items: baseline; - gap: 9px; - min-width: 0; -} - -.name { - margin: 0; - color: var(--repo-text-black); - font-size: var(--repo-font-title-small-size); - font-weight: var(--repo-font-title-small-weight); - line-height: var(--repo-font-title-small-line-height); - letter-spacing: 0; -} - -.major { - color: var(--repo-gray-500); - font-size: 9px; - font-weight: 500; - line-height: 1; - white-space: nowrap; -} - -.meta { - margin: 0; - color: var(--repo-text-black); - font-size: var(--repo-font-body-very-tiny-size); - font-weight: var(--repo-font-body-very-tiny-weight); - line-height: var(--repo-font-body-very-tiny-line-height); -} - -.qrCode { - width: 47px; - height: 47px; - margin-left: auto; - background: - linear-gradient(90deg, #000 50%, transparent 0) 0 0 / 9px 9px, - linear-gradient(#000 50%, transparent 0) 0 0 / 9px 9px, - linear-gradient(90deg, transparent 35%, #000 0 65%, transparent 0) 0 0 / 18px 18px, - var(--repo-gray-50); - image-rendering: pixelated; - outline: 4px solid var(--repo-gray-50); -} - -.introBox { - display: grid; - gap: 7px; - margin-top: 14px; - padding: 11px 13px; - border-radius: 8px; - background: var(--repo-gray-100); -} - -.introBox h2, -.section h2 { - margin: 0; - color: var(--repo-text-black); - font-size: var(--repo-font-body-tiny-size); - font-weight: var(--repo-font-body-tiny-weight); - line-height: var(--repo-font-body-tiny-line-height); - letter-spacing: 0; -} - -.introBox p { - margin: 0; - color: var(--repo-gray-800); - font-size: var(--repo-font-resume-small-size); - font-weight: var(--repo-font-resume-small-weight); - line-height: var(--repo-font-resume-small-line-height); -} - -.section { - display: grid; - gap: 9px; - margin-top: 14px; -} - -.skillList { - display: flex; - flex-wrap: wrap; - gap: 8px; - padding: 0; - margin: 0; - list-style: none; -} - -.skillList li { - min-width: 52px; - padding: 5px 12px; - border-radius: var(--repo-radius-pill); - background: var(--repo-gray-100); - font-size: 8px; - line-height: 1; - text-align: center; -} - -.activityList { - display: grid; - gap: 10px; - padding: 11px 0 13px; - margin: 0; - border-top: 1px solid var(--repo-gray-300); - list-style: none; -} - -.activityList li { - display: grid; - gap: 3px; - color: var(--repo-text-black); - font-size: 11px; - line-height: 1.25; -} - -.activityList time, -.detailSection h3 { - color: var(--repo-gray-500); - font-size: 12px; - font-weight: 500; -} - -.detailSection { - padding-top: 5px; - border-top: 1px dashed var(--repo-gray-300); -} - -.detailSection + .detailSection { - margin-top: 9px; -} - -.detailSection h3 { - margin: 0 0 4px; -} - -.detailSection ul { - padding-left: 16px; - margin: 0; - color: var(--repo-text-black); - font-size: 11px; - line-height: 1.25; -} - .filterPanel { position: absolute; top: 0; diff --git a/src/app/(public)/resume-books/[bookId]/page.tsx b/src/app/(public)/resume-books/[bookId]/page.tsx index b9a217e..7995ca6 100644 --- a/src/app/(public)/resume-books/[bookId]/page.tsx +++ b/src/app/(public)/resume-books/[bookId]/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' import { useRouter } from 'next/navigation' import type { AppHeaderItem } from '@/shared/ui' -import { AppHeader, Button, CheckboxOption, SearchField, Tag, Toast } from '@/shared/ui' +import { AppHeader, Button, CheckboxOption, ResumeBookSheet, SearchField, Tag, Toast } from '@/shared/ui' import styles from './page.module.css' @@ -14,16 +14,6 @@ const navigationItems = [ { href: '/library', label: '도서관', value: 'library' }, ] satisfies readonly AppHeaderItem[] -const skills = ['Figma', 'illustrator', 'photoshop'] - -const activities = [ - { date: '2025.12.25', title: '제 1회 SCSC 온라인 해커톤 2위' }, - { date: '2025.07.18', title: '2025 교내 해커톤 우수상' }, -] - -const contests = ['제4회 2026 블레이버스 MVP 개발 해커톤', '제 1회 SCSC온라인 해커톤', '2025 교내 해커톤'] -const projects = ['TEENS', '스플', 'D-ask', 'DSG', 'Studiz', 'hear', '마음씨', 'Repo'] - const majorFilters = [ { chipLabel: 'Frontend', label: 'Frontend Developer', value: 'frontend' }, { chipLabel: 'Backend', label: 'Backend Developer', value: 'backend' }, @@ -217,8 +207,8 @@ export default function ResumeBookPage() { ‹
- - + +
+ + + ) : null} + +
+ + +
+
+ + {isFeedbackVisible ? ( + + ) : null} +
+
+ + {isFeedbackVisible ? ( + <> + + + + ) : null} +
+
+ + +
+ +

+ {currentPage} / {totalPages} +

+ +
+
+ + +
+ +
+ +
+ + +
+ + {isFeedbackVisible ? ( + + ) : null} + + + ) +} diff --git a/src/app/(teacher)/students/page.module.css b/src/app/(teacher)/students/page.module.css new file mode 100644 index 0000000..44a1333 --- /dev/null +++ b/src/app/(teacher)/students/page.module.css @@ -0,0 +1,307 @@ +.page { + min-height: 100dvh; + overflow: hidden; + color: var(--repo-gray-50); + background: var(--repo-bg-main); +} + +.content { + position: relative; + min-height: calc(100dvh - var(--repo-size-header-min-height)); + overflow: hidden; + padding: var(--space-14) 64px var(--space-20); +} + +.contentLayer { + display: flex; + min-height: calc(100dvh - var(--repo-size-header-min-height) - 136px); + flex-direction: column; + align-items: center; + transition: + filter 200ms ease-in-out, + opacity 200ms ease-in-out; +} + +.page[data-dialog-open='true'] .contentLayer { + filter: blur(6px); + opacity: 0.35; + pointer-events: none; + user-select: none; +} + +.toastLayer { + position: fixed; + z-index: 12; + top: 84px; + left: 50%; + display: flex; + width: min(calc(100% - 48px), 600px); + justify-content: center; + transform: translateX(-50%); +} + +.hero { + display: grid; + width: min(100%, 640px); + justify-items: center; + gap: var(--space-4); + text-align: center; +} + +.title { + margin: 0; + color: var(--repo-gray-50); + font-size: var(--repo-font-title-medium-size); + font-weight: var(--repo-font-title-medium-weight); + line-height: var(--repo-font-title-medium-line-height); + letter-spacing: 0; +} + +.description { + margin: 0 0 var(--space-10); + color: var(--repo-gray-500); + font-size: var(--repo-font-body-large-size); + font-weight: var(--repo-font-body-large-weight); + line-height: var(--repo-font-body-large-line-height); + letter-spacing: 0; +} + +.searchField { + width: min(580px, 100%); +} + +.searchField:focus-within { + outline: 0; +} + +.gradeGrid { + display: grid; + width: min(100%, 1200px); + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 56px; + margin-top: var(--space-18); +} + +.gradeColumn { + min-width: 0; +} + +.gradeTitle { + margin: 0; + color: var(--repo-gray-50); + font-size: var(--repo-font-title-small-size); + font-weight: var(--repo-font-title-small-weight); + line-height: var(--repo-font-title-small-line-height); + letter-spacing: 0; +} + +.divider { + height: 1px; + margin-top: var(--space-5); + background: var(--repo-bg-main-point); +} + +.classList { + display: grid; + gap: var(--space-6); + margin-top: var(--space-10); +} + +.classList > button { + min-height: 52px; + padding: var(--space-4) var(--space-5); + border: 1px solid var(--repo-bg-main-point); + border-radius: var(--repo-radius-control); + background: var(--repo-bg-main-lighter); +} + +.classList > button:hover { + background: var(--repo-bg-main-point); +} + +.classList > button span:first-child { + gap: var(--space-2); +} + +.classList > button span span:first-child { + font-size: var(--repo-font-body-medium-2-size); + font-weight: var(--repo-font-body-medium-2-weight); + line-height: var(--repo-font-body-medium-2-line-height); +} + +.classList > button span span:nth-child(2) { + font-size: var(--repo-font-body-tiny-size); + font-weight: var(--repo-font-body-tiny-weight); + line-height: var(--repo-font-body-tiny-line-height); +} + +.classList > button svg { + width: 20px; + height: 20px; +} + +.dialogLayer { + position: fixed; + z-index: 10; + inset: var(--repo-size-header-min-height) 0 0; + display: grid; + place-items: center; + padding: var(--space-10); + background: color-mix(in srgb, var(--repo-bg-main) 76%, transparent); +} + +.dialog { + position: relative; + width: min(900px, calc(100vw - 128px)); + min-height: 562px; + padding: 52px 56px 56px; + border: 1px solid var(--repo-bg-main-point); + border-radius: var(--repo-radius-panel); + background: var(--repo-bg-main-lighter); + box-shadow: var(--repo-shadow-modal); +} + +.closeButton { + position: absolute; + top: 50px; + right: 56px; + display: inline-flex; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + border: 0; + color: var(--repo-gray-50); + background: transparent; + font-size: var(--repo-font-title-small-size); + font-weight: var(--repo-font-title-small-weight); + line-height: var(--repo-font-title-small-line-height); + cursor: pointer; +} + +.closeButton:focus-visible { + outline: 2px solid var(--repo-main); + outline-offset: 2px; +} + +.dialogHeader { + padding-right: var(--space-12); +} + +.dialogTitle { + margin: 0; + color: var(--repo-gray-50); + font-size: var(--repo-font-title-small-size); + font-weight: var(--repo-font-title-small-weight); + line-height: var(--repo-font-title-small-line-height); + letter-spacing: 0; +} + +.updatedAt { + margin: var(--space-2) 0 0; + color: var(--repo-gray-500); + font-size: var(--repo-font-body-tiny-size); + font-weight: var(--repo-font-body-tiny-weight); + line-height: var(--repo-font-body-tiny-line-height); + letter-spacing: 0; +} + +.dialogDivider { + height: 1px; + margin-top: var(--space-4); + background: var(--repo-bg-main-point); +} + +.studentRows { + display: grid; + gap: var(--space-2); + margin-top: var(--space-8); +} + +.studentRows > a { + min-height: 42px; + padding: var(--space-2) 0; + background: transparent; +} + +.studentRows > a:hover { + transform: none; + background: var(--repo-bg-main); +} + +.studentRow > span:first-child > span:first-child { + font-size: var(--repo-font-body-medium-2-size); + font-weight: var(--repo-font-body-medium-2-weight); + line-height: var(--repo-font-body-medium-2-line-height); +} + +.studentRow > span:first-child > span:nth-child(2), +.studentRow > span:nth-child(2) span { + font-size: var(--repo-font-body-tiny-size); + font-weight: var(--repo-font-body-tiny-weight); + line-height: var(--repo-font-body-tiny-line-height); +} + +@media (max-width: 1080px) { + .content { + padding-inline: var(--space-8); + } + + .gradeGrid { + grid-template-columns: 1fr; + gap: var(--space-10); + overflow-y: auto; + padding-bottom: var(--space-10); + } + + .dialog { + width: min(720px, calc(100vw - 48px)); + padding: var(--space-10); + } + + .closeButton { + top: var(--space-8); + right: var(--space-8); + } +} + +@media (max-width: 720px) { + .page { + overflow: auto; + } + + .content { + overflow: visible; + padding: var(--space-10) var(--space-6); + } + + .contentLayer { + min-height: auto; + } + + .hero { + align-items: center; + } + + .description { + margin-bottom: var(--space-6); + font-size: var(--repo-font-body-small-size); + line-height: var(--repo-font-body-small-line-height); + } + + .gradeGrid { + margin-top: var(--space-10); + } + + .dialogLayer { + inset: 0; + align-items: start; + overflow-y: auto; + padding: var(--space-6); + } + + .dialog { + min-height: auto; + padding: var(--space-8) var(--space-5); + } +} diff --git a/src/app/(teacher)/students/page.tsx b/src/app/(teacher)/students/page.tsx new file mode 100644 index 0000000..00aa4cc --- /dev/null +++ b/src/app/(teacher)/students/page.tsx @@ -0,0 +1,218 @@ +'use client' + +import { Suspense, useEffect, useMemo, useRef, useState } from 'react' +import { useSearchParams } from 'next/navigation' + +import type { AppHeaderItem, LinkRowTone } from '@/shared/ui' +import { AppHeader, ClassCard, LinkRow, SearchField, Toast } from '@/shared/ui' + +import styles from './page.module.css' + +const navigationItems = [ + { href: '/majors', label: '전공 관리', value: 'majors' }, + { href: '/students', label: '학생 관리', value: 'students' }, + { href: '/library', label: '도서관', value: 'library' }, +] satisfies readonly AppHeaderItem[] + +const grades = [ + { label: '1학년', value: 1 }, + { label: '2학년', value: 2 }, + { label: '3학년', value: 3 }, +] as const + +const classes = [1, 2, 3, 4] as const + +type Grade = (typeof grades)[number]['value'] +type ClassNumber = (typeof classes)[number] +type SubmissionStatus = 'missing' | 'submitted' + +type SelectedClass = { + readonly grade: Grade + readonly classNumber: ClassNumber +} + +type StudentSummary = { + readonly id: number + readonly name: string + readonly number: string + readonly status: SubmissionStatus +} + +const classSize = 16 +const lastUpdatedDate = '2023.05.23' + +const students = [ + { id: 1, name: '최하은', number: '2415', status: 'missing' }, + { id: 2, name: '최하은', number: '2415', status: 'submitted' }, + { id: 3, name: '최하은', number: '2415', status: 'missing' }, + { id: 4, name: '최하은', number: '2415', status: 'missing' }, + { id: 5, name: '최하은', number: '2415', status: 'missing' }, + { id: 6, name: '최하은', number: '2415', status: 'submitted' }, + { id: 7, name: '최하은', number: '2415', status: 'submitted' }, + { id: 8, name: '최하은', number: '2415', status: 'missing' }, + { id: 9, name: '최하은', number: '2415', status: 'missing' }, +] satisfies readonly StudentSummary[] + +const statusLabel: Record = { + missing: '미제출', + submitted: '제출됨', +} + +const statusTone: Record = { + missing: 'missing', + submitted: 'submitted', +} + +export default function TeacherStudentsPage() { + return ( + + + + ) +} + +function TeacherStudentsContent() { + const searchParams = useSearchParams() + const [selectedClass, setSelectedClass] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const dialogCloseButtonRef = useRef(null) + const dialogTriggerRef = useRef(null) + const showsLoadError = searchParams.get('error') === 'students' + + useEffect(() => { + if (!selectedClass) { + return undefined + } + + dialogCloseButtonRef.current?.focus() + + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setSelectedClass(null) + } + } + + window.addEventListener('keydown', closeOnEscape) + + return () => { + window.removeEventListener('keydown', closeOnEscape) + dialogTriggerRef.current?.focus() + dialogTriggerRef.current = null + } + }, [selectedClass]) + + const selectedClassLabel = useMemo(() => { + if (!selectedClass) { + return '' + } + + return `2026 ${selectedClass.grade}학년 ${selectedClass.classNumber}반` + }, [selectedClass]) + + const filteredStudents = useMemo(() => { + const normalizedSearchQuery = searchQuery.trim().toLocaleLowerCase('ko-KR') + + if (!normalizedSearchQuery) { + return students + } + + return students.filter((student) => student.name.toLocaleLowerCase('ko-KR').includes(normalizedSearchQuery)) + }, [searchQuery]) + + return ( +
+ + {showsLoadError ? ( +
+ 학생 목록을 불러오는 데 실패했습니다. 잠시 후 다시 시도해 주세요. +
+ ) : null} + +
+
+
+

+ 학생 관리 +

+

학생들의 이력서를 확인하고, 피드백을 달아보세요!

+ setSearchQuery(event.target.value)} + spellCheck={false} + /> +
+ +
+ {grades.map((grade) => ( +
+

+ {grade.label} +

+
+
+ {classes.map((classNumber) => ( + { + dialogTriggerRef.current = event.currentTarget + setSelectedClass({ classNumber, grade: grade.value }) + }} + /> + ))} +
+
+ ))} +
+
+ + {selectedClass ? ( +
setSelectedClass(null)}> +
event.stopPropagation()} + > + + +
+

+ {selectedClassLabel} +

+

마지막 업데이트 : {lastUpdatedDate}

+
+ +
+ +
+ {filteredStudents.map((student) => ( + + ))} +
+
+
+ ) : null} +
+
+ ) +} diff --git a/src/shared/ui/ResumeBookSheet/ResumeBookSheet.module.css b/src/shared/ui/ResumeBookSheet/ResumeBookSheet.module.css new file mode 100644 index 0000000..555acee --- /dev/null +++ b/src/shared/ui/ResumeBookSheet/ResumeBookSheet.module.css @@ -0,0 +1,175 @@ +.sheet { + width: 423px; + height: 599px; + overflow: hidden; + padding: 20px; + color: var(--repo-text-black); + background: var(--repo-gray-50); +} + +.sheetHeader { + display: grid; + grid-template-columns: 50px minmax(0, 1fr) 47px; + gap: 13px; + align-items: start; +} + +.profileImage { + width: 47px; + height: 47px; + border-radius: var(--repo-radius-pill); + background: + radial-gradient(circle at 58% 42%, #ffffff 0 8px, #d7d7d7 9px 10px, transparent 11px), + radial-gradient(circle at 37% 41%, #f7f7f7 0 12px, #d8d8d8 13px 15px, transparent 16px), + radial-gradient(circle at 40% 38%, #323232 0 2px, transparent 3px), + radial-gradient(circle at 61% 37%, #323232 0 2px, transparent 3px), + linear-gradient(135deg, #e8e4de, #c8c1b7 46%, #f1eee9); +} + +.identity { + display: grid; + gap: 5px; + min-width: 0; +} + +.nameRow { + display: flex; + align-items: baseline; + gap: 9px; + min-width: 0; +} + +.name { + margin: 0; + color: var(--repo-text-black); + font-size: var(--repo-font-title-small-size); + font-weight: var(--repo-font-title-small-weight); + line-height: var(--repo-font-title-small-line-height); + letter-spacing: 0; +} + +.major { + color: var(--repo-gray-500); + font-size: 9px; + font-weight: 500; + line-height: 1; + white-space: nowrap; +} + +.meta { + margin: 0; + color: var(--repo-text-black); + font-size: var(--repo-font-body-very-tiny-size); + font-weight: var(--repo-font-body-very-tiny-weight); + line-height: var(--repo-font-body-very-tiny-line-height); +} + +.qrCode { + width: 47px; + height: 47px; + margin-left: auto; + background: + linear-gradient(90deg, #000 50%, transparent 0) 0 0 / 9px 9px, + linear-gradient(#000 50%, transparent 0) 0 0 / 9px 9px, + linear-gradient(90deg, transparent 35%, #000 0 65%, transparent 0) 0 0 / 18px 18px, + var(--repo-gray-50); + image-rendering: pixelated; + outline: 4px solid var(--repo-gray-50); +} + +.introBox { + display: grid; + gap: 7px; + margin-top: 14px; + padding: 11px 13px; + border-radius: 8px; + background: var(--repo-gray-100); +} + +.introBox h3, +.section h3 { + margin: 0; + color: var(--repo-text-black); + font-size: var(--repo-font-body-tiny-size); + font-weight: var(--repo-font-body-tiny-weight); + line-height: var(--repo-font-body-tiny-line-height); + letter-spacing: 0; +} + +.introBox p { + margin: 0; + color: var(--repo-gray-800); + font-size: var(--repo-font-resume-small-size); + font-weight: var(--repo-font-resume-small-weight); + line-height: var(--repo-font-resume-small-line-height); +} + +.section { + display: grid; + gap: 9px; + margin-top: 14px; +} + +.skillList { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 0; + margin: 0; + list-style: none; +} + +.skillList li { + min-width: 52px; + padding: 5px 12px; + border-radius: var(--repo-radius-pill); + background: var(--repo-gray-100); + font-size: 8px; + line-height: 1; + text-align: center; +} + +.activityList { + display: grid; + gap: 10px; + padding: 11px 0 13px; + margin: 0; + border-top: 1px solid var(--repo-gray-300); + list-style: none; +} + +.activityList li { + display: grid; + gap: 3px; + color: var(--repo-text-black); + font-size: 11px; + line-height: 1.25; +} + +.activityList time, +.detailSection h4 { + color: var(--repo-gray-500); + font-size: 12px; + font-weight: 500; +} + +.detailSection { + padding-top: 5px; + border-top: 1px dashed var(--repo-gray-300); +} + +.detailSection + .detailSection { + margin-top: 9px; +} + +.detailSection h4 { + margin: 0 0 4px; +} + +.detailSection ul { + padding-left: 16px; + margin: 0; + color: var(--repo-text-black); + font-size: 11px; + line-height: 1.25; +} diff --git a/src/shared/ui/ResumeBookSheet/ResumeBookSheet.tsx b/src/shared/ui/ResumeBookSheet/ResumeBookSheet.tsx new file mode 100644 index 0000000..b7bc868 --- /dev/null +++ b/src/shared/ui/ResumeBookSheet/ResumeBookSheet.tsx @@ -0,0 +1,83 @@ +import styles from './ResumeBookSheet.module.css' + +const defaultSkills = ['Figma', 'illustrator', 'photoshop'] + +const defaultActivities = [ + { date: '2025.12.25', title: '제 1회 SCSC 온라인 해커톤 2위' }, + { date: '2025.07.18', title: '2025 교내 해커톤 우수상' }, +] + +const defaultContests = ['제4회 2026 블레이버스 MVP 개발 해커톤', '제 1회 SCSC온라인 해커톤', '2025 교내 해커톤'] +const defaultProjects = ['TEENS', '스플', 'D-ask', 'DSG', 'Studiz', 'hear', '마음씨', 'Repo'] + +export type ResumeBookSheetProps = { + readonly ariaLabel?: string + readonly className?: string +} + +export function ResumeBookSheet({ ariaLabel = '최하은 포트폴리오', className }: ResumeBookSheetProps) { + const sheetClassName = [styles.sheet, className].filter(Boolean).join(' ') + + return ( +
+
+
+
+
+

최하은

+ Frontend Developer +
+

2415 인공지능소프트웨어과 | mare2mare6@gmail.com

+
+
+
+ +
+

안녕하세요 저는 디자이너가 되고 싶은 인간입니다

+

+ 새벽자습너무 졸립니다. 뭘 적지.. 한줄소개는 이런식으로 쭉쭉 들어갑니다. + 줄넘김 가능합니다. 자기소개자기소개자기소개자기소개자기소개자기소개.. 최대 4줄이면 충분하겠지만.. +

+
+ +
+

기술스택

+
    + {defaultSkills.map((skill) => ( +
  • {skill}
  • + ))} +
+
+ +
+

활동

+
    + {defaultActivities.map((activity) => ( +
  1. + + {activity.title} +
  2. + ))} +
+
+ +
+

대회

+
    + {defaultContests.map((contest) => ( +
  • {contest}
  • + ))} +
+
+ +
+

Project

+
    + {defaultProjects.map((project) => ( +
  • {project}
  • + ))} +
+
+
+ ) +} diff --git a/src/shared/ui/ResumeBookSheet/index.ts b/src/shared/ui/ResumeBookSheet/index.ts new file mode 100644 index 0000000..5192eb7 --- /dev/null +++ b/src/shared/ui/ResumeBookSheet/index.ts @@ -0,0 +1,2 @@ +export { ResumeBookSheet } from './ResumeBookSheet' +export type { ResumeBookSheetProps } from './ResumeBookSheet' diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 2ee5c93..fd83dfa 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -17,6 +17,7 @@ export { MajorList } from './MajorList'; export { OptionList } from './OptionList'; export { PortfolioUrlModal } from './PortfolioUrlModal'; export { PortfolioResumeSheet } from './PortfolioResumeSheet'; +export { ResumeBookSheet } from './ResumeBookSheet'; export { SearchField } from './SearchField'; export { Switch } from './Switch'; export { Tag } from './Tag'; @@ -38,6 +39,7 @@ export type { MajorListItem, MajorListProps } from './MajorList'; export type { OptionListItem, OptionListProps } from './OptionList'; export type { PortfolioUrlModalProps } from './PortfolioUrlModal'; export type { PortfolioResumeSheetProps } from './PortfolioResumeSheet'; +export type { ResumeBookSheetProps } from './ResumeBookSheet'; export type { SearchFieldProps } from './SearchField'; export type { SwitchProps } from './Switch'; export type { TagProps, TagTone } from './Tag'; diff --git a/tests/e2e/resume-sheet-headings.spec.ts b/tests/e2e/resume-sheet-headings.spec.ts new file mode 100644 index 0000000..b506fed --- /dev/null +++ b/tests/e2e/resume-sheet-headings.spec.ts @@ -0,0 +1,11 @@ +import { expect, test } from '@playwright/test' + +test('uses a consistent heading hierarchy inside each resume book sheet', async ({ page }) => { + await page.goto('/resume-books/1') + + const leftSheet = page.getByRole('article').first() + + await expect(leftSheet.getByRole('heading', { level: 2, name: '최하은' })).toBeVisible() + await expect(leftSheet.getByRole('heading', { level: 3, name: '기술스택' })).toBeVisible() + await expect(leftSheet.getByRole('heading', { level: 4, name: '대회' })).toBeVisible() +}) diff --git a/tests/e2e/resume-sheet-landmarks.spec.ts b/tests/e2e/resume-sheet-landmarks.spec.ts new file mode 100644 index 0000000..ab8318b --- /dev/null +++ b/tests/e2e/resume-sheet-landmarks.spec.ts @@ -0,0 +1,8 @@ +import { expect, test } from '@playwright/test' + +test('gives each resume book sheet a unique landmark name', async ({ page }) => { + await page.goto('/resume-books/1') + + await expect(page.getByRole('article', { name: '최하은 포트폴리오 왼쪽 페이지' })).toBeVisible() + await expect(page.getByRole('article', { name: '최하은 포트폴리오 오른쪽 페이지' })).toBeVisible() +}) diff --git a/tests/e2e/teacher-feedback-mobile.spec.ts b/tests/e2e/teacher-feedback-mobile.spec.ts new file mode 100644 index 0000000..10cf47c --- /dev/null +++ b/tests/e2e/teacher-feedback-mobile.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from '@playwright/test' + +test('keeps feedback controls available on narrow screens', async ({ page }) => { + await page.setViewportSize({ height: 854, width: 720 }) + await page.goto('/students/1') + + await page.getByRole('button', { name: /피드백 추가/ }).click() + + await expect(page.getByLabel('피드백 저장')).toBeVisible() + await expect(page.getByLabel('학생 이력서 검토 설정')).toBeVisible() + await expect(page.getByRole('button', { name: '임시저장' })).toBeVisible() + await expect(page.getByRole('switch', { name: '피드백 보기' })).toBeVisible() +}) diff --git a/tests/e2e/teacher-student-modal-focus.spec.ts b/tests/e2e/teacher-student-modal-focus.spec.ts new file mode 100644 index 0000000..f137304 --- /dev/null +++ b/tests/e2e/teacher-student-modal-focus.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/test' + +test('moves focus into the class dialog and restores it when closed', async ({ page }) => { + await page.goto('/students') + + const classTrigger = page.getByRole('button', { name: /1반/ }).first() + const backgroundContent = page.locator('section[aria-labelledby="students-title"] > div').first() + + await classTrigger.click() + + const closeButton = page.getByRole('button', { name: '반 상세 닫기' }) + await expect(closeButton).toBeFocused() + await expect(backgroundContent).toHaveAttribute('inert', '') + + await closeButton.click() + await expect(classTrigger).toBeFocused() + await expect(backgroundContent).not.toHaveAttribute('inert', '') +}) diff --git a/tests/e2e/teacher-student-review.spec.ts b/tests/e2e/teacher-student-review.spec.ts new file mode 100644 index 0000000..ae7cd14 --- /dev/null +++ b/tests/e2e/teacher-student-review.spec.ts @@ -0,0 +1,83 @@ +import { expect, test } from '@playwright/test' + +test.describe('teacher student portfolio review', () => { + test('opens the teacher-only review route from the student list', async ({ page }) => { + await page.goto('/students') + await page.getByRole('button', { name: /1반/ }).first().click() + + const firstStudentLink = page.getByRole('link', { name: /레주메 보러가기/ }).first() + await expect(firstStudentLink).toHaveAttribute('href', '/students/1') + + await firstStudentLink.click() + await expect(page).toHaveURL(/\/students\/1$/) + await expect(page.getByRole('navigation', { name: '주요 메뉴' }).getByText('학생 관리')).toHaveAttribute( + 'aria-current', + 'page', + ) + }) + + test('provides feedback, visibility, save, and page controls instead of library tools', async ({ page }) => { + await page.setViewportSize({ height: 854, width: 1528 }) + await page.goto('/students/1') + + await expect(page.getByLabel('학생 포트폴리오 검토')).toBeVisible() + await expect(page.getByRole('article', { name: /최하은 포트폴리오/ })).toHaveCount(2) + await expect(page.getByRole('button', { name: '필터 열기' })).toHaveCount(0) + await expect(page.getByRole('button', { name: '전체 PDF 다운로드' })).toHaveCount(0) + + const feedbackSwitch = page.getByRole('switch', { name: '피드백 보기' }) + await expect(feedbackSwitch).toHaveAttribute('aria-checked', 'false') + await expect(page.getByRole('button', { name: '피드백 내용 보기' })).toHaveCount(0) + + await page.getByRole('button', { name: /피드백 추가/ }).click() + await expect(page.getByRole('button', { name: '임시저장' })).toBeVisible() + await expect(page.getByRole('button', { name: '저장', exact: true })).toBeVisible() + await expect(feedbackSwitch).toHaveAttribute('aria-checked', 'true') + await expect(page.getByRole('button', { name: '피드백 내용 보기' })).toHaveCount(3) + await expect(page.getByRole('heading', { name: '피드백 목록' })).toBeVisible() + await expect(page.getByRole('button', { name: /피드백 제목/ })).toHaveCount(8) + await expect(page.getByText('피드백에 대한 상세 내용')).toBeVisible() + + await expect + .poll(async () => { + const [documentPages, feedbackPanel, saveActions, reviewSettings] = await Promise.all([ + page.getByLabel('최하은 포트폴리오 문서 페이지').boundingBox(), + page.getByRole('complementary').boundingBox(), + page.getByLabel('피드백 저장').boundingBox(), + page.getByLabel('학생 이력서 검토 설정').boundingBox(), + ]) + + if (!documentPages || !feedbackPanel || !saveActions || !reviewSettings) { + return false + } + + return [documentPages, saveActions, reviewSettings].every( + (element) => element.x + element.width <= feedbackPanel.x, + ) + }) + .toBe(true) + + await page.getByRole('button', { name: '피드백 목록 닫기' }).click() + await expect(page.getByRole('heading', { name: '피드백 목록' })).toHaveCount(0) + await expect(feedbackSwitch).toHaveAttribute('aria-checked', 'false') + await expect(page.getByRole('button', { name: '피드백 내용 보기' })).toHaveCount(0) + + await page.getByRole('button', { name: '다음 페이지' }).last().click() + await expect(page.getByLabel('현재 페이지')).toHaveText('3 / 5') + + await page.getByRole('button', { name: '임시저장' }).click() + await expect(page.getByRole('status')).toContainText('피드백을 임시저장했습니다.') + }) + + test('reports a visibility update failure without changing the switch', async ({ page }) => { + await page.goto('/students/1?error=visibility') + + const visibilitySwitch = page.getByRole('switch', { name: '이력서 공개' }) + await visibilitySwitch.click() + + await expect(page.getByLabel('학생 포트폴리오 검토').getByRole('alert')).toContainText( + '이력서 공개 상태 변경에 실패하였습니다.', + ) + await expect(visibilitySwitch).toHaveAttribute('aria-checked', 'false') + }) +}) diff --git a/tests/e2e/teacher-student-search.spec.ts b/tests/e2e/teacher-student-search.spec.ts new file mode 100644 index 0000000..9bc4b79 --- /dev/null +++ b/tests/e2e/teacher-student-search.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test' + +test('filters the selected class student list by name', async ({ page }) => { + await page.goto('/students') + + const searchField = page.getByRole('searchbox', { name: '학생 이름 검색' }) + const classTrigger = page.getByRole('button', { name: /1반/ }).first() + + await classTrigger.click() + await expect(page.getByRole('dialog').getByRole('link', { name: /레주메 보러가기/ })).toHaveCount(9) + + await page.getByRole('button', { name: '반 상세 닫기' }).click() + await searchField.fill('없는 학생') + await classTrigger.click() + await expect(page.getByRole('dialog').getByRole('link', { name: /레주메 보러가기/ })).toHaveCount(0) + + await page.getByRole('button', { name: '반 상세 닫기' }).click() + await searchField.fill('최하') + await classTrigger.click() + await expect(page.getByRole('dialog').getByRole('link', { name: /레주메 보러가기/ })).toHaveCount(9) +})