ESP32-S3 Bench Guide
ESP32-S3 N16R8 bench guide: live pinout, lessons, simulators and the bench log.
수학 수업과 업무를 위한 브라우저 기반 도구 모음
Translation pending
AI summary · website and README
EducationHTML · JavaScriptGitHub @math7942OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
브라우저에서 바로 실행되는 수업·업무용 HTML 도구 모음입니다. 서버가 필요 없고, GitHub Pages로 그대로 공개할 수 있습니다. 폴더마다 있는 index.html이 그 분류의 목록 페이지이고, 도구 파일은 같은 폴더에 둡니다. AI와 함께하는 수학교사의 1학기 살이는 04-festival/에 8단계 가이드·도구로 전체 공개됐습니다. 수수는 아직 비어 있습니다.Read the full 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을 붙여넣고 저장합니다.