ESP32-S3 Bench Guide
ESP32‑S3 N16R8 벤치 가이드: 실시간 핀아웃, 강의, 시뮬레이터 및 벤치 로그
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
브라우저에서 바로 실행되는 수업·업무용 HTML 도구 모음입니다. 서버가 필요 없고, GitHub Pages로 그대로 공개할 수 있습니다. 폴더마다 있는 index.html이 그 분류의 목록 페이지이고, 도구 파일은 같은 폴더에 둡니다. AI와 함께하는 수학교사의 1학기 살이는 04-festival/에 8단계 가이드·도구로 전체 공개됐습니다. 수수는 아직 비어 있습니다.README 전문 보기 ↗
브라우저에서 바로 실행되는 수업·업무용 HTML 도구 모음입니다. 서버가 필요 없고, GitHub Pages로 그대로 공개할 수 있습니다.
공개 주소: https://math7942.github.io/SU3/
index.html 허브 (7개 분류)
assets/site.css 모든 페이지가 함께 쓰는 스타일
01-play/ 1. 수학과 놀자
02-lab/ 2. 수학실험 (아래 11개 실험실)
01-common/ 공통수학
02-algebra/ 대수
03-statistics/ 확률과 통계
04-geometry/ 기하
05-calculus/ 미적분
06-ai-math/ 인공지능 수학
07-economics/ 경제수학
08-middle1/ 중학교 1학년
09-middle2/ 중학교 2학년
10-middle3/ 중학교 3학년
11-experience/ 수학체험
03-work/ 3. 업무 도구
04-festival/ 4. AI와 함께하는 수학교사의 1학기 살이
05-susu/ 5. 수수
06-group/ 6. AI&에듀테크 사용 설명서
07-problems/ 7. 수학문제 (2022 개정 교육과정 15개 과목)
assets/ 문제 생성·채점 엔진과 전용 스타일
01-common-math-1/ 공통수학1
02-common-math-2/ 공통수학2
03-basic-math-1/ 기본수학1
04-basic-math-2/ 기본수학2
05-algebra/ 대수
06-calculus-1/ 미적분Ⅰ
07-statistics/ 확률과 통계
08-geometry/ 기하
09-calculus-2/ 미적분Ⅱ
10-economics/ 경제 수학
11-ai-math/ 인공지능 수학
12-vocational/ 직무 수학
13-culture/ 수학과 문화
14-practical-statistics/ 실용 통계
15-inquiry/ 수학과제 탐구
폴더마다 있는 index.html이 그 분류의 목록 페이지이고, 도구 파일은 같은 폴더에 둡니다.
| 위치 | 파일 | 내용 |
|---|---|---|
| 수학과 놀자 | 01-play/teachable-machine.html | 티처블 머신 소리 실험 |
| 수학과 놀자 | 01-play/river-crossing-game.html | 강 건너기 게임 |
| 수학과 놀자 | 01-play/pig-dice-lab.html | 피그 주사위 실험실 |
| 수학실험 › 대수 | 02-lab/02-algebra/sound-anc.html | 삼각함수와 소음 제거 |
| 수학실험 › 인공지능 수학 | 02-lab/06-ai-math/rgb-matrix.html | 픽셀 에디터와 RGB 행렬 |
| 수학실험 › 인공지능 수학 | 02-lab/06-ai-math/image-blender.html | 이미지 블렌더 |
| 수학실험 › 인공지능 수학 | 02-lab/06-ai-math/handwriting.html | 손글씨 숫자 인식 |
| 업무 도구 | 03-work/score-reports.html | 학생별 정오표 출력 |
| 업무 도구 | 03-work/random-order.html | 랜덤 순서 뽑기 |
| 업무 도구 | 03-work/image-resize.html | 이미지 크기 변환 |
| 업무 도구 | 03-work/photo-format.html | 원서용 사진 규격 변환 |
| 업무 도구 | 03-work/exam-range.html | 3학년 시험범위 수합 |
| 업무 도구 | 03-work/interview-prep.html | 면접+논술+실기+적성 (편성표) |
| 업무 도구 | 03-work/apply-print.html | 원서접수 출력 (학생별 A4 가로 1페이지) |
| 업무 도구 | 03-work/apply-interview-combined.html | 원서접수+면접 통합 출력 (엑셀 한 번 업로드) |
| 업무 도구 | 03-work/signature-transparent.html | 배경이 투명한 서명 이미지 만들기 |
| 업무 도구 | 03-work/overtime-calc.html | 초과근무 계산기 |
| 업무 도구 | 03-work/training-roster.html | 연수등록부 (교직원 명단 관리 → 교직원/교사 유형별 등록부 생성 → 로그인 없이 서명) |
AI와 함께하는 수학교사의 1학기 살이는 04-festival/에 8단계 가이드·도구로 전체 공개됐습니다. 수수는 아직 비어 있습니다.
07-problems/)2022 개정 교육과정의 수학 과목 15개를 과목별 연습 페이지로 만들어 둔 분류입니다. 과목을 고르면 영역과 주제가 나오고, 주제를 누르면 문제가 그 자리에서 새로 만들어집니다. 같은 주제를 다시 눌러도 숫자가 매번 달라지므로 반복 연습에 쓸 수 있습니다.
| 구분 | 과목 | 주제 수 |
|---|---|---|
| 공통 과목 | 공통수학1, 공통수학2, 기본수학1, 기본수학2 | 9, 11, 6, 5 |
| 일반 선택 | 대수, 미적분Ⅰ, 확률과 통계 | 10, 8, 9 |
| 진로 선택 | 기하, 미적분Ⅱ, 경제 수학, 인공지능 수학, 직무 수학 | 7, 11, 6, 6, 5 |
| 융합 선택 | 수학과 문화, 실용 통계, 수학과제 탐구 | 5, 6, 5 |
전체 109개 주제이고, 주제마다 개념 정리 → 문제 → 채점 → 해설의 순서로 이어집니다. (미적분Ⅱ의 "쪽지시험 출력" 4개는 한 문제씩 푸는 주제가 아니라 인쇄용 시험지입니다. 아래 "쪽지시험 출력" 참고) 푼 문제 수와 정답 수는 그 기기의 브라우저(localStorage)에만 저장되며 서버로 보내지 않습니다.
07-problems/assets/problems.js — 문제를 만들고 채점하는 공통 엔진. 화면도 이 파일이 그립니다.07-problems/assets/problems.css — 수학문제 분류 전용 스타일 (assets/site.css 위에 얹어 씁니다).07-problems/<과목폴더>/index.html — 과목 페이지. 내용은 거의 같고 제목·아이콘만 다릅니다.07-problems/<과목폴더>/data.js — 그 과목의 영역·주제·문제가 모두 들어 있는 파일.미적분Ⅱ에는 한 문제씩 푸는 주제 외에, A4 시험지를 인쇄하는 주제 4개가 "쪽지시험 출력" 영역에 있습니다.
| 주제 | 구성 |
|---|---|
| 미적분 공식 쪽지시험 | 미분 16 + 적분 15 = 31문항. 기본형(공식 그대로, 세트마다 순서만 바뀜) / 변형(계수·밑·안쪽 식이 바뀐 꼴) |
| 치환적분법 쪽지시험 | 1번 공식 + 2~10번 치환적분 9문항(정적분 2개 포함) |
| 부분적분법 쪽지시험 | 1번 공식 + 2~10번 부분적분 9문항(정적분 2개 포함) |
| 정적분과 급수의 합 쪽지시험 | 시그마 6문항 + 나열된 합 4문항 |
파일 구성은 다음과 같습니다.
07-problems/assets/sheet-kit.js — 쪽지시험 생성기가 함께 쓰는 TeX 도우미(window.SU3K)07-problems/assets/sheets.js — 미리보기·시험지 모양·인쇄를 맡는 화면 코드(window.SU3Sheets)07-problems/09-calculus-2/sheet-*.js — 주제별 문제 생성기. 각각 window.SU3_SHEETS.<이름> 에
{ layout, title, total, modes, note, build(모드) } 를 등록하고, build 는 A~D 4세트를 돌려줍니다.07-problems/09-calculus-2/data.js — 위 생성기를 주제로 연결하는 곳 (sheet: window.SU3_SHEETS.parts 처럼)시험지 종류(layout)는 formula(2열 공식표), quiz(2열×5행), series(2열×3행 + 가로 4행) 세 가지입니다.
항목은 { q, a, sol: [풀이 줄...] } 이고 수식은 모두 TeX 문자열입니다. 다른 과목에 쪽지시험을 더하고 싶으면
그 과목 index.html 에 sheet-kit.js, sheets.js 와 새 생성기 파일을 data.js 앞에 불러오세요.
해당 과목의 data.js를 열고, areas 안의 알맞은 영역에 아래 덩어리를 붙여 넣습니다.
{
id: 'my-topic', // 같은 과목 안에서 겹치지 않는 이름 (기록 저장에 씁니다)
name: '주제 이름',
desc: '카드에 보일 한 줄 설명',
concept: '<p>개념 정리 한두 문단. 수식은 $x^2$ 처럼 씁니다.</p>',
gen: function () {
var a = M.i(1, 9); // M.i(a, b): a 이상 b 이하의 정수
return {
q: '$' + a + ' \\times 7$ 의 값을 구하시오.',
type: 'num', // 'num'(숫자) · 'text'(문자열) · 'choice'(보기)
ans: a * 7,
sol: '<p>$' + a + ' \\times 7 = ' + (a * 7) + '$</p>'
};
}
}
type: 'choice' 이면 choices: ['보기1', '보기2', ...] 와 정답 번호(0부터) ans 를 함께 적습니다.
type: 'text' 이면 alt: ['허용할 다른 답'] 을 줄 수 있고, 공백·대소문자는 무시하고 비교합니다.
답이 분수이면 ans 에는 수(예: 3/4 는 0.75)를, ansText 에는 보여 줄 문자열을 적습니다.
문제를 만들 때 쓰는 도우미는 M(= window.SU3M)에 있습니다.
M.i(a,b) 정수, M.nz(a,b) 0이 아닌 정수, M.pick(arr) 하나 고르기, M.shuffle(arr) 섞기,
M.two(a,b) 서로 다른 두 정수, M.frac/fracTex/fracStr 분수, M.poly([[계수,'문자'], ...]) 다항식,
M.nCr/nPr/fact 경우의 수, M.num(x, 자릿수) 반올림.
주제를 더하거나 뺀 뒤에는 과목 카드의 "주제 N개" 표시를 07-problems/index.html에서 고쳐 주세요.
07-problems/에 폴더를 만들고 기존 과목의 index.html을 복사해 제목·아이콘·설명만 고칩니다.data.js를 만들어 window.SU3_SUBJECT = { id, name, areas: [...] } 를 채웁니다.07-problems/index.html의 알맞은 구분(공통/일반 선택/진로 선택/융합 선택)에 카드를 추가합니다.03-work/exam-range.html)이 도구만은 다른 도구와 달리 브라우저 안에서 끝나지 않고, 구글 시트를 저장소로 씁니다. 선생님마다 로그인 없이 링크만 열어 입력할 수 있게 하기 위해서입니다. 아래 순서로 한 번만 연결하면 됩니다.
데이터를 담을 구글 시트가 이미 만들어져 있습니다: 2026-2학기 1차 정기시험 3학년 시험범위 수합
(열: 날짜, 교시, 시간, 과목, 담당교사, 시험범위, 최종수정, 3학년 22과목이 미리 채워져 있음)
한 과목을 여러 선생님이 나눠 맡는 경우를 지원하려면 시트에 열을 하나 더 추가해야 합니다.
시트에서 H1 칸을 클릭해 id라고 입력하세요 (담당교사별 제출 항목을 구분하는 값으로,
Apps Script가 자동으로 채웁니다. 직접 값을 넣지 않아도 됩니다).
그 시트를 열고 확장 프로그램 → Apps Script로 들어가 기존 코드를 전부 지우고 아래 코드를
붙여넣습니다. (한 과목에 담당교사가 이미 있는 행이 있으면 새 담당교사는 새 행을 추가하고,
id가 있는 요청은 그 행만 찾아 수정합니다. 맨 위 ADMIN_PASSWORD는 평가계님만 아는 값으로
반드시 바꿔주세요 — 이 비밀번호로 표를 잠그고 여는 것을 통제합니다.)
var ADMIN_PASSWORD = "여기에_비밀번호를_정하세요"; // 반드시 바꾸세요
function doGet(e) {
var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0];
var data = sheet.getDataRange().getValues();
var headers = data[0];
var col = {
subject: headers.indexOf("과목"),
teacher: headers.indexOf("담당교사"),
range_text: headers.indexOf("시험범위"),
updated_at: headers.indexOf("최종수정"),
id: headers.indexOf("id")
};
var rows = data.slice(1).map(function (row) {
return {
id: row[col.id],
subject: row[col.subject],
teacher: row[col.teacher],
range_text: row[col.range_text],
updated_at: row[col.updated_at]
};
});
var locked = PropertiesService.getScriptProperties().getProperty("locked") === "true";
return ContentService.createTextOutput(JSON.stringify({ rows: rows, locked: locked }))
.setMimeType(ContentService.MimeType.JSON);
}
function doPost(e) {
var payload = JSON.parse(e.postData.contents);
// 잠금/해제는 비밀번호가 맞으면 잠긴 상태에서도 항상 처리한다(그래야 다시 열 수 있음)
if (payload.action === "setLock") {
if (payload.password !== ADMIN_PASSWORD) {
return ContentService.createTextOutput(JSON.stringify({ ok: false, error: "비밀번호가 올바르지 않습니다" }))
.setMimeType(ContentService.MimeType.JSON);
}
PropertiesService.getScriptProperties().setProperty("locked", payload.locked ? "true" : "false");
return ContentService.createTextOutput(JSON.stringify({ ok: true, locked: !!payload.locked }))
.setMimeType(ContentService.MimeType.JSON);
}
// 잠겨 있으면 그 외의 모든 쓰기(입력/수정/삭제)를 거부한다
var isLocked = PropertiesService.getScriptProperties().getProperty("locked") === "true";
if (isLocked) {
return ContentService.createTextOutput(JSON.stringify({ ok: false, error: "제출이 마감되었습니다" }))
.setMimeType(ContentService.MimeType.JSON);
}
var subject = payload.subject;
var teacher = payload.teacher || "";
var rangeText = payload.rangeText || "";
var id = payload.id || "";
var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0];
var data = sheet.getDataRange().getValues();
var headers = data[0];
var col = {
date: headers.indexOf("날짜"),
period: headers.indexOf("교시"),
time: headers.indexOf("시간"),
subject: headers.indexOf("과목"),
teacher: headers.indexOf("담당교사"),
range_text: headers.indexOf("시험범위"),
updated_at: headers.indexOf("최종수정"),
id: headers.indexOf("id")
};
var now = new Date();
// 0) 삭제 요청이면 그 행의 담당교사/시험범위/id만 비워서 다시 빈 자리로 되돌림
if (payload.action === "delete") {
for (var rDel = 1; rDel < data.length; rDel++) {
if (String(data[rDel][col.id]) === String(id)) {
sheet.getRange(rDel + 1, col.teacher + 1).setValue("");
sheet.getRange(rDel + 1, col.range_text + 1).setValue("");
sheet.getRange(rDel + 1, col.updated_at + 1).setValue("");
sheet.getRange(rDel + 1, col.id + 1).setValue("");
return ContentService.createTextOutput(JSON.stringify({ ok: true }))
.setMimeType(ContentService.MimeType.JSON);
}
}
return ContentService.createTextOutput(JSON.stringify({ ok: false, error: "id not found" }))
.setMimeType(ContentService.MimeType.JSON);
}
// 1) id로 지정된 기존 항목이면 그 행만 찾아 수정
if (id) {
for (var r = 1; r < data.length; r++) {
if (String(data[r][col.id]) === String(id)) {
sheet.getRange(r + 1, col.teacher + 1).setValue(teacher);
sheet.getRange(r + 1, col.range_text + 1).setValue(rangeText);
sheet.getRange(r + 1, col.updated_at + 1).setValue(now);
return ContentService.createTextOutput(JSON.stringify({ ok: true, id: id }))
.setMimeType(ContentService.MimeType.JSON);
}
}
}
// 2) 그 과목의 미리 만들어둔 빈 자리(담당교사 없음)가 있으면 그 자리를 채움
for (var r2 = 1; r2 < data.length; r2++) {
if (data[r2][col.subject] === subject && !data[r2][col.teacher]) {
var newId = Utilities.getUuid();
sheet.getRange(r2 + 1, col.teacher + 1).setValue(teacher);
sheet.getRange(r2 + 1, col.range_text + 1).setValue(rangeText);
sheet.getRange(r2 + 1, col.updated_at + 1).setValue(now);
sheet.getRange(r2 + 1, col.id + 1).setValue(newId);
return ContentService.createTextOutput(JSON.stringify({ ok: true, id: newId }))
.setMimeType(ContentService.MimeType.JSON);
}
}
// 3) 빈 자리가 없으면(두 번째 이상 담당교사) 새 행을 추가
var newId2 = Utilities.getUuid();
sheet.appendRow([
payload.date || "", payload.period || "", payload.time || "",
subject, teacher, rangeText, now, newId2
]);
return ContentService.createTextOutput(JSON.stringify({ ok: true, id: newId2 }))
.setMimeType(ContentService.MimeType.JSON);
}
이미 배포해 두었다면: 배포 → 배포 관리 → 연필(수정) 아이콘 → 버전: 새 버전 → 배포로 코드만 새로 반영하면 됩니다 (URL은 그대로 유지).
페이지의 🔒 잠금/열기 (평가계용) 버튼을 누르면 위에서 정한 ADMIN_PASSWORD를 물어봅니다.
잠그면 학생·선생님 화면에서는 표를 계속 볼 수 있지만, 입력·수정·삭제는 (버튼이 아예 안 보이고)
서버(Apps Script)에서도 거부됩니다. 비밀번호는 선생님들께 알리지 말고 평가계님만 아셔야 합니다.
이후 시트 내용을 바꾸려면(과목 추가·삭제, 오타 수정) 시트를 직접 편집해도 되고, 코드를 수정한 뒤 배포 → 배포 관리 → 수정 → 새 버전으로 다시 배포하면 됩니다(웹앱 URL은 그대로 유지됩니다).
시트를 좀 더 보기 좋게 꾸미고 싶다면: 1행을 선택해 굵게 하고 보기 → 고정 → 1행으로 머리글을 고정하면 스크롤해도 열 제목이 계속 보입니다.
03-work/training-roster.html)이 도구도 시험범위 수합 도구와 마찬가지로 구글 시트 + Apps Script를 저장소로 씁니다. 연수자가 로그인 없이 서명할 수 있어야 하기 때문입니다 (Claude 계정 로그인이 필요 없습니다).
데이터를 담을 구글 시트가 이미 만들어져 있습니다: 연수등록부 저장소
(https://docs.google.com/spreadsheets/d/1Kwt7XlArEoWG5fjJzYkrApZ_zcaXK9fyPpLOtTAaZJc/edit)
Staff_<학교명> — 그 학교의 교직원 명단(관리자가 입력·삭제). 학교마다 별도 시트로 나뉘며,
교직원 명단 관리 화면에서 학교명을 입력하면 자동으로 그 이름의 시트가 만들어지거나 열립니다.
열(왼쪽부터): 아이디, 소속, 직위, 과목, 성명, 순서Rosters — 생성된 연수등록부(모든 학교 공통 한 시트). 열(왼쪽부터): 아이디, 학교명, 연수명, 유형,
담당부서, 일시, 명단데이터, 생성일시
(명단데이터는 등록부를 만든 시점의 교직원/교사 명단을 그대로 찍어 둔 값이라, 나중에 해당
Staff_<학교명> 시트를 수정해도 이미 만든 등록부는 바뀌지 않습니다.)Signatures — 서명 기록(모든 학교 공통 한 시트). 열(왼쪽부터): 등록부아이디, 순번, 성명, 서명이미지,
서명일시그 시트를 열고 확장 프로그램 → Apps Script로 들어가 기존 코드를 전부 지우고 아래 코드를
붙여넣습니다. 맨 위 ADMIN_PASSWORD는 담당자들만 아는 값으로 반드시 바꿔주세요 — 교직원 명단
관리와 연수등록부 생성 모두 이 비밀번호로 보호됩니다. 서명 자체는 비밀번호 없이 누구나 할 수
있습니다(링크만 있으면 됩니다).
var ADMIN_PASSWORD = "여기에_비밀번호를_정하세요"; // 반드시 바꾸세요
function doGet(e) {
var action = e.parameter.action;
var ss = SpreadsheetApp.getActiveSpreadsheet();
if (action === "roster") {
var roster = findRoster(ss, e.parameter.id);
if (!roster) return json({ ok: false, error: "등록부를 찾을 수 없습니다" });
return json({ ok: true, roster: roster, signatures: getSignatures(ss, roster.id) });
}
if (action === "list") {
if (e.parameter.password !== ADMIN_PASSWORD) return json({ ok: false, error: "비밀번호가 올바르지 않습니다" });
return json({ ok: true, rosters: listRosters(ss) });
}
if (action === "staffList") {
if (e.parameter.password !== ADMIN_PASSWORD) return json({ ok: false, error: "비밀번호가 올바르지 않습니다" });
var school = String(e.parameter.school || "").trim();
if (!school) return json({ ok: false, error: "학교명을 입력해주세요" });
return json({ ok: true, staff: listStaff(ss, school) });
}
return json({ ok: false, error: "알 수 없는 요청입니다" });
}
function doPost(e) {
var payload = JSON.parse(e.postData.contents);
var ss = SpreadsheetApp.getActiveSpreadsheet();
var action = payload.action;
if (action === "createRoster") {
if (payload.password !== ADMIN_PASSWORD) return json({ ok: false, error: "비밀번호가 올바르지 않습니다" });
var school = String(payload.school || "").trim();
var title = String(payload.title || "").trim();
var type = payload.type === "teacher" ? "teacher" : "staff";
if (!school) return json({ ok: false, error: "학교명을 입력해주세요" });
if (!title) return json({ ok: false, error: "연수명을 입력해주세요" });
var staff = listStaff(ss, school);
var entries = staff
.filter(function (s) { return type === "teacher" ? s.position === "교사" : true; })
.map(function (s) { return { school: s.school, position: s.position, subject: s.subject, name: s.name }; });
if (!entries.length) return json({ ok: false, error: "그 학교의 교직원 명단이 비어 있습니다. 먼저 교직원 명단을 등록해주세요." });
var id = Utilities.getUuid();
getSheet(ss, "Rosters").appendRow([
id, school, title, type, payload.department || "", payload.datetime || "",
JSON.stringify(entries), new Date().toISOString()
]);
return json({ ok: true, id: id });
}
if (action === "sign") {
var rosterId = payload.rosterId;
var idx = payload.index;
var dataUrl = payload.dataUrl || "";
if (!rosterId || idx === undefined || idx === null || !dataUrl) {
return json({ ok: false, error: "잘못된 요청입니다" });
}
var roster = findRoster(ss, rosterId);
if (!roster) return json({ ok: false, error: "등록부를 찾을 수 없습니다" });
if (idx < 0 || idx >= roster.entries.length) return json({ ok: false, error: "잘못된 요청입니다" });
var sheet = getSheet(ss, "Signatures");
var data = sheet.getDataRange().getValues();
for (var r = 1; r < data.length; r++) {
if (String(data[r][0]) === String(rosterId) && String(data[r][1]) === String(idx)) {
return json({ ok: false, error: "이미 서명한 사람입니다" });
}
}
var displayName = String(payload.name || "").trim() || roster.entries[idx].name || "";
sheet.appendRow([rosterId, idx, displayName, dataUrl, new Date().toISOString()]);
return json({ ok: true });
}
if (action === "addStaff") {
if (payload.password !== ADMIN_PASSWORD) return json({ ok: false, error: "비밀번호가 올바르지 않습니다" });
var school1 = String(payload.school || "").trim();
if (!school1) return json({ ok: false, error: "학교명을 입력해주세요" });
if (!String(payload.name || "").trim()) return json({ ok: false, error: "성명을 입력해주세요" });
appendStaffRow(ss, school1, payload);
return json({ ok: true });
}
if (action === "addStaffBulk") {
if (payload.password !== ADMIN_PASSWORD) return json({ ok: false, error: "비밀번호가 올바르지 않습니다" });
var school2 = String(payload.school || "").trim();
if (!school2) return json({ ok: false, error: "학교명을 입력해주세요" });
var rows = payload.rows || [];
rows.forEach(function (r) { if (String(r.name || "").trim()) appendStaffRow(ss, school2, r); });
return json({ ok: true, added: rows.length });
}
if (action === "deleteStaff") {
if (payload.password !== ADMIN_PASSWORD) return json({ ok: false, error: "비밀번호가 올바르지 않습니다" });
var school3 = String(payload.school || "").trim();
if (!school3) return json({ ok: false, error: "학교명을 입력해주세요" });
var sheet2 = getStaffSheet(ss, school3);
var data2 = sheet2.getDataRange().getValues();
for (var r2 = 1; r2 < data2.length; r2++) {
if (String(data2[r2][0]) === String(payload.id)) {
sheet2.deleteRow(r2 + 1);
return json({ ok: true });
}
}
return json({ ok: false, error: "대상을 찾지 못했습니다" });
}
return json({ ok: false, error: "알 수 없는 요청입니다" });
}
function appendStaffRow(ss, school, r) {
var sheet = getStaffSheet(ss, school);
var id = Utilities.getUuid();
var lastRow = sheet.getLastRow();
sheet.appendRow([id, school, r.position || "", r.subject || "", r.name || "", lastRow]);
}
function staffSheetName(school) {
var s = String(school || "").trim() || "미지정";
s = s.replace(/[\\\/\?\*\[\]]/g, "_");
if (s.length > 90) s = s.slice(0, 90);
return "Staff_" + s;
}
function getStaffSheet(ss, school) {
var name = staffSheetName(school);
var sheet = ss.getSheetByName(name);
if (!sheet) {
sheet = ss.insertSheet(name);
sheet.appendRow(["아이디", "소속", "직위", "과목", "성명", "순서"]);
}
return sheet;
}
function getSheet(ss, name) {
var sheet = ss.getSheetByName(name);
if (!sheet) {
sheet = ss.insertSheet(name);
if (name === "Rosters") sheet.appendRow(["아이디", "학교명", "연수명", "유형", "담당부서", "일시", "명단데이터", "생성일시"]);
if (name === "Signatures") sheet.appendRow(["등록부아이디", "순번", "성명", "서명이미지", "서명일시"]);
}
return sheet;
}
function findRoster(ss, id) {
if (!id) return null;
var data = getSheet(ss, "Rosters").getDataRange().getValues();
for (var r = 1; r < data.length; r++) {
if (String(data[r][0]) === String(id)) {
return {
id: data[r][0],
school: data[r][1],
title: data[r][2],
type: data[r][3],
department: data[r][4],
datetime: data[r][5],
entries: JSON.parse(data[r][6] || "[]"),
createdAt: data[r][7]
};
}
}
return null;
}
function listRosters(ss) {
var data = getSheet(ss, "Rosters").getDataRange().getValues();
var sigData = getSheet(ss, "Signatures").getDataRange().getValues();
var counts = {};
for (var s = 1; s < sigData.length; s++) {
var rid = String(sigData[s][0]);
counts[rid] = (counts[rid] || 0) + 1;
}
var out = [];
for (var r = 1; r < data.length; r++) {
var id = String(data[r][0]);
var entries = JSON.parse(data[r][6] || "[]");
out.push({
id: id, school: data[r][1], title: data[r][2], type: data[r][3], department: data[r][4], datetime: data[r][5],
total: entries.length, createdAt: data[r][7], signedCount: counts[id] || 0
});
}
out.sort(function (a, b) { return a.createdAt < b.createdAt ? 1 : -1; });
return out;
}
function listStaff(ss, school) {
var data = getStaffSheet(ss, school).getDataRange().getValues();
var out = [];
for (var r = 1; r < data.length; r++) {
if (!data[r][0]) continue;
out.push({ id: data[r][0], school: data[r][1], position: data[r][2], subject: data[r][3], name: data[r][4], order: data[r][5] });
}
out.sort(function (a, b) { return (Number(a.order) || 0) - (Number(b.order) || 0); });
return out;
}
function getSignatures(ss, rosterId) {
var data = getSheet(ss, "Signatures").getDataRange().getValues();
var out = [];
for (var r = 1; r < data.length; r++) {
if (String(data[r][0]) === String(rosterId)) {
out.push({ idx: Number(data[r][1]), name: data[r][2], dataUrl: data[r][3], signedAt: data[r][4] });
}
}
return out;
}
function json(obj) {
return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(ContentService.MimeType.JSON);
}
이미 배포해 두었다면: 배포 → 배포 관리 → 연필(수정) 아이콘 → 버전: 새 버전 → 배포로 코드만 새로 반영하면 됩니다 (URL은 그대로 유지).
사용 흐름: 담당자가 페이지를 열어 "교직원 명단 관리"에서 맨 먼저 학교명을 입력하고
비밀번호를 넣어 목록을 엽니다 — 학교명을 기준으로 구글 시트에 Staff_<학교명> 시트가 자동으로
만들어지거나 열리므로, 학교가 다르면 완전히 다른 시트에 명단이 쌓입니다. 그 학교 안에서 교직원을
한 명씩 추가하거나(직위·과목·성명) 엑셀 파일로 한 번에 올립니다(직위/과목/성명 열이 있는 표를
자동으로 인식하고, 못 찾으면 이름 한 줄씩으로 받아 "빈 칸 한번에 채우기"로 직위를 일괄 적용할 수
있습니다). 이후 각 업무 담당자는 홈 화면에서 학교명과 "교직원 전체" 또는 "교사만" 유형,
연수명·담당부서·일시, 비밀번호를 입력하면 그 학교·그 시점의 교직원 명단이 그대로 복사되어
등록부가 만들어지고, 전용 서명 링크가 생깁니다. 그 링크를 연수자들에게 보내면 누구나(로그인
없이) 자기 줄을 찾아 서명할 수 있습니다.
-를 씁니다.index.html을 열어 <div class="tools"> 안에 아래 덩어리를 붙여 넣습니다.
도구가 아직 없던 폴더라면 <div class="blank">...</div> 전체를
<div class="tools"> ... </div>로 바꾸면 됩니다.<a class="tool" href="파일이름.html">
<span class="mark" aria-hidden="true">1</span>
<span class="body">
<h3>도구 이름</h3>
<p>한 줄 설명.</p>
</span>
<span class="go">열기</span>
</a>
02-lab/index.html의 해당 subcard에서, 최상위 분류라면
루트 index.html의 해당 카드에서 고칩니다.index.html의 <h1>과, 한 단계 위 페이지의 카드 문구.<body>에 붙은 g1~g6 클래스가 정합니다.
색 값은 assets/site.css 맨 위 "그룹별 강조색"에서 바꿉니다.
수학실험의 하위 실험실은 모두 수학실험 색(g2)을 따릅니다.오른쪽 위 배포 → 새 배포를 누르고, 유형은 웹앱을 선택합니다.
배포하면 나오는 웹앱 URL(https://script.google.com/macros/s/.../exec)을 복사합니다.
03-work/exam-range.html 파일 맨 아래 <script>의 CONFIG.APPS_SCRIPT_URL 값에
그 URL을 붙여넣고 저장합니다.
오른쪽 위 배포 → 새 배포를 누르고, 유형은 웹앱을 선택합니다.
배포하면 나오는 웹앱 URL(https://script.google.com/macros/s/.../exec)을 복사합니다.
03-work/training-roster.html 파일 맨 아래 <script>의 CONFIG.APPS_SCRIPT_URL 값에
그 URL을 붙여넣고 저장합니다.