Cara Membuat Aplikasi Absensi Karyawan dengan Google Spreadsheet
9 menit baca · Diperbarui 2026-07-22
Absensi manual pakai buku atau satu spreadsheet yang dibuka rame-rame gampang kacau: baris ketimpa, jam diisi ngasal, dan susah direkap akhir bulan. Kamu bisa ganti itu dengan satu link yang dibuka karyawan dari HP, tekan satu tombol, lalu kehadiran tercatat dengan timestamp yang diambil dari server, bukan dari jam HP masing-masing orang.
Backend-nya cukup Google Spreadsheet plus Apps Script, tanpa server tambahan dan tanpa biaya hosting. Panduan ini bahas tuntas dari nol: struktur sheet, kode server, halaman yang dibuka dari HP, cara deploy, dan bagian yang paling sering dilupakan orang, yaitu mencegah satu orang absen dua kali dalam waktu berdekatan.
Satu keputusan penting di depan. Identitas karyawan diambil dari input di halaman, bukan dari akun Google. Alasannya, kita deploy Web App dengan akses "Anyone" supaya karyawan tinggal buka link tanpa perlu login. Di mode itu, `Session.getActiveUser().getEmail()` balikannya kosong, jadi kita tidak bisa mengandalkan akun Google untuk tahu siapa yang absen. Gantinya, karyawan memilih namanya dari daftar (atau nanti bisa kamu kembangkan jadi ketik ID/PIN).
Yang akan kamu bangun
Lima potongan, tidak lebih:
- Sheet **Karyawan**: master daftar orang (ID dan Nama).
- Sheet **Absensi**: log mentah tiap ketukan tombol (timestamp, tanggal, ID, nama, tipe Masuk/Pulang).
- **Web App**: satu halaman HTML yang dibuka dari HP, isinya dropdown nama plus dua tombol.
- **google.script.run**: jembatan dari halaman ke fungsi server. Ini pengganti `fetch`, bukan `fetch`.
- **LockService**: pengaman supaya dua ketukan yang datang bersamaan tidak bikin baris dobel.
Kalau kamu baru pertama menyentuh Apps Script, baca dulu Google Apps Script untuk pemula supaya paham editor, fungsi, dan cara jalankannya. Sisanya di sini bisa langsung salin-tempel.
Siapkan struktur Spreadsheet
Anggap tiap tab adalah satu tabel database. Buat dua tab dengan header di baris 1 persis seperti ini (urutan kolom penting, karena kode di bawah membacanya berdasarkan posisi kolom). Untuk pola baca-tulis borongan yang dipakai di sini, cara baca dan tulis data Sheet dari Apps Script jadi rujukan yang enak dibaca.
- **Karyawan**, kolom A: ID (unik, misal NIK atau kode singkat); kolom B: Nama
- **Absensi**, kolom A: Timestamp (waktu penuh dari server); B: Tanggal (format `yyyy-MM-dd`, dipakai untuk cek dobel); C: ID; D: Nama; E: Tipe (Masuk / Pulang)
- Sheet Absensi ini log mentah. Jangan pernah diedit manual, dan jangan simpan rekap di sini. Rekap dihitung dari log, bukan disimpan terpisah.
Kode server: sajikan halaman dan daftar karyawan
Buka Extensions > Apps Script dari spreadsheet-mu. Di file `Code.gs`, tempel ini. `doGet(e)` yang menyajikan halaman saat link dibuka, dan `getDaftarKaryawan()` yang mengisi dropdown. Perhatikan kita membaca kolom sekaligus dengan `getRange(...).getValues()`, bukan `getValue()` per sel di dalam loop, karena baca borongan jauh lebih cepat dan hemat kuota.
// ===== Konfigurasi (Code.gs) =====
const SHEET_KARYAWAN = 'Karyawan';
const SHEET_ABSENSI = 'Absensi';
const TZ = 'Asia/Jakarta';
// Sajikan halaman absensi saat link Web App dibuka
function doGet(e) {
return HtmlService.createHtmlOutputFromFile('Index')
.setTitle('Absensi Karyawan')
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
// Dipanggil dari HP untuk mengisi dropdown nama
function getDaftarKaryawan() {
const sh = SpreadsheetApp.getActive().getSheetByName(SHEET_KARYAWAN);
const nRow = sh.getLastRow();
if (nRow < 2) return [];
// baca borongan kolom A (ID) & B (Nama), lewati baris header
const data = sh.getRange(2, 1, nRow - 1, 2).getValues();
return data
.filter(function (r) { return r[0] !== '' && r[1] !== ''; })
.map(function (r) { return { id: String(r[0]), nama: String(r[1]) }; });
}Catat absen tanpa dobel: LockService
Ini bagian yang bikin app-nya beneran layak dipakai. Bayangkan skenario ini: karyawan nekan tombol Masuk, jaringan lambat, dia kira gagal lalu nekan lagi. Atau dua orang absen di detik yang sama. Kalau fungsi jalan bersamaan, keduanya membaca log, keduanya melihat "belum ada absen Masuk hari ini", lalu keduanya menulis. Hasilnya baris dobel. `LockService` menyelesaikan ini dengan memaksa penulisan berjalan satu per satu.
// Catat satu ketukan absen. payload: { id: '...', tipe: 'Masuk' | 'Pulang' }
function catatAbsen(payload) {
const id = String(payload && payload.id || '').trim();
const tipe = String(payload && payload.tipe || '').trim();
if (!id || (tipe !== 'Masuk' && tipe !== 'Pulang')) {
return { ok: false, pesan: 'Data absen tidak lengkap.' };
}
const lock = LockService.getScriptLock();
try {
lock.waitLock(10000); // antre maksimal 10 detik
} catch (err) {
return { ok: false, pesan: 'Server sibuk, coba lagi sebentar.' };
}
try {
const ss = SpreadsheetApp.getActive();
const nama = cariNama_(ss, id);
if (!nama) return { ok: false, pesan: 'Karyawan tidak terdaftar.' };
const sh = ss.getSheetByName(SHEET_ABSENSI);
const now = new Date();
const tglKey = Utilities.formatDate(now, TZ, 'yyyy-MM-dd');
// cek dobel absen: baca log borongan, saring di memori
const nRow = sh.getLastRow();
if (nRow >= 2) {
// kolom: [Timestamp, TglKey, ID, Nama, Tipe]
const log = sh.getRange(2, 1, nRow - 1, 5).getValues();
const sudah = log.some(function (r) {
return String(r[2]) === id && String(r[1]) === tglKey && String(r[4]) === tipe;
});
if (sudah) {
return { ok: false, pesan: 'Kamu sudah absen ' + tipe + ' hari ini.' };
}
}
sh.appendRow([now, tglKey, id, nama, tipe]);
const jam = Utilities.formatDate(now, TZ, 'HH:mm');
return { ok: true, pesan: 'Absen ' + tipe + ' tercatat pukul ' + jam + '. Terima kasih, ' + nama + '.' };
} finally {
lock.releaseLock();
}
}
// Cari nama dari ID di sheet Karyawan (baca borongan)
function cariNama_(ss, id) {
const sh = ss.getSheetByName(SHEET_KARYAWAN);
const nRow = sh.getLastRow();
if (nRow < 2) return '';
const data = sh.getRange(2, 1, nRow - 1, 2).getValues();
for (var i = 0; i < data.length; i++) {
if (String(data[i][0]) === id) return String(data[i][1]);
}
return '';
}Halaman yang dibuka dari HP
Di editor Apps Script, buat file HTML baru bernama `Index` (File > New > HTML). Kunci di sini: halaman memanggil fungsi server lewat `google.script.run`, bukan `fetch`. Fungsi `catatAbsen` dan `getDaftarKaryawan` di `Code.gs` bukan endpoint REST, jadi tidak ada URL JSON untuk di-`fetch`. `google.script.run` memanggil fungsi server langsung dan mengembalikan hasilnya ke `withSuccessHandler`.
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<meta charset="utf-8">
<style>
body { font-family: system-ui, sans-serif; max-width: 420px; margin: 24px auto; padding: 0 16px; }
select, button { width: 100%; padding: 12px; font-size: 16px; margin-top: 8px; border-radius: 8px; }
button { border: 0; color: #fff; }
.masuk { background: #16a34a; }
.pulang { background: #dc2626; }
#pesan { margin-top: 16px; padding: 12px; border-radius: 8px; min-height: 20px; }
</style>
</head>
<body>
<h2>Absensi Karyawan</h2>
<label>Nama</label>
<select id="karyawan"><option>Memuat daftar...</option></select>
<button class="masuk" onclick="kirim('Masuk')">Absen Masuk</button>
<button class="pulang" onclick="kirim('Pulang')">Absen Pulang</button>
<div id="pesan"></div>
<script>
// Isi dropdown saat halaman siap. Panggil fungsi server, BUKAN fetch.
google.script.run.withSuccessHandler(function (list) {
var sel = document.getElementById('karyawan');
sel.innerHTML = '';
list.forEach(function (k) {
var opt = document.createElement('option');
opt.value = k.id;
opt.textContent = k.nama;
sel.appendChild(opt);
});
}).getDaftarKaryawan();
function kirim(tipe) {
var id = document.getElementById('karyawan').value;
if (!id) { return setPesan('Pilih nama dulu.', '#fee2e2'); }
setBusy(true);
setPesan('Mengirim...', '#f1f5f9');
google.script.run
.withSuccessHandler(function (res) {
setPesan(res.pesan, res.ok ? '#dcfce7' : '#fee2e2');
setBusy(false);
})
.withFailureHandler(function (err) {
setPesan('Gagal: ' + err.message, '#fee2e2');
setBusy(false);
})
.catatAbsen({ id: id, tipe: tipe });
}
function setBusy(on) {
var btns = document.querySelectorAll('button');
for (var i = 0; i < btns.length; i++) { btns[i].disabled = on; }
}
function setPesan(teks, warna) {
var p = document.getElementById('pesan');
p.textContent = teks;
p.style.background = warna;
}
</script>
</body>
</html>Tombol sengaja di-disable selama pengiriman biar orang tidak spam nekan. Kalau kamu mau perdalam pola form yang dikirim dari HP ke Sheet, cara input data ke Google Spreadsheet dari HP mengupas variasinya. Untuk gambaran utuh cara halaman dan server nyambung, ada juga cara membuat Web App dari Google Spreadsheet.
Deploy sebagai Web App anonim
- Di editor Apps Script, klik **Deploy > New deployment**.
- Pilih tipe **Web app** (ikon gerigi > Web app).
- **Execute as: Me**, ini penting supaya script punya izin menulis ke Sheet milikmu.
- **Who has access: Anyone**, supaya karyawan buka link tanpa login.
- Klik **Deploy**, izinkan (authorize) saat diminta, lalu salin URL yang berakhiran `/exec`.
- Bagikan URL itu ke karyawan. Di HP, mereka bisa Add to Home Screen supaya jadi seperti ikon aplikasi.
Rekap kehadiran
Karena log-nya sudah rapi (satu baris per ketukan, plus kolom tanggal), rekap tidak butuh kode tambahan. Buat tab baru bernama `Rekap`, lalu pakai salah satu dari ini: Pivot Table bawaan (Insert > Pivot table, baris = Nama, kolom = Tipe, nilai = COUNT), atau satu formula QUERY yang menghitung jumlah hari hadir per orang.
Mau rekap harian dikirim otomatis ke email atau grup tiap sore tanpa harus buka spreadsheet? Itu pakai time-driven trigger. Caranya ada di cara menjadwalkan script Apps Script dengan trigger.
Batasan yang wajib kamu tahu
- Satu eksekusi dibatasi sekitar 6 menit di akun gratis. Untuk absensi ringan ini tidak akan kena, tapi sadari batasnya sebelum menambah proses berat.
- Fungsi `catatAbsen` membaca seluruh log tiap kali dipanggil. Saat log masih ribuan baris ini cepat. Kalau sudah menuju puluhan atau ratusan ribu baris, batasi cek ke baris hari ini saja (misal simpan penanda absen terakhir per karyawan) supaya tidak makin lambat.
- Ada kuota harian Apps Script untuk jumlah eksekusi dan panggilan layanan. Angka pastinya dan cara menyiasatinya ada di [batas dan kuota Google Apps Script](/panduan/batas-kuota-google-apps-script).
Dari kerangka ke aplikasi absensi beneran
Snippet di atas kerangka yang jalan, tapi absensi produksi biasanya butuh lebih: validasi PIN biar orang tidak absen atas nama teman, aturan jam kerja dan hitung keterlambatan, absensi per cabang, pengecualian hari libur, sampai tombol izin/sakit. Kalau kamu langsung minta agent AI seperti Cursor atau Claude Code membangun semua itu sambil dia menebak-nebak struktur tab dan nama fungsi, hasilnya sering meleset dan kamu bolak-balik mengoreksi.
Lebih rapi kalau kamu tulis dulu PRD, dokumen yang menjelaskan apa yang dibangun sebelum satu baris kode ditulis. Kalau istilah ini baru buatmu, mulai dari apa itu PRD dan cara membuat PRD untuk aplikasi. Yang paling menentukan hasil akhir justru cara menjelaskan ide aplikasi ke AI: makin jelas kamu sebut struktur data dan aturannya, makin sedikit tebakan yang dilakukan agent.
Di Skematik ada mode Web GAS yang khusus untuk kasus seperti ini. Kamu jelaskan idenya, dan PRD yang keluar sudah memetakan struktur tab Spreadsheet sebagai skema data, daftar fungsi server yang perlu dibuat, plus konvensi yang sadar batasan Apps Script (baca-tulis borongan, LockService, batas 6 menit). Ada juga export `setup-sheets.gs` untuk menyiapkan tab-tabnya sekali jalan. Tinggal serahkan PRD itu ke agent codingmu.
Buat PRD Web GAS gratis di Skematik →Kalau kamu ikuti sampai sini, kamu sudah punya app absensi yang jalan: dibuka dari HP, timestamp dari server, aman dari dobel absen, dan rekap otomatis dari log. Sisanya soal menambah aturan bisnismu sendiri, dan bagian itu jauh lebih lancar kalau rencananya sudah tertulis rapi sejak awal.