From 55463f4939d2211b8b3f7ffa8c13f4e77e4662a7 Mon Sep 17 00:00:00 2001 From: hyemin Date: Tue, 25 Aug 2026 14:45:07 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat=20(#26)=20::=20=EC=84=A0=EC=83=9D?= =?UTF-8?q?=EB=8B=98=20=EC=A0=84=EA=B3=B5=20=EA=B4=80=EB=A6=AC=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constraint: 제공된 4개 화면 상태를 기존 디자인 토큰과 공용 UI만으로 구현 Rejected: 신규 상태 관리·폼 의존성 도입 | 현재 로컬 상호작용 범위에 불필요함 Confidence: high Scope-risk: moderate Directive: API 연동 시 생성·삭제·필터 상태의 UI 계약과 Toast 문구 유지 Tested: pnpm lint; pnpm build; pnpm exec playwright test tests/e2e/teacher-major-management.spec.ts --workers=1 Not-tested: 백엔드 영속 데이터 연동 --- src/app/(teacher)/majors/page.module.css | 330 +++++++++++++++++++++++ src/app/(teacher)/majors/page.tsx | 269 ++++++++++++++++++ 2 files changed, 599 insertions(+) create mode 100644 src/app/(teacher)/majors/page.module.css create mode 100644 src/app/(teacher)/majors/page.tsx diff --git a/src/app/(teacher)/majors/page.module.css b/src/app/(teacher)/majors/page.module.css new file mode 100644 index 0000000..406a4aa --- /dev/null +++ b/src/app/(teacher)/majors/page.module.css @@ -0,0 +1,330 @@ +.page { + min-height: 100dvh; + color: var(--repo-gray-50); + background: var(--repo-bg-main); +} + +.content { + position: relative; + width: min(100%, 1268px); + margin: 0 auto; + padding: 76px 32px 96px; +} + +.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; + justify-items: center; + gap: 24px; + text-align: center; +} + +.hero h1 { + 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; +} + +.hero p { + margin: 0; + 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; +} + +.majorForm { + display: grid; + width: min(100%, 1032px); + grid-template-columns: minmax(0, 1fr) 160px; + align-items: start; + gap: 16px; + margin: 28px auto 0; +} + +.addButton { + width: 160px; + min-height: 56px; + border-radius: var(--repo-radius-control); +} + +.management { + display: grid; + width: 100%; + margin-top: 60px; +} + +.page[data-major-selected='true'] .management { + grid-template-columns: 296px minmax(0, 1fr); + gap: 40px; +} + +.majorList { + min-width: 0; +} + +.majorList section { + gap: 0; +} + +.majorList section > h2 { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + padding: 0; + margin: -1px; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.majorList section > div { + gap: 16px; +} + +.majorList button { + min-height: 56px; + padding: 14px 24px; + border: 1px solid var(--repo-bg-main-point); + border-radius: 8px; + background: var(--repo-bg-main-lighter); +} + +.majorList button:hover { + transform: none; + background: var(--repo-bg-main-point); +} + +.majorList button span:first-child > span:first-child { + 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); +} + +.detailPanel { + position: relative; + min-width: 0; + min-height: 628px; + padding: 24px 38px 72px; + border: 1px solid var(--repo-bg-main-point); + border-radius: var(--repo-radius-control); + background: var(--repo-bg-main-lighter); +} + +.detailHeader { + display: grid; + gap: 3px; +} + +.detailHeader h2 { + 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; +} + +.detailHeader p { + margin: 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); +} + +.detailDivider { + height: 1px; + margin-top: 12px; + background: var(--repo-bg-main-point); +} + +.filters { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-top: 12px; +} + +.yearTabs { + display: flex; + align-items: center; + gap: 8px; +} + +.yearTabs button { + min-width: 48px; + padding: 8px 0 10px; + border: 0; + border-bottom: 1px solid transparent; + color: var(--repo-gray-500); + background: transparent; + 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); + cursor: pointer; +} + +.yearTabs button:hover, +.yearTabs button:focus-visible, +.yearTabs .activeYear { + border-bottom-color: var(--repo-gray-50); + outline: 0; + color: var(--repo-gray-50); +} + +.classDropdown { + min-width: 96px; +} + +.studentContent { + min-height: 414px; +} + +.studentList { + display: grid; + gap: 2px; + margin-top: 12px; +} + +.studentRow { + min-height: 39px; + padding: 8px 0; + background: transparent; +} + +.studentRow:hover { + transform: none; + background: var(--repo-bg-main-point); +} + +.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: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); +} + +.emptyMessage { + display: grid; + min-height: 410px; + place-items: center; + margin: 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); +} + +.deleteButton { + position: absolute; + right: 38px; + bottom: 26px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 0; + border: 0; + color: var(--repo-state-error); + background: transparent; + 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); + cursor: pointer; +} + +.deleteButton:focus-visible { + outline: 2px solid var(--repo-state-error); + outline-offset: 3px; +} + +@media (max-width: 1080px) { + .content { + padding-top: 56px; + } + + .page[data-major-selected='true'] .management { + grid-template-columns: minmax(220px, 0.42fr) minmax(0, 1fr); + gap: 24px; + } + + .detailPanel { + padding-inline: 24px; + } + + .yearTabs { + gap: 2px; + } + + .yearTabs button { + min-width: 42px; + } +} + +@media (max-width: 760px) { + .content { + padding: 40px 20px 64px; + } + + .hero { + gap: 16px; + } + + .hero p { + font-size: var(--repo-font-body-small-size); + line-height: var(--repo-font-body-small-line-height); + } + + .majorForm { + grid-template-columns: 1fr; + } + + .addButton { + width: 100%; + } + + .management, + .page[data-major-selected='true'] .management { + grid-template-columns: 1fr; + gap: 24px; + margin-top: 40px; + } + + .detailPanel { + min-height: 560px; + padding: 24px 20px 72px; + } + + .filters { + gap: 16px; + overflow-x: auto; + } + + .yearTabs { + flex: 0 0 auto; + } + + .deleteButton { + right: 20px; + } +} diff --git a/src/app/(teacher)/majors/page.tsx b/src/app/(teacher)/majors/page.tsx new file mode 100644 index 0000000..395204b --- /dev/null +++ b/src/app/(teacher)/majors/page.tsx @@ -0,0 +1,269 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' + +import type { AppHeaderItem, MajorListItem, ToastVariant } from '@/shared/ui' +import { AppHeader, Button, Dropdown, LinkRow, MajorInputGroup, MajorList, 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[] + +type Major = MajorListItem & { + readonly createdAt: string + readonly hasStudents: boolean +} + +type Student = { + readonly classNumber: string + readonly id: number + readonly name: string + readonly number: string + readonly year: string +} + +type Notice = { + readonly message: string + readonly variant: ToastVariant +} + +const initialMajors: readonly Major[] = Array.from({ length: 9 }, (_, index) => ({ + createdAt: '2023.05.23', + hasStudents: index === 0, + id: `major-${index + 1}`, + name: 'Frontend Developer', +})) + +const students: readonly Student[] = Array.from({ length: 8 }, (_, index) => ({ + classNumber: '4', + id: index + 1, + name: '최하은', + number: '2415', + year: String(2023 + (index % 4)), +})) + +const yearFilters = [ + { label: '전체', value: 'all' }, + { label: '2023', value: '2023' }, + { label: '2024', value: '2024' }, + { label: '2025', value: '2025' }, + { label: '2026', value: '2026' }, +] as const + +const classFilters = [ + { label: '전체', value: 'all' }, + { label: '1반', value: '1' }, + { label: '2반', value: '2' }, + { label: '3반', value: '3' }, + { label: '4반', value: '4' }, +] + +export default function TeacherMajorsPage() { + const [majors, setMajors] = useState(initialMajors) + const [selectedMajorId, setSelectedMajorId] = useState(null) + const [majorName, setMajorName] = useState('') + const [majorNameError, setMajorNameError] = useState() + const [selectedYear, setSelectedYear] = useState('all') + const [selectedClass, setSelectedClass] = useState('all') + const [notice, setNotice] = useState(null) + const nextMajorId = useRef(initialMajors.length + 1) + const noticeTimerId = useRef | null>(null) + + const selectedMajor = majors.find((major) => major.id === selectedMajorId) ?? null + const filteredStudents = useMemo(() => { + if (!selectedMajor?.hasStudents) { + return [] + } + + return students.filter( + (student) => + (selectedYear === 'all' || student.year === selectedYear) && + (selectedClass === 'all' || student.classNumber === selectedClass), + ) + }, [selectedClass, selectedMajor, selectedYear]) + + useEffect(() => { + return () => { + if (noticeTimerId.current) { + clearTimeout(noticeTimerId.current) + } + } + }, []) + + const showNotice = (nextNotice: Notice) => { + if (noticeTimerId.current) { + clearTimeout(noticeTimerId.current) + } + + setNotice(nextNotice) + noticeTimerId.current = setTimeout(() => { + setNotice(null) + noticeTimerId.current = null + }, 2500) + } + + const selectMajor = (majorId: string) => { + setSelectedMajorId(majorId) + setSelectedYear('all') + setSelectedClass('all') + } + + const addMajor = () => { + const normalizedMajorName = majorName.trim() + + if (!normalizedMajorName) { + setMajorNameError('전공명을 입력해 주세요.') + return + } + + const newMajor: Major = { + createdAt: '2026.08.25', + hasStudents: false, + id: `major-${nextMajorId.current}`, + name: normalizedMajorName, + } + + nextMajorId.current += 1 + setMajors((currentMajors) => [...currentMajors, newMajor]) + setMajorName('') + setMajorNameError(undefined) + showNotice({ message: '전공이 추가되었습니다.', variant: 'success' }) + } + + const deleteSelectedMajor = () => { + if (!selectedMajor) { + return + } + + setMajors((currentMajors) => currentMajors.filter((major) => major.id !== selectedMajor.id)) + setSelectedMajorId(null) + showNotice({ message: '전공이 삭제되었습니다.', variant: 'success' }) + } + + return ( +
+ + +
+ {notice ? ( +
+ {notice.message} +
+ ) : null} + +
+

전공 관리

+

학생들의 전공을 체계적으로 관리하세요.

+
+ +
{ + event.preventDefault() + addMajor() + }} + > + { + setMajorName(value) + if (majorNameError) { + setMajorNameError(undefined) + } + }} + /> + + + +
+
+ +
+ + {selectedMajor ? ( +
+
+

{selectedMajor.name}

+

생성일 : {selectedMajor.createdAt}

+
+ +
+ +
+
+ {yearFilters.map((filter) => ( + + ))} +
+ +
+ +
+ {filteredStudents.length > 0 ? ( +
+ {filteredStudents.map((student) => ( + + ))} +
+ ) : ( +

해당 전공에 소속된 학생이 없습니다.

+ )} +
+ + +
+ ) : null} +
+
+
+ ) +} + +function TrashIcon() { + return ( + + ) +} From 5598d0c328874bec600dfda1d9d6be6a9ec49697 Mon Sep 17 00:00:00 2001 From: hyemin Date: Tue, 25 Aug 2026 14:45:37 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test=20(#26)=20::=20=EC=A0=84=EA=B3=B5=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constraint: 기본 목록·입력 검증·학생 상태·삭제 Toast를 브라우저 수준에서 검증 Rejected: 정적 렌더링 확인만 추가 | 상태 전환과 접근 가능한 이름 회귀를 잡지 못함 Confidence: high Scope-risk: narrow Directive: API 연동 후에도 동일 사용자 관점 시나리오를 유지 Tested: pnpm exec playwright test tests/e2e/teacher-major-management.spec.ts --workers=1 Not-tested: 실제 서버 오류 응답 시나리오 --- tests/e2e/teacher-major-management.spec.ts | 61 ++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/e2e/teacher-major-management.spec.ts diff --git a/tests/e2e/teacher-major-management.spec.ts b/tests/e2e/teacher-major-management.spec.ts new file mode 100644 index 0000000..1c0adf6 --- /dev/null +++ b/tests/e2e/teacher-major-management.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test' + +test.describe('teacher major management', () => { + test('renders the teacher-only major list and active navigation', async ({ page }) => { + await page.goto('/majors') + + await expect(page.getByRole('heading', { level: 1, name: '전공 관리' })).toBeVisible() + await expect(page.getByRole('navigation', { name: '주요 메뉴' }).getByText('전공 관리')).toHaveAttribute( + 'aria-current', + 'page', + ) + await expect(page.getByRole('region', { name: '전공 목록' }).getByRole('button')).toHaveCount(9) + await expect(page.getByRole('heading', { name: 'Frontend Developer' })).toHaveCount(0) + }) + + test('validates, adds, and announces a major', async ({ page }) => { + await page.goto('/majors') + + const majorInput = page.getByRole('textbox', { name: '추가할 전공 이름' }) + await page.getByRole('button', { name: /전공 추가/ }).click() + + await expect(page.getByText('전공명을 입력해 주세요.')).toBeVisible() + await expect(majorInput).toHaveAttribute('aria-invalid', 'true') + + await majorInput.fill('Backend Developer') + await page.getByRole('button', { name: /전공 추가/ }).click() + + await expect(page.getByRole('status')).toContainText('전공이 추가되었습니다.') + await expect(page.getByRole('region', { name: '전공 목록' }).getByRole('button')).toHaveCount(10) + await expect(majorInput).toHaveValue('') + }) + + test('shows students for a populated major and an empty state for another major', async ({ page }) => { + await page.goto('/majors') + + const majorButtons = page.getByRole('region', { name: '전공 목록' }).getByRole('button') + await majorButtons.first().click() + + const detailPanel = page.getByRole('region', { name: 'Frontend Developer' }) + await expect(detailPanel.getByText('생성일 : 2023.05.23')).toBeVisible() + await expect(detailPanel.getByRole('link', { name: /레주메 보러가기/ })).toHaveCount(8) + + await majorButtons.nth(1).click() + await expect(page.getByText('해당 전공에 소속된 학생이 없습니다.')).toBeVisible() + }) + + test('filters students and deletes the selected major with a toast', async ({ page }) => { + await page.goto('/majors') + + const majorButtons = page.getByRole('region', { name: '전공 목록' }).getByRole('button') + await majorButtons.first().click() + await page.getByRole('button', { name: '2023' }).click() + await expect(page.getByRole('region', { name: 'Frontend Developer' }).getByRole('link')).toHaveCount(2) + + await page.getByRole('button', { name: '전공 삭제' }).click() + + await expect(page.getByRole('status')).toContainText('전공이 삭제되었습니다.') + await expect(page.getByRole('region', { name: '전공 목록' }).getByRole('button')).toHaveCount(8) + await expect(page.getByRole('heading', { name: 'Frontend Developer' })).toHaveCount(0) + }) +}) From 646cc0e73249ca0e09294a1f7a4a7d669b28be30 Mon Sep 17 00:00:00 2001 From: hyemin Date: Tue, 25 Aug 2026 14:53:20 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix=20(#26)=20::=20=EC=A0=84=EA=B3=B5=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=88=A8=EA=B9=80=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=ED=98=84=EB=8C=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constraint: 접근 가능한 제목은 유지하면서 화면에서는 기존과 동일하게 숨겨야 함 Rejected: deprecated clip 유지 | 최신 CSS 기준과 정적 리뷰 요구를 충족하지 못함 Confidence: high Scope-risk: narrow Directive: visually-hidden 패턴은 clip-path 기반으로 유지 Tested: pnpm lint; pnpm build --- src/app/(teacher)/majors/page.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/(teacher)/majors/page.module.css b/src/app/(teacher)/majors/page.module.css index 406a4aa..29ca7c7 100644 --- a/src/app/(teacher)/majors/page.module.css +++ b/src/app/(teacher)/majors/page.module.css @@ -88,7 +88,7 @@ overflow: hidden; padding: 0; margin: -1px; - clip: rect(0, 0, 0, 0); + clip-path: inset(50%); white-space: nowrap; border: 0; }