designpaca/apps/site/public/work/dure-enrollment/index.html
Yun Chan 72337b7ee0
Some checks failed
ci / build (push) Failing after 5s
feat(skill): 자율 검증 폐쇄 루프 내재화 — 0.6.0
- 핵심 규칙 6: 완료의 정의는 게이트 통과. 사용자를 QA로 쓰지 않는다
- 5단계 기계 검사 4번째: design-gate 실행 의무화
- references/audit-gate.md: 사고-검사 매핑, SEO/meta 체크리스트, OS/브라우저 특성, 하니스 규칙
- tools/design-gate.mjs: 범용 게이트(메타/SEO·대비·수축·리듬·트랙·스케일×폭 + 옵션 L0/L3/L4, checks 깊은 병합)
- 리듬·트랙 불변식: 숫자 라벨 등폭·빈 셀 트랙 균일·등간격 — 시간표 자동배치 결함 재현 픽스처 검출, 앱 15뷰 통과(오탐 0)

feat(site): 관리 앱 2종 프로덕션 콘솔(v6→v18)

- 가온 학적부 9뷰·두레 수강신청 6뷰: shadcn 문법, Pretendard/Noto Serif/IBM Plex 폰트 전략, 볼드 금지(400/500/600), WCAG AA 대비 전면 교정, 한글 keep-all 조판
- LMS 필수 요소(알림 센터·공지·진도·평가 유형·출결 사유·학점 경고), 12명 기준 데이터 정합, SEO 구조(h1 유일·OG/twitter·og.png)
- 시간표 자동배치 결함 수리(명시적 격자 좌표)
- QA 게이트: L0 stylelint/html-validate · L1 단위 30 · L2 감사 61+불변식 · L3 시각회귀 30화면 · L4 WebKit · L5 키보드 탐색 — npm run verify 실패 시 배포 금지 체인
2026-08-22 21:22:05 +09:00

771 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>두레대학 수강신청 · 시간표</title>
<meta property="og:type" content="website" />
<meta property="og:title" content="두레대학 수강신청 — 시간표 도장 앱" />
<meta property="og:url" content="https://designpaca.chanpaca.net/work/dure-enrollment/" />
<meta property="og:image" content="https://designpaca.chanpaca.net/work/dure-enrollment/og.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="두레대학 수강신청 — 시간표 도장 앱" />
<meta name="twitter:image" content="https://designpaca.chanpaca.net/work/dure-enrollment/og.png" />
<meta
name="description"
content="과목을 고르면 시간표에 얹히는 수강신청 데모. 관심 과목, 신청 내역, 이수 현황까지 여섯 화면. 시간 겹침과 학점 초과를 실제로 검사합니다."
/>
<meta property="og:title" content="두레대학 수강신청 · 시간표" />
<meta property="og:description" content="시간표에 얹히는 수강신청." />
<meta property="og:type" content="website" />
<meta name="theme-color" content="#FBFAF7" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=IBM+Plex+Sans+KR:wght@400;500;600&family=Noto+Serif+KR:wght@500;600&display=swap"
/>
<link rel="stylesheet" href="./styles/main.css?v=18" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23FBFAF7'/%3E%3Crect x='6' y='6' width='20' height='20' fill='none' stroke='%2317202E' stroke-width='2'/%3E%3Crect x='13' y='13' width='6' height='6' fill='%23C14B2B'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">본문으로 건너뛰기</a>
<h1 class="sr-only">두레대학 수강신청</h1>
<header class="appbar">
<div class="appbar-inner">
<a class="brand" href="#main">
<span class="brand-seal" aria-hidden="true"></span>
두레대학 수강신청
</a>
<p class="credits" aria-live="polite">
신청 학점 <strong id="credit-now">0</strong> / 18
</p>
</div>
<nav class="tabs" aria-label="화면">
<button type="button" class="tab is-current" data-view="catalog" aria-current="page">과목 찾기</button>
<button type="button" class="tab" data-view="starred">관심 과목</button>
<button type="button" class="tab" data-view="enrollments">신청 내역</button>
<button type="button" class="tab" data-view="credits">이수 현황</button>
<button type="button" class="tab" data-view="archive">과목 총람</button>
<button type="button" class="tab" data-view="notices">공지 · 변경</button>
</nav>
</header>
<main id="main" class="layout">
<!-- ── 과목 찾기 ── -->
<section class="view is-on" id="view-catalog" aria-labelledby="cat-h" tabindex="-1">
<section class="catalog" aria-labelledby="cat-h">
<h2 class="v-h" id="cat-h">2026학년도 1학기 과목</h2>
<p class="view-sub">과목을 누르면 강의계획과 신청 버튼이 열립니다. 신청하면 시간표에 얹힙니다.</p>
<div class="sheet">
<div class="toolbar" role="search">
<label class="sr-only" for="q">과목 검색</label>
<input id="q" type="search" placeholder="과목명, 교수, 코드로 검색" autocomplete="off" />
<div class="chips" role="group">
<button type="button" class="chip is-on" data-dept="all" aria-pressed="true">전체</button>
<button type="button" class="chip" data-dept="전공" aria-pressed="false">전공</button>
<button type="button" class="chip" data-dept="교양" aria-pressed="false">교양</button>
</div>
</div>
<ul class="course-list" id="course-list"></ul>
<p class="empty muted" id="course-empty" hidden>조건에 맞는 과목이 없습니다.</p>
</div>
</section>
<aside class="timetable-panel" aria-labelledby="tt-h">
<h2 id="tt-h">내 시간표</h2>
<p class="muted tt-hint">시간표 블록을 누르면 취소할 수 있습니다.</p>
<div class="sheet sheet-tt">
<div class="tt-scroll">
<div class="tt" id="tt">
<!-- JS: 축 + 블록 -->
</div>
</div>
<p class="status" id="status" role="status" aria-live="polite"></p>
<p class="tt-legend">
<span class="key"><i class="k-major"></i>전공</span>
<span class="key"><i class="k-gen"></i>교양</span>
<span class="key"><i class="k-seal"></i>신청됨</span>
</p>
</div>
</aside>
</section>
<!-- ── 관심 과목 ── -->
<section class="view" id="view-starred" aria-labelledby="st-h" tabindex="-1">
<h2 class="v-h" id="st-h">관심 과목</h2>
<p class="view-sub">강의계획에서 관심을 남기면 여기에 모입니다. 여석과 시간은 실시간입니다.</p>
<div class="statline" id="st-stats"></div>
<p class="st-warn" id="st-warn" role="status" hidden></p>
<div class="toolbar">
<div class="chips" role="group" id="st-chips">
<button type="button" class="chip is-on" data-st="all" aria-pressed="true">전체</button>
<button type="button" class="chip" data-st="전공" aria-pressed="false">전공</button>
<button type="button" class="chip" data-st="교양" aria-pressed="false">교양</button>
</div>
</div>
<div class="sheet">
<ul class="course-list" id="starred-list"></ul>
<div class="empty-box" id="starred-empty" hidden>
<p>아직 관심 과목이 없습니다.</p>
<p class="muted">과목 찾기에서 과목을 열고 관심 남기기를 누르면 여기에 쌓입니다.</p>
<button type="button" class="btn-line" data-goto="catalog">과목 찾기로 가기</button>
</div>
<section class="panel st-suggest" id="st-suggest" aria-labelledby="stsg-h" hidden>
<h2 id="stsg-h">지금 여석이 임박한 과목 <span class="h-note">관심을 남겨 두면 마감 전에 확인할 수 있습니다</span></h2>
<ul class="course-list" id="st-suggest-list"><!-- JS --></ul>
</section>
</div>
</section>
<!-- ── 신청 내역 ── -->
<section class="view" id="view-enrollments" aria-labelledby="en-h" tabindex="-1">
<h2 class="v-h" id="en-h">신청 내역</h2>
<p class="view-sub">이번 학기에 신청한 과목의 원장. 여기서도 취소할 수 있습니다.</p>
<div class="statline" id="en-stats"></div>
<div class="sheet">
<p class="en-sum mono">합계 <strong id="en-credits">0</strong>학점 / 18</p>
<div class="credit-gauge" role="img">
<i id="en-gauge" style="width: 0%"></i>
</div>
<p class="panel-note muted">정정 기간은 3/93/13입니다. 18학점 초과 신청은 학부 사무실 승인이 필요하고, 두 번 결석하면 탈강 처리됩니다 — 변경은 이 화면에서 할 수 있습니다.</p>
<div class="table-wrap">
<table id="en-table">
<caption class="sr-only">신청 과목 목록</caption>
<thead>
<tr><th scope="col">과목</th><th scope="col">시간</th><th scope="col">장소</th><th scope="col" class="num">학점</th><th scope="col">상태</th></tr>
</thead>
<tbody><!-- JS --></tbody>
</table>
</div>
<div class="empty-box" id="en-empty" hidden>
<p>신청한 과목이 없습니다.</p>
<p class="muted">시간표가 비어 있습니다. 과목 찾기에서 첫 과목을 얹어보세요.</p>
<button type="button" class="btn-line" data-goto="catalog">과목 찾기로 가기</button>
</div>
<p class="status" id="en-status" role="status" aria-live="polite"></p>
</div>
</section>
<!-- ── 이수 현황 ── -->
<section class="view" id="view-credits" aria-labelledby="cr-h" tabindex="-1">
<h2 class="v-h" id="cr-h">이수 현황</h2>
<p class="view-sub">네 학기를 마친 시점의 기록에 이번 학기 신청을 얹어 봅니다. 신청은 확정이 아닙니다.</p>
<div class="statline" id="cr-stats"></div>
<div class="sheet table-wrap">
<table id="cr-table">
<caption class="sr-only">졸업 기준 대비 이수 학점</caption>
<thead>
<tr>
<th scope="col">구분</th><th scope="col" class="num">졸업 기준</th>
<th scope="col" class="num">이수</th><th scope="col" class="num">이번 학기 신청</th>
<th scope="col" class="num">합계</th><th scope="col" class="num">잔여</th><th scope="col">진행</th>
</tr>
</thead>
<tbody><!-- JS --></tbody>
</table>
<p class="sheet-note muted">잔여가 0보다 작아지면 초과입니다. 학점 초과 신청은 학부 사무실 승인이 필요합니다.</p>
<div class="cr-total">
<p class="panel-note muted">졸업 기준 대비 총 이수율 <strong class="cr-total-num mono"></strong></p>
<div class="cr-bar"><i id="cr-total-bar" style="width: 0%"></i></div>
</div>
</div>
</section>
<!-- ── 과목 총람 ── -->
<section class="view" id="view-archive" aria-labelledby="ar-h" tabindex="-1">
<h2 class="v-h" id="ar-h">과목 총람</h2>
<p class="view-sub">개설 과목 전부를 계열별로 묶어 적습니다. 여석이 5 이하인 과목은 붉게 표시합니다.</p>
<div class="statline" id="ar-stats"></div>
<div class="sheet table-wrap">
<table id="ar-table">
<caption class="sr-only">개설 과목 전체 목록</caption>
<thead>
<tr>
<th scope="col">코드</th><th scope="col">과목</th><th scope="col">교수</th>
<th scope="col">시간</th><th scope="col" class="num">학점</th>
<th scope="col" class="num">여석 / 정원</th>
</tr>
</thead>
<tbody><!-- JS --></tbody>
</table>
</div>
</section>
<!-- ── 공지 · 변경 ── -->
<section class="view" id="view-notices" aria-labelledby="no-h" tabindex="-1">
<h2 class="v-h" id="no-h">공지 · 변경</h2>
<div class="statline" id="nc-stats"></div>
<p class="view-sub">수강신청 기간의 게시판. 강의실 변경은 이 원장에 남깁니다.</p>
<div class="sheet">
<ul class="notice-list" id="notice-list"><!-- JS --></ul>
</div>
</section>
</main>
<!-- ── 강의계획 모달: 펼침 장부. 왼쪽 남색 표지판 + 오른쪽 계획 내용 ── -->
<dialog id="course-dialog" class="modal modal-split" aria-labelledby="cd-h">
<div class="mspread">
<div class="mcover">
<span class="mcover-mark" aria-hidden="true"></span>
<span class="mcover-seal" aria-hidden="true"></span>
<h2 class="mcover-name" id="cd-h">과목명</h2>
<p class="mcover-meta mono" id="cd-code">CODE · 계열</p>
<dl class="mcover-stats">
<div><dt>학점</dt><dd class="mono" id="cd-credit"></dd></div>
<div><dt>여석</dt><dd class="mono" id="cd-seats"></dd></div>
<div><dt>시간</dt><dd class="mono" id="cd-time"></dd></div>
</dl>
<p class="mcover-foot mono">2026학년도 1학기</p>
</div>
<div class="msheet">
<button type="button" class="modal-close" data-close aria-label="닫기">
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M3.5 3.5l9 9M12.5 3.5l-9 9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" /></svg>
</button>
<p class="cd-line" id="cd-prof-line">교수 · 강의실</p>
<section class="cd-week msec">
<h3>주간 자리</h3>
<div class="cd-strip" id="cd-strip" role="img"></div>
</section>
<section class="eval msec">
<h3>평가</h3>
<div class="eval-bar" id="cd-eval-bar" aria-hidden="true"></div>
<dl class="eval-legend" id="cd-eval"></dl>
</section>
<p class="warn" id="cd-warn" hidden></p>
<div class="modal-actions">
<button type="button" class="btn-seal" id="cd-apply">수강 신청</button>
<button type="button" class="btn-line" id="cd-cancel" hidden>신청 취소</button>
<button type="button" class="btn-star" id="cd-star" aria-pressed="false">관심 남기기</button>
</div>
</div>
</div>
</dialog>
<script src="calc.js"></script>
<script>
/*
수강신청 상태 기계 — 겹침 검사, 학점 한도, 여석 감소까지 전부 진짜.
여섯 화면이 같은 상태(enrolled, starred)를 공유한다.
새로고침하면 초기화됩니다(데모 원칙: 저장을 흉내내지 않는다).
*/
const fmtDay = (d) => ({ 1: "월", 2: "화", 3: "수", 4: "목", 5: "금" })[d];
const COURSES = [
{ id: "hum201", code: "HUM201", name: "문학과 사회", dept: "교양", prof: "김선희", room: "인문관 302", credits: 3, cap: 40, seats: 12, days: [1, 3], start: 10, end: 11.5, eval: [["출석", 10], ["발표", 30], ["기말", 60]] },
{ id: "cs101", code: "CS101", name: "컴퓨팅 사고", dept: "교양", prof: "이준호", room: "공학관 105", credits: 3, cap: 60, seats: 31, days: [2, 4], start: 13, end: 14.5, eval: [["과제", 40], ["중간", 30], ["기말", 30]] },
{ id: "econ210", code: "ECON210", name: "미시경제학", dept: "전공", prof: "박민재", room: "경상관 210", credits: 3, cap: 45, seats: 8, days: [1, 3], start: 13, end: 14.5, eval: [["과제", 30], ["중간", 30], ["기말", 40]] },
{ id: "stat110", code: "STAT110", name: "통계의 기초", dept: "교양", prof: "정하나", room: "자연관 118", credits: 3, cap: 50, seats: 20, days: [5], start: 9, end: 12, eval: [["퀴즈", 20], ["중간", 40], ["기말", 40]] },
{ id: "phil115", code: "PHIL115", name: "논리와 토론", dept: "교양", prof: "오세영", room: "인문관 115", credits: 2, cap: 35, seats: 14, days: [2], start: 16, end: 18, eval: [["발표", 50], ["기말", 50]] },
{ id: "soc220", code: "SOC220", name: "도시와 사람", dept: "전공", prof: "한유진", room: "사회관 204", credits: 3, cap: 40, seats: 17, days: [4], start: 10, end: 12.5, eval: [["리포트", 40], ["발표", 20], ["기말", 40]] },
{ id: "bio105", code: "BIO105", name: "생명과학 산책", dept: "교양", prof: "남기훈", room: "자연관 B1", credits: 2, cap: 60, seats: 25, days: [1], start: 14, end: 16, eval: [["출석", 20], ["리포트", 80]] },
{ id: "art130", code: "ART130", name: "드로잉 입문", dept: "교양", prof: "서지우", room: "예술관 301", credits: 3, cap: 25, seats: 3, days: [3], start: 14, end: 17, eval: [["작품", 60], ["기말작품", 40]] },
{ id: "econ315", code: "ECON315", name: "게임이론", dept: "전공", prof: "최도윤", room: "경상관 308", credits: 3, cap: 30, seats: 6, days: [2, 4], start: 10, end: 11.5, eval: [["과제", 30], ["중간", 30], ["기말", 40]] },
{ id: "cs210", code: "CS210", name: "자료구조", dept: "전공", prof: "강태양", room: "공학관 212", credits: 3, cap: 40, seats: 22, days: [5], start: 13, end: 16, eval: [["실습", 40], ["중간", 30], ["기말", 30]] },
];
const NOTICES = [
{ date: "03-08", kind: "변경", body: "게임이론(ECON315) 강의실이 경상관 308호에서 309호로 바뀝니다. 이 목록에는 이미 반영했습니다." },
{ date: "03-07", kind: "공지", body: "수강신청 기간은 3월 2일부터 3월 13일 자정까지입니다. 기간 끝에는 시간표가 확정됩니다." },
{ date: "03-06", kind: "안내", body: "기준 학점(18)을 넘는 신청은 학부 사무실의 승인을 받아야 합니다. 이 데모에서는 넘는 신청을 막아둡니다." },
{ date: "03-05", kind: "안내", body: "드로잉 입문(ART130)은 재료비 3만 원이 듭니다. 첫 수업에 안내합니다." },
{ date: "03-03", kind: "공지", body: "정원이 마감된 과목은 대기자 신청을 받지 않습니다. 분반 개설은 학부 상황에 따라 결정됩니다." },
{ date: "03-02", kind: "공지", body: "2026학년도 1학기 수강신청이 열렸습니다. 시간이 겹치는 과목은 함께 담을 수 없습니다." },
];
const MAX_CREDITS = 18;
// 네 학기를 마친 시점의 이수 기록 — 이 데모의 가상 학생
const RECORD = [
{ label: "전공", need: 66, done: 45 },
{ label: "교양", need: 24, done: 21 },
{ label: "자유", need: 40, done: 28 },
];
const enrolled = new Set();
const starred = new Set();
const $ = (id) => document.getElementById(id);
const hhmm = (t) => `${Math.floor(t)}:${t % 1 ? "30" : "00"}`;
const timeLabel = (c) => c.days.map(fmtDay).join("·") + " " + hhmm(c.start) + "" + hhmm(c.end);
// ── 탭(뷰) 전환 ──
const views = document.querySelectorAll(".view");
const gotoView = (name) => {
document.querySelectorAll(".tab").forEach((b) => {
const on = b.dataset.view === name;
b.classList.toggle("is-current", on);
if (on) b.setAttribute("aria-current", "page"); else b.removeAttribute("aria-current");
});
views.forEach((v) => v.classList.toggle("is-on", v.id === "view-" + name));
$("view-" + name).focus({ preventScroll: true });
};
document.querySelectorAll(".tab").forEach((btn) => {
btn.addEventListener("click", () => gotoView(btn.dataset.view));
});
document.querySelectorAll("[data-goto]").forEach((btn) => {
btn.addEventListener("click", () => gotoView(btn.dataset.goto));
});
// ── 겹침 검사 ──
const conflictsWith = (course) =>
[...enrolled]
.map((id) => COURSES.find((c) => c.id === id))
.filter((o) => DURE.isOverlap(course, o))
.map((o) => o.name);
const credits = () => [...enrolled].reduce((sum, id) => sum + COURSES.find((c) => c.id === id).credits, 0);
const creditsOf = (dept) => DURE.credits([...enrolled].map((id) => COURSES.find((c) => c.id === id)).filter((c) => c.dept === dept));
const setStatus = (msg, isWarn) => {
const el = $("status");
el.textContent = msg;
el.classList.toggle("is-warn", !!isWarn);
};
// ── 과목 행(과목 찾기 · 관심 과목 공용 문법) ──
const courseRow = (c) => {
const li = document.createElement("li");
li.innerHTML = `
<button type="button" class="course-row" data-course="${c.id}">
<span class="c-main">
<span class="c-name">${c.name} <span class="c-dept">${c.dept}</span></span>
<span class="mono c-sub"><i>${c.code}</i> · <i>${c.prof}</i> · <i>${timeLabel(c)}</i> · <i>${c.room}</i></span>
</span>
<span class="c-side mono">
<span class="c-credit">${c.credits}학점</span>
<span class="c-seats ${c.seats <= 5 ? "is-low" : ""}">${c.seats === 0 ? "정원 마감" : "여석 " + c.seats}</span>
${starred.has(c.id) ? '<span class="c-star">★</span>' : ""}
${enrolled.has(c.id) ? '<span class="c-seal">申</span>' : ""}
</span>
</button>`;
return li;
};
// ── 과목 찾기 목록 ──
let deptFilter = "all";
const renderList = () => {
const q = $("q").value.trim();
const rows = COURSES.filter(
(c) =>
(deptFilter === "all" || c.dept === deptFilter) &&
(!q || c.name.includes(q) || c.prof.includes(q) || c.code.toLowerCase().includes(q.toLowerCase()))
);
$("course-list").replaceChildren(...rows.map(courseRow));
$("course-empty").hidden = rows.length> 0;
};
$("q").addEventListener("input", renderList);
document.querySelectorAll(".chip[data-dept]").forEach((chip) => {
chip.addEventListener("click", () => {
document.querySelectorAll(".chip[data-dept]").forEach((c) => {
c.classList.toggle("is-on", c === chip);
c.setAttribute("aria-pressed", c === chip ? "true" : "false");
});
deptFilter = chip.dataset.dept;
renderList();
});
});
// ── 관심 과목 ──
let stDept = "all";
const renderStarred = () => {
const all = COURSES.filter((c) => starred.has(c.id));
const rows = all.filter((c) => stDept === "all" || c.dept === stDept);
$("starred-list").replaceChildren(...rows.map(courseRow));
$("starred-empty").hidden = rows.length> 0;
// 빈 상태일 때 — 여석 임박 추천으로 화면을 채운다(빈 상태는 안내다)
const suggest = $("st-suggest");
if (!all.length) {
suggest.hidden = false;
$("st-suggest-list").replaceChildren(
...COURSES.filter((c) => !enrolled.has(c.id) && c.seats> 0).sort((a, b) => a.seats - b.seats).slice(0, 3).map(courseRow)
);
} else {
suggest.hidden = true;
}
// 요약 — 관심 기준 실계산
const credits = all.reduce((a, c) => a + c.credits, 0);
const enrolledCredits = COURSES.filter((c) => enrolled.has(c.id)).reduce((a, c) => a + c.credits, 0);
const near = all.filter((c) => c.seats> 0 && c.seats <= 5).length;
const closed = all.filter((c) => c.seats === 0).length;
$("st-stats").innerHTML =
`<div>관심 <strong>${all.length}과목</strong></div>` +
`<div>합계 <strong>${credits}학점</strong></div>` +
`<div>여석 임박 <strong>${near}과목</strong></div>` +
`<div>정원 마감 <strong>${closed}과목</strong></div>`;
// 학점 초과 사전 경고 — 오류 예방
const warn = $("st-warn");
const wouldBe = enrolledCredits + credits;
if (!all.length) { warn.hidden = true; return; }
warn.hidden = false;
if (wouldBe> MAX_CREDITS) {
warn.textContent = `모두 신청하면 기준 ${MAX_CREDITS}학점을 ${wouldBe - MAX_CREDITS}학점 초과합니다(${wouldBe}학점) — 신청 전에 조합을 줄이세요.`;
warn.classList.add("is-over");
} else {
warn.textContent = `모두 신청해도 기준 ${MAX_CREDITS}학점 안에 듭니다 — 합계 ${wouldBe}학점 · 잔여 ${MAX_CREDITS - wouldBe}학점.`;
warn.classList.remove("is-over");
}
};
document.getElementById("st-chips").addEventListener("click", (e) => {
const chip = e.target.closest("[data-st]");
if (!chip) return;
document.querySelectorAll("#st-chips .chip").forEach((c) => {
c.classList.toggle("is-on", c === chip);
c.setAttribute("aria-pressed", String(c === chip));
});
stDept = chip.dataset.st;
renderStarred();
});
// ── 신청 내역 ──
const renderEnrollments = () => {
{
const list = COURSES.filter((c) => enrolled.has(c.id));
$("en-gauge").style.width = Math.round((list.reduce((a, c) => a + c.credits, 0) / MAX_CREDITS) * 100) + "%";
const credits = list.reduce((a, c) => a + c.credits, 0);
$("en-stats").innerHTML =
`<div>신청 <strong>${list.length}과목</strong></div>` +
`<div>${credits}학점 / ${MAX_CREDITS}</div>` +
`<div>잔여 <strong>${MAX_CREDITS - credits}학점</strong></div>` +
`<div>신청 변경 마감 <strong>3/13</strong></div>`;
}
const body = document.querySelector("#en-table tbody");
body.replaceChildren(
...[...enrolled].map((id) => COURSES.find((c) => c.id === id)).map((c) => {
const tr = document.createElement("tr");
tr.innerHTML =
`<th scope="row">${c.name}</th><td class="mono">${timeLabel(c)}</td><td>${c.room}</td>` +
`<td class="num mono">${c.credits}</td>` +
`<td><button type="button" class="give-btn" data-cancel="${c.id}">취소</button></td>`;
return tr;
})
);
$("en-empty").hidden = enrolled.size> 0;
$("en-credits").textContent = String(credits());
};
document.querySelector("#en-table tbody").addEventListener("click", (e) => {
const btn = e.target.closest("[data-cancel]");
if (!btn) return;
const c = COURSES.find((x) => x.id === btn.dataset.cancel);
enrolled.delete(c.id);
c.seats += 1;
$("en-status").textContent = `${c.name} 취소 · ${credits()}학점`;
renderAll();
});
// ── 이수 현황: 기록 + 이번 학기 신청을 합친다 ──
const renderCreditsView = () => {
const body = document.querySelector("#cr-table tbody");
const totalNeed = RECORD.reduce((a, r) => a + r.need, 0);
// 개설 과목은 전공·교양뿐이라 자유의 신청은 항상 0 — 표에는 '—' 로 나온다
const rows = RECORD.map((r) => ({
label: r.label,
need: r.need,
done: r.done,
now: creditsOf(r.label),
}));
const sum = rows.reduce(
(a, r) => ({ need: a.need + r.need, done: a.done + r.done, now: a.now + r.now }),
{ need: 0, done: 0, now: 0 }
);
{
const done = sum.done, now = sum.now, need = sum.need;
$("cr-stats").innerHTML =
`<div>기준 <strong>${need}학점</strong></div>` +
`<div>이수 <strong>${done}학점</strong></div>` +
`<div>이번 학기 신청 <strong>${now}학점</strong></div>` +
`<div>합계 <strong>${done + now}학점</strong></div>` +
`<div>잔여 <strong>${need - done - now}학점</strong></div>`;
}
{
$("cr-stats").innerHTML =
`<div>기준 <strong>${sum.need}학점</strong></div>` +
`<div>이수 <strong>${sum.done}학점</strong></div>` +
`<div>이번 학기 신청 <strong>${sum.now}학점</strong></div>` +
`<div>합계 <strong>${sum.done + sum.now}학점</strong></div>` +
`<div>잔여 <strong>${sum.need - sum.done - sum.now}학점</strong></div>`;
}
{
const pct = Math.min(100, Math.round(((sum.done + sum.now) / sum.need) * 100));
document.getElementById("cr-total-bar").style.width = pct + "%";
document.querySelector(".cr-total-num").textContent = pct + "%";
}
body.replaceChildren(
...rows.map((r) => {
const tr = document.createElement("tr");
const total = r.done + r.now;
const left = r.need - total;
const pct = r.need ? Math.min(100, Math.round((total / r.need) * 100)) : 0;
tr.innerHTML =
`<th scope="row">${r.label}</th><td class="num mono">${r.need || "—"}</td>` +
`<td class="num mono">${r.done || "—"}</td><td class="num mono">${r.now ? "+ " + r.now : "—"}</td>` +
`<td class="num mono">${r.need ? total : total || "—"}</td>` +
`<td class="num mono">${r.need ? (left> 0 ? left : left === 0 ? "0" : "초과") : "—"}</td>` +
`<td><div class="cr-bar" role="img"><i style="width: ${pct}%"></i></div></td>`;
return tr;
}),
(() => {
const tr = document.createElement("tr");
const left = totalNeed - sum.done - sum.now;
tr.innerHTML =
`<th scope="row">총계</th><td class="num mono">${totalNeed}</td>` +
`<td class="num mono">${sum.done}</td><td class="num mono">+ ${sum.now}</td>` +
`<td class="num mono"><strong>${sum.done + sum.now}</strong></td>` +
`<td class="num mono">${left> 0 ? left : left === 0 ? "0" : "초과"}</td>`;
return tr;
})()
);
};
// ── 과목 총람: 계열별 소계 포함 ──
const renderArchive = () => {
const body = document.querySelector("#ar-table tbody");
const frag = document.createDocumentFragment();
for (const dept of ["전공", "교양"]) {
const group = COURSES.filter((c) => c.dept === dept);
for (const c of group) {
const tr = document.createElement("tr");
tr.innerHTML =
`<th scope="row" class="mono">${c.code}</th><td>${c.name}</td><td>${c.prof}</td>` +
`<td class="mono">${timeLabel(c)}</td><td class="num mono">${c.credits}</td>` +
`<td class="num mono">${c.seats <= 5 ? `<span class="low-seats">${c.seats} / ${c.cap}</span>` : `${c.seats} / ${c.cap}`}</td>`;
frag.append(tr);
}
const sub = document.createElement("tr");
sub.className = "ar-sub";
const seats = group.reduce((a, c) => a + c.seats, 0);
sub.innerHTML =
`<th scope="row" colspan="4">${dept} 소계 · ${group.length}과목</th>` +
`<td class="num mono">${group.reduce((a, c) => a + c.credits, 0)}</td>` +
`<td class="num mono">여석 ${seats}</td>`;
frag.append(sub);
}
body.replaceChildren(frag);
{
const closed = COURSES.filter((c) => c.seats === 0).length;
const near = COURSES.filter((c) => c.seats> 0 && c.seats <= 5).length;
$("ar-stats").innerHTML =
`<div>개설 <strong>${COURSES.length}과목</strong></div>` +
`<div>전공 <strong>${COURSES.filter((c) => c.dept === "전공").length}</strong></div>` +
`<div>교양 <strong>${COURSES.filter((c) => c.dept === "교양").length}</strong></div>` +
`<div>여석 임박 <strong>${near}과목</strong></div>` +
`<div>정원 마감 <strong>${closed}과목</strong></div>`;
}
};
// ── 공지 ──
const renderNotices = () => {
{
const changed = NOTICES.filter((n) => n.kind === "변경").length;
$("nc-stats").innerHTML =
`<div>공지 <strong>${NOTICES.length}건</strong></div>` +
`<div>변경 <strong>${changed}건</strong></div>` +
`<div>신청 마감 <strong>3/13 자정</strong></div>`;
}
$("notice-list").replaceChildren(
...NOTICES.map((n) => {
const li = document.createElement("li");
li.innerHTML =
`<span class="no-kind" data-kind="${n.kind}">${n.kind}</span>` +
`<span class="mono no-date">${n.date}</span>` +
`<p class="no-body">${n.body}</p>`;
return li;
})
);
};
// ── 시간표 렌더: 축(정적) + 블록(신청분) ──
const buildAxis = () => {
const tt = $("tt");
tt.style.setProperty("--rows", 18); // 9:0018:00, 30분 단위
const hours = Array.from({ length: 9 }, (_, i) => 9 + i);
tt.innerHTML =
`<div class="tt-corner" aria-hidden="true" style="grid-area: 1 / 1"></div>` +
[1, 2, 3, 4, 5].map((d) => `<div class="tt-day" style="grid-row: 1; grid-column: ${d + 1}">${fmtDay(d)}</div>`).join("") +
hours
.map((h) => {
const row = (h - 9) * 2 + 2; // 1행은 요일 헤더, 9시=2행
return (
`<div class="tt-hour mono" style="grid-row: ${row}; grid-column: 1">${String(h).padStart(2, "0")}</div>` +
[1, 2, 3, 4, 5]
.map((d) => `<div class="tt-cell" style="grid-row: ${row} / span 2; grid-column: ${d + 1}"></div>`)
.join("")
);
})
.join("");
};
const renderBlocks = () => {
$("tt").querySelectorAll(".tt-block").forEach((b) => b.remove());
const tt = $("tt");
enrolled.forEach((id) => {
const c = COURSES.find((x) => x.id === id);
c.days.forEach((d) => {
const b = document.createElement("button");
b.type = "button";
b.className = `tt-block ${c.dept === "전공" ? "is-major" : "is-gen"}`;
b.dataset.course = c.id;
b.style.gridColumn = String(d + 1);
/* 행 오프셋 +2: 1행은 요일 헤더. 9시=(9-9)*2+2=2행 */
b.style.gridRow = `${(c.start - 9) * 2 + 2} / span ${(c.end - c.start) * 2}`;
b.innerHTML = `<span class="tt-block-name">${c.name}</span><span class="tt-block-room">${c.room}</span>`;
tt.append(b);
});
});
if (!enrolled.size) {
const empty = document.createElement("p");
empty.className = "tt-empty";
empty.textContent = "과목을 신청하면 여기에 놓입니다";
tt.append(empty);
}
$("credit-now").textContent = String(credits());
};
const renderAll = () => {
renderList();
renderStarred();
renderEnrollments();
renderCreditsView();
renderArchive();
renderBlocks();
};
// ── 모달 ──
const dialog = $("course-dialog");
let current = null;
const openCourse = (c) => {
current = c;
$("cd-code").textContent = `${c.code} · ${c.dept}`;
$("cd-h").textContent = c.name;
$("cd-prof-line").textContent = `${c.prof} 교수 · ${c.room}`;
$("cd-time").textContent = timeLabel(c);
$("cd-credit").textContent = String(c.credits);
$("cd-seats").textContent = `${c.seats} / ${c.cap}`;
// 주간 자리 — 9:0018:00 를 세로로 접은 미니 격자
const strip = $("cd-strip");
strip.setAttribute("aria-label", `${timeLabel(c)} — 시간표에 놓이는 자리`);
strip.innerHTML = [1, 2, 3, 4, 5]
.map((d) => {
const day = fmtDay(d);
if (!c.days.includes(d)) {
return `<div class="cd-track"><span class="cd-rail"><span class="cd-off mono" aria-hidden="true">·</span></span><span class="cd-day-l">${day}</span></div>`;
}
const top = ((c.start - 9) / 9) * 100;
const h = ((c.end - c.start) / 9) * 100;
return `<div class="cd-track"><span class="cd-rail"><span class="cd-slot ${c.dept === "전공" ? "is-major" : "is-gen"}" style="top:${top}%; height:${h}%"></span></span><span class="cd-day-l is-on">${day}</span></div>`;
})
.join("");
// 평가 — 같은 잉크의 농도로 누적 막대 하나에 담는다
$("cd-eval-bar").replaceChildren(
...c.eval.map(([, v], idx) => {
const seg = document.createElement("span");
seg.className = `eval-seg seg-${idx + 1}`;
seg.style.width = v + "%";
return seg;
})
);
$("cd-eval").replaceChildren(
...c.eval.map(([k, v]) => {
const div = document.createElement("div");
div.innerHTML = `<dt>${k}</dt><dd class="mono">${v}%</dd>`;
return div;
})
);
const apply = $("cd-apply");
const cancel = $("cd-cancel");
const warn = $("cd-warn");
warn.hidden = true;
cancel.hidden = !enrolled.has(c.id);
if (enrolled.has(c.id)) {
apply.hidden = true;
} else {
apply.hidden = false;
apply.disabled = true;
const clash = conflictsWith(c);
if (c.seats === 0) {
warn.textContent = "정원이 마감되었습니다. 다음 학기를 기다리거나 분반을 확인하세요.";
warn.hidden = false;
} else if (clash.length) {
warn.textContent = `${clash.join(", ")}와(과) 시간이 겹칩니다. 둘 중 하나만 담을 수 있습니다.`;
warn.hidden = false;
} else if (credits() + c.credits> MAX_CREDITS) {
warn.textContent = `기준 학점(${MAX_CREDITS})을 초과합니다. 지금 ${credits()}학점입니다.`;
warn.hidden = false;
} else {
apply.disabled = false;
}
}
const star = $("cd-star");
star.setAttribute("aria-pressed", String(starred.has(c.id)));
star.textContent = starred.has(c.id) ? "관심 빼기" : "관심 남기기";
dialog.showModal();
};
document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-course]");
if (btn && !btn.dataset.cancel) openCourse(COURSES.find((c) => c.id === btn.dataset.course));
});
$("cd-apply").addEventListener("click", () => {
if (!current || $("cd-apply").disabled) return;
current.seats -= 1;
enrolled.add(current.id);
setStatus(`${current.name} 신청 완료 · ${timeLabel(current)} · ${credits()}학점`, false);
dialog.close();
renderAll();
});
$("cd-cancel").addEventListener("click", () => {
if (!current || !enrolled.has(current.id)) return;
enrolled.delete(current.id);
current.seats += 1;
setStatus(`${current.name} 취소 · ${credits()}학점`, false);
dialog.close();
renderAll();
});
$("cd-star").addEventListener("click", () => {
if (!current) return;
if (starred.has(current.id)) {
starred.delete(current.id);
setStatus(`${current.name} 관심에서 뺐습니다.`, false);
} else {
starred.add(current.id);
setStatus(`${current.name} 관심에 남겼습니다. 관심 과목 화면에서 보세요.`, false);
}
const star = $("cd-star");
star.setAttribute("aria-pressed", String(starred.has(current.id)));
star.textContent = starred.has(current.id) ? "관심 빼기" : "관심 남기기";
renderList();
renderStarred();
});
dialog.addEventListener("click", (e) => { if (e.target === dialog) dialog.close(); });
dialog.querySelector("[data-close]").addEventListener("click", () => dialog.close());
// ── 시작 ──
buildAxis();
renderAll();
renderNotices();
setStatus("과목을 눌러 강의계획을 확인하세요.", false);
</script>
</body>
</html>