/**
* Sistem Absensi Guru & Kepala Sekolah — Google Apps Script
* Pasang Code.gs dan Index.html dalam satu proyek Apps Script.
* Jalankan setupSpreadsheet() sekali dari editor, lalu deploy sebagai Web App.
*/
const SPREADSHEET_ID = '1ErzL6V_jA-JKwFd5B1M--Nx_oBCO3DQyVSdJbjU6s-g'; // Isi ID spreadsheet bila proyek bukan terikat (bound) ke Google Sheets.
const SHEET_STAFF = 'Guru';
const SHEET_ATTENDANCE = 'AbsensiGuru';
const SHEET_SETTINGS = 'Settings';
const SHEET_HEADERS = {
Guru: ['NIP', 'Nama', 'Jabatan', 'MataPelajaranUnit', 'Status', 'FaceDescriptor', 'FotoReferensi', 'DibuatPada'],
AbsensiGuru: ['ID', 'NIP', 'Nama', 'Jabatan', 'Tanggal', 'JamMasuk', 'StatusMasuk', 'FotoMasuk', 'SkorWajahMasuk', 'LatitudeMasuk', 'LongitudeMasuk', 'AlamatMasuk', 'JamPulang', 'StatusPulang', 'FotoPulang', 'SkorWajahPulang', 'LatitudePulang', 'LongitudePulang', 'AlamatPulang', 'Keterangan'],
Settings: ['Kunci', 'Nilai']
};
function doGet(e) {
const page = (e && e.parameter && e.parameter.page === 'admin') ? 'admin' : 'absen';
return HtmlService.createTemplateFromFile('Index').evaluate()
.setTitle(page === 'admin' ? 'Admin Absensi Guru' : 'Absensi Guru & Kepala Sekolah')
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
function include(filename) { return HtmlService.createHtmlOutputFromFile(filename).getContent(); }
function getSpreadsheet_() {
if (SPREADSHEET_ID && SPREADSHEET_ID.trim()) return SpreadsheetApp.openById(SPREADSHEET_ID.trim());
const ss = SpreadsheetApp.getActiveSpreadsheet();
if (!ss) throw new Error('Spreadsheet belum ditentukan. Isi SPREADSHEET_ID pada Code.gs atau buka Apps Script dari Google Sheets.');
return ss;
}
function getSheet_(name) {
const ss = getSpreadsheet_();
let sh = ss.getSheetByName(name);
if (!sh) sh = ss.insertSheet(name);
return sh;
}
function setupSpreadsheet() {
const ss = getSpreadsheet_();
Object.keys(SHEET_HEADERS).forEach(name => {
const sh = getSheet_(name);
if (sh.getLastRow() === 0) sh.appendRow(SHEET_HEADERS[name]);
else {
const first = sh.getRange(1, 1, 1, Math.max(sh.getLastColumn(), SHEET_HEADERS[name].length)).getDisplayValues()[0];
if (first.join('').trim() === '') sh.getRange(1, 1, 1, SHEET_HEADERS[name].length).setValues([SHEET_HEADERS[name]]);
}
sh.setFrozenRows(1);
sh.getRange(1, 1, 1, SHEET_HEADERS[name].length).setFontWeight('bold').setBackground('#dbeafe');
});
const settings = getSheet_(SHEET_SETTINGS);
const existing = settings.getLastRow() > 1 ? settings.getRange(2, 1, settings.getLastRow() - 1, 2).getDisplayValues() : [];
const keys = existing.map(r => r[0]);
const defaults = [
['JamMasuk', '07:15'], ['JamPulang', '14:00'], ['AmbangWajah', '0.50'],
['AdminPIN', 'GANTI_PIN_ADMIN'], ['FolderFotoID', '']
];
defaults.forEach(r => { if (!keys.includes(r[0])) settings.appendRow(r); });
// Buat folder khusus foto absensi jika belum ada.
const folderId = getSetting_('FolderFotoID');
if (!folderId) {
const folder = DriveApp.createFolder('Foto Absensi Guru dan Kepala Sekolah');
setSetting_('FolderFotoID', folder.getId());
}
return 'Setup selesai. Sheet Guru, AbsensiGuru, dan Settings siap digunakan. Segera ganti AdminPIN di sheet Settings.';
}
function getSetting_(key) {
const sh = getSheet_(SHEET_SETTINGS);
if (sh.getLastRow() < 2) return '';
const rows = sh.getRange(2, 1, sh.getLastRow() - 1, 2).getDisplayValues();
for (let i = 0; i < rows.length; i++) if (rows[i][0] === key) return rows[i][1];
return '';
}
function setSetting_(key, value) {
const sh = getSheet_(SHEET_SETTINGS);
const rows = sh.getLastRow() > 1 ? sh.getRange(2, 1, sh.getLastRow() - 1, 2).getDisplayValues() : [];
for (let i = 0; i < rows.length; i++) {
if (rows[i][0] === key) { sh.getRange(i + 2, 2).setValue(value); return; }
}
sh.appendRow([key, value]);
}
function requireAdmin_(pin) {
const configured = getSetting_('AdminPIN');
if (!configured || configured === 'GANTI_PIN_ADMIN') throw new Error('AdminPIN belum diganti. Ubah nilai AdminPIN di sheet Settings terlebih dahulu.');
if (String(pin || '') !== String(configured)) throw new Error('PIN admin salah.');
}
function adminLogin(pin) {
requireAdmin_(pin);
return { success: true, message: 'Login admin berhasil.' };
}
function saveSettings(pin, data) {
requireAdmin_(pin);
if (data.jamMasuk) setSetting_('JamMasuk', data.jamMasuk);
if (data.jamPulang) setSetting_('JamPulang', data.jamPulang);
if (data.ambangWajah !== undefined && data.ambangWajah !== '') setSetting_('AmbangWajah', String(data.ambangWajah));
return { success: true };
}
function getPublicSettings() {
return { jamMasuk: getSetting_('JamMasuk') || '07:15', jamPulang: getSetting_('JamPulang') || '14:00', ambangWajah: Number(getSetting_('AmbangWajah') || 0.5) };
}
function addStaff(pin, data) {
requireAdmin_(pin);
const nip = String(data.nip || '').trim();
const nama = String(data.nama || '').trim();
const jabatan = String(data.jabatan || '').trim();
if (!nip || !nama || !['Guru', 'Kepala Sekolah'].includes(jabatan)) throw new Error('NIP, nama, dan jabatan wajib diisi.');
const sh = getSheet_(SHEET_STAFF);
const rows = sh.getDataRange().getDisplayValues();
for (let i = 1; i < rows.length; i++) if (String(rows[i][0]).trim() === nip) throw new Error('NIP sudah terdaftar.');
sh.appendRow([nip, nama, jabatan, String(data.unit || '').trim(), 'Aktif', '', '', new Date()]);
return { success: true };
}
function updateStaffStatus(pin, nip, status) {
requireAdmin_(pin);
const sh = getSheet_(SHEET_STAFF), rows = sh.getDataRange().getDisplayValues();
for (let i = 1; i < rows.length; i++) if (String(rows[i][0]).trim() === String(nip).trim()) {
sh.getRange(i + 1, 5).setValue(status === 'Nonaktif' ? 'Nonaktif' : 'Aktif');
return { success: true };
}
throw new Error('Data pegawai tidak ditemukan.');
}
function getStaffByNip(nip) {
const target = String(nip || '').trim();
if (!target) throw new Error('Masukkan NIP.');
const sh = getSheet_(SHEET_STAFF), rows = sh.getDataRange().getValues();
for (let i = 1; i < rows.length; i++) {
if (String(rows[i][0]).trim() === target && String(rows[i][4]) === 'Aktif') {
return { success: true, staff: { nip: String(rows[i][0]), nama: String(rows[i][1]), jabatan: String(rows[i][2]), unit: String(rows[i][3] || ''), faceDescriptor: rows[i][5] ? JSON.parse(rows[i][5]) : null, fotoReferensi: String(rows[i][6] || '') } };
}
}
return { success: false, message: 'NIP tidak ditemukan atau status pegawai nonaktif.' };
}
function registerStaffFace(pin, data) {
requireAdmin_(pin);
const nip = String(data.nip || '').trim();
if (!nip || !Array.isArray(data.faceDescriptor) || data.faceDescriptor.length < 64) throw new Error('Data wajah tidak valid. Ambil foto dengan wajah terlihat jelas.');
const sh = getSheet_(SHEET_STAFF), rows = sh.getDataRange().getValues();
let rowNumber = 0;
for (let i = 1; i < rows.length; i++) if (String(rows[i][0]).trim() === nip) { rowNumber = i + 1; break; }
if (!rowNumber) throw new Error('NIP tidak ditemukan. Tambahkan pegawai terlebih dahulu.');
let photoUrl = '';
if (data.photoData) photoUrl = savePhoto_(data.photoData, 'Referensi_' + nip + '_' + Date.now() + '.jpg');
sh.getRange(rowNumber, 6).setValue(JSON.stringify(data.faceDescriptor));
if (photoUrl) sh.getRange(rowNumber, 7).setValue(photoUrl);
return { success: true };
}
function savePhoto_(dataUrl, filename) {
const match = String(dataUrl || '').match(/^data:image\/(jpeg|jpg|png);base64,(.+)$/);
if (!match) return '';
const bytes = Utilities.base64Decode(match[2]);
const blob = Utilities.newBlob(bytes, match[1] === 'png' ? 'image/png' : 'image/jpeg', filename);
let folderId = getSetting_('FolderFotoID'), folder;
try { folder = folderId ? DriveApp.getFolderById(folderId) : DriveApp.createFolder('Foto Absensi Guru dan Kepala Sekolah'); }
catch (e) { folder = DriveApp.createFolder('Foto Absensi Guru dan Kepala Sekolah'); setSetting_('FolderFotoID', folder.getId()); }
return folder.createFile(blob).getUrl();
}
function submitStaffAttendance(data) {
const nip = String(data.nip || '').trim();
const kind = String(data.kind || '');
if (!nip || !['Masuk', 'Pulang'].includes(kind)) throw new Error('Data absensi tidak valid.');
const staffResult = getStaffByNip(nip);
if (!staffResult.success) throw new Error(staffResult.message);
const staff = staffResult.staff;
if (!staff.faceDescriptor) throw new Error('Wajah belum didaftarkan oleh admin.');
const score = Number(data.faceScore || 0), threshold = Number(getSetting_('AmbangWajah') || 0.5);
if (!isFinite(score) || score < threshold) throw new Error('Verifikasi wajah tidak cocok. Silakan coba lagi atau hubungi admin.');
const now = new Date(), date = Utilities.formatDate(now, 'Asia/Jakarta', 'yyyy-MM-dd');
const time = Utilities.formatDate(now, 'Asia/Jakarta', 'HH:mm:ss');
const sh = getSheet_(SHEET_ATTENDANCE), rows = sh.getDataRange().getValues();
let rowNumber = 0;
for (let i = 1; i < rows.length; i++) if (String(rows[i][1]) === nip && String(rows[i][4]) === date) { rowNumber = i + 1; break; }
if (!rowNumber) {
if (kind === 'Pulang') throw new Error('Absensi masuk belum tercatat untuk hari ini.');
const lateAt = getSetting_('JamMasuk') || '07:15';
const status = time.substring(0, 5) > lateAt ? 'Terlambat' : 'Tepat Waktu';
const photoUrl = data.photoData ? savePhoto_(data.photoData, 'Masuk_' + nip + '_' + date + '.jpg') : '';
sh.appendRow(['ATG' + Date.now(), nip, staff.nama, staff.jabatan, date, time, status, photoUrl, score, data.latitude || '', data.longitude || '', data.alamat || '', '', 'Belum Absen', '', '', '', '', '', '']);
return { success: true, kind: kind, time: time, status: status, message: 'Absensi masuk berhasil disimpan.' };
}
if (kind === 'Masuk') {
if (rows[rowNumber - 1][5]) throw new Error('Absensi masuk hari ini sudah tercatat pada ' + rows[rowNumber - 1][5] + '.');
throw new Error('Data absensi masuk hari ini sudah ada. Hubungi admin jika ada kesalahan.');
}
if (rows[rowNumber - 1][12]) throw new Error('Absensi pulang sudah tercatat pada ' + rows[rowNumber - 1][12] + '.');
const photoUrl = data.photoData ? savePhoto_(data.photoData, 'Pulang_' + nip + '_' + date + '.jpg') : '';
const planned = getSetting_('JamPulang') || '14:00';
const statusOut = time.substring(0, 5) < planned ? 'Pulang Lebih Awal' : 'Sesuai Jadwal';
sh.getRange(rowNumber, 13, 1, 8).setValues([[time, statusOut, photoUrl, score, data.latitude || '', data.longitude || '', data.alamat || '', String(data.keterangan || '')]]);
return { success: true, kind: kind, time: time, status: statusOut, message: 'Absensi pulang berhasil disimpan.' };
}
function getAdminSummary(pin) {
requireAdmin_(pin);
const today = Utilities.formatDate(new Date(), 'Asia/Jakarta', 'yyyy-MM-dd');
const staffRows = getSheet_(SHEET_STAFF).getDataRange().getDisplayValues();
const attRows = getSheet_(SHEET_ATTENDANCE).getDataRange().getDisplayValues();
const staff = [];
for (let i = 1; i < staffRows.length; i++) if (staffRows[i][0]) staff.push({ nip: staffRows[i][0], nama: staffRows[i][1], jabatan: staffRows[i][2], unit: staffRows[i][3], status: staffRows[i][4], faceRegistered: !!staffRows[i][5] });
const attendance = [];
for (let i = 1; i < attRows.length; i++) if (attRows[i][0]) attendance.push({ id: attRows[i][0], nip: attRows[i][1], nama: attRows[i][2], jabatan: attRows[i][3], tanggal: attRows[i][4], jamMasuk: attRows[i][5], statusMasuk: attRows[i][6], jamPulang: attRows[i][12], statusPulang: attRows[i][13], fotoMasuk: attRows[i][7], fotoPulang: attRows[i][14] });
const todayRows = attendance.filter(a => a.tanggal === today);
return { success: true, staff: staff, attendance: attendance.reverse(), stats: { totalStaff: staff.filter(s => s.status === 'Aktif').length, hadir: todayRows.filter(a => a.jamMasuk).length, terlambat: todayRows.filter(a => a.statusMasuk === 'Terlambat').length, belumPulang: todayRows.filter(a => a.jamMasuk && !a.jamPulang).length }, settings: getPublicSettings() };
}