Setup SITEGUH

Hubungkan aplikasi ke backend Google Apps Script sekolah Anda.

Siapkan backend Google Apps Script

  1. Buat Google Spreadsheet baru.
  2. Buka menu Extensions → Apps Script.
  3. Hapus isi editor, tempel kode Code.gs di bawah.
  4. Deploy → New deployment → Web app. Execute as: Me, Who has access: Anyone.
  5. Salin URL yang berakhiran /exec.
Lihat kode Code.gs
/**
 * SITEGUH - Sistem Informasi Terpadu Guru Hebat
 * Backend REST API (Google Apps Script + Google Spreadsheet)
 *
 * CARA PAKAI
 * 1. Buat Google Spreadsheet baru.
 * 2. Menu Extensions > Apps Script, hapus isinya, tempel seluruh kode ini.
 * 3. Deploy > New deployment > Type: Web app
 *    - Execute as: Me
 *    - Who has access: Anyone
 * 4. Salin URL Web App (/exec) lalu tempel di halaman Setup aplikasi SITEGUH.
 * 5. Akun admin awal: username "admin" password "admin123" (WAJIB diganti).
 */

var SS = SpreadsheetApp.getActiveSpreadsheet();
var SALT = 'siteguh-v1-salt';
var FOLDER_NAME = 'SITEGUH Uploads';

var SHEETS = {
  Settings: ['key', 'value'],
  Users: ['id', 'nama', 'username', 'password', 'role', 'nip', 'mapel', 'telepon', 'foto'],
  Classes: ['id', 'nama', 'waliKelas', 'tingkat'],
  Students: ['nis', 'nama', 'idKelas', 'jenisKelamin'],
  Schedules: ['id', 'username', 'hari', 'jamKe', 'jamMulai', 'jamSelesai', 'kelas', 'mapel'],
  Attendance: ['id', 'waktu', 'username', 'nama', 'jenis', 'lat', 'long', 'jarak', 'status', 'foto'],
  Journals: ['id', 'waktu', 'username', 'nama', 'jamKe', 'kelas', 'mapel', 'materi', 'catatan', 'bukti'],
  Grades: ['id', 'username', 'nis', 'namaSiswa', 'kelas', 'mapel', 'jenis', 'nilai', 'tanggal']
};

var DEFAULT_SETTINGS = {
  namaSekolah: 'SITEGUH',
  logo: '',
  alamat: '',
  lat: '0',
  long: '0',
  radius: '50',
  jamMasuk: '07:00',
  jamPulang: '15:00'
};

/* ============ ENTRY POINTS ============ */

function doGet(e) {
  var action = (e && e.parameter && e.parameter.action) || 'ping';
  if (action === 'ping') return json({ ok: true, data: { service: 'SITEGUH API', version: '1.0' } });
  return handle(action, {}, null);
}

function doPost(e) {
  var body = {};
  try {
    body = JSON.parse(e.postData.contents);
  } catch (err) {
    return json({ ok: false, error: 'Body request tidak valid.' });
  }
  return handle(body.action, body.payload || {}, body.token || null);
}

function doOptions() {
  return ContentService.createTextOutput('').setMimeType(ContentService.MimeType.TEXT);
}

function json(obj) {
  return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(
    ContentService.MimeType.JSON
  );
}

/* ============ ROUTER ============ */

function handle(action, p, token) {
  try {
    var lock = LockService.getScriptLock();
    lock.waitLock(20000);
    try {
      var out = route(action, p, token);
      return json({ ok: true, data: out });
    } finally {
      lock.releaseLock();
    }
  } catch (err) {
    return json({ ok: false, error: String(err && err.message ? err.message : err) });
  }
}

function route(action, p, token) {
  switch (action) {
    case 'ping': return { service: 'SITEGUH API', version: '1.0', initialized: hasSheet('Users') };
    case 'init': return initDatabase();
    case 'login': return login(p);
    case 'session': return auth(token);

    case 'getSettings': return getSettings();
    case 'saveSettings': requireRole(token, 'admin'); return saveSettings(p);

    case 'changePassword': return changePassword(auth(token), p);
    case 'me': return auth(token);

    case 'listUsers': requireRole(token, 'admin'); return readAll('Users').map(stripPassword);
    case 'saveUser': requireRole(token, 'admin'); return saveUser(p);
    case 'deleteUser': requireRole(token, 'admin'); return removeRow('Users', 'id', p.id);

    case 'listClasses': auth(token); return readAll('Classes');
    case 'saveClass': requireRole(token, 'admin'); return upsert('Classes', 'id', p, true);
    case 'deleteClass': requireRole(token, 'admin'); return removeRow('Classes', 'id', p.id);

    case 'listStudents': auth(token); return readAll('Students');
    case 'saveStudent': requireRole(token, 'admin'); return upsert('Students', 'nis', p, false);
    case 'deleteStudent': requireRole(token, 'admin'); return removeRow('Students', 'nis', p.nis);
    case 'importStudents': requireRole(token, 'admin'); return importRows('Students', 'nis', p.rows || []);

    case 'listSchedules': auth(token); return readAll('Schedules').map(function (r) {
      r.jamMulai = toHHmm(r.jamMulai, '');
      r.jamSelesai = toHHmm(r.jamSelesai, '');
      return r;
    });
    case 'saveSchedule': requireRole(token, 'admin');
      p.jamMulai = "'" + toHHmm(p.jamMulai, '');
      p.jamSelesai = "'" + toHHmm(p.jamSelesai, '');
      return upsert('Schedules', 'id', p, true);
    case 'deleteSchedule': requireRole(token, 'admin'); return removeRow('Schedules', 'id', p.id);

    case 'checkAttendance': return attend(auth(token), p);
    case 'myAttendance': return myAttendance(auth(token), p);
    case 'listAttendance': requireRole(token, 'admin'); return filterByDate(readAll('Attendance'), p, 'waktu');

    case 'saveJournal': return saveJournal(auth(token), p);
    case 'myJournals': return byUser(readAll('Journals'), auth(token).username);
    case 'listJournals': requireRole(token, 'admin'); return filterByDate(readAll('Journals'), p, 'waktu');

    case 'saveGrades': return saveGrades(auth(token), p);
    case 'myGrades': return byUser(readAll('Grades'), auth(token).username);
    case 'listGrades': requireRole(token, 'admin'); return readAll('Grades');

    case 'dashboard': requireRole(token, 'admin'); return dashboard();
    default: throw new Error('Action tidak dikenal: ' + action);
  }
}

/* ============ SHEET HELPERS ============ */

function hasSheet(name) {
  return !!SS.getSheetByName(name);
}

function sheet(name) {
  var sh = SS.getSheetByName(name);
  if (!sh) throw new Error('Sheet "' + name + '" belum ada. Jalankan Inisialisasi Database.');
  return sh;
}

function initDatabase() {
  Object.keys(SHEETS).forEach(function (name) {
    var sh = SS.getSheetByName(name);
    if (!sh) sh = SS.insertSheet(name);
    var headers = SHEETS[name];
    var current = sh.getLastColumn() > 0 ? sh.getRange(1, 1, 1, sh.getLastColumn()).getValues()[0] : [];
    if (current.join('|') !== headers.join('|')) {
      sh.getRange(1, 1, 1, headers.length).setValues([headers]);
      sh.setFrozenRows(1);
      sh.getRange(1, 1, 1, headers.length).setFontWeight('bold');
    }
  });

  var st = sheet('Settings');
  var existing = {};
  readAll('Settings').forEach(function (r) { existing[r.key] = true; });
  Object.keys(DEFAULT_SETTINGS).forEach(function (k) {
    if (!existing[k]) st.appendRow([k, DEFAULT_SETTINGS[k]]);
  });

  var users = readAll('Users');
  var created = false;
  if (users.length === 0) {
    sheet('Users').appendRow([
      'U' + Date.now(), 'Administrator', 'admin', hashPassword('admin123'), 'admin', '', '', '', ''
    ]);
    created = true;
  }
  return { sheets: Object.keys(SHEETS), adminCreated: created };
}

function readAll(name) {
  var sh = sheet(name);
  var last = sh.getLastRow();
  if (last < 2) return [];
  var headers = sh.getRange(1, 1, 1, sh.getLastColumn()).getValues()[0];
  var values = sh.getRange(2, 1, last - 1, sh.getLastColumn()).getValues();
  return values.map(function (row) {
    var obj = {};
    headers.forEach(function (h, i) { obj[h] = row[i]; });
    return obj;
  }).filter(function (o) { return String(o[headers[0]]).length > 0; });
}

function upsert(name, key, payload, autoId) {
  var sh = sheet(name);
  var headers = SHEETS[name];
  if (autoId && !payload[key]) payload[key] = name.charAt(0) + Date.now();
  var rows = readAll(name);
  var index = -1;
  for (var i = 0; i < rows.length; i++) {
    if (String(rows[i][key]) === String(payload[key])) { index = i; break; }
  }
  var record = headers.map(function (h) {
    return payload[h] !== undefined && payload[h] !== null ? payload[h] : (index >= 0 ? rows[index][h] : '');
  });
  if (index >= 0) sh.getRange(index + 2, 1, 1, headers.length).setValues([record]);
  else sh.appendRow(record);
  var out = {};
  headers.forEach(function (h, i) { out[h] = record[i]; });
  return out;
}

function importRows(name, key, rows) {
  rows.forEach(function (r) { upsert(name, key, r, false); });
  return { imported: rows.length };
}

function removeRow(name, key, value) {
  var sh = sheet(name);
  var rows = readAll(name);
  for (var i = 0; i < rows.length; i++) {
    if (String(rows[i][key]) === String(value)) {
      sh.deleteRow(i + 2);
      return { deleted: true };
    }
  }
  throw new Error('Data tidak ditemukan.');
}

/* ============ AUTH ============ */

function hashPassword(pwd) {
  var raw = Utilities.computeDigest(
    Utilities.DigestAlgorithm.SHA_256,
    SALT + String(pwd)
  );
  return raw.map(function (b) {
    var v = (b < 0 ? b + 256 : b).toString(16);
    return v.length === 1 ? '0' + v : v;
  }).join('');
}

function makeToken(user) {
  var exp = Date.now() + 1000 * 60 * 60 * 12;
  var payload = user.username + '|' + exp;
  return Utilities.base64EncodeWebSafe(payload + '|' + hashPassword(payload));
}

function auth(token) {
  if (!token) throw new Error('Sesi tidak ditemukan. Silakan login kembali.');
  var decoded;
  try {
    decoded = Utilities.newBlob(Utilities.base64DecodeWebSafe(token)).getDataAsString();
  } catch (e) {
    throw new Error('Sesi tidak valid.');
  }
  var parts = decoded.split('|');
  if (parts.length !== 3) throw new Error('Sesi tidak valid.');
  if (hashPassword(parts[0] + '|' + parts[1]) !== parts[2]) throw new Error('Sesi tidak valid.');
  if (Number(parts[1]) < Date.now()) throw new Error('Sesi berakhir. Silakan login kembali.');
  var user = findUser(parts[0]);
  if (!user) throw new Error('Akun tidak ditemukan.');
  return stripPassword(user);
}

function requireRole(token, role) {
  var user = auth(token);
  if (String(user.role) !== role) throw new Error('Akses ditolak. Khusus ' + role + '.');
  return user;
}

function findUser(identifier) {
  var key = String(identifier || '').trim().toLowerCase();
  if (!key) return null;
  var rows = readAll('Users');
  var i;
  for (i = 0; i < rows.length; i++) {
    if (String(rows[i].username).trim().toLowerCase() === key) return rows[i];
  }
  // fallback: login memakai NIP
  for (i = 0; i < rows.length; i++) {
    var nip = String(rows[i].nip || '').trim().toLowerCase();
    if (nip && nip === key) return rows[i];
  }
  return null;
}

function stripPassword(u) {
  return {
    id: u.id, nama: u.nama, username: u.username, role: u.role,
    nip: u.nip, mapel: u.mapel, telepon: u.telepon, foto: u.foto
  };
}

function login(p) {
  if (!hasSheet('Users')) initDatabase();
  var user = findUser(p.username || '');
  if (!user) throw new Error('Username tidak terdaftar.');
  if (String(user.password) !== hashPassword(p.password || '')) throw new Error('Password salah.');
  return { token: makeToken(user), user: stripPassword(user), exp: Date.now() + 1000 * 60 * 60 * 12 };
}

function saveUser(p) {
  var data = {
    id: p.id || 'U' + Date.now(),
    nama: p.nama, username: p.username, role: p.role || 'guru',
    nip: p.nip || '', mapel: p.mapel || '', telepon: p.telepon || '', foto: p.foto || ''
  };
  if (p.password) data.password = hashPassword(p.password);
  var saved = upsert('Users', 'id', data, false);
  return stripPassword(saved);
}

function changePassword(user, p) {
  var full = findUser(user.username);
  if (String(full.password) !== hashPassword(p.oldPassword || '')) throw new Error('Password lama salah.');
  if (!p.newPassword || String(p.newPassword).length < 6) throw new Error('Password baru minimal 6 karakter.');
  upsert('Users', 'id', { id: full.id, password: hashPassword(p.newPassword) }, false);
  return { updated: true };
}

/* ============ SETTINGS ============ */

/** Ubah nilai jam (Date dari Sheets atau teks) menjadi "HH:mm". */
function toHHmm(value, fallback) {
  if (value === null || value === undefined || value === '') return fallback || '';
  if (Object.prototype.toString.call(value) === '[object Date]') {
    return Utilities.formatDate(value, Session.getScriptTimeZone(), 'HH:mm');
  }
  var s = String(value).trim();
  var m = /^(\d{1,2}):(\d{2})/.exec(s);
  if (m) return ('0' + m[1]).slice(-2) + ':' + m[2];
  var d = new Date(s);
  if (!isNaN(d.getTime())) return Utilities.formatDate(d, Session.getScriptTimeZone(), 'HH:mm');
  return fallback || s;
}

function getSettings() {
  if (!hasSheet('Settings')) initDatabase();
  var out = {};
  Object.keys(DEFAULT_SETTINGS).forEach(function (k) { out[k] = DEFAULT_SETTINGS[k]; });
  readAll('Settings').forEach(function (r) { out[r.key] = r.value; });
  out.lat = Number(out.lat) || 0;
  out.long = Number(out.long) || 0;
  out.radius = Number(out.radius) || 50;
  out.jamMasuk = toHHmm(out.jamMasuk, DEFAULT_SETTINGS.jamMasuk);
  out.jamPulang = toHHmm(out.jamPulang, DEFAULT_SETTINGS.jamPulang);
  return out;
}

function saveSettings(p) {
  var sh = sheet('Settings');
  var rows = readAll('Settings');
  Object.keys(p).forEach(function (key) {
    var value = p[key];
    var isTime = key === 'jamMasuk' || key === 'jamPulang';
    if (isTime) value = toHHmm(value, '');
    var found = -1;
    for (var i = 0; i < rows.length; i++) { if (rows[i].key === key) { found = i; break; } }
    if (found >= 0) {
      var cell = sh.getRange(found + 2, 2);
      if (isTime) cell.setNumberFormat('@');
      cell.setValue(value);
    } else {
      sh.appendRow([key, '']);
      var newCell = sh.getRange(sh.getLastRow(), 2);
      if (isTime) newCell.setNumberFormat('@');
      newCell.setValue(value);
      rows.push({ key: key, value: value });
    }
  });
  return getSettings();
}

/* ============ FILE UPLOAD ============ */

function getFolder() {
  var it = DriveApp.getFoldersByName(FOLDER_NAME);
  if (it.hasNext()) return it.next();
  return DriveApp.createFolder(FOLDER_NAME);
}

function saveBase64(dataUrl, prefix) {
  if (!dataUrl || String(dataUrl).indexOf('data:') !== 0) return dataUrl || '';
  var parts = String(dataUrl).split(',');
  var meta = parts[0];
  var contentType = meta.substring(meta.indexOf(':') + 1, meta.indexOf(';'));
  var ext = contentType.split('/')[1] || 'jpg';
  var blob = Utilities.newBlob(Utilities.base64Decode(parts[1]), contentType, prefix + '-' + Date.now() + '.' + ext);
  var file = getFolder().createFile(blob);
  file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
  return 'https://drive.google.com/thumbnail?id=' + file.getId() + '&sz=w1000';
}

/* ============ ABSENSI ============ */

function attend(user, p) {
  var settings = getSettings();
  var jenis = p.jenis === 'Pulang' ? 'Pulang' : 'Masuk';
  var now = new Date();
  var today = Utilities.formatDate(now, Session.getScriptTimeZone(), 'yyyy-MM-dd');

  var existing = readAll('Attendance').filter(function (r) {
    return String(r.username) === user.username &&
      Utilities.formatDate(new Date(r.waktu), Session.getScriptTimeZone(), 'yyyy-MM-dd') === today &&
      String(r.jenis) === jenis;
  });
  if (existing.length > 0) throw new Error('Anda sudah melakukan absen ' + jenis + ' hari ini.');

  var jarak = Number(p.jarak || 0);
  if (settings.radius > 0 && jarak > settings.radius) {
    throw new Error('Anda berada ' + jarak + ' m dari sekolah (maks ' + settings.radius + ' m).');
  }

  var jam = Utilities.formatDate(now, Session.getScriptTimeZone(), 'HH:mm');
  var status = 'Tepat Waktu';
  var jamMasuk = toHHmm(settings.jamMasuk, '07:00');
  var jamPulang = toHHmm(settings.jamPulang, '15:00');
  if (jenis === 'Masuk' && jam > jamMasuk) status = 'Terlambat';
  if (jenis === 'Pulang' && jam < jamPulang) status = 'Pulang Awal';

  var foto = saveBase64(p.foto, 'selfie-' + user.username);
  var record = {
    id: 'A' + Date.now(),
    waktu: now.toISOString(),
    username: user.username,
    nama: user.nama,
    jenis: jenis,
    lat: p.lat, long: p.long, jarak: jarak,
    status: status,
    foto: foto
  };
  upsert('Attendance', 'id', record, false);
  return record;
}

function myAttendance(user, p) {
  var rows = byUser(readAll('Attendance'), user.username);
  if (p && p.date) {
    rows = rows.filter(function (r) {
      return Utilities.formatDate(new Date(r.waktu), Session.getScriptTimeZone(), 'yyyy-MM-dd') === p.date;
    });
  }
  return rows;
}

function byUser(rows, username) {
  return rows.filter(function (r) { return String(r.username) === String(username); })
    .sort(function (a, b) { return new Date(b.waktu || b.tanggal) - new Date(a.waktu || a.tanggal); });
}

function filterByDate(rows, p, field) {
  var from = p && p.from ? new Date(p.from + 'T00:00:00') : null;
  var to = p && p.to ? new Date(p.to + 'T23:59:59') : null;
  return rows.filter(function (r) {
    var d = new Date(r[field]);
    if (from && d < from) return false;
    if (to && d > to) return false;
    if (p && p.username && String(r.username) !== String(p.username)) return false;
    return true;
  }).sort(function (a, b) { return new Date(b[field]) - new Date(a[field]); });
}

/* ============ JURNAL & NILAI ============ */

function saveJournal(user, p) {
  var record = {
    id: 'J' + Date.now(),
    waktu: new Date().toISOString(),
    username: user.username,
    nama: user.nama,
    jamKe: p.jamKe || '', kelas: p.kelas || '', mapel: p.mapel || '',
    materi: p.materi || '', catatan: p.catatan || '',
    bukti: saveBase64(p.bukti, 'bukti-' + user.username)
  };
  upsert('Journals', 'id', record, false);
  return record;
}

function saveGrades(user, p) {
  var list = p.items || [];
  var saved = [];
  list.forEach(function (item) {
    var record = {
      id: 'G' + Date.now() + '-' + item.nis,
      username: user.username,
      nis: item.nis, namaSiswa: item.namaSiswa || '',
      kelas: p.kelas || '', mapel: p.mapel || '', jenis: p.jenis || 'Harian',
      nilai: item.nilai, tanggal: new Date().toISOString()
    };
    upsert('Grades', 'id', record, false);
    saved.push(record);
  });
  return { saved: saved.length };
}

/* ============ DASHBOARD ============ */

function dashboard() {
  var tz = Session.getScriptTimeZone();
  var today = Utilities.formatDate(new Date(), tz, 'yyyy-MM-dd');
  var att = readAll('Attendance');
  var users = readAll('Users').filter(function (u) { return String(u.role) === 'guru'; });
  var todayIn = att.filter(function (r) {
    return Utilities.formatDate(new Date(r.waktu), tz, 'yyyy-MM-dd') === today && r.jenis === 'Masuk';
  });
  var hadirUsernames = {};
  todayIn.forEach(function (r) { hadirUsernames[r.username] = true; });

  var trend = [];
  for (var i = 6; i >= 0; i--) {
    var d = new Date();
    d.setDate(d.getDate() - i);
    var key = Utilities.formatDate(d, tz, 'yyyy-MM-dd');
    var count = att.filter(function (r) {
      return Utilities.formatDate(new Date(r.waktu), tz, 'yyyy-MM-dd') === key && r.jenis === 'Masuk';
    }).length;
    trend.push({ tanggal: key, hadir: count });
  }

  var journalsToday = readAll('Journals').filter(function (r) {
    return Utilities.formatDate(new Date(r.waktu), tz, 'yyyy-MM-dd') === today;
  });

  return {
    totalGuru: users.length,
    hadir: todayIn.length,
    terlambat: todayIn.filter(function (r) { return r.status === 'Terlambat'; }).length,
    belumAbsen: users.filter(function (u) { return !hadirUsernames[u.username]; })
      .map(function (u) { return { nama: u.nama, username: u.username }; }),
    jurnalHariIni: journalsToday.length,
    totalKelas: readAll('Classes').length,
    totalSiswa: readAll('Students').length,
    trend: trend
  };
}

Tempel URL Web App

URL disimpan di perangkat ini (localStorage) dan dipakai untuk seluruh permintaan data.

3

Inisialisasi database

Membuat sheet Settings, Users, Classes, Students, Schedules, Attendance, Journals, dan Grades beserta akun admin awal.

Kembali ke login