/* ═══════════════════════════════════════════════════════════════ 원서 준비함 — 저장소(백엔드) 코드 · 앱 화면: https://wonseo-junbi.pages.dev 이 파일은 선생님 구글 계정의 Apps Script에 붙여 넣어 배포합니다. 학생·교사 화면은 위 주소에서 열리고, 입력한 내용은 이 스크립트를 통해 선생님 드라이브(폴더)와 스프레드시트에만 저장됩니다. 다른 곳으로 가지 않습니다. 설치: ① 아래 ★ 5줄 확인 → ② 저장 → ③ 함수 init 실행(권한 허용) → ④ 배포 → 새 배포 → 웹 앱 → 실행: 나 / 액세스: 모든 사용자 → 배포 → URL 복사 → ⑤ https://wonseo-junbi.pages.dev 에서 「내 저장소 연결」에 URL 붙여넣기 ═══════════════════════════════════════════════════════════════ */ // ★ 바꿔 쓰세요 const GRADE = 3, CLASS_NO = 1; // 학번 앞자리: 3학년 1반 → 301xx const SCHOOL_NAME = '은계중학교'; const CLASS_LABEL = GRADE + '학년 ' + CLASS_NO + '반'; const TEACHER_PIN = '7272'; // 교사 화면 비밀번호 (꼭 바꾸기) const DOC_TYPES = ['자격증 사본', '원서 확인서(학부모 서명)', '증명사진', '자기소개서', '기타']; const APPLY_TYPES = ['일반고', '특성화고', '마이스터고', '외고·국제고', '자사고', '예술·체육고', '과학고', '미정']; const RELATIONS = ['부', '모', '조부', '조모', '법정대리인']; const MAX_FILES_PER_SUBMIT = 5; const MAX_STUDENTS = 40; // 한 반 최대 인원(현황 시트 행 수) const MAX_FILE_BYTES = 5 * 1024 * 1024; // PDF 등 원본 파일 상한 (이미지는 폰에서 미리 줄여서 옴) const SHEET_LOG = '제출기록', SHEET_ROSTER = '명단', SHEET_STATUS = '현황', SHEET_INFO = '학생정보'; const LOG_HEADERS = ['제출시각', '학번', '번호', '이름', '서류종류', '파일명', '파일링크', '메모', '기기', '제출토큰']; const INFO_HEADERS = ['학번', '번호', '이름', '제출시각', '학생휴대폰', '보호자성명', '보호자관계', '보호자휴대폰', '우편번호', '주소', '상세주소', '지원유형', '특별전형희망', '지원학교', '학과1', '학과2', '학과3', '학과4', '학과5', '후기대안학교', '자격증', '동의여부', '동의일', '학생서명', '보호자서명', '동의서PDF', '메모', '기기', '담임확인', '담임확인시각', '담임메모', '제출토큰']; const INFO_KEYS = ['id', 'num', 'name', 'time', 'sPhone', 'gName', 'gRel', 'gPhone', 'zip', 'addr', 'addr2', 'type', 'special', 'school', 'm1', 'm2', 'm3', 'm4', 'm5', 'alt', 'certs', 'agree', 'agreeDate', 'sigS', 'sigG', 'pdf', 'memo', 'ua', 'chk', 'chkTime', 'tNote', 'token']; const TEACHER_KEYS = ['chk', 'chkTime', 'tNote']; // 학생이 저장해도 지워지지 않는 교사 전용 칸 /* ───────── 1회 실행: 시트·폴더 만들기 ───────── */ function init() { const props = PropertiesService.getScriptProperties(); let ssId = props.getProperty('SS_ID'), folderId = props.getProperty('FOLDER_ID'); let ss; if (ssId) { try { ss = SpreadsheetApp.openById(ssId); } catch (e) { ss = null; } } if (!ss) { ss = SpreadsheetApp.create(CLASS_LABEL + ' 원서서류 제출기록'); props.setProperty('SS_ID', ss.getId()); } let sh = ss.getSheetByName(SHEET_ROSTER); if (!sh) { sh = ss.insertSheet(SHEET_ROSTER); sh.getRange(1, 1, 1, 3).setValues([['번호', '이름', '학번']]).setFontWeight('bold'); sh.getRange(2, 3, MAX_STUDENTS, 1).setFormula('=IF(A2="","",' + GRADE + '&TEXT(' + CLASS_NO + ',"00")&TEXT(A2,"00"))'); sh.setFrozenRows(1); } sh = ss.getSheetByName(SHEET_INFO); if (!sh) { sh = ss.insertSheet(SHEET_INFO); sh.setFrozenRows(1); sh.setFrozenColumns(3); } ensureHeaders_(sh, INFO_HEADERS); sh = ss.getSheetByName(SHEET_LOG); if (!sh) { sh = ss.insertSheet(SHEET_LOG); sh.setFrozenRows(1); sh.setColumnWidth(1, 140); sh.setColumnWidth(6, 220); sh.setColumnWidth(7, 260); } ensureHeaders_(sh, LOG_HEADERS); sh = ss.getSheetByName(SHEET_STATUS); if (!sh) { sh = ss.insertSheet(SHEET_STATUS); const header = ['학번', '이름', '정보·동의서'].concat(DOC_TYPES).concat(['제출 종류 수']); sh.getRange(1, 1, 1, header.length).setValues([header]).setFontWeight('bold'); const n = MAX_STUDENTS; sh.getRange(2, 1, n, 1).setFormula('=IF(' + SHEET_ROSTER + '!A2="","",' + SHEET_ROSTER + '!C2)'); sh.getRange(2, 2, n, 1).setFormula('=IF(' + SHEET_ROSTER + '!A2="","",' + SHEET_ROSTER + '!B2)'); sh.getRange(2, 3, n, 1).setFormula('=IF($A2="","",COUNTIFS(' + SHEET_INFO + '!$A:$A,$A2,' + SHEET_INFO + '!$V:$V,"동의"))'); for (let c = 0; c < DOC_TYPES.length; c++) { const col = 4 + c, L = columnLetter_(col); sh.getRange(2, col, n, 1).setFormula('=IF($A2="","",COUNTIFS(' + SHEET_LOG + '!$B:$B,$A2,' + SHEET_LOG + '!$E:$E,' + L + '$1))'); } const first = columnLetter_(3), last = columnLetter_(3 + DOC_TYPES.length); sh.getRange(2, 4 + DOC_TYPES.length, n, 1).setFormula('=IF($A2="","",COUNTIF(' + first + '2:' + last + '2,">0"))'); sh.setFrozenRows(1); sh.setFrozenColumns(2); const rule = SpreadsheetApp.newConditionalFormatRule().whenNumberEqualTo(0).setBackground('#fde2e2') .setRanges([sh.getRange(2, 3, n, DOC_TYPES.length + 1)]).build(); sh.setConditionalFormatRules([rule]); } const s1 = ss.getSheetByName('Sheet1') || ss.getSheetByName('시트1'); if (s1 && ss.getSheets().length > 1) ss.deleteSheet(s1); let folder = null; if (folderId) { try { folder = DriveApp.getFolderById(folderId); } catch (e) { folder = null; } } if (!folder) { folder = DriveApp.createFolder(CLASS_LABEL + ' 원서서류'); props.setProperty('FOLDER_ID', folder.getId()); } try { DriveApp.getFileById(ss.getId()).moveTo(folder); } catch (e) {} Logger.log('완료\n시트: ' + ss.getUrl() + '\n폴더: ' + folder.getUrl()); return { sheet: ss.getUrl(), folder: folder.getUrl() }; } /* ───────── 공통 ───────── */ /* 시트 열이 부족하면 늘리고, 비어 있는 헤더를 채운다 (예전 버전 init으로 만든 시트도 그대로 쓰게) */ function ensureHeaders_(sh, headers) { if (sh.getMaxColumns() < headers.length) sh.insertColumnsAfter(sh.getMaxColumns(), headers.length - sh.getMaxColumns()); const cur = sh.getRange(1, 1, 1, headers.length).getValues()[0]; let changed = false; headers.forEach((h, i) => { if (String(cur[i] || '') !== h) { cur[i] = h; changed = true; } }); if (changed) sh.getRange(1, 1, 1, headers.length).setValues([cur]).setFontWeight('bold'); return sh; } function infoSheet_() { return ensureHeaders_(ss_().getSheetByName(SHEET_INFO), INFO_HEADERS); } function logSheet_() { return ensureHeaders_(ss_().getSheetByName(SHEET_LOG), LOG_HEADERS); } /* 같은 제출을 두 번 받지 않기 (응답이 유실돼 학생 폰이 재시도해도 한 번만 저장) */ function tokenGet_(token) { if (!token) return null; try { const v = CacheService.getScriptCache().get('tok:' + token); return v ? JSON.parse(v) : null; } catch (e) { return null; } } function tokenPut_(token, result) { if (!token) return; try { CacheService.getScriptCache().put('tok:' + token, JSON.stringify(result), 21600); } catch (e) {} } function columnLetter_(col) { let s = ''; while (col > 0) { const m = (col - 1) % 26; s = String.fromCharCode(65 + m) + s; col = (col - m - 1) / 26; } return s; } function ss_() { const id = PropertiesService.getScriptProperties().getProperty('SS_ID'); if (!id) throw new Error('init()을 먼저 실행하세요'); return SpreadsheetApp.openById(id); } function folder_() { const id = PropertiesService.getScriptProperties().getProperty('FOLDER_ID'); if (!id) throw new Error('init()을 먼저 실행하세요'); return DriveApp.getFolderById(id); } function roster_() { const sh = ss_().getSheetByName(SHEET_ROSTER); const v = sh.getRange(2, 1, Math.max(sh.getLastRow() - 1, 1), 2).getValues(); return v.filter(r => r[0] !== '' && r[1] !== '').map(r => ({ num: Number(r[0]), name: String(r[1]).trim() })); } function idOf_(num) { return String(GRADE) + pad2_(CLASS_NO) + pad2_(num); } function student_(id) { id = String(id || '').replace(/\D/g, ''); if (id.length !== 5) throw new Error('학번은 숫자 5자리입니다 (예: ' + idOf_(1) + ')'); if (Number(id.slice(0, 1)) !== GRADE || Number(id.slice(1, 3)) !== CLASS_NO) throw new Error('우리 반 학번이 아닙니다 (' + CLASS_LABEL + ' 학생만 쓸 수 있어요)'); const num = Number(id.slice(3)); const stu = roster_().find(s => s.num === num); if (!stu) throw new Error('명단에 없는 번호입니다. 담임 선생님께 말해 주세요'); return { id: id, num: num, name: stu.name }; } function stuFolder_(stu) { const root = folder_(), name = stu.id + '_' + stu.name; const it = root.getFoldersByName(name); return it.hasNext() ? it.next() : root.createFolder(name); } function replaceFile_(folder, name, blob) { const it = folder.getFilesByName(name); while (it.hasNext()) it.next().setTrashed(true); return folder.createFile(blob.setName(name)); } function infoRow_(id) { const sh = infoSheet_(), last = sh.getLastRow(); if (last < 2) return { sh: sh, row: 0, data: null }; const ids = sh.getRange(2, 1, last - 1, 1).getValues(); for (let i = 0; i < ids.length; i++) if (String(ids[i][0]) === String(id)) { const v = sh.getRange(i + 2, 1, 1, INFO_HEADERS.length).getValues()[0], o = {}; INFO_KEYS.forEach((k, j) => { o[k] = v[j] instanceof Date ? fmt_(v[j]) : v[j]; }); return { sh: sh, row: i + 2, data: o }; } return { sh: sh, row: 0, data: null }; } /* ───────── 웹 API (앱 화면이 fetch로 호출) ───────── */ function config_() { return { classLabel: CLASS_LABEL, schoolName: SCHOOL_NAME, grade: GRADE, classNo: CLASS_NO, docTypes: DOC_TYPES, applyTypes: APPLY_TYPES, relations: RELATIONS, maxFiles: MAX_FILES_PER_SUBMIT, sampleId: idOf_(1), rosterCount: (function(){ try { return roster_().length; } catch (e) { return -1; } })(), version: 'v4' }; } function json_(o) { return ContentService.createTextOutput(JSON.stringify(o)).setMimeType(ContentService.MimeType.JSON); } function doGet(e) { return json_({ ok: true, app: 'wonseo-junbi', config: config_() }); } function doPost(e) { let body = {}; try { body = JSON.parse((e && e.postData && e.postData.contents) || '{}'); } catch (err) { return json_({ ok: false, error: '요청 형식 오류' }); } const a = String(body.action || ''); try { let r; if (a === 'config') r = config_(); else if (a === 'whoAmI') r = whoAmI(body.id); else if (a === 'saveInfo') r = saveInfo(body); else if (a === 'submitDocs') r = submitDocs(body); else if (a === 'getStatus') r = getStatus(body.pin); else if (a === 'setCheck') r = setCheck(body.pin, body.id, body.checked, body.note); else if (a === 'setRoster') r = setRoster(body.pin, body.text); else throw new Error('알 수 없는 요청: ' + a); return json_({ ok: true, result: r }); } catch (err) { return json_({ ok: false, error: String(err && err.message ? err.message : err) }); } } /* 명단 입력(교사): 한 줄에 "번호 이름" — 기존 명단을 통째로 바꾼다 */ function setRoster(pin, text) { if (String(pin || '') !== TEACHER_PIN) throw new Error('PIN이 맞지 않습니다'); const rows = []; String(text || '').split(/\r?\n/).forEach(line => { const m = line.trim().match(/^(\d{1,2})[\s,\t.]+(\S.*)$/); if (m) rows.push([Number(m[1]), m[2].trim()]); }); if (!rows.length) throw new Error('"번호 이름" 형식의 줄이 없습니다 (예: 7 김예은)'); if (rows.length > MAX_STUDENTS) throw new Error('최대 ' + MAX_STUDENTS + '명까지'); const nums = rows.map(r => r[0]); if (new Set(nums).size !== nums.length) throw new Error('번호가 겹칩니다'); const sh = ss_().getSheetByName(SHEET_ROSTER); sh.getRange(2, 1, MAX_STUDENTS, 2).setValues(Array.from({ length: MAX_STUDENTS }, (_, i) => rows[i] ? rows[i] : ['', ''])); sh.getRange(2, 3, MAX_STUDENTS, 1).setFormula('=IF(A2="","",' + GRADE + '&TEXT(' + CLASS_NO + ',"00")&TEXT(A2,"00"))'); SpreadsheetApp.flush(); return { ok: true, count: rows.length, sample: rows.slice(0, 3).map(r => idOf_(r[0]) + ' ' + r[1]) }; } /* ───────── 학생용 API ───────── */ function whoAmI(id) { const s = student_(id); const sh = logSheet_(), last = sh.getLastRow(); const v = last < 2 ? [] : sh.getRange(2, 1, last - 1, LOG_HEADERS.length).getValues(); const hist = v.filter(r => String(r[1]) === s.id).map(r => ({ time: fmt_(r[0]), type: r[4], file: r[5], token: String(r[9] || '') })).reverse().slice(0, 30); let info = infoRow_(s.id).data; if (info && info.agree !== '동의') info = null; // 담임 체크만 있고 학생이 저장한 적 없는 행은 미제출로 if (info) { delete info.sigS; delete info.sigG; delete info.ua; delete info.chk; delete info.chkTime; delete info.tNote; } return { id: s.id, num: s.num, name: s.name, hist: hist, info: info }; } /* 정보 + 동의서 저장. payload: 아래 INFO_KEYS 중 입력값 + sigS/sigG(base64 PNG) */ function saveInfo(p) { const dup = tokenGet_(p.token); if (dup) return dup; const stu = student_(p.id); { const need = (v, msg) => { if (!String(v || '').trim()) throw new Error(msg); }; if (!String(p.sPhone || '').trim() && !String(p.gPhone || '').trim()) throw new Error('학생 또는 보호자 휴대폰 중 하나는 꼭 적어 주세요'); need(p.gName, '보호자 성명을 적어 주세요'); need(p.gRel, '보호자 관계를 고르세요'); if (RELATIONS.indexOf(p.gRel) < 0) throw new Error('보호자 관계 값이 올바르지 않습니다'); need(p.addr, '주소를 적어 주세요'); if (APPLY_TYPES.indexOf(p.type) < 0) throw new Error('지원 유형을 고르세요'); if (p.type !== '미정') need(p.school, '지원 학교를 적어 주세요'); if (p.agree !== true) throw new Error('개인정보 수집·이용에 동의해야 저장할 수 있습니다'); if (!p.sigS || !p.sigG) throw new Error('학생 서명과 보호자 서명이 모두 필요합니다'); const folder = stuFolder_(stu), now = new Date(); const sigS = replaceFile_(folder, '서명_학생.png', Utilities.newBlob(Utilities.base64Decode(p.sigS), 'image/png')); const sigG = replaceFile_(folder, '서명_보호자.png', Utilities.newBlob(Utilities.base64Decode(p.sigG), 'image/png')); const pdf = makeConsentPdf_(stu, p, now, sigS.getBlob(), sigG.getBlob(), folder); // 느린 작업은 락 밖에서 const row = { id: stu.id, num: stu.num, name: stu.name, time: now, sPhone: clean_(p.sPhone), gName: clean_(p.gName), gRel: p.gRel, gPhone: clean_(p.gPhone), zip: clean_(p.zip), addr: clean_(p.addr), addr2: clean_(p.addr2), type: p.type, special: p.special ? '예' : '아니오', school: clean_(p.school), m1: clean_(p.m1), m2: clean_(p.m2), m3: clean_(p.m3), m4: clean_(p.m4), m5: clean_(p.m5), alt: clean_(p.alt), certs: clean_(p.certs), agree: '동의', agreeDate: Utilities.formatDate(now, 'Asia/Seoul', 'yyyy-MM-dd'), sigS: sigS.getUrl(), sigG: sigG.getUrl(), pdf: pdf.getUrl(), memo: clean_(p.memo).slice(0, 200), ua: String(p.ua || '').slice(0, 80), token: String(p.token || '').slice(0, 40) }; const lock = LockService.getScriptLock(); lock.waitLock(60000); try { const r = infoRow_(stu.id); if (r.data) TEACHER_KEYS.forEach(k => { row[k] = r.data[k]; }); const vals = INFO_KEYS.map(k => row[k] === undefined ? '' : row[k]); if (r.row) r.sh.getRange(r.row, 1, 1, vals.length).setValues([vals]); else r.sh.appendRow(vals); SpreadsheetApp.flush(); } finally { lock.releaseLock(); } const result = { ok: true, name: stu.name, id: stu.id, pdf: pdf.getUrl(), token: row.token, time: fmt_(now) }; tokenPut_(p.token, result); return result; } } function makeConsentPdf_(stu, p, now, sigSBlob, sigGBlob, folder) { const doc = DocumentApp.create('tmp_동의서_' + stu.id); const b = doc.getBody(); b.setMarginTop(50).setMarginBottom(40).setMarginLeft(56).setMarginRight(56); const C = DocumentApp.HorizontalAlignment.CENTER; b.appendParagraph('2027학년도 고교입학원서 개인정보 수집·이용 동의서').setHeading(DocumentApp.ParagraphHeading.HEADING2).setAlignment(C); b.appendParagraph('「개인정보 보호법」 제15조 및 제17조에 따라 고교입학원서 작성·제출을 위해 아래와 같이 학생의 개인정보를 수집·이용하고자 하오니 내용을 확인하신 후 동의 여부를 표시해 주시기 바랍니다.').setFontSize(9.5); const secs = [ ['1. 수집 목적', '고등학교 입학전형(' + p.type + (p.school ? ' · ' + p.school : '') + ') 원서 작성·접수 및 심사'], ['2. 수집 항목', '학생 인적사항(성명·생년월일·주소·연락처·사진), 보호자 성명·연락처, 학교생활기록부 기재사항(교과성적·출결·봉사활동·수상·임원경력 등)'], ['3. 보유·이용 기간', '해당 전형 절차 종료 시까지. 종료 후 관계 법령 및 학교 기록물 관리 규정에 따라 처리'], ['4. 동의 거부 권리', '동의하지 않을 권리가 있으나, 동의하지 않을 경우 원서 접수 및 전형 진행이 불가능할 수 있음'] ]; secs.forEach(s => { b.appendParagraph(s[0]).setBold(true).setFontSize(10.5).setSpacingBefore(8); b.appendParagraph(s[1]).setBold(false).setFontSize(10); }); const t = b.appendTable([ ['학번 / 성명', stu.id + ' / ' + stu.name, '지원 학교', String(p.school || '미정')], ['보호자 성명', String(p.gName), '보호자 연락처', String(p.gPhone || '')], ['주소', [p.zip, p.addr, p.addr2].filter(Boolean).join(' '), '학생 연락처', String(p.sPhone || '')] ]); t.setFontSize(9.5); b.appendParagraph('위와 같이 개인정보를 수집·이용하는 것에 ☑ 동의함').setBold(true).setAlignment(C).setSpacingBefore(14); b.appendParagraph(Utilities.formatDate(now, 'Asia/Seoul', 'yyyy년 M월 d일')).setBold(false).setAlignment(C); const st = b.appendTable([['학생 ' + stu.name + ' (서명)', '보호자 ' + p.gName + ' (서명)'], ['', '']]); st.setBorderWidth(0); st.getRow(1).getCell(0).appendImage(sigSBlob).setWidth(150).setHeight(60); st.getRow(1).getCell(1).appendImage(sigGBlob).setWidth(150).setHeight(60); b.appendParagraph(SCHOOL_NAME + '장 귀하').setBold(true).setAlignment(DocumentApp.HorizontalAlignment.RIGHT).setSpacingBefore(10); b.appendParagraph('※ 전자서명본. 학생·보호자가 ' + Utilities.formatDate(now, 'Asia/Seoul', 'yyyy-MM-dd HH:mm') + '에 ' + CLASS_LABEL + ' 원서 준비 앱에서 직접 서명함.').setFontSize(8).setBold(false).setForegroundColor('#777777'); doc.saveAndClose(); const f = DriveApp.getFileById(doc.getId()); const pdf = replaceFile_(folder, '개인정보활용동의서_' + stu.id + '_' + stu.name + '.pdf', f.getAs('application/pdf')); f.setTrashed(true); return pdf; } function submitDocs(payload) { const dup = tokenGet_(payload.token); if (dup) return dup; const stu = student_(payload.id); if (DOC_TYPES.indexOf(payload.docType) < 0) throw new Error('서류 종류를 고르세요'); const files = payload.files || []; if (!files.length) throw new Error('파일이 없습니다'); if (files.length > MAX_FILES_PER_SUBMIT) throw new Error('한 번에 ' + MAX_FILES_PER_SUBMIT + '개까지만 올릴 수 있습니다'); const folder = stuFolder_(stu), now = new Date(), stamp = Utilities.formatDate(now, 'Asia/Seoul', 'MMdd_HHmm'); const token = String(payload.token || '').slice(0, 40), rows = [], saved = []; files.forEach((f, i) => { // 파일 저장은 락 밖에서 const bytes = Utilities.base64Decode(f.data); if (bytes.length > MAX_FILE_BYTES) throw new Error(f.name + ' — 파일이 너무 큽니다(5MB 이하)'); const fname = payload.docType + '_' + stamp + (files.length > 1 ? '_' + (i + 1) : '') + extOf_(f.name, f.mime); const file = folder.createFile(Utilities.newBlob(bytes, f.mime || 'application/octet-stream', fname)); rows.push([now, stu.id, stu.num, stu.name, payload.docType, fname, file.getUrl(), String(payload.memo || '').slice(0, 200), String(payload.ua || '').slice(0, 80), token]); saved.push(fname); }); const lock = LockService.getScriptLock(); lock.waitLock(60000); try { const sh = logSheet_(); sh.getRange(sh.getLastRow() + 1, 1, rows.length, LOG_HEADERS.length).setValues(rows); SpreadsheetApp.flush(); } finally { lock.releaseLock(); } const result = { ok: true, saved: saved, name: stu.name, id: stu.id, token: token, time: fmt_(now) }; tokenPut_(token, result); return result; } /* ───────── 교사용 API ───────── */ function getStatus(pin) { if (String(pin || '') !== TEACHER_PIN) throw new Error('PIN이 맞지 않습니다'); const roster = roster_(); const sh = logSheet_(), last = sh.getLastRow(); const v = last < 2 ? [] : sh.getRange(2, 1, last - 1, 7).getValues(); const map = {}, files = {}; roster.forEach(s => { map[s.num] = {}; files[s.num] = []; }); let lastTime = ''; v.forEach(r => { const n = Number(r[2]); if (!map[n]) { map[n] = {}; files[n] = []; } map[n][r[4]] = (map[n][r[4]] || 0) + 1; files[n].push({ type: r[4], time: fmt_(r[0]), name: r[5], url: r[6] }); lastTime = fmt_(r[0]); }); // 학생정보 const ish = infoSheet_(), ilast = ish.getLastRow(); const iv = ilast < 2 ? [] : ish.getRange(2, 1, ilast - 1, INFO_HEADERS.length).getValues(); const info = {}; iv.forEach(r => { const o = {}; INFO_KEYS.forEach((k, j) => { o[k] = r[j] instanceof Date ? fmt_(r[j]) : r[j]; }); info[String(o.id)] = o; }); return { docTypes: DOC_TYPES, infoKeys: INFO_KEYS, infoHeaders: INFO_HEADERS, rows: roster.map(s => { const id = idOf_(s.num); return { id: id, num: s.num, name: s.name, counts: DOC_TYPES.map(t => map[s.num][t] || 0), files: files[s.num] || [], info: info[id] || null }; }), total: v.length, lastTime: lastTime, url: ScriptApp.getService().getUrl(), sheetUrl: ss_().getUrl(), folderUrl: folder_().getUrl() }; } /* 담임 확인 표시·메모 저장 (학생 미제출이어도 저장됨) */ function setCheck(pin, id, checked, note) { if (String(pin || '') !== TEACHER_PIN) throw new Error('PIN이 맞지 않습니다'); const stu = student_(id), r = infoRow_(stu.id), now = new Date(); const row = r.data || { id: stu.id, num: stu.num, name: stu.name }; row.chk = checked ? '확인' : ''; row.chkTime = checked ? fmt_(now) : ''; row.tNote = clean_(note).slice(0, 300); const vals = INFO_KEYS.map(k => row[k] === undefined ? '' : row[k]); if (r.row) r.sh.getRange(r.row, 1, 1, vals.length).setValues([vals]); else r.sh.appendRow(vals); return { ok: true, chk: row.chk, chkTime: row.chkTime, tNote: row.tNote }; } /* ───────── 유틸 ───────── */ function pad2_(n) { return (n < 10 ? '0' : '') + n; } function clean_(s) { return String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); } function fmt_(d) { try { return Utilities.formatDate(new Date(d), 'Asia/Seoul', 'M/d HH:mm'); } catch (e) { return String(d); } } function extOf_(name, mime) { const m = String(name || '').match(/\.[a-zA-Z0-9]{2,5}$/); if (m) return m[0].toLowerCase(); if (/jpeg|jpg/.test(mime)) return '.jpg'; if (/png/.test(mime)) return '.png'; if (/pdf/.test(mime)) return '.pdf'; if (/heic/.test(mime)) return '.heic'; return ''; }