feat(site,skill): 모바일 앱 수준 UX — 바텀내비·백 키 히스토리·바텀 시트·safe area·PWA 와 L6 시나리오 E2E

- HIG(탭바 5~6·시트)·M3(바텀내비 3~5·48dp)·NN/g·CSS-Tricks 조사(research/mobile-app-ux-v1.md) 후 Nielsen 10 감사로 위반 도출
- 바텀 내비 5탭 + 더보기 바텀 시트(그룹 헤더), 뷰·다이얼로그 pushState/popstate — 안드로이드 백 키가 앱 내 뒤로 작동
- safe area(env+max)·입력 16px(iOS 확대 방지)·coarse 44px 타깃·tap-highlight·overscroll 위생, 인쇄에서 모바일 크롬 제거
- PWA: manifest + sharp 생성 아이콘(가온=장부 문항, 두레=시간표 블록)
- tools/e2e.mjs L6 시나리오: RED 1,742 단언 318 실패 → GREEN 1,762/0, verify 체인 7계층 통과
- 실기기(안드로이드 에뮬레이터·Chrome) 텍스트 근거 검증 — 백 키·시트·탭 전환, iOS 는 WebKit(L4) 대체
- 스킬 내재화: mobile-app-ux.md, audit-gate 7계층·하니스 12~14, design-gate 터치타깃 검사, SKILL.md 규칙 7
- 도그푸드: 게이트가 죽은 선택자 8종·thresholds 병합 버그 적발 — CSS v22(양앱) 범프 후 전체 재검증
This commit is contained in:
Yun Chan 2026-08-23 14:33:15 +09:00
parent 72337b7ee0
commit 14cb138e1a
133 changed files with 2139 additions and 208 deletions

View file

@ -2,8 +2,12 @@
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>가온 학적부 · 학습관리 대시보드</title>
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="apple-touch-icon" href="./icon-192.png" />
<meta property="og:type" content="website" />
<meta property="og:title" content="가온 학적부 — 학습관리 콘솔" />
<meta property="og:url" content="https://designpaca.chanpaca.net/work/gaon-lms/" />
@ -18,7 +22,7 @@
<meta property="og:title" content="가온 학적부 · 학습관리 대시보드" />
<meta property="og:description" content="출석부에서 출발한 학습관리 대시보드." />
<meta property="og:type" content="website" />
<meta name="theme-color" content="#F7F5EF" />
<meta name="theme-color" content="#fafafa" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" />
@ -31,10 +35,10 @@
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="stylesheet" href="./styles/main.css?v=22" />
<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"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23fafafa'/%3E%3Crect x='6.5' y='6.5' width='19' height='19' rx='4' fill='%23059669'/%3E%3Crect x='10' y='12' width='12' height='1.8' rx='0.9' fill='%23ffffff'/%3E%3Crect x='10' y='15.5' width='8' height='1.8' rx='0.9' fill='%23ffffff' opacity='0.85'/%3E%3Crect x='10' y='19' width='10' height='1.8' rx='0.9' fill='%23ffffff' opacity='0.7'/%3E%3C/svg%3E"
/>
</head>
@ -88,6 +92,7 @@
강사 김가온 · 3월 2주차<br />
<span class="mono">2026학년도 1학기</span>
</p>
<p class="side-kbd"><kbd>1</kbd>–<kbd>9</kbd> 화면 전환 · <kbd>/</kbd> 수강생 검색</p>
</aside>
<main id="main" class="main">
@ -95,6 +100,9 @@
<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>
<button type="button" class="icon-btn" id="print-btn" aria-label="현재 화면 인쇄">
<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 9V3h12v6"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="7" rx="1"/></svg>
</button>
<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>
@ -133,7 +141,6 @@
<h2 class="v-h" id="dash-h">이번 주, 열두 명 중 열 명</h2>
<p class="view-sub">3월 9일 월요일 오전 기준. 명단 12명 중 재학 10명, 주간 출석은 지난 주(3/2–3/6) 결산입니다.</p>
</div>
<p class="stamp" aria-hidden="true">3월 2주</p>
</header>
<dl class="headline">
@ -369,7 +376,6 @@
<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">
@ -631,7 +637,6 @@
<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>
@ -721,6 +726,7 @@
</tbody>
</table>
</div>
<p class="score-warn" id="as-warn" role="alert" hidden></p>
<p class="as-summary mono" aria-live="polite">
제출 3명 · 평균 <strong class="hl" id="as-avg">91.7</strong>점
</p>
@ -731,7 +737,64 @@
</div>
</dialog>
<script src="calc.js"></script>
<!-- ── 바텀 내비(모바일) — 엄지 존의 앱 문법. 다섯 탭 + 더보기 시트 ── -->
<nav class="bottom-nav" aria-label="화면">
<button type="button" class="bnav-item is-current" data-view="dashboard" aria-current="page">
<svg viewBox="0 0 24 24" width="20" height="20" 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>
<span>홈</span>
</button>
<button type="button" class="bnav-item" data-view="students">
<svg viewBox="0 0 24 24" width="20" height="20" 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>
<span>수강생</span>
</button>
<button type="button" class="bnav-item" data-view="attendance">
<svg viewBox="0 0 24 24" width="20" height="20" 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>
<span>출석부</span>
</button>
<button type="button" class="bnav-item" data-view="grades">
<svg viewBox="0 0 24 24" width="20" height="20" 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>
<span>성적</span>
</button>
<button type="button" class="bnav-item" data-more aria-expanded="false" aria-haspopup="dialog">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="5" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="19" cy="5" r="1"/><circle cx="5" cy="19" r="1"/><circle cx="12" cy="19" r="1"/><circle cx="19" cy="19" r="1"/></svg>
<span>더보기</span>
</button>
</nav>
<!-- ── 더보기 시트 — 전체 아홉 화면의 원장. 그룹 문법은 사이드 내비와 같다 ── -->
<dialog id="more-dialog" class="modal more-sheet" aria-labelledby="more-h">
<div class="more-head">
<h2 id="more-h">전체 화면</h2>
<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>
</div>
<div class="more-list">
<p class="more-group">학적</p>
<button type="button" class="more-item" data-view="dashboard">
<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="more-item" 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="more-item" 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="more-group">수업</p>
<button type="button" class="more-item" 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="more-item" 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="more-group">학사</p>
<button type="button" class="more-item" 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="more-item" 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="more-item" 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="more-item" 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>
</div>
</dialog>
<script src="calc.js?v=2"></script>
<script>
/*
앱 상태는 전부 이 안에 있다. 서버도 localStorage 도 없다 — 데모 원칙.
@ -754,17 +817,70 @@
];
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 VIEW_LABEL = { dashboard: "대시보드", students: "수강생", attendance: "출석부", grades: "과제 · 성적", files: "자료실", calendar: "학사 일정", counsel: "상담 기록", report: "학기 리포트", notice: "공지 · 알림" };
const currentView = () => (history.state && history.state.v) || "dashboard";
const setCurrentView = (name) => {
document.querySelectorAll(".side-link").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");
});
document.querySelectorAll(".bnav-item[data-view]").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");
});
const inMore = !document.querySelector(`.bnav-item[data-view="${name}"]`);
document.querySelector("[data-more]").classList.toggle("is-current", inMore);
document.querySelectorAll(".more-item[data-view]").forEach((b) => b.classList.toggle("is-current", b.dataset.view === name));
views.forEach((v) => v.classList.toggle("is-on", v.id === "view-" + name));
document.getElementById("crumb-view").textContent = VIEW_LABEL[name] || name;
document.getElementById("view-" + name).focus({ preventScroll: true });
};
const gotoView = (name, push = true) => {
if (push) history.pushState({ v: name }, "");
setCurrentView(name);
};
history.replaceState({ v: currentView() }, "");
document.querySelectorAll(".side-link, .bnav-item[data-view]").forEach((btn) => {
btn.addEventListener("click", () => gotoView(btn.dataset.view));
});
// ── 다이얼로그 × 히스토리 — 열림마다 pushState, 뒤로 가기는 시트 닫기→이전 화면(Android 내비 원칙·Baymard) ──
const DIALOGS = ["stu-dialog", "as-dialog", "more-dialog"];
let suppressPop = false; // 닫기가 부른 back 의 popstate — 이미 처리된 상태
let popClosing = false; // popstate 가 직접 닫는 중 — close 핸들러가 back 을 다시 부르지 않게
const openDialog = (dlg) => {
if (dlg.open) return;
history.pushState({ v: currentView(), d: dlg.id }, "");
dlg.showModal();
if (dlg.id === "more-dialog") document.querySelector("[data-more]").setAttribute("aria-expanded", "true");
};
DIALOGS.forEach((id) => {
const dlg = document.getElementById(id);
dlg.addEventListener("close", () => {
if (dlg.id === "more-dialog") document.querySelector("[data-more]").setAttribute("aria-expanded", "false");
if (popClosing) return;
if (history.state && history.state.d === dlg.id) { suppressPop = true; history.back(); }
});
});
let pendingMoreSelect = null;
window.addEventListener("popstate", () => {
if (suppressPop) { suppressPop = false; return; }
const s = history.state || {};
popClosing = true;
DIALOGS.forEach((id) => { const d = document.getElementById(id); if (d.open && s.d !== id) d.close(); });
popClosing = false;
if (s.v) setCurrentView(s.v);
if (pendingMoreSelect) { const v = pendingMoreSelect; pendingMoreSelect = null; gotoView(v, true); }
});
document.querySelector("[data-more]").addEventListener("click", () => openDialog(document.getElementById("more-dialog")));
document.querySelectorAll(".more-item[data-view]").forEach((b) => {
b.addEventListener("click", () => {
pendingMoreSelect = b.dataset.view;
history.back(); // {d} 엔트리 정리 → popstate 에서 닫힘+전환까지 마친다
});
});
@ -1223,18 +1339,38 @@
renderCounsel(); // 상담 기록 화면도 함께 갱신
input.focus();
};
stuDialog.showModal();
openDialog(stuDialog);
});
// ── 채점 모달: 점수를 고치면 평균이 따라온다 ──
// ── 채점 모달: 점수를 고치면 검증과 평균이 따라온다. 범위 밖이면 이유를 말하고 저장을 막는다 ──
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);
const syncGrading = () => {
const results = scoreInputs.map((inp) => ({ inp, ok: GAON.validateScore(inp.value).ok }));
results.forEach(({ inp, ok }) => {
inp.classList.toggle("is-bad", !ok);
inp.setAttribute("aria-invalid", String(!ok));
});
const bad = results.filter((r) => !r.ok);
const warnEl = document.getElementById("as-warn");
const saveBtn = document.getElementById("as-save");
if (bad.length) {
const names = bad
.map(({ inp }) => document.querySelector(`label[for="${inp.id}"]`).textContent.replace(" 점수", ""))
.join(", ");
warnEl.textContent = `0에서 100 사이의 점수를 넣어주세요. 범위 밖: ${names}`;
warnEl.hidden = false;
saveBtn.disabled = true;
} else {
warnEl.hidden = true;
saveBtn.disabled = false;
}
const vals = results.filter(({ ok }) => ok).map(({ inp }) => +inp.value);
document.getElementById("as-avg").textContent = vals.length
? (vals.reduce((a, b) => a + b, 0) / vals.length).toFixed(1)
: "—";
};
scoreInputs.forEach((inp) => inp.addEventListener("input", updateAvg));
scoreInputs.forEach((inp) => inp.addEventListener("input", syncGrading));
const ASSIGN = { a1: "미분 응용 세트 3 · 수학 심화 B", a2: "자기소개 논술 초고 · 국어 논술 A", a3: "변수와 조건 워크시트 · 코딩 입문", a4: "측정과 그래프 리포트 · 과학 실험" };
let currentAssign = null;
document.addEventListener("click", (e) => {
@ -1243,9 +1379,11 @@
currentAssign = btn.dataset.assign;
document.getElementById("as-dlg-meta").textContent = ASSIGN[currentAssign] || "";
document.getElementById("as-saved").hidden = true;
asDialog.showModal();
syncGrading();
openDialog(asDialog);
});
document.getElementById("as-save").addEventListener("click", () => {
if (document.getElementById("as-save").disabled) return;
document.getElementById("as-saved").hidden = false;
const row = document.querySelector(`#as-table .row-open[data-assign="${currentAssign}"]`);
const cell = row?.closest("tr").querySelector(".state");
@ -1286,6 +1424,15 @@
});
syncBell();
// 알림 센터도 Esc 로 닫는다 — 모달과 같은 근육
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape" || !bellMenu.classList.contains("is-open")) return;
bellMenu.classList.remove("is-open");
bell.classList.remove("is-open");
bell.setAttribute("aria-expanded", "false");
bell.focus();
});
// ── 공지: 분류 필터 ──
document.getElementById("nt-chips").addEventListener("click", (e) => {
const chip = e.target.closest("[data-nt]");
@ -1311,6 +1458,25 @@
row.setAttribute("aria-expanded", row.classList.contains("is-open"));
});
});
</script>
// ── 단축키 — 콘솔의 손가락 문법. 숫자키 화면 전환, / 검색. 입력 중에는 쓰지 않는다 ──
const VIEW_ORDER = ["dashboard", "students", "attendance", "grades", "files", "calendar", "counsel", "report", "notice"];
document.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
if (e.key === "/") {
e.preventDefault();
document.querySelector('.side-link[data-view="students"]').click();
document.getElementById("q").focus();
} else if (/^[1-9]$/.test(e.key)) {
const view = VIEW_ORDER[+e.key - 1];
if (view) document.querySelector(`.side-link[data-view="${view}"]`).click();
}
});
// ── 인쇄 — 활성 화면(모달이 열려 있으면 그 서류)을 종이로 ──
document.getElementById("print-btn").addEventListener("click", () => window.print());
</script>
</body>
</html>