designpaca/apps/site/public/work/gaon-lms/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

1316 lines
76 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/gaon-lms/" />
<meta property="og:image" content="https://designpaca.chanpaca.net/work/gaon-lms/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/gaon-lms/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="#F7F5EF" />
<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=Noto+Serif+KR:wght@500;600&display=swap"
/>
<link rel="stylesheet" href="./styles/main.css?v=17" />
<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='%23F7F5EF'/%3E%3Crect x='7' y='6' width='18' height='2.4' fill='%2324272B'/%3E%3Crect x='7' y='12' width='18' height='2.4' fill='%23F5D662'/%3E%3Crect x='7' y='18' width='12' height='2.4' fill='%2324272B'/%3E%3Crect x='7' y='24' width='18' height='2.4' fill='%23F5D662'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">본문으로 건너뛰기</a>
<h1 class="sr-only">가온 학적부</h1>
<div class="app">
<!-- 사이드 내비 — 여덟 화면을 세 묶음으로. 그룹 헤더가 묶음의 이름을 말한다 -->
<aside class="side">
<a class="brand" href="#main">
<span class="brand-mark" aria-hidden="true"></span>
가온 학적부
</a>
<nav class="side-nav" aria-label="화면">
<p class="side-group" aria-hidden="true">학적</p>
<button type="button" class="side-link is-current" data-view="dashboard" aria-current="page">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>
대시보드
</button>
<button type="button" class="side-link" data-view="students">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
수강생</button>
<button type="button" class="side-link" data-view="attendance">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2v4"/><path d="M16 2v4"/><rect x="3" y="4" width="18" height="18" rx="2"/><path d="m9 14 2 2 4-4"/></svg>
출석부</button>
<p class="side-group" aria-hidden="true">수업</p>
<button type="button" class="side-link" data-view="grades">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M9 14h6"/><path d="M9 10h6"/></svg>
과제 · 성적</button>
<button type="button" class="side-link" data-view="files">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>
자료실</button>
<p class="side-group" aria-hidden="true">학사</p>
<button type="button" class="side-link" data-view="calendar">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2v4"/><path d="M16 2v4"/><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M3 10h18"/></svg>
학사 일정</button>
<button type="button" class="side-link" data-view="counsel">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
상담 기록</button>
<button type="button" class="side-link" data-view="report">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>
학기 리포트</button>
<button type="button" class="side-link" data-view="notice">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/></svg>
공지 · 알림</button>
</nav>
<p class="side-foot">
강사 김가온 · 3월 2주차<br />
<span class="mono">2026학년도 1학기</span>
</p>
</aside>
<main id="main" class="main">
<div class="topbar">
<p class="crumb">가온 학적부 <span aria-hidden="true">/</span> <strong id="crumb-view">대시보드</strong></p>
<div class="topbar-right">
<span class="term-badge mono">2026학년도 1학기 · 3월 2주차</span>
<div class="bell-wrap">
<button type="button" class="bell" id="bell" aria-label="읽지 않은 알림 3건" aria-expanded="false" aria-haspopup="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<span class="bell-dot" id="bell-dot" aria-hidden="true"></span>
</button>
<div class="bell-menu" id="bell-menu">
<div class="bell-head">
알림
<button type="button" id="bell-all">모두 읽음 처리</button>
</div>
<div class="ntc is-unread">
<span class="n-ico" aria-hidden="true"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M9 14h6"/><path d="M9 10h6"/></svg></span>
<p>자기소개 논술 초고 2명 제출 · 1명 미제출 — 채점 대기 <time>10분 전</time></p>
</div>
<div class="ntc is-unread">
<span class="n-ico" aria-hidden="true"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4"/><path d="M16 2v4"/><rect x="3" y="4" width="18" height="18" rx="2"/><path d="m9 14 2 2 4-4"/></svg></span>
<p>국어 논술 A 미응시 1명 — 학부모 알림톡 발송 완료 <time>1시간 전</time></p>
</div>
<div class="ntc is-unread">
<span class="n-ico" aria-hidden="true"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/></svg></span>
<p>새 공지 “3월 단원 평가 일정 안내” 등록 <time>어제</time></p>
</div>
<div class="ntc is-read">
<span class="n-ico" aria-hidden="true"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4"/><path d="M16 2v4"/><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M3 10h18"/></svg></span>
<p>강이안 학생 보강 신청 — 3/12(목) 17:00 코딩 입문 <time>어제</time></p>
</div>
</div>
</div>
<span class="avatar"></span>
</div>
</div>
<!-- ── 대시보드 ─────────────────────────────────────────────── -->
<section class="view is-on" id="view-dashboard" aria-labelledby="dash-h" tabindex="-1">
<header class="view-head">
<div>
<h2 class="v-h" id="dash-h">이번 주, 열두 명 중 열 명</h2>
<p class="view-sub">3월 9일 월요일 오전 기준. 명단 12명 중 재학 10명, 주간 출석은 지난 주(3/23/6) 결산입니다.</p>
</div>
<p class="stamp" aria-hidden="true">3월 2주</p>
</header>
<dl class="headline">
<div><dt>명단</dt><dd>12명</dd><dd class="stat-sub">재학 10 · 휴학 2 · 이번 달 전입 1</dd></div>
<div><dt>주간 출석률</dt><dd>92%</dd><dd class="stat-sub">출석 46 · 지각 2 · 결석 2 (지난 주 · 재학 10명)</dd></div>
<div><dt>미채점 과제</dt><dd>2건</dd><dd class="stat-sub">세트 4 · 워크시트 채점 대기 — 3/11 마감</dd></div>
</dl>
<div class="dash-grid">
<!-- 오늘의 수업: 카드가 아니라 장부의 행 -->
<section class="panel ledger" aria-labelledby="today-h">
<h2 id="today-h">오늘의 수업</h2>
<div class="table-wrap">
<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></tr>
</thead>
<tbody>
<tr>
<td class="mono">10:0011:30</td>
<td>수학 심화 B</td><td>2실</td>
<td><span class="state state-run">진행 중</span></td>
<td class="num"><span class="hl">3 / 3</span></td>
</tr>
<tr>
<td class="mono">14:0015:30</td>
<td>국어 논술 A</td><td>1실</td>
<td><span class="state state-off">예정</span></td>
<td class="num">2 / 3 · <span class="t-warn">미응시 1</span></td>
</tr>
<tr>
<td class="mono">16:3018:00</td>
<td>코딩 입문</td><td>3실</td>
<td><span class="state state-off">예정</span></td>
<td class="num">2 / 2</td>
</tr>
</tbody>
<tfoot>
<tr><td colspan="5" class="tsum">금일 3교시 · 남은 수업 2교시 — 국어 논술 A 미응시 1명(한지우) 사유 확인 필요</td></tr>
</tfoot>
</table>
</div>
</section>
<!-- 주간 출석: 쌓인 막대. 상태색 3종은 상태 예외 -->
<section class="panel week-panel" aria-labelledby="week-h">
<h2 id="week-h">주간 출석</h2>
<figure class="week-chart">
<svg viewBox="0 0 320 150" role="img" aria-label="주간 출석 막대 — 월 10 전원, 화 9 출석 1 지각, 수 10, 목 9 출석 1 결석, 금 8 출석 1 지각 1 결석">
<g class="axis">
<line x1="28" y1="8" x2="28" y2="122" />
<line x1="28" y1="122" x2="308" y2="122" />
</g>
<g class="bars">
<g transform="translate(44 0)"><rect y="22" width="26" height="100" class="b-att" /></g>
<g transform="translate(101 0)"><rect y="32" width="26" height="90" class="b-att" /><rect y="25" width="26" height="7" class="b-late" /></g>
<g transform="translate(158 0)"><rect y="22" width="26" height="100" class="b-att" /></g>
<g transform="translate(215 0)"><rect y="32" width="26" height="90" class="b-att" /><rect y="25" width="26" height="7" class="b-abs" /></g>
<g transform="translate(272 0)"><rect y="42" width="26" height="80" class="b-att" /><rect y="35" width="26" height="7" class="b-late" /><rect y="28" width="26" height="7" class="b-abs" /></g>
</g>
<g class="axis-labels">
<text x="57" y="138" text-anchor="middle"></text>
<text x="114" y="138" text-anchor="middle"></text>
<text x="171" y="138" text-anchor="middle"></text>
<text x="228" y="138" text-anchor="middle"></text>
<text x="285" y="138" text-anchor="middle"></text>
</g>
</svg>
<figcaption>
<span class="key"><i class="k-att"></i>출석</span>
<span class="key"><i class="k-late"></i>지각</span>
<span class="key"><i class="k-abs"></i>결석</span>
</figcaption>
</figure>
<table class="wk-sum">
<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></tr></thead>
<tbody>
<tr><td>월 3/2</td><td class="num">10</td><td class="num">0</td><td class="num">0</td><td class="num">100%</td></tr>
<tr><td>화 3/3</td><td class="num">9</td><td class="num">1</td><td class="num">0</td><td class="num">90%</td></tr>
<tr><td>수 3/4</td><td class="num">10</td><td class="num">0</td><td class="num">0</td><td class="num">100%</td></tr>
<tr><td>목 3/5</td><td class="num">9</td><td class="num">0</td><td class="num">1</td><td class="num">90%</td></tr>
<tr><td>금 3/6</td><td class="num">8</td><td class="num">1</td><td class="num">1</td><td class="num">80%</td></tr>
</tbody>
<tfoot><tr><td>합계</td><td class="num">46</td><td class="num">2</td><td class="num">2</td><td class="num"><span class="hl">92%</span></td></tr></tfoot>
</table>
</section>
<!-- 마감 임박 -->
<section class="panel" aria-labelledby="due-h">
<h2 id="due-h">마감 임박 과제</h2>
<ul class="due-list">
<li><button type="button" class="due-row" data-assign="a1">
<span>미분 응용 세트 4</span>
<span class="mono muted">수학 심화 B · 3/11 마감 · <strong class="hl">1명 미제출</strong></span>
</button></li>
<li><button type="button" class="due-row" data-assign="a2">
<span>자기소개 논술 초고</span>
<span class="mono muted">국어 논술 A · 3/12 마감 · 1명 미제출</span>
</button></li>
<li><button type="button" class="due-row" data-assign="a3">
<span>변수와 조건 워크시트</span>
<span class="mono muted">코딩 입문 · 3/13 마감 · 1명 미제출</span>
</button></li>
</ul>
<p class="panel-note muted">행을 누르면 채점 화면이 열립니다.</p>
</section>
</div>
<section class="panel" aria-labelledby="prog-h">
<h2 id="prog-h">수업 진행 <span class="h-note">3월 2주차 기준 · 개설 과목 4개</span></h2>
<div class="prog-grid">
<div class="prog">
<div class="prog-head"><strong>수학 심화 B</strong><span class="mono muted">12/16</span></div>
<div class="prog-bar" role="img" aria-label="수학 심화 B 진행률 75퍼센트"><i style="width: 75%"></i></div>
<p class="prog-meta">75% · 이번 주 2차시 완료 · 단원 평가 D-5</p>
</div>
<div class="prog">
<div class="prog-head"><strong>국어 논술 A</strong><span class="mono muted">8/12</span></div>
<div class="prog-bar" role="img" aria-label="국어 논술 A 진행률 67퍼센트"><i style="width: 67%"></i></div>
<p class="prog-meta">67% · 이번 주 2차시 완료 · 초고 채점 중</p>
</div>
<div class="prog">
<div class="prog-head"><strong>코딩 입문</strong><span class="mono muted">5/10</span></div>
<div class="prog-bar" role="img" aria-label="코딩 입문 진행률 50퍼센트"><i style="width: 50%"></i></div>
<p class="prog-meta">50% · 이번 주 1차시 완료 · 워크시트 2명 미제출</p>
</div>
<div class="prog">
<div class="prog-head"><strong>과학 실험</strong><span class="mono muted">9/12</span></div>
<div class="prog-bar" role="img" aria-label="수학 심화 B 진행률 75퍼센트"><i style="width: 75%"></i></div>
<p class="prog-meta">75% · 이번 주 2차시 완료 · 리포트 채점 완료</p>
</div>
</div>
</section>
</section>
<!-- ── 공지 · 알림 ──────────────────────────────────────────── -->
<section class="view" id="view-notice" aria-labelledby="no-h" tabindex="-1">
<header class="view-head">
<div>
<h2 class="v-h" id="no-h">공지 · 알림</h2>
<p class="view-sub">반에 전달한 안내입니다. 행을 누르면 본문이 열리고 읽음 처리됩니다.</p>
</div>
</header>
<div class="toolbar">
<div class="chips" role="group" id="nt-chips">
<button type="button" class="chip is-on" data-nt="전체" aria-pressed="true">전체</button>
<button type="button" class="chip" data-nt="시험" aria-pressed="false">시험</button>
<button type="button" class="chip" data-nt="수업" aria-pressed="false">수업</button>
<button type="button" class="chip" data-nt="소통" aria-pressed="false">소통</button>
<button type="button" class="chip" data-nt="행정" aria-pressed="false">행정</button>
</div>
</div>
<div class="panel">
<ul class="notice-list">
<li><button type="button" class="notice-row" aria-expanded="false">
<span class="n-t"><svg class="pin" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>3월 단원 평가 일정 안내</span>
<span class="tag tag-exam">시험</span>
<span class="n-meta"><span>3/14 16:00 · 김가온</span><span>읽은 사람 11 / 12</span></span>
<span class="n-body">3/17(화) 19:00 수학 심화 B 단원 평가를 실시합니다. 범위는 미분 응용 1~3단원이며 지참물은 필기구와 계산기입니다. 당일 결석자는 3/19(목) 보강 평가로 대체하며, 평가 결과는 3/20 자정 전 학부모 알림톡으로 발송됩니다.</span>
</button></li>
<li><button type="button" class="notice-row is-unread" aria-expanded="false">
<span class="n-t">미분 응용 세트 4 마감 연장</span>
<span class="tag">수업</span>
<span class="n-meta"><span>3/11 08:30 · 김가온</span><span>읽은 사람 8 / 12</span></span>
<span class="n-body">제출 시스템 오류 문의가 이어져 마감을 하루 연장합니다. 새 마감은 3/12(목) 23:59이며, 기존 제출분은 그대로 유효합니다.</span>
</button></li>
<li><button type="button" class="notice-row is-unread" aria-expanded="false">
<span class="n-t">강이안 학생 보강 수업 배정</span>
<span class="tag">소통</span>
<span class="n-meta"><span>3/10 14:00 · 김가온</span><span>읽은 사람 3 / 3</span></span>
<span class="n-body">3/12(목) 17:00 코딩 입문 5차시 보강이 배정되었습니다. 보호자에게 알림톡을 발송했으니 시간을 확인해 주세요.</span>
</button></li>
<li><button type="button" class="notice-row" aria-expanded="false">
<span class="n-t">이번 주 학부모 알림톡 발송 내역</span>
<span class="tag">소통</span>
<span class="n-meta"><span>3/9 19:00 · 시스템</span><span>읽은 사람 10 / 12</span></span>
<span class="n-body">이번 주 결석 2건 모두 발송이 완료되었습니다. 목요일 병결 1건은 답변을 받아 기록에 남겼습니다.</span>
</button></li>
<li><button type="button" class="notice-row" aria-expanded="false">
<span class="n-t">4월 학사 일정 초안 의견을 받습니다</span>
<span class="tag">행정</span>
<span class="n-meta"><span>3/8 10:00 · 김가온</span><span>읽은 사람 9 / 12</span></span>
<span class="n-body">4월 중간고사 기간(4/14~4/16)과 그에 맞춘 수업 커리큘럼 조정안을 자료실에 올렸습니다. 3/20까지 의견을 주세요.</span>
</button></li>
</ul>
</div>
</section>
<!-- ── 수강생 ───────────────────────────────────────────────── -->
<section class="view" id="view-students" aria-labelledby="st-h" tabindex="-1">
<header class="view-head">
<div>
<h2 class="v-h" id="st-h">수강생 기록</h2>
<p class="view-sub">행을 누르면 상담 기록까지 볼 수 있습니다.</p>
</div>
</header>
<div class="statline" id="stu-stats"></div>
<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-filter="all" aria-pressed="true">전체 12</button>
<button type="button" class="chip" data-filter="재학" aria-pressed="false">재학 10</button>
<button type="button" class="chip" data-filter="휴학" aria-pressed="false">휴학 2</button>
</div>
</div>
<div class="panel table-wrap">
<table id="stu-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><th scope="col">상태</th>
</tr>
</thead>
<tbody><!-- JS 가 그린다 --></tbody>
</table>
<p class="panel-note muted" id="stu-empty" hidden>조건에 맞는 수강생이 없습니다 — 검색어를 줄이거나 필터를 ‘전체’로 바꿔 보세요.</p>
</div>
</section>
<!-- ── 출석부 ───────────────────────────────────────────────── -->
<section class="view" id="view-attendance" aria-labelledby="att-h" tabindex="-1">
<header class="view-head">
<div>
<h2 class="v-h" id="att-h">출석부</h2>
<p class="view-sub">칸을 누르면 출석 · 지각 · 결석이 순환합니다. 반과 주를 바꿔가며 적습니다.</p>
</div>
<p class="stamp" aria-hidden="true">3월</p>
</header>
<div class="toolbar">
<div class="chips" role="group" id="att-class"></div>
<div class="week-nav">
<button type="button" class="btn-line" id="att-prev">이전 주</button>
<span class="mono week-label" id="att-week" aria-live="polite">3월 2주</span>
<button type="button" class="btn-line" id="att-next">다음 주</button>
</div>
</div>
<div class="statline" id="att-stats" role="status"></div>
<div class="panel table-wrap">
<table id="att-table">
<caption class="sr-only">주간 출석부 — 학생별 월요일부터 금요일까지 출석 상태</caption>
<thead>
<tr>
<th scope="col">이름</th>
<th scope="col" class="att-day"></th><th scope="col" class="att-day"></th>
<th scope="col" class="att-day"></th><th scope="col" class="att-day"></th>
<th scope="col" class="att-day"></th>
<th scope="col" class="num">이번 주</th>
</tr>
</thead>
<tbody><!-- JS 가 그린다 --></tbody>
<tfoot id="att-foot"><!-- JS: 일별 합계 --></tfoot>
</table>
<p class="panel-note muted" id="att-status" role="status"></p>
<div class="att-chips">
<span class="tag">병결 1</span>
<span class="tag">조퇴 1</span>
<span class="tag tag-warn">사유 검토 1</span>
<span class="tag">보강 배정 1</span>
<span class="tag tag-ok">학부모 알림톡 2/2 발송</span>
</div>
</div>
</section>
<!-- ── 과제 · 성적 ──────────────────────────────────────────── -->
<section class="view" id="view-grades" aria-labelledby="gr-h" tabindex="-1">
<header class="view-head">
<div>
<h2 class="v-h" id="gr-h">과제 · 성적</h2>
<p class="view-sub">채점 대기 행을 눌러 점수를 남깁니다.</p>
</div>
</header>
<div class="statline">
<div>채점 완료 <strong>3</strong></div>
<div>채점 대기 <strong class="warn-ink">2</strong></div>
<div>자동 채점 <strong>1</strong></div>
<div>예정 <strong class="muted-ink">1</strong></div>
<div>평균 제출률 <strong>83%</strong></div>
</div>
<div class="toolbar">
<div class="chips" role="group" id="as-chips">
<button type="button" class="chip is-on" data-as="all" aria-pressed="true">전체</button>
<button type="button" class="chip" data-as="수학 심화 B" aria-pressed="false">수학 심화 B</button>
<button type="button" class="chip" data-as="국어 논술 A" aria-pressed="false">국어 논술 A</button>
<button type="button" class="chip" data-as="코딩 입문" aria-pressed="false">코딩 입문</button>
<button type="button" class="chip" data-as="과학 실험" aria-pressed="false">과학 실험</button>
</div>
</div>
<div class="panel table-wrap">
<table id="as-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">상태</th></tr>
</thead>
<tbody>
<tr>
<th scope="row"><button type="button" class="row-open" data-assign="a1">미분 응용 세트 3</button></th>
<td><span class="tag">과제</span></td>
<td>수학 심화 B</td><td class="mono">03-04</td>
<td class="num mono">3/3</td><td><span class="state">채점 완료</span></td>
</tr>
<tr>
<th scope="row"><button type="button" class="row-open" data-assign="a2">자기소개 논술 초고</button></th>
<td><span class="tag">과제</span></td>
<td>국어 논술 A</td><td class="mono">03-12</td>
<td class="num mono">2/3</td><td><span class="state state-wait">채점 대기</span></td>
</tr>
<tr>
<th scope="row"><button type="button" class="row-open" data-assign="a3">변수와 조건 워크시트</button></th>
<td><span class="tag">과제</span></td>
<td>코딩 입문</td><td class="mono">03-13</td>
<td class="num mono">1/2</td><td><span class="state state-wait">채점 대기</span></td>
</tr>
<tr>
<th scope="row"><button type="button" class="row-open" data-assign="a4">측정과 그래프 리포트</button></th>
<td><span class="tag">과제</span></td>
<td>과학 실험</td><td class="mono">03-06</td>
<td class="num mono">3/3</td><td><span class="state">채점 완료</span></td>
</tr>
<tr>
<th scope="row">단원 평가 퀴즈 — 미분 응용</th>
<td><span class="tag tag-quiz">퀴즈</span></td>
<td>수학 심화 B</td><td class="mono">03-08</td>
<td class="num mono">3/3</td><td><span class="state">자동 채점</span></td>
</tr>
<tr>
<th scope="row">3월 단원 평가</th>
<td><span class="tag tag-exam">시험</span></td>
<td>수학 심화 B</td><td class="mono">03-17</td>
<td class="num mono"></td><td><span class="state state-off">예정</span></td>
</tr>
</tbody>
</table>
</div>
<section class="panel dist" aria-labelledby="dist-h">
<h2 id="dist-h">수학 심화 B · 세트 3 점수 분포</h2>
<div class="dist-body">
<figure class="dist-chart">
<svg viewBox="0 0 340 150" role="img" aria-label="점수 분포 — 70점 1명, 80점 3명, 90점 4명">
<g class="axis">
<line x1="34" y1="8" x2="34" y2="122" /><line x1="34" y1="122" x2="326" y2="122" />
</g>
<g class="bars">
<g transform="translate(58 0)"><rect y="97" width="44" height="25" class="b-att" /><text x="22" y="88" text-anchor="middle">1명</text></g>
<g transform="translate(148 0)"><rect y="47" width="44" height="75" class="b-att" /><text x="22" y="38" text-anchor="middle">3명</text></g>
<g transform="translate(238 0)"><rect y="22" width="44" height="100" class="b-hl" /><text x="22" y="13" text-anchor="middle">4명</text></g>
</g>
<g class="axis-labels">
<text x="80" y="138" text-anchor="middle">70점</text>
<text x="170" y="138" text-anchor="middle">80점</text>
<text x="260" y="138" text-anchor="middle">90점</text>
</g>
</svg>
<figcaption>점수 구간별 인원 · 채점 인원 8명</figcaption>
</figure>
<dl class="dist-stats">
<div><dt>평균</dt><dd>84.4점</dd></div>
<div><dt>최고 · 최저</dt><dd>96 · 70점</dd></div>
<div><dt>90점 이상</dt><dd>4명 <span class="muted">(50%)</span></dd></div>
<div><dt>재응시 대상</dt><dd>70점대 1명</dd></div>
<div><dt>미제출</dt><dd>0명</dd></div>
</dl>
</div>
</section>
</section>
<!-- ── 자료실 ───────────────────────────────────────────────── -->
<section class="view" id="view-files" aria-labelledby="fi-h" tabindex="-1">
<header class="view-head">
<div>
<h2 class="v-h" id="fi-h">자료실</h2>
<p class="view-sub">배부 자료의 원장. 배부를 눌렀는지에 따라 도장이 남습니다.</p>
</div>
</header>
<div class="statline" id="file-stats"></div>
<div class="toolbar">
<div class="chips" role="group" id="file-chips">
<button type="button" class="chip is-on" data-kind="all" aria-pressed="true">전체</button>
<button type="button" class="chip" data-kind="인쇄물" aria-pressed="false">인쇄물</button>
<button type="button" class="chip" data-kind="PDF" aria-pressed="false">PDF</button>
<button type="button" class="chip" data-kind="양식" aria-pressed="false">양식</button>
</div>
</div>
<div class="panel table-wrap">
<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" class="num">조회</th><th scope="col" class="num">날인일</th><th scope="col">배부</th></tr>
</thead>
<tbody id="file-body"><!-- JS --></tbody>
</table>
<p class="panel-note muted">다운로드 링크를 만들지 않는다 — 이 데모에는 파일이 없다. 배부 상태만 기록한다.</p>
</div>
</section>
<!-- ── 학사 일정 ─────────────────────────────────────────────── -->
<section class="view" id="view-calendar" aria-labelledby="cal-h" tabindex="-1">
<header class="view-head">
<div>
<h2 class="v-h" id="cal-h">학사 일정 · 2026년 3월</h2>
<p class="view-sub">마감과 휴원을 먼저 보는 달력. 오른쪽은 3월 9일 이후의 일정입니다.</p>
</div>
</header>
<div class="statline" id="cal-stats"></div>
<div class="cal-layout">
<div class="panel cal-panel" aria-hidden="false">
<table class="cal" id="cal-table">
<caption class="sr-only">2026년 3월 달력 — 월요일 시작, 일정이 있는 날은 제목이 적힙니다</caption>
<thead>
<tr><th scope="col"></th><th scope="col"></th><th scope="col"></th><th scope="col"></th><th scope="col"></th><th scope="col"></th><th scope="col"></th></tr>
</thead>
<tbody><!-- JS --></tbody>
</table>
<div class="cal-legend">
<span class="key"><i class="lg-exam"></i>평가 · 시험</span>
<span class="key"><i class="lg-due"></i>과제 마감</span>
<span class="key"><i class="lg-admin"></i>행정 · 상담</span>
<span class="key"><i class="lg-holi"></i>휴원</span>
</div>
</div>
<section class="panel" aria-labelledby="up-h">
<h2 id="up-h">다가오는 일정</h2>
<ul class="due-list" id="up-list"><!-- JS --></ul>
</section>
</div>
</section>
<!-- ── 상담 기록 ─────────────────────────────────────────────── -->
<section class="view" id="view-counsel" aria-labelledby="co-h" tabindex="-1">
<header class="view-head">
<div>
<h2 class="v-h" id="co-h">상담 기록</h2>
<p class="view-sub">수강생 화면에 남긴 기록을 모아 봅니다. 카드를 누르면 그 학생의 기록이 열립니다.</p>
</div>
</header>
<div class="toolbar">
<div class="chips" role="group" id="co-chips">
<button type="button" class="chip is-on" data-sub="all" aria-pressed="true">전 과목</button>
<button type="button" class="chip" data-sub="수학 심화 B" aria-pressed="false">수학 심화 B</button>
<button type="button" class="chip" data-sub="국어 논술 A" aria-pressed="false">국어 논술 A</button>
<button type="button" class="chip" data-sub="코딩 입문" aria-pressed="false">코딩 입문</button>
<button type="button" class="chip" data-sub="과학 실험" aria-pressed="false">과학 실험</button>
</div>
</div>
<div class="statline" id="co-stats"></div>
<div class="counsel-grid" id="counsel-grid"><!-- JS --></div>
<p class="empty-note muted" id="counsel-empty" hidden>조건에 맞는 상담 기록이 없습니다.</p>
<section class="panel" aria-labelledby="co-next-h" style="margin-top: var(--space-4);">
<h2 id="co-next-h">예정된 상담 <span class="h-note">3월 9일 기준</span></h2>
<ul class="due-list">
<li><p class="due-row is-static">
<span>오세림 학부모 상담 — 추가 과외 일정 조율</span>
<span class="mono muted">3/12(목) 18:00 <span class="tag tag-ok">알림톡 발송</span></span>
</p></li>
<li><p class="due-row is-static">
<span>박서연 진로 상담 — 영재 교육원 지원 검토</span>
<span class="mono muted">3/13(금) 17:30 <span class="tag">발송 대기</span></span>
</p></li>
<li><p class="due-row is-static">
<span>한지우 학습 상담 — 등교 시간 재조정 후속</span>
<span class="mono muted">3/16(월) 19:00 <span class="tag">발송 대기</span></span>
</p></li>
</ul>
</section>
</section>
<!-- ── 학기 리포트 ───────────────────────────────────────────── -->
<section class="view" id="view-report" aria-labelledby="re-h" tabindex="-1">
<header class="view-head">
<div>
<h2 class="v-h" id="re-h">학기 리포트</h2>
<p class="view-sub">아래 표는 명단에서 직접 계산합니다. 채점이 끝나지 않은 과목의 점수는 비워 둡니다.</p>
</div>
<p class="stamp" aria-hidden="true">1학기 중간</p>
</header>
<div class="headline headline-4" id="rep-stats"><!-- JS --></div>
<div class="panel table-wrap">
<table id="report-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>
</tr>
</thead>
<tbody><!-- JS 가 그린다 --></tbody>
</table>
<p class="panel-note muted">휴학생은 재원과 계산에서 제외합니다. 채점 평균은 완료된 과제만 반영합니다. 학부모 리포트는 3/20에 알림톡으로 일괄 발송됩니다 — 대상 재학생 보호자 전원.</p>
</div>
</section>
</main>
</div>
<!-- ── 수강생 상세 모달: 학적 카드 ── -->
<!-- ── 수강생 상세: 펼침 장부. 왼쪽 표지판 + 오른쪽 내용판 ── -->
<dialog id="stu-dialog" class="modal modal-split" aria-labelledby="stu-dlg-h">
<div class="mspread">
<div class="mcover">
<span class="mcover-mark" id="stu-mark" aria-hidden="true"></span>
<span class="mcover-seal" id="stu-seal" aria-hidden="true"></span>
<h2 class="mcover-name" id="stu-dlg-h">수강생</h2>
<p class="mcover-meta" id="stu-meta">주 과목 · 상태</p>
<dl class="mcover-stats">
<div><dt>출석률</dt><dd class="mono" id="stu-att"></dd></div>
<div><dt>과제 제출</dt><dd class="mono" id="stu-hw"></dd></div>
<div><dt>상담 기록</dt><dd class="mono" id="stu-memo-n"></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>
<section class="msec">
<h3>보호자 · 알림</h3>
<p class="guard-line" id="stu-guardian"></p>
<ul class="guard-log mono" id="stu-alklog">
<li>3/05 출결 알림톡 발송 완료</li>
<li>2/28 월간 학습 리포트 발송 완료</li>
</ul>
</section>
<section class="msec">
<h3>3월 2주까지의 출석</h3>
<div class="att-strip" id="stu-att-strip"></div>
</section>
<section class="msec">
<h3>상담 기록</h3>
<ul id="stu-note-list"></ul>
<form class="note-form" id="note-form">
<label class="sr-only" for="note-input">새 상담 기록</label>
<input id="note-input" type="text" placeholder="3/10 학부모 상담 — 심야 스터디 조율" required />
<button type="submit" class="btn-line">기록 추가</button>
</form>
</section>
</div>
</div>
</dialog>
<!-- ── 채점 모달: 점수를 넣으면 평균이 따라온다 ── -->
<dialog id="as-dialog" class="modal" aria-labelledby="as-dlg-h">
<div class="modal-inner">
<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>
<h2 class="modal-title" id="as-dlg-h">채점</h2>
<p class="modal-meta mono" id="as-dlg-meta">과제 · 과목</p>
<div class="table-wrap">
<table class="grade-table">
<caption class="sr-only">제출자 목록과 점수 입력</caption>
<thead><tr><th scope="col">제출자</th><th scope="col" class="num">점수</th></tr></thead>
<tbody>
<tr><th scope="row">박서연</th><td class="num"><label class="sr-only" for="g1">박서연 점수</label><input id="g1" class="score" type="number" min="0" max="100" value="92" /></td></tr>
<tr><th scope="row">김도현</th><td class="num"><label class="sr-only" for="g2">김도현 점수</label><input id="g2" class="score" type="number" min="0" max="100" value="88" /></td></tr>
<tr><th scope="row">이수아</th><td class="num"><label class="sr-only" for="g3">이수아 점수</label><input id="g3" class="score" type="number" min="0" max="100" value="95" /></td></tr>
</tbody>
</table>
</div>
<p class="as-summary mono" aria-live="polite">
제출 3명 · 평균 <strong class="hl" id="as-avg">91.7</strong>
</p>
<div class="modal-actions">
<button type="button" class="btn-solid" id="as-save">채점 저장</button>
<span class="modal-note muted" id="as-saved" hidden>저장되었습니다. 상태가 채점 완료로 바뀝니다.</span>
</div>
</div>
</dialog>
<script src="calc.js"></script>
<script>
/*
앱 상태는 전부 이 안에 있다. 서버도 localStorage 도 없다 — 데모 원칙.
뷰 전환 · 검색/필터 · 출석 순환 · 배부 도장 · 리포트 계산까지
네이티브 기능 위에 얹은 얇은 JS.
*/
const STUDENTS = [
{ name: "박서연", grade: "중3", sub: "수학 심화 B", att: 100, hw: "8/8", status: "재학", guard: "모 · 박지현", phone: "010-****-4821", memo: ["3/04 세트 3 만점. 다음 세트 조기 완료."] },
{ name: "김도현", grade: "중3", sub: "수학 심화 B", att: 96, hw: "8/8", status: "재학", guard: "부 · 김성호", phone: "010-****-7310", memo: [] },
{ name: "이수아", grade: "중2", sub: "국어 논술 A", att: 92, hw: "8/9", status: "재학", guard: "모 · 이경민", phone: "010-****-2245", memo: ["2/26 논술 주제 선정 상담."] },
{ name: "최민준", grade: "중2", sub: "코딩 입문", att: 89, hw: "7/9", status: "재학", guard: "모 · 최은정", phone: "010-****-9012", memo: [] },
{ name: "정하율", grade: "중3", sub: "과학 실험", att: 100, hw: "6/6", status: "재학", guard: "부 · 정민수", phone: "010-****-3378", memo: ["3/06 리포트 우수 사례로 게시."] },
{ name: "한지우", grade: "중2", sub: "국어 논술 A", att: 85, hw: "8/9", status: "재학", guard: "모 · 한송이", phone: "010-****-6654", memo: ["3/08 지각 2회 — 등교 시간 재조정 약속."] },
{ name: "윤채원", grade: "고1", sub: "코딩 입문", att: 93, hw: "7/9", status: "재학", guard: "모 · 윤정아", phone: "010-****-1189", memo: [] },
{ name: "오세림", grade: "중3", sub: "수학 심화 B", att: 78, hw: "7/8", status: "재학", guard: "모 · 오현주", phone: "010-****-5503", memo: ["3/05 학부모 상담 — 주 2회 추가 과외 조율."] },
{ name: "남유진", grade: "고1", sub: "과학 실험", att: 100, hw: "6/6", status: "재학", guard: "부 · 남기훈", phone: "010-****-7742", memo: [] },
{ name: "서다인", grade: "중2", sub: "국어 논술 A", att: 90, hw: "8/9", status: "재학", guard: "모 · 서지원", phone: "010-****-2960", memo: [] },
{ name: "강이안", grade: "중2", sub: "코딩 입문", att: 0, hw: "0/9", status: "휴학", guard: "모 · 강은심", phone: "010-****-8031", memo: ["2/28 개인 사정으로 3월 말 복학 예정."] },
{ name: "조은채", grade: "고1", sub: "과학 실험", att: 0, hw: "0/6", status: "휴학", guard: "모 · 조민경", phone: "010-****-4417", memo: [] },
];
const SUBJECTS = ["수학 심화 B", "국어 논술 A", "코딩 입문", "과학 실험"];
// ── 뷰 전환 ──
const views = document.querySelectorAll(".view");
document.querySelectorAll(".side-link").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".side-link").forEach((b) => {
b.classList.toggle("is-current", b === btn);
if (b === btn) b.setAttribute("aria-current", "page"); else b.removeAttribute("aria-current");
});
views.forEach((v) => v.classList.toggle("is-on", v.id === "view-" + btn.dataset.view));
document.getElementById("crumb-view").textContent = btn.textContent.trim();
document.getElementById("view-" + btn.dataset.view).focus({ preventScroll: true });
});
});
// ── 수강생 테이블: 검색 + 상태 칩 ──
const tbody = document.querySelector("#stu-table tbody");
let filter = "all";
const renderStudents = () => {
const q = document.getElementById("q").value.trim();
const rows = STUDENTS
.map((s, i) => ({ ...s, i }))
.filter((s) => (filter === "all" || s.status === filter))
.filter((s) => !q || s.name.includes(q) || s.sub.includes(q));
tbody.replaceChildren(
...rows.map((s) => {
const tr = document.createElement("tr");
tr.innerHTML =
`<th scope="row"><button type="button" class="row-open" data-stu="${s.i}">${s.name}</button></th>` +
`<td>${s.grade}</td><td>${s.sub}</td><td class="muted">${s.guard} · ${s.phone}</td>` +
`<td class="num mono">${s.att}%</td><td class="num mono">${s.hw}</td>` +
`<td><span class="state ${s.status === "휴학" ? "state-off" : ""}">${s.status}</span></td>`;
return tr;
})
);
document.getElementById("stu-empty").hidden = rows.length > 0;
};
const renderStuStats = () => {
const active = STUDENTS.filter((s) => s.status === "재학");
const avg = Math.round(active.reduce((a, s) => a + s.att, 0) / active.length);
document.getElementById("stu-stats").innerHTML =
`<div>전체 <strong>${STUDENTS.length}명</strong></div>` +
`<div>재학 <strong>${active.length}명</strong></div>` +
`<div>휴학 <strong>${STUDENTS.length - active.length}명</strong></div>` +
`<div>재학 평균 출석률 <strong>${avg}%</strong></div>` +
`<div>학부모 알림톡 수신 <strong>${active.length}/${active.length}</strong></div>`;
};
renderStuStats();
document.getElementById("q").addEventListener("input", renderStudents);
document.querySelectorAll("#view-students .chip").forEach((chip) => {
chip.addEventListener("click", () => {
document.querySelectorAll("#view-students .chip").forEach((c) => {
c.classList.toggle("is-on", c === chip);
c.setAttribute("aria-pressed", c === chip ? "true" : "false");
});
filter = chip.dataset.filter;
renderStudents();
});
});
renderStudents();
// ── 출석부: 상태 2=출석 1=지각 0=결석. 휴학은 null ──
const WEEKS = [
{ label: "3월 1주", days: ["03-02", "03-03", "03-04", "03-05", "03-06"] },
{ label: "3월 2주", days: ["03-09", "03-10", "03-11", "03-12", "03-13"] },
];
const mk = (a) => a.split("").map(Number);
// 명단 순서(STUDENTS 인덱스)와 1:1. 대시보드 차트(전원 기준)와 반 단위라 정합한다
const ATT = [
mk("22222"), mk("22222"), mk("22122"), mk("22221"), mk("22222"),
mk("21222"), mk("22222"), mk("22022"), mk("22222"), mk("22222"),
null, null,
];
const ATT_W2 = [
mk("22222"), mk("22222"), mk("21222"), mk("22222"), mk("22222"),
mk("20222"), mk("22220"), mk("21222"), mk("22222"), mk("22122"),
null, null,
];
const ATT_ALL = [ATT, ATT_W2];
const ATT_MARK = { 2: "○", 1: "△", 0: "✕" };
const ATT_NAME = { 2: "출석", 1: "지각", 0: "결석" };
let attWeek = 1; // 3월 2주 기준
let attClass = SUBJECTS[0];
const attData = (i) => ATT_ALL[attWeek][i];
const attSummary = GAON.attSummary;
const renderAttClass = () => {
document.getElementById("att-class").replaceChildren(
...SUBJECTS.map((sub) => {
const b = document.createElement("button");
b.type = "button";
b.className = "chip" + (sub === attClass ? " is-on" : "");
b.dataset.sub = sub;
b.setAttribute("aria-pressed", String(sub === attClass));
b.textContent = sub;
return b;
})
);
};
const renderAttendance = () => {
document.getElementById("att-week").textContent = WEEKS[attWeek].label;
document.getElementById("att-prev").disabled = attWeek === 0;
document.getElementById("att-next").disabled = attWeek === WEEKS.length - 1;
const body = document.querySelector("#att-table tbody");
body.replaceChildren(
...STUDENTS.map((s, i) => ({ s, i }))
.filter(({ s }) => s.sub === attClass)
.map(({ s, i }) => {
const tr = document.createElement("tr");
const row = attData(i);
let cells = `<th scope="row">${s.name}${s.status === "휴학" ? ' <span class="state state-off">휴학</span>' : ""}</th>`;
for (let d = 0; d < 5; d++) {
if (!row) {
cells += `<td class="att-cell is-na">·</td>`;
} else {
cells += `<td class="att-cell"><button type="button" class="att-btn ${row[d] === 2 ? "v-att" : row[d] === 1 ? "v-late" : "v-abs"}" data-stu="${i}" data-day="${d}" aria-label="${s.name} ${WEEKS[attWeek].days[d]} ${ATT_NAME[row[d]]}">${ATT_MARK[row[d]]}</button></td>`;
}
}
cells += `<td class="num mono">${row ? attSummary(row) : "—"}</td>`;
tr.innerHTML = cells;
return tr;
})
);
renderAttClass();
// 요약 스트립 + 일별 합계 — 현재 반·주 기준 실계산
const roster = STUDENTS.map((s, i) => ({ s, i })).filter(({ s }) => s.sub === attClass);
const marks = roster.map(({ i }) => attData(i)).filter(Boolean);
const C = GAON.counts(marks); const rate = C.rate;
document.getElementById("att-stats").innerHTML =
`<div>출석 <strong>${C.att}</strong></div>` +
`<div>지각 <strong>${C.late}</strong></div>` +
`<div>결석 <strong>${C.abs}</strong></div>` +
`<div>출석률 <strong>${rate}%</strong></div>` +
`<div>대상 <strong>${marks.length}명</strong></div>`;
document.getElementById("att-foot").innerHTML =
`<tr><td class="tsum">일별 출석</td>` +
[0, 1, 2, 3, 4].map((d) => `<td class="num mono">${GAON.dayTotal(marks, d) ? GAON.dayPresent(marks, d) + "/" + GAON.dayTotal(marks, d) : "—"}</td>`)
.join("") +
`<td class="num mono">${rate}%</td></tr>`;
};
document.getElementById("att-class").addEventListener("click", (e) => {
const chip = e.target.closest("[data-sub]");
if (!chip) return;
attClass = chip.dataset.sub;
renderAttendance();
});
document.getElementById("att-prev").addEventListener("click", () => { if (attWeek > 0) { attWeek -= 1; renderAttendance(); } });
document.getElementById("att-next").addEventListener("click", () => { if (attWeek < WEEKS.length - 1) { attWeek += 1; renderAttendance(); } });
// 칸 순환: 출석 → 지각 → 결석 → 출석. 합계와 상태줄을 함께 갱신
document.querySelector("#att-table tbody").addEventListener("click", (e) => {
const btn = e.target.closest(".att-btn");
if (!btn) return;
const i = +btn.dataset.stu;
const d = +btn.dataset.day;
const row = attData(i);
row[d] = (row[d] + 2) % 3; // 2→1→0→2
const s = STUDENTS[i];
const status = document.getElementById("att-status");
status.textContent = `${s.name} · ${WEEKS[attWeek].days[d]}${ATT_NAME[row[d]]}`;
renderAttendance();
});
renderAttendance();
// ── 과제·성적: 과목 필터 ──
document.getElementById("as-chips").addEventListener("click", (e) => {
const chip = e.target.closest("[data-as]");
if (!chip) return;
document.querySelectorAll("#as-chips .chip").forEach((c) => {
c.classList.toggle("is-on", c === chip);
c.setAttribute("aria-pressed", String(c === chip));
});
const sub = chip.dataset.as;
document.querySelectorAll("#as-table tbody tr").forEach((tr) => {
tr.hidden = sub !== "all" && tr.children[2].textContent.trim() !== sub;
});
});
// ── 자료실 ──
const FILES = [
{ name: "미분 응용 세트 3 해설지", sub: "수학 심화 B", kind: "인쇄물", size: "2.4MB", views: 64, date: "03-05", given: true },
{ name: "논술 첨삭 기준표", sub: "국어 논술 A", kind: "PDF", size: "860KB", views: 51, date: "03-03", given: true },
{ name: "변수와 조건 워크시트 예시", sub: "코딩 입문", kind: "PDF", size: "1.1MB", views: 37, date: "03-08", given: false },
{ name: "측정과 그래프 리포트 양식", sub: "과학 실험", kind: "양식", size: "540KB", views: 29, date: "03-02", given: true },
{ name: "4월 학사일정 안내장", sub: "공통", kind: "인쇄물", size: "3.0MB", views: 22, date: "03-10", given: false },
{ name: "오답 정리 노트 양식", sub: "공통", kind: "양식", size: "420KB", views: 83, date: "02-26", given: true },
];
let fileKind = "all";
const renderFiles = () => {
document.querySelector("#file-body").replaceChildren(
...FILES.map((f, i) => ({ f, i }))
.filter(({ f }) => fileKind === "all" || f.kind === fileKind)
.map(({ f, i }) => {
const tr = document.createElement("tr");
tr.innerHTML =
`<th scope="row">${f.name}</th><td>${f.sub}</td>` +
`<td><span class="state state-off">${f.kind}</span></td>` +
`<td class="num mono">${f.size}</td><td class="num mono">${f.views}</td>` +
`<td class="num mono">${f.date}</td>` +
`<td><button type="button" class="give-btn ${f.given ? "is-given" : ""}" data-file="${i}" aria-pressed="${f.given}">${f.given ? "배부됨" : "배부"}</button></td>`;
return tr;
})
);
};
document.getElementById("file-chips").addEventListener("click", (e) => {
const chip = e.target.closest("[data-kind]");
if (!chip) return;
document.querySelectorAll("#file-chips .chip").forEach((c) => {
c.classList.toggle("is-on", c === chip);
c.setAttribute("aria-pressed", String(c === chip));
});
fileKind = chip.dataset.kind;
renderFiles();
});
document.querySelector("#file-body").addEventListener("click", (e) => {
const btn = e.target.closest("[data-file]");
if (!btn) return;
const f = FILES[+btn.dataset.file];
f.given = !f.given;
renderFiles();
renderFileStats();
});
const renderFileStats = () => {
const given = FILES.filter((f) => f.given).length;
document.getElementById("file-stats").innerHTML =
`<div>자료 <strong>${FILES.length}건</strong></div>` +
`<div>배부 완료 <strong>${given}건</strong></div>` +
`<div>미배부 <strong>${FILES.length - given}건</strong></div>` +
`<div>총 조회 <strong>${FILES.reduce((a, f) => a + f.views, 0)}회</strong></div>`;
};
renderFiles();
renderFileStats();
// ── 학사 일정: 2026년 3월, 월요일 시작 ──
const EVENTS = {
6: { name: "리포트 마감", type: "due" },
11: { name: "모의고사", type: "exam" },
12: { name: "논술 초고 마감", type: "due" },
13: { name: "워크시트 마감", type: "due" },
16: { name: "학부모 상담 주간", type: "admin" },
19: { name: "휴원", type: "holi" },
25: { name: "4월 시간표 확정", type: "admin" },
31: { name: "1차 평가 마감", type: "exam" },
};
const EV_TODAY = 9;
const renderCalendar = () => {
// 2026-03-01 은 일요일. 월요일 시작 격자: 첫 주 앞 6칸은 비고 1일이 마지막 칸
const body = document.querySelector("#cal-table tbody");
const grid = new Array(6).fill(null);
grid.push(1);
for (let d = 2; d <= 31; d++) grid.push(d); // 3/2 부터 순서대로 이어붙인다
while (grid.length % 7 !== 0) grid.push(null);
const rows = [];
for (let r = 0; r < grid.length; r += 7) rows.push(grid.slice(r, r + 7));
body.replaceChildren(
...rows.map((weekRow) => {
const tr = document.createElement("tr");
tr.innerHTML = weekRow
.map((d) => {
if (!d) return `<td class="cal-day is-empty"></td>`;
const ev = EVENTS[d] ? `<span class="cal-ev is-${EVENTS[d].type}">${EVENTS[d].name}</span>` : "";
const today = d === EV_TODAY ? " is-today" : "";
return `<td class="cal-day${today}"><span class="cal-num mono">${d}</span>${ev}</td>`;
})
.join("");
return tr;
})
);
// 다가오는 일정 (3월 9일 이후)
document.getElementById("up-list").replaceChildren(
...Object.entries(EVENTS)
.map(([d, ev]) => ({ d: +d, ...ev }))
.filter(({ d }) => d >= EV_TODAY)
.sort((a, b) => a.d - b.d)
.map(({ d, name, type }) => {
const li = document.createElement("li");
const item = document.createElement("p");
item.className = "due-row is-static";
const dd = d === EV_TODAY ? "오늘" : "D-" + (d - EV_TODAY);
item.innerHTML = `<span><i class="ev-dot is-${type}" aria-hidden="true"></i>${name}</span><span class="mono muted">${dd} · 3월 ${d}일</span>`;
li.append(item);
return li;
})
);
const byType = (t) => Object.values(EVENTS).filter((e) => e.type === t).length;
document.getElementById("cal-stats").innerHTML =
`<div>3월 일정 <strong>${Object.keys(EVENTS).length}건</strong></div>` +
`<div>평가 · 시험 <strong>${byType("exam")}건</strong></div>` +
`<div>과제 마감 <strong>${byType("due")}건</strong></div>` +
`<div>행정 · 상담 <strong>${byType("admin")}건</strong></div>` +
`<div>휴원 <strong>${byType("holi")}일</strong></div>`;
};
renderCalendar();
// ── 상담 기록: memo 에서 카드로 ──
let coSub = "all";
const renderCounsel = () => {
const cards = STUDENTS
.map((s, i) => ({ s, i }))
.filter(({ s }) => (coSub === "all" || s.sub === coSub))
.flatMap(({ s, i }) =>
s.memo.map((m) => {
const date = m.match(/^(\d+\/\d+)/);
return { s, i, date: date ? date[1] : "날짜 미기재", text: m };
})
)
.sort((a, b) => (a.date < b.date ? 1 : -1));
document.getElementById("counsel-grid").replaceChildren(
...cards.map((c) => {
const card = document.createElement("button");
card.type = "button";
card.className = "counsel-card";
card.dataset.stu = c.i;
card.innerHTML =
`<span class="co-head"><strong>${c.s.name}</strong><span class="mono muted">${c.date}</span></span>` +
`<span class="co-sub muted">${c.s.sub} · ${c.s.status}</span>` +
`<span class="co-text">${c.text}</span>`;
return card;
})
);
document.getElementById("counsel-empty").hidden = cards.length > 0;
};
const renderCoStats = () => {
const all = STUDENTS.flatMap((s) => s.memo);
const march = all.filter((m) => m.startsWith("3/"));
const parent = all.filter((m) => m.includes("학부모"));
document.getElementById("co-stats").innerHTML =
`<div>기록 총 <strong>${all.length}건</strong></div>` +
`<div>3월 <strong>${march.length}건</strong></div>` +
`<div>학부모 상담 <strong>${parent.length}건</strong></div>` +
`<div>예정 <strong>3건</strong></div>`;
};
renderCoStats();
document.getElementById("co-chips").addEventListener("click", (e) => {
const chip = e.target.closest("[data-sub]");
if (!chip) return;
document.querySelectorAll("#co-chips .chip").forEach((c) => {
c.classList.toggle("is-on", c === chip);
c.setAttribute("aria-pressed", String(c === chip));
});
coSub = chip.dataset.sub;
renderCounsel();
});
renderCounsel();
// ── 학기 리포트: 명단에서 직접 계산 ──
const renderReport = () => {
const body = document.querySelector("#report-table tbody");
body.replaceChildren(
...SUBJECTS.map((sub) => {
const roster = STUDENTS.filter((s) => s.sub === sub && s.status === "재학");
const avgAtt = roster.length
? Math.round(roster.reduce((a, s) => a + s.att, 0) / roster.length)
: 0;
const hw = roster.reduce(
(a, s) => {
const [done, total] = s.hw.split("/").map(Number);
return { done: a.done + done, total: a.total + total };
},
{ done: 0, total: 0 }
);
const hwRate = hw.total ? Math.round((hw.done / hw.total) * 100) : 0;
// 채점이 끝난 과목만 평균을 쓴다 — 나머지는 비워 둔다
const graded = sub === "수학 심화 B";
const PROG = { "수학 심화 B": [12, 16], "국어 논술 A": [8, 12], "코딩 입문": [5, 10], "과학 실험": [9, 12] };
const [pDone, pTotal] = PROG[sub];
const tr = document.createElement("tr");
tr.innerHTML =
`<th scope="row">${sub}</th><td class="num mono">${roster.length}명</td>` +
`<td class="num mono">${avgAtt}%</td>` +
`<td class="num mono">${hw.done}/${hw.total} · ${hwRate}%</td>` +
`<td class="num mono">${graded ? '<span class="hl">84.4점</span>' : "채점 중"}</td>` +
`<td class="num mono">${pDone}/${pTotal} · ${Math.round((pDone / pTotal) * 100)}%</td>`;
return tr;
})
);
};
renderReport();
{
const active = STUDENTS.filter((s) => s.status === "재학");
const avgAtt = Math.round(active.reduce((a, s) => a + s.att, 0) / active.length);
const hw = active.reduce(
(a, s) => {
const [done, total] = s.hw.split("/").map(Number);
return { done: a.done + done, total: a.total + total };
},
{ done: 0, total: 0 }
);
const hwRate = Math.round((hw.done / hw.total) * 100);
document.getElementById("rep-stats").innerHTML =
`<div><dt>재학생</dt><dd>${active.length}명</dd><dd class="stat-sub">4과목 합산 · 휴학 2 제외</dd></div>` +
`<div><dt>평균 출석률</dt><dd>${avgAtt}%</dd><dd class="stat-sub">재학생 기준 실계산</dd></div>` +
`<div><dt>과제 제출률</dt><dd>${hwRate}%</dd><dd class="stat-sub">${hw.done}/${hw.total} 제출</dd></div>` +
`<div><dt>채점 완료 과목</dt><dd>1/4</dd><dd class="stat-sub">학부모 발송 3/20</dd></div>`;
}
// ── 수강생 상세 모달: 학적 카드 ──
const stuDialog = document.getElementById("stu-dialog");
document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-stu]");
if (!btn || btn.classList.contains("att-btn")) return;
const i = +btn.dataset.stu;
const s = STUDENTS[i];
document.getElementById("stu-seal").textContent = s.name[0];
document.getElementById("stu-mark").textContent = s.name[0];
document.getElementById("stu-dlg-h").innerHTML = `<span class="hl">${s.name}</span>`;
document.getElementById("stu-meta").textContent = `${s.sub} · ${s.status} · 개강 2026-03-02`;
document.getElementById("stu-guardian").textContent = `${s.guard} · ${s.phone}`;
document.getElementById("stu-att").textContent = s.att + "%";
document.getElementById("stu-hw").textContent = s.hw;
document.getElementById("stu-memo-n").textContent = s.memo.length + "건";
// 2주 출석 스트립 — 출석부 화면과 같은 데이터에서 뽑는다
const strip = document.getElementById("stu-att-strip");
if (s.status === "휴학") {
strip.replaceChildren();
const na = document.createElement("p");
na.className = "att-strip-na muted";
na.textContent = "휴학 중입니다. 복학하면 기록이 쌓입니다.";
strip.append(na);
} else {
const cells = [];
WEEKS.forEach((w, wi) => {
w.days.forEach((d, di) => {
const v = ATT_ALL[wi][i][di];
cells.push(
`<span class="att-cell2"><span class="att-mark v-${v === 2 ? "att" : v === 1 ? "late" : "abs"}">${ATT_MARK[v]}</span><span class="att-date mono">${d.slice(5).replace("-", "/")}</span></span>`
);
});
});
strip.innerHTML = cells.join("");
}
const noteLi = (m) => {
const li = document.createElement("li");
const dm = m.match(/^(\d+\/\d+)/);
const date = document.createElement("span");
date.className = "note-date mono";
date.textContent = dm ? dm[1] : "—";
const text = document.createElement("span");
text.className = "note-text";
text.textContent = m;
li.append(date, text);
return li;
};
const list = document.getElementById("stu-note-list");
list.replaceChildren(
...(s.memo.length ? s.memo.map(noteLi) : ["아직 기록이 없습니다."].map((t) => {
const li = document.createElement("li");
li.textContent = t;
return li;
}))
);
const form = document.getElementById("note-form");
const input = document.getElementById("note-input");
form.onsubmit = (ev) => {
ev.preventDefault();
if (!input.value.trim()) return;
s.memo.push(input.value.trim());
input.value = "";
const li = noteLi(s.memo[s.memo.length - 1]);
if (list.firstElementChild?.textContent === "아직 기록이 없습니다.") list.replaceChildren();
list.append(li);
document.getElementById("stu-memo-n").textContent = s.memo.length + "건";
renderCounsel(); // 상담 기록 화면도 함께 갱신
input.focus();
};
stuDialog.showModal();
});
// ── 채점 모달: 점수를 고치면 평균이 따라온다 ──
const asDialog = document.getElementById("as-dialog");
const scoreInputs = [...document.querySelectorAll(".grade-table .score")];
const updateAvg = () => {
const vals = scoreInputs.map((inp) => +inp.value || 0);
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
document.getElementById("as-avg").textContent = avg.toFixed(1);
};
scoreInputs.forEach((inp) => inp.addEventListener("input", updateAvg));
const ASSIGN = { a1: "미분 응용 세트 3 · 수학 심화 B", a2: "자기소개 논술 초고 · 국어 논술 A", a3: "변수와 조건 워크시트 · 코딩 입문", a4: "측정과 그래프 리포트 · 과학 실험" };
let currentAssign = null;
document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-assign]");
if (!btn) return;
currentAssign = btn.dataset.assign;
document.getElementById("as-dlg-meta").textContent = ASSIGN[currentAssign] || "";
document.getElementById("as-saved").hidden = true;
asDialog.showModal();
});
document.getElementById("as-save").addEventListener("click", () => {
document.getElementById("as-saved").hidden = false;
const row = document.querySelector(`#as-table .row-open[data-assign="${currentAssign}"]`);
const cell = row?.closest("tr").querySelector(".state");
if (cell) { cell.textContent = "채점 완료"; cell.classList.remove("state-wait"); }
});
// ── 모달 공용: 닫기 · 배경 탭 ──
document.querySelectorAll(".modal").forEach((d) => {
d.addEventListener("click", (e) => { if (e.target === d) d.close(); });
d.querySelector("[data-close]")?.addEventListener("click", () => d.close());
});
// ── 알림 센터 ──
const bell = document.getElementById("bell");
const bellMenu = document.getElementById("bell-menu");
const bellDot = document.getElementById("bell-dot");
const syncBell = () => {
const n = bellMenu.querySelectorAll(".ntc.is-unread").length;
bellDot.hidden = n === 0;
bell.setAttribute("aria-label", n > 0 ? "읽지 않은 알림 " + n + "건" : "알림");
};
bell.addEventListener("click", (e) => {
e.stopPropagation();
const open = bellMenu.classList.toggle("is-open");
bell.classList.toggle("is-open", open);
bell.setAttribute("aria-expanded", open);
});
document.addEventListener("click", (e) => {
if (!bellMenu.contains(e.target) && !bell.contains(e.target)) {
bellMenu.classList.remove("is-open");
bell.classList.remove("is-open");
bell.setAttribute("aria-expanded", "false");
}
});
document.getElementById("bell-all").addEventListener("click", () => {
bellMenu.querySelectorAll(".ntc.is-unread").forEach((n) => n.classList.remove("is-unread"));
syncBell();
});
syncBell();
// ── 공지: 분류 필터 ──
document.getElementById("nt-chips").addEventListener("click", (e) => {
const chip = e.target.closest("[data-nt]");
if (!chip) return;
document.querySelectorAll("#nt-chips .chip").forEach((c) => {
c.classList.toggle("is-on", c === chip);
c.setAttribute("aria-pressed", String(c === chip));
});
const cat = chip.dataset.nt;
document.querySelectorAll(".notice-row").forEach((row) => {
const li = row.closest("li");
const isPinned = row.querySelector(".pin") !== null;
row.hidden = cat !== "전체" && !isPinned && row.querySelector(".tag").textContent.trim() !== cat;
if (li) li.hidden = row.hidden;
});
});
// ── 공지: 열기 + 읽음 ──
document.querySelectorAll(".notice-row").forEach((row) => {
row.addEventListener("click", () => {
row.classList.toggle("is-open");
row.classList.remove("is-unread");
row.setAttribute("aria-expanded", row.classList.contains("is-open"));
});
});
</script>
</body>
</html>